Read object ownership, coordinates, troops, and town building status out of
Heroes of Might and Magic III save files (.GM1). Tested on Shadow of Death 4.0
(Chinese) + HD Mod 5.2; other versions are unverified.
Pure standard-library Python 3. No game running required, no memory reading — it parses the save file only.
本文档以中文为主。 上面两段英文是给国际读者的摘要,正文全部中文。
python h3objs.py <save.GM1> <matching.h3m> # 对象:归属 / 坐标 / 兵种
python h3objs.py <save.GM1> <matching.h3m> --only 城镇
python h3build.py <save.GM1> <matching.h3m> # 城镇建筑:已建 / 可建从《英雄无敌3》存档里读冒险地图上各类对象的归属和状态。只读存档文件,不开游戏、 不读内存。
七类对象,每类的记录布局都不一样(这是这个项目的核心发现,不是设计缺陷)。
城镇记录里另外还存着两份 48 位位图,记着建筑状态,由 h3build.py 单独读(见下面「城镇建筑」)。
⚠️ 下表所有偏移都是**相对「记录真起点」**的,不是相对坐标位置。 有的对象这两者不重合(巢穴差0x0D),这个区别就是"读错一条"和"读对"的分界线。
| 对象 | 每条字节 | 坐标在记录里 | owner | 坐标偏移 | 额外读到的 |
|---|---|---|---|---|---|
| 矿 | 62 | +0x3B |
+0x00 |
x-1 |
矿种 |
| 灯塔 | 62 | +0x3B |
+0x00 |
x-1 |
在矿数组里,type=100 |
| 巢穴 | 75 | +0x0D |
+0x49 |
按模板 | 种类(sub) |
| 驻军 | 61 | +0x3A |
+0x01 |
横 x-1 / 纵 y-1 |
兵种 + 数量 |
| 城镇 | ~388(变长) | +0x04 |
+0x00 |
x-2 |
守军、建筑位图 |
| 船坞 | 26 × 3 格 | 散在格子表 | +0x08 |
— | — |
| 船 | 28 | +0x08 |
无归属 | x-1 |
三种型号 |
需要两个文件:存档(*.GM1)和它对应的地图(*.h3m)。
存档是数据源,地图是定位用的锚——存档本身不记录「数组在哪」,所以两个都要给。
# 全部七类
python h3objs.py games\MySave.GM1 Maps\MyMap.h3m
# 只看一类
python h3objs.py games\MySave.GM1 Maps\MyMap.h3m --only 矿
python h3objs.py games\MySave.GM1 Maps\MyMap.h3m --only 驻军输出示例:
=== 驻军 ===
数组 @0x01CE48 12 条
#0 ( 5,36,0) 红 枪兵×10 + 弓箭手×10 + 狮鹫×5
#1 ( 5,44,0) 红 农民×20 + 剑士×8 + 骑兵×4
坐标是游戏里的坐标(已经换算回 .h3m 的口径)。
文件头 "H3SVG"
第 10 字节起是 raw deflate
0x35 处 u32 = 地图边长
必须同时有 .h3m。但不是因为存档里缺数据——矿种、巢穴种类、兵种、船型,
这些在存档记录里全都有。
真正的原因是:存档不会告诉你「这类对象的数组在哪」。
七类对象散落在文件不同位置,定位靠的是「拿 .h3m 里这类对象的坐标,
去存档里搜,投票找出 stride 链」(见下面「定位方法」)。
分开说就是:
.h3m提供有哪些对象、各在哪 —— 定位用的锚- 存档提供这些对象现在归谁、还有多少兵 —— 状态
少了 .h3m,你能拿到一整段字节,但不知道该从哪读起。
同一个数组(H3API 里叫 minesLighthouses),每条 62 字节:
+0x00 owner 0xFF 无主,0-7 颜色
+0x01 type 0-6 = 矿种(木材/水银/矿石/硫磺/水晶/宝石/金币)
100 = 灯塔
+0x02 abandoned
+0x3B x ★ 比 .h3m 的 x 小 1
+0x3C y
+0x3D z
数组长度 = 矿数 + 灯塔数,只按矿数截断会把灯塔挤进矿的位置。
每条 75 字节:
+0x00 sub 巢穴种类(0-81)
+0x0D x, y, z
+0x49 owner
+0x3C(碰巧对),
sub 看着在 +0x3E 但"每条都是下一条的值"。
每条 61 字节,兵种和数量分两段存:
+0x01 owner
+0x02 + k*4 第 k 槽兵种 ID(u16),k = 0..6
+0x1E + k*4 第 k 槽数量(u32),k = 0..6
+0x3A x, y, z
变长,平均 388 字节,实测 386–392 抖动:
+0x00 owner
+0x03 城镇类型 0-8,见下面「城镇建筑」
+0x04 x ★ 比 .h3m 的 x 小 2
+0x05 y
+0x06 z
+0x09 + k*4 守军兵种(u32)×7
+0x25 + k*4 守军数量(u32)×7
变长所以不能按固定 stride 扫,得拿 .h3m 的坐标去搜,再往前退 4 字节当记录头。
城镇记录里还有两份 6 字节位图(48 位),记的是「建了什么」和「现在能建什么」:
位图 A = 已建造
位图 B = 当前可建造(不满足前置、或已升级掉的,B 里没有)
48 位分三段:
| 位 | 内容 | 数量 |
|---|---|---|
| 0-16 | 通用建筑 | 17(所有城镇相同) |
| 17-27 | 城镇特有建筑 | 11(各城镇不同) |
| 28-29 | 空 | — |
| 30-43 | 巢穴 | 14(7 基础 + 7 升级,各城镇不同) |
| 44-47 | 空 | — |
城镇类型在记录 +0x03:0 城堡 / 1 壁垒 / 2 塔楼 / 3 地狱 / 4 墓园 /
5 地下城 / 6 据点 / 7 要塞 / 8 元素城。
位图位置不固定,用结尾锚点定位:
位图 B 的结尾固定是 ff 0f 00 00
在记录范围内 rfind 它,减 4 = 位图 B 起点
位图 A 在它前面 8 或 16 字节(城堡差 16,其他城差 8)
判据:位图 A 必含位 30(每座城开局就有一级巢穴),两个候选里取位数多的那个
python h3build.py games\MySave.GM1 Maps\MyMap.h3m城镇 1 座
=== 城镇 0 (18,18,0) 城堡 [记录 +0x7bcf, 位图 +0xb0/+0xb8] ===
已建 10 个:
1级魔法行会、酒馆、堡垒、议事堂、市场、铁匠铺、岗楼、射手塔楼、狮鹫塔楼、兵营
位: 0 5 7 10 14 16 30 31 32 33
可建 34 个:
1级魔法行会、2级魔法行会、3级魔法行会、4级魔法行会、酒馆、堡垒、大本营、城堡、内政厅、议会、国会、市场、资源仓库、铁匠铺、灯塔、狮鹫城堡、狮鹫城堡、马厩、兄弟会、战神像、岗楼、射手塔楼、狮鹫塔楼、兵营、寺院、训练场、云中城、高级岗楼、高级射手塔楼、高级狮鹫塔楼、高级兵营、高级寺院、高级训练场、高级云中城
位: 0 1 2 3 5 7 8 9 11 12 13 14 15 16 17 18 19 21 22 26 30 31 32 33 34 35 36 37 38 39 40 41 42 43
「可建」是按位图 B 原样读出来的,没有再做前置条件推理—— B 里没有的,就是这一刻建不了。
建筑名对照表在 data/城镇建筑表.json。
空槽不是废案:游戏先按功能划好位,各城镇按平衡设计取其中一部分;
空槽在「已建」位图里往往仍写成 1,读的时候按表里的 null 过滤。
不在对象数组里,散在格子表中,每条 26 字节,每座占 3 格:
+0x02 u16 class = 87
+0x06 u16 格子块编号
+0x08 owner
+0x0B 恒为 FF —— 用来筛掉误命中
定位方式是全文件搜 57 00 00 00,回退 2 字节当记录头,
用 +0x0B == 0xFF 筛掉误命中。
前面一个字节 = 船数,然后 N 条 × 28 字节:
+0x01 序号
+0x02 型号 sub(0/1/2)
+0x04 FF FF FF 00 船没有归属
+0x08 x(比 .h3m 小 1) 00 y 00
data/ 下四个 JSON,都是查表用的:
| 文件 | 内容 | 来源 / 许可 |
|---|---|---|
class对照表.json |
H3M 对象 class 枚举,232 个(0–231) | h3mtxt 的 ObjectClass.h,MIT |
兵种ID表.json |
兵种 ID ↔ 中文名,161 条 | h3mtxt 的 CreatureType.h(MIT)+ 社区数据库补中文名 |
对象类型表.json |
部分 class 的中文名 / def / 类别 / 状态 | 社区数据库整理 |
城镇建筑表.json |
48 位位图 → 建筑名(通用 17 / 特有 11 / 巢穴 14 × 9 城镇) | 游戏数据文本 BldgNeut.txt BldgSpec.txt Dwelling.txt 整理 |
许可说明:h3mtxt 是 MIT,本项目是 GPL-3.0。两者兼容(MIT 允许再许可到 GPL), 但 MIT 的版权声明必须保留——完整声明在 data/NOTICE.md。
已知的几个容易踩的编号:
17= 普通巢穴(招 1 种),20= 招 4 种的巢穴(石人工场 / 元素汇合点)18/19是未实现的巢穴编号,地图上放了游戏会启动失败- 随机巢穴是
216/217/218,开局就解析成17或20,存档里不会有 87= 船坞,42= 灯塔,53/220= 矿,33/219= 横 / 竖驻军,98= 城镇
存档里每类对象是一个数组:N 条 × 固定大小。但记录起点不一定等于你的直觉,
所以定位逻辑不是"扫 stride",而是:
- 从
.h3m拿到这类对象的所有坐标 - 每个坐标做 ±1 的邻域,在存档里搜三字节,收集所有出现位置
- 投票:这些位置
mod stride取众数。真记录的坐标位置 mod stride 都一样, 误命中会散到别的余数 - 选中的余数里,每个候选位置按 stride 排 N 条打分(坐标落在
.h3m邻域里几条) - 往前退到不再像记录为止
第 4 步不能省:同一余数下任意两个位置互差都是 stride 的倍数, 光看间距分不出真假,贪心连链会从最小的误命中开始把整条带偏。
没有内置测试框架——跑测试得先有存档和地图,这俩不能随仓库分发。手工三步:
- 拿一个你确定内容的存档(最好是刚开局、还没动过的)
python h3objs.py save.GM1 map.h3m(对象);python h3build.py save.GM1 map.h3m(城镇建筑)- 输出和游戏里看到的对照
实测通过的组合(SoD 4.0 中文版 + HD Mod 5.2):
| 存档 | 地图 | 读到的 |
|---|---|---|
big.GM1 |
Pandora's Box(144×144,有地下层) | 矿 99、灯塔 16、巢穴 102、城镇 28、船坞 8 |
cheng.GM1 |
城驻测试(72×72) | 城镇 8、驻军 12,守军兵种全对 |
chao.GM1 |
巢穴测试 | 巢穴 6,sub = 56 56 56 57 57 57 |
kuang.GM1 |
矿测试 | 矿 7,归属 7/7 |
tb0.GM1–tb6.GM1 |
单城(城堡,18,18) | 每次改建一份存档,差分出来的位就是刚建的那座:0 / 14 / 16 / 30 / 31 / 32 / 33 |
117.GM1 |
好好干(36×36) | 四座满城,建筑名全对;城镇类型 元素城 / 塔楼 / 要塞 / 据点 与游戏一致 |
如果结果明显不对,先查这两个:
- 存档和地图是不是配对的。拿错地图,坐标会全对不上
- 是不是随机地图。随机图没有原始
.h3m,静态坐标对不上
- 只读。不写存档。
- 需要对应的
.h3m。随机地图的.h3m不是原始文件,可能对不上。 - 巢穴记录里还有十几个字节没解(
+0x00sub、+0x0D坐标、+0x49owner 之外的),不影响主要字段。 - 只测过 SoD 4.0 中文版 + HD Mod。其他版本、其他语言可能布局一致但未验证。
- 坐标偏移是按模板走的(矿
-1、城镇-2…),不是全局常数。
GNU General Public License v3.0,见 LICENSE。
这个项目是逆向工程的结果,不含游戏本体任何资源。使用它需要你自己拥有正版游戏。