RNG 逐次种子日志开关与运行期确定性 Oracle
1. 结论
发行版 Torchlight2.exe 在静态 VA 0x361A870 留有一个一字节 RNG trace 开关。 默认值为 0,正文 .text 没有代码会写它或取得它的地址,因此正常游戏无法自行打开; 只能由调试器或运行期探针修改内存。
开关为 1 时,每个随机抽数入口都在真正推进 RNG 状态之前调用 Ogre::LogManager::logMessage,把当前种子写为:
Rand Between Seed VOLATILE: <seed>
Rand Integer Between Seed VOLATILE: <seed>
Rand Between Seed: <seed>
...2026-09-07 的运行期正反对照已经把这件事从“分支形状推断”升级为实测事实:
- 关闭窗口:
0x677200进入 159 次,日志分支进入 0 次; - 打开窗口:
0x677200为36 次入口 / 36 次日志分支; - 同一打开窗口:
0x6773F0为50 / 50; - 同秒
Ogre.log恰好新增 36 条 float volatile 与 50 条 int volatile 种子行; - 探针结束后开关恢复为原值
0。
这给重制工程提供了现成的确定性 oracle:同一输入、同一初始状态下,可以逐抽比较原版和 Godot 实现的种子序列,从第一处分歧定位行为偏差。
2. 九个抽数入口
四个只负责设置种子/状态的入口不计入“抽数”。九个真正推进或消费 RNG 的入口如下。 每个函数都读取同一开关,并最终调用同一个 Ogre 日志导入槽。
| 抽数入口 | 引擎日志名 | 流 | logMessage 调用点 |
|---|---|---|---|
0x676CA0 | Rand Between Seed - unique randomizer : | 调用方自带 | 0x676D2A |
0x676D90 | Rand Integer Between Seed - unique randomizer : | 调用方自带 | 0x676E1B |
0x676E70 | Rand Between Seed : | synced | 0x676EFA |
0x676F60 | Rand Integer Between Seed : | synced | 0x676FEA |
0x677040 | Rand Unsigned Integer Between Seed : | volatile | 0x6770CA |
0x677120 | Rand Unsigned Integer Between Seed : | synced | 0x6771AA |
0x677200 | Rand Between Seed VOLATILE: | volatile | 0x67728A |
0x6772F0 | Rand Integer Between Seed VOLATILE: | volatile | 0x67737A |
0x6773F0 | Rand Integer Between Seed VOLATILE: | volatile | 0x67747A |
九个调用点的机器码完全相同:
FF 15 44 75 11 02 call dword ptr [0x2117544]导入符号为:
Ogre::LogManager::logMessage(
std::string const&,
Ogre::LogMessageLevel,
bool
);每个入口的开关测试也直接引用相同地址:
80 3D 70 A8 61 03 00 cmp byte ptr [0x361A870], 0因此这不是通过全局 logger 钩子“猜到某些日志属于 RNG”;开关读取、具名字符串和 每个函数自己的 logMessage 调用点三者能够逐入口闭合。
3. 三类 RNG 状态
| 流 | seed | carry | 特征 |
|---|---|---|---|
| synced | 0x361A8A0 | 0x361A8A4 | 共享/确定性用途 |
| volatile | 0x361A8B0 | 0x361A8B4 | 本地独立用途 |
| caller-owned | 参数指针 | 参数指针 | 调用者持有独立状态 |
日志名与实际引用的状态全局是两条独立证据。尤其 0x677200 的 IDA 名称没有 volatile 后缀,但它的日志自称 VOLATILE,并且实际引用 0x361A8B0/0x361A8B4; 所以不能只凭旧 IDB 名称划分流。
另一个边界是 0x677040 与 0x677120:二者日志文本相同,实际分别使用 volatile 与 synced 状态。只看落盘文字无法区分这两个入口,逐现场比对时必须同时记录调用地址。
4. 运行期验证方法
验证使用 ASLR 模块基址 0x00FB0000。静态 VA 转运行时地址的公式是:
runtime = module_base + (static_va - 0x400000)
flag = 0x00FB0000 + 0x321A870 = 0x041CA870探针同时挂住抽数入口和它自己的日志路径,再做两个短窗口:
- 强制开关为
0,统计 1 秒,证明抽数继续发生但日志路径不进入; - 写入
1,统计 250 ms; - 立即恢复原字节;
- 对照入口数、日志路径数和
Ogre.log同秒新增行数。
4.1 关闭窗口
{
"flag": 0,
"window_ms": 1000,
"0x677200": {"entries": 159, "log_branch_entries": 0}
}这是关键阴性对照:零条日志不是“这段时间没有随机抽数”。入口明明运行了 159 次, 只是开关把日志分支全部截断。
4.2 打开窗口
{
"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.log 在 18:09:32 同秒新增:
| 日志前缀 | 行数 | 对应入口计数 |
|---|---|---|
Rand Between Seed VOLATILE | 36 | 0x677200 = 36 |
Rand Integer Between Seed VOLATILE | 50 | 0x6773F0 = 50 |
两个活跃入口都满足:
函数入口次数 = 日志分支次数 = Ogre.log 新增种子行数0x6772F0 在这个短窗口没有被调用,不能从零样本单独作动态结论;不过它与其余六个未活跃 入口的开关读取和日志调用机器码均已静态钉住。动态结果验证的是共享开关机制确实在发行版运行, 而不是声称 250 ms 内覆盖了所有游戏玩法。
5. 可重复探针
仓库提供:
原版游戏分析/runtime_probes/rng_log_switch.js在游戏已到主菜单或已载入关卡后运行:
frida -p <Torchlight2 PID> -l 原版游戏分析/runtime_probes/rng_log_switch.js结果事件名为 rng-log-switch-result。探针会保存原字节、执行关闭/打开两个窗口并自动恢复, 无需手动记 ASLR 地址。
CAUTION
不要在 Ogre 日志系统完成初始化前打开该字节。启动过早时日志分支可能在 LogManager 尚不可用的阶段被 RNG 调用。应等到主菜单出现,或关卡已经载入后再附加探针。
6. 作为重制确定性 Oracle 的用法
推荐每条记录至少保留:
序号、入口 VA、流别、抽取前 seed/carry、参数范围、返回值、调用上下文然后以相同关卡 seed 重放:
原版 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