RNG 逐次种子日志开关与运行期确定性 Oracle

1. 结论

发行版 Torchlight2.exe 在静态 VA 0x361A870 留有一个一字节 RNG trace 开关。 默认值为 0,正文 .text 没有代码会写它或取得它的地址,因此正常游戏无法自行打开; 只能由调试器或运行期探针修改内存。

开关为 1 时,每个随机抽数入口都在真正推进 RNG 状态之前调用 Ogre::LogManager::logMessage,把当前种子写为:

text
Rand Between Seed VOLATILE: <seed>
Rand Integer Between Seed VOLATILE: <seed>
Rand Between Seed: <seed>
...

2026-09-07 的运行期正反对照已经把这件事从“分支形状推断”升级为实测事实:

  • 关闭窗口:0x677200 进入 159 次,日志分支进入 0 次;
  • 打开窗口:0x67720036 次入口 / 36 次日志分支
  • 同一打开窗口:0x6773F050 / 50
  • 同秒 Ogre.log 恰好新增 36 条 float volatile 与 50 条 int volatile 种子行;
  • 探针结束后开关恢复为原值 0

这给重制工程提供了现成的确定性 oracle:同一输入、同一初始状态下,可以逐抽比较原版和 Godot 实现的种子序列,从第一处分歧定位行为偏差。

2. 九个抽数入口

四个只负责设置种子/状态的入口不计入“抽数”。九个真正推进或消费 RNG 的入口如下。 每个函数都读取同一开关,并最终调用同一个 Ogre 日志导入槽。

抽数入口引擎日志名logMessage 调用点
0x676CA0Rand Between Seed - unique randomizer :调用方自带0x676D2A
0x676D90Rand Integer Between Seed - unique randomizer :调用方自带0x676E1B
0x676E70Rand Between Seed :synced0x676EFA
0x676F60Rand Integer Between Seed :synced0x676FEA
0x677040Rand Unsigned Integer Between Seed :volatile0x6770CA
0x677120Rand Unsigned Integer Between Seed :synced0x6771AA
0x677200Rand Between Seed VOLATILE:volatile0x67728A
0x6772F0Rand Integer Between Seed VOLATILE:volatile0x67737A
0x6773F0Rand Integer Between Seed VOLATILE:volatile0x67747A

九个调用点的机器码完全相同:

asm
FF 15 44 75 11 02    call dword ptr [0x2117544]

导入符号为:

cpp
Ogre::LogManager::logMessage(
    std::string const&,
    Ogre::LogMessageLevel,
    bool
);

每个入口的开关测试也直接引用相同地址:

asm
80 3D 70 A8 61 03 00    cmp byte ptr [0x361A870], 0

因此这不是通过全局 logger 钩子“猜到某些日志属于 RNG”;开关读取、具名字符串和 每个函数自己的 logMessage 调用点三者能够逐入口闭合。

3. 三类 RNG 状态

seedcarry特征
synced0x361A8A00x361A8A4共享/确定性用途
volatile0x361A8B00x361A8B4本地独立用途
caller-owned参数指针参数指针调用者持有独立状态

日志名与实际引用的状态全局是两条独立证据。尤其 0x677200 的 IDA 名称没有 volatile 后缀,但它的日志自称 VOLATILE,并且实际引用 0x361A8B0/0x361A8B4; 所以不能只凭旧 IDB 名称划分流。

另一个边界是 0x6770400x677120:二者日志文本相同,实际分别使用 volatile 与 synced 状态。只看落盘文字无法区分这两个入口,逐现场比对时必须同时记录调用地址。

4. 运行期验证方法

验证使用 ASLR 模块基址 0x00FB0000。静态 VA 转运行时地址的公式是:

text
runtime = module_base + (static_va - 0x400000)
flag    = 0x00FB0000 + 0x321A870 = 0x041CA870

探针同时挂住抽数入口和它自己的日志路径,再做两个短窗口:

  1. 强制开关为 0,统计 1 秒,证明抽数继续发生但日志路径不进入;
  2. 写入 1,统计 250 ms;
  3. 立即恢复原字节;
  4. 对照入口数、日志路径数和 Ogre.log 同秒新增行数。

4.1 关闭窗口

json
{
  "flag": 0,
  "window_ms": 1000,
  "0x677200": {"entries": 159, "log_branch_entries": 0}
}

这是关键阴性对照:零条日志不是“这段时间没有随机抽数”。入口明明运行了 159 次, 只是开关把日志分支全部截断。

4.2 打开窗口

json
{
  "flag_during_window": 1,
  "window_ms": 250,
  "restored_flag": 0,
  "0x677200": {"entries": 36, "log_branch_entries": 36},
  "0x6772F0": {"entries": 0, "log_branch_entries": 0},
  "0x6773F0": {"entries": 50, "log_branch_entries": 50}
}

Ogre.log18:09:32 同秒新增:

日志前缀行数对应入口计数
Rand Between Seed VOLATILE360x677200 = 36
Rand Integer Between Seed VOLATILE500x6773F0 = 50

两个活跃入口都满足:

text
函数入口次数 = 日志分支次数 = Ogre.log 新增种子行数

0x6772F0 在这个短窗口没有被调用,不能从零样本单独作动态结论;不过它与其余六个未活跃 入口的开关读取和日志调用机器码均已静态钉住。动态结果验证的是共享开关机制确实在发行版运行, 而不是声称 250 ms 内覆盖了所有游戏玩法。

5. 可重复探针

仓库提供:

text
原版游戏分析/runtime_probes/rng_log_switch.js

在游戏已到主菜单或已载入关卡后运行:

text
frida -p <Torchlight2 PID> -l 原版游戏分析/runtime_probes/rng_log_switch.js

结果事件名为 rng-log-switch-result。探针会保存原字节、执行关闭/打开两个窗口并自动恢复, 无需手动记 ASLR 地址。

CAUTION

不要在 Ogre 日志系统完成初始化前打开该字节。启动过早时日志分支可能在 LogManager 尚不可用的阶段被 RNG 调用。应等到主菜单出现,或关卡已经载入后再附加探针。

6. 作为重制确定性 Oracle 的用法

推荐每条记录至少保留:

text
序号、入口 VA、流别、抽取前 seed/carry、参数范围、返回值、调用上下文

然后以相同关卡 seed 重放:

text
原版 Ogre.log 的第 N 次抽数

Godot RNG trace 的第 N 次抽数

第一次不一致的位置比最终地图差异更有诊断力:它能区分“RNG 核心算法错”“多抽/少抽一次” 以及“调用顺序错”。不过 volatile 流本来就允许本地差异;做跨端确定性验收时,应优先对齐 synced 和 caller-owned 流,并把入口地址纳入日志键,避免同名日志混淆。

7. 证据文件

  • 字节与运行期登记:原版游戏分析/reversed_cpp/REGISTRY/rng_family.json
  • 静态/运行期强制门:tests/truth/test_rng_family.py
  • 可重复运行探针:原版游戏分析/runtime_probes/rng_log_switch.js