Skip to content

About

Pure-Python parser for Heroes of Might and Magic III saves (.GM1): object ownership, coordinates, troops, tile state, and town building bitmaps. No game running, no memory reading.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Latest commit

 

History

10 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

h3-map-objects

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

⚠️ 记录的「真起点」比坐标位置早 0x0D 字节。 这一条坑了很久—— 把坐标位置当起点的话,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 字节当记录头。

城镇建筑(h3build.py)

城镇记录里还有两份 6 字节位图(48 位),记的是「建了什么」和「现在能建什么」:

位图 A = 已建造
位图 B = 当前可建造(不满足前置、或已升级掉的,B 里没有)

⚠️ A 不是 B 的子集。 升级链上把低级的建掉换成高级之后,A 里低级那位还在, 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",而是:

  1. 从 .h3m 拿到这类对象的所有坐标
  2. 每个坐标做 ±1 的邻域,在存档里搜三字节,收集所有出现位置
  3. 投票:这些位置 mod stride 取众数。真记录的坐标位置 mod stride 都一样, 误命中会散到别的余数
  4. 选中的余数里,每个候选位置按 stride 排 N 条打分(坐标落在 .h3m 邻域里几条)
  5. 往前退到不再像记录为止

第 4 步不能省:同一余数下任意两个位置互差都是 stride 的倍数, 光看间距分不出真假,贪心连链会从最小的误命中开始把整条带偏。

怎么验证它能读你的存档

没有内置测试框架——跑测试得先有存档和地图,这俩不能随仓库分发。手工三步:

  1. 拿一个你确定内容的存档(最好是刚开局、还没动过的)
  2. python h3objs.py save.GM1 map.h3m(对象);python h3build.py save.GM1 map.h3m(城镇建筑)
  3. 输出和游戏里看到的对照

实测通过的组合(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 不是原始文件,可能对不上。
  • 巢穴记录里还有十几个字节没解(+0x00 sub、+0x0D 坐标、+0x49 owner 之外的),不影响主要字段。
  • 只测过 SoD 4.0 中文版 + HD Mod。其他版本、其他语言可能布局一致但未验证。
  • 坐标偏移是按模板走的(矿 -1、城镇 -2…),不是全局常数。

许可

GNU General Public License v3.0,见 LICENSE。

这个项目是逆向工程的结果,不含游戏本体任何资源。使用它需要你自己拥有正版游戏。

About

Pure-Python parser for Heroes of Might and Magic III saves (.GM1): object ownership, coordinates, troops, tile state, and town building bitmaps. No game running, no memory reading.

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages