F2 默认值 O1 观测配方:A-4 缺口收口报告
对象:
Torchlight2.exe(静态基址0x400000)
证据等级:本报告完成的是“可执行 O1 配方”,不是 O1 实测结果
登记表:原版游戏分析/reversed_cpp/REGISTRY/o1_sampling_recipes.json
强制门:python tools/o1_recipe_gate.py
1. 结论
M3-1 判据 4 的“怎么观测”缺口已经收口:当前 35 个 O2_unsampled 字段全部拥有完整配方,门禁输出为:
证伪的配方 4 条,其中补了替代配方的 4 条
校验了 39 条配方,0 条不合格
O2_unsampled 配方缺口:0 条需要区分两个状态:
- 配方闭合:已经知道在何处停、读哪个返回值、用哪个缺键样本、怎样区分真假。A-4 已完成。
- 默认值获 O1 证实:必须真正启动游戏并采到返回值,之后才能把对应字段从
O2_unsampled升档。本报告没有虚报这一步已经完成。
旧 backlog 写的是“31 个字段没有观测方法”,那是较早快照。以本轮开始时的当前登记表重算:35 个 O2_unsampled 中,13 个已有配方、22 个没有配方;13 个里又有 ARMOR_MAGICAL、ARMOR_ALL 两条旧配方已被证伪且尚无替代,因此本轮实际欠债是 22 + 2 = 24 条。
2. 为什么统一观测 getter 返回值
“加载完后读对象字段”并不总能回答“缺键时 getter 的默认值是什么”。已经发现四类破坏判别力的情况:
- 哨兵值写入字段后立刻被派生值覆盖,例如
MANACOST、LEVEL_REQUIRED。 - 条件分支跳过字段写入,例如元素护甲值为
-1时不写槽位。 - 后续流程重新缩放并写回字段,例如精英怪的
XP、ARMOR。 0、空串等默认值与构造函数初值同形,最终字段无法说明 getter 当时返回了什么。
因此,本轮 24 条配方全部把观测点前移到 CDataGroup__getXxx 的返回瞬间:
缺少目标键的 DAT
→ CDataGroup__getXxx(key, hardcoded_default)
→ 在 call 的下一条指令处停住
→ 直接读取返回值返回约定如下:
| getter | 观测值 |
|---|---|
getInt | EAX |
getBool | AL |
getFloat | x87 ST(0) |
getString | EAX 指向的 std::wstring;复制对象或读取其长度/内容 |
这条路径不经过字段写回、取整、钳位、曲线求值或精英缩放,所以候选默认值不同,返回读数必然不同。
3. 精确观测表
表内地址是静态 VA;运行时存在 ASLR,应使用 Torchlight2 + (VA - 0x400000)。call bytes 同时作为版本/地址漂移保护。
3.1 怪物字段
| 字段 | call → return | call bytes | 返回值 | 预期 / 排除 | 缺键样本 |
|---|---|---|---|---|---|
MINHP | 0x55D0A4 → 0x55D0A9 | E8 F7 AE 11 00 | ST(0) | 1.0 / 0.0 | MONSTERS/QUESTUNITS/A3_GHOST_CART.DAT |
MAXHP | 0x55D0DB → 0x55D0E0 | E8 C0 AE 11 00 | ST(0) | 1.0 / 0.0 | 同上 |
MINDAMAGE | 0x55D8A9 → 0x55D8AE | E8 D2 A6 11 00 | EAX | 0 / 1 | 同上 |
MAXDAMAGE | 0x55D8ED → 0x55D8F2 | E8 8E A6 11 00 | EAX | 0 / 1 | 同上 |
HEALTH_GRAPH | 0x55D23B → 0x55D240 | E8 C0 AC 11 00 | EAX → wstring | length=0 / length>0 | MONSTERS/ARMADAX/ARMADAX.DAT |
ARMOR_GRAPH | 0x55D5D0 → 0x55D5D5 | E8 2B A9 11 00 | EAX → wstring | length=0 / length>0 | 同上 |
DAMAGE_GRAPH | 0x55D947 → 0x55D94C | E8 B4 A5 11 00 | EAX → wstring | length=0 / length>0 | 同上 |
CHAMPION_SCALE | 0x522CAA → 0x522CAF | E8 81 52 15 00 | AL | 1(true) / 0(false) | 同上 |
ARMOR_MAGICAL | 0x55D7DC → 0x55D7E1 | E8 9F A7 11 00 | EAX | -1 / 0 | MONSTERS/ARMADAX/ARMADAX.DAT |
ARMOR_FIRE | 0x55D7DC → 0x55D7E1 | 同上 | EAX | -1 / 0 | MONSTERS/ARMOR_RACK/ARMOR_RACK.DAT |
ARMOR_ICE | 0x55D7DC → 0x55D7E1 | 同上 | EAX | -1 / 0 | 同上 |
ARMOR_ELECTRIC | 0x55D7DC → 0x55D7E1 | 同上 | EAX | -1 / 0 | 同上 |
ARMOR_POISON | 0x55D7DC → 0x55D7E1 | 同上 | EAX | -1 / 0 | 同上 |
ARMOR_ALL | 0x55D7DC → 0x55D7E1 | 同上 | EAX | -1 / 0 | MONSTERS/ARMADAX/ARMADAX.DAT |
六个 ARMOR_* 共享一个调用点不是抄写错误。可执行文件只保存 ARMOR_ 前缀,完整键在循环里动态拼接;采样时必须读取/记录本次传入的运行时键,并只接受与目标字段同名的那次命中。ARMOR_MAGICAL 与 ARMOR_ALL 在 EXE 中没有完整宽字符串字面量,因此不能为它们伪造独立 literal xref。
3.2 物品字段
| 字段 | call → return | call bytes | 返回值 | 预期 / 排除 | 缺键样本 |
|---|---|---|---|---|---|
MINDAMAGE | 0x58F106 → 0x58F10B | E8 75 8E 0E 00 | EAX | 0 / 1 | ITEMS/BASE.DAT |
MAXDAMAGE | 0x58F14B → 0x58F150 | E8 30 8E 0E 00 | EAX | 0 / 1 | 同上 |
RARITY_DMG_MOD | 0x58F686 → 0x58F68B | E8 F5 88 0E 00 | EAX | 100 / 0 | 同上 |
SPEED_DMG_MOD | 0x58F6D5 → 0x58F6DA | E8 A6 88 0E 00 | EAX | 100 / 0 | ITEMS/2HAXE/BASE_2HAXE.DAT |
SPEED | 0x58F45C → 0x58F461 | E8 1F 8B 0E 00 | EAX | 100 / 0 | 同上 |
RARITY_AMR_MOD | 0x58F4AB → 0x58F4B0 | E8 D0 8A 0E 00 | EAX | 100 / 0 | 同上 |
SPECIAL_AMR_MOD | 0x58F4FA → 0x58F4FF | E8 81 8A 0E 00 | EAX | 100 / 0 | 同上 |
四个百分比修正值的 100 是乘法单位元。不要用最终伤害/护甲反推:100% 与“某段乘法没有执行”可能同形;必须直接读 getter 的 EAX。
3.3 技能字段
| 字段 | call → return | call bytes | 返回值 | 预期 / 排除 | 缺键样本 |
|---|---|---|---|---|---|
TARGET_TYPE | 0x6DC6F0 → 0x6DC6F5 | E8 0B B8 F9 FF | EAX → wstring | length=0 / length>0 | SKILLS/ALCHEMIST/EMBERBEAM/EMBERBEAM.DAT |
TARGET_UNITTYPE | 0x6DC88C → 0x6DC891 | E8 6F B6 F9 FF | EAX → wstring | length=0 / length>0 | 同上 |
REQUIREMENT_LEFT | 0x6DCFFA → 0x6DCFFF | E8 01 AF F9 FF | EAX → wstring | length=0 / length>0 | 同上 |
这三个默认值都是运行时构造的空 std::wstring,其存储位于 BSS,不能靠 EXE 文件里的静态内容证明为空。正确的 O1 观测是在 getter 返回点复制字符串对象或读取其运行期长度。
4. 安全执行步骤
以 MINHP 为例,返回点的 RVA 是 0x15D0A9:
bu Torchlight2+15D0A9
g命中后再单独查看 ST(0)。整数/布尔字段分别查看 EAX/AL。字符串建议由现有 runtime-mod 的 detour 包装器复制返回的 std::wstring;若使用调试器,也必须先让断点停住,再单独检查 EAX 所指对象。
安全约束:
- 调试器附加时使用
-pd,避免调试器退出连带终止游戏。 - 断点命令链只负责停住;不要把
dd、du等内存读取与自动gc塞进命令链。 - 每次观测先确认加载的是表中缺键样本,并按 BASEFILE 继承链复核目标键确实缺席。
- 元素护甲的共享点必须过滤动态键名,否则一次循环中的其他元素会污染结果。
- 采到值后保存:游戏版本/EXE 哈希、模块基址、静态 VA、运行时地址、样本路径、键是否经继承后缺席、原始返回值。
5. 门禁与可复现性
tools/o1_recipe_gate.py 现在会执行以下检查:
- 每个
O2_unsampled行必须有o1_recipe、observe、discriminates,缺一即失败。 - 目录型配方必须同时给出 call、return、返回载体、call bytes 和样本路径。
- call bytes 必须与发行 EXE 逐字节相同,且
return == call + 指令长度。 - 字面量键的 call 必须是该键实际的 getter 现场。
- 动态
ARMOR_*键必须显式声明过滤键名,并验证共享调用点机器码。 - 已证伪配方不再是
O2_unsampled的豁免符;四条证伪记录都必须附替代方案。
执行:
$env:PYTHONIOENCODING='utf-8'
python tools/o1_recipe_gate.py
python -m unittest tests.truth.test_o1_recipe_gate6. 对重制实现的约束
在真正完成 O1 采样前,35 个字段仍应保留 O2_unsampled 身份,不得因为“配方已经写好”而升格为已验证默认值。实现侧可以继续使用静态反编译所得默认值,但必须保留证据等级;一旦 O1 返回与预期不同,应优先修正 F2 默认值和相关语义测试,而不是把不一致解释成采样噪声。
本轮最重要的工程产物不是又抄了一张地址表,而是把“还有没有观测方法”变成了会失败的门:以后新增 O2_unsampled 字段却没有完整配方,tools/o1_recipe_gate.py 会直接返回非零。