【免费下载链接】d2r-mobile
Open-source iOS / macOS compatibility layer for Diablo II: Resurrected with the Reign of the Warlock expansion. Runs your own Switch copy natively on iPhone, iPad and Mac — full touch controls, 60 fps, new endgame modes. Bring your own game files. 开源的《暗黑破坏神 II:狱火重生》术士君临 iOS / macOS 兼容层
导读
d2r-mobile 用改写器把 Switch 版《暗黑破坏神 II:狱火重生》3.3.94012 程序搬到 iOS / macOS 上直接执行,因此"改写过后的宿主程序与原版游戏是否跑得一模一样"是这个项目的立身之本。dm/verify/tick_state_format.zh-CN.md定义了其中最关键的一环:逐拍游戏状态哈希格式——游戏服务端逻辑按"拍"推进,每一拍开始前把全场单位的关键字段压缩成 6 格 FNV-1a 64 哈希、记成一行 JSON;真值侧(打了补丁的 Ryujinx 模拟器跑原版)与宿主侧(dm/host的dmhost)分别产出ticks.jsonl,逐拍对齐即可逐字节判定逻辑是否一致。读完本文,你将掌握该格式的采集点与基址推导、五类单位表的内存布局、哈希折叠规则,以及真值录制、宿主侧DM_TICKS系列环境变量、确定性对拍前置条件与仓库内验证闸门的完整用法。
逐拍哈希在验证体系中的定位
d2r-mobile 的验证目录dm/verify回答两个问题:改写后的程序行为是否与原版一致、某个改动是否破坏了已工作的功能。dm/verify/README.zh-CN.md将本格式与命令包序列格式、UI 消息流格式并列,作为三种记录文件格式之一:
- 真值侧:
dm/verify/oracle/swtick.py经模拟器 GDB 接口读,录一局时写成<局目录>/ticks.jsonl; - 宿主侧:环境变量
DM_TICKS=<路径>打开,DM_TICKS_N=<拍数>记满即正常退出,DM_TICKS_FULL=1另出逐单位明细; - 对拍:两份
ticks.jsonl按拍号对齐,报每一格首个不同的拍号;共同拍数少于 10 不下结论。
必须强调的是,两边跑的是同一份 Switch 版 3.3.94012 程序——模拟器跑的main带着改写器登记的剪口(标为"仅宿主"的除外),所以真值与宿主跑的是带同样定点修改的程序。真值侧的录制用模拟器驱动脚本完成,这些脚本不随仓库提供,依赖真值的检查(如rulegate.sh、accept.sh的第 ② 项)在仓库外跑不了;仓库内保留的是读取这一侧的最小部件oracle/swgdb.py、oracle/swtick.py与格式说明。
采集点与基址 G
哈希的采样时机和基址是整套格式的地基,两者都钉死在一个函数入口上:
- 采集点:服务端拍入口
main + 0x5d3bc0,在该入口第一条指令执行之前取,此时拍号尚未自增。 - 基址 G= 入口处的
x0。G 是游戏对象,嵌在游戏哈希表节点里:G = 节点 + 0x10(节点 = 槽数组main + 0x2d665d8的 ID 查哈希表*(main + 0x2d685e0)所得)。
本文所有偏移都以 G 为基址,这与游戏内部以节点为基址的写法存在一个 +0x10 的换算:例如建局函数0x5d0bd0以节点为基址写入+0x1b8,对应这里的G + 0x1a8。拍号 =u32 [G+0x170](入口处读,等于本拍开始前的值)。
两条采集纪律:
- 同一拍号只取一次:模拟器会重复报告同一次断点命中,必须按拍号去重;
- 拍号必须逐一连续:断档报红。
这两条纪律在真值侧与宿主侧都有实现。swtick.py主循环里if tick == last: dup += 1; g.step_over(bp, stop); continue去重、if last is not None and tick != last + 1: gaps.append(...)记断档、结束时if gaps: sys.exit('拍号不连续…')以非零码退出;宿主侧dm/host/diag.c的ticks_hash(diag.c)同样if (tick == g_tklast) return,并在断档时输出【红】DM_TICKS 拍号断档。宿主还处理了一个合法特例:同一进程先后进两次局(退回选角再进、连建两个角色),新一局的拍号从 0 / 1 重数,这不叫断档,只有局内跳号才报红。
每拍一行 JSON
真值与宿主的输出都遵循同一行格式,一局一个 JSONL 文件:
{"tick":39,"n":[4,12,24,0,21],"h":["…16 位小写十六进制…" ×6],"bad":0}各字段含义:
| 字段 | 含义 | 是否参与对拍 |
|---|---|---|
tick | 本拍拍号(入口处读,拍尚未自增) | 作为对齐键 |
n | 5 类单位的个数:0 玩家 / 1 怪 / 2 物件 / 3 飞弹 / 4 物品 | 由h隐含校验 |
h | 6 格哈希(5 类单位 + 全局种子),每格 16 位小写十六进制 | 是 |
bad | 读不到的单位指针数 + 自报类型与所在表不符的单位数 | 是(非 0 即异常信号) |
game、dt、reads | 游戏对象地址、相对耗时、读取次数 | 否,只供人看 |
宿主侧在ticks_hash中输出该行时字段与swtick.py的json.dumps同序同分隔(diag.c),保证两份文件可直接按列对齐;宿主额外多输出一个frames字段(dm_nvn_frame_count()),同样只供人看、不参与对拍。
单位表与字段布局
五类单位各占一张哈希表,偏移相对 G;表中的每个桶都是 u64 指针,桶内是单链表。表地址见下表:
| 类 t | 表 | 说明 |
|---|---|---|
| 0 玩家 | G+0x2230 | 每表 128 个桶(u64 指针),桶内单链next = [u + 0x158],每链最多 4096 |
| 1 怪 | G+0x2630 | |
| 2 物件 | G+0x2a30 | |
| 3 飞弹 | G+0x3230 | 注意 3、4 的表序与地址不单调 |
| 4 物品 | G+0x2e30 |
真值侧实现印证了这一点:swtick.py的TBASE = (0x2230, 0x2630, 0x2a30, 0x3230, 0x2e30),一次读下0x2230..0x3630连续区(5 类 × 128 桶 × 8 字节),再逐桶沿next = +0x158走链(NEXT, MAXS = 0x158, 96);宿主侧ticks_hash的TB[5]常量与之完全一致(diag.c)。
每个单位u的字段读法(偏移相对单位结构起点):
| 字段 | 读法 |
|---|---|
| type, cls, id, mode | u32 [u+0x0], [u+0x4], [u+0x8], [u+0xC] |
| act | u8 [u+0x18] |
| rlo, rhi(单位种子) | u32 [u+0x28], [u+0x2C] |
| 坐标(仅 t = 0/1/3) | path = u64 [u+0x38];u16 xf=[path+0], x=[path+2], yf=[path+4], y=[path+6];path 为 0 或读不到时四项都取 0;其余类四项恒 0 |
| statlist(t = 0/1/4) | sl = u64 [u+0x88];arr = u64 [sl+0x30],cnt = u32 [sl+0x38];取 min(cnt, 96) 项,每项 8 字节原样(u32 a = 层<<16 | statId,u32 b = 值) |
| 物品附加(仅 t = 4) | d = u64 [u+0x10];iq=[d+0x0],is_lo=[d+0x4],is_hi=[d+0x8],iflags=[d+0x18],iinit=[d+0x10](u32);d 为 0 时五项取 0 |
其中swtick.py的文件头还记录了一条口径来源:这些偏移出自 PC 版 M0 插件 v7,Switch 94012 的静态证据是拍入口结构逐项相同、五个单位表偏移各出现约 260 处立即数、LCG 附近+0x28出现最频繁,另有--check活体复核。
哈希折叠规则
哈希采用 FNV-1a 64:偏移基0xcbf29ce484222325,乘数0x100000001b3,逐字节折叠,所有数值一律小端 u32。两侧实现逐字节可比——宿主侧tk_fnv(diag.c)与swtick.py的fnv是同一段逻辑。
折叠分四步:
- 每类 t 的哈希从偏移基起;全部单位按 (type, id) 升序后,依次并入其 type 对应的格(type ≥ 5 的不计)。排序必须稳定:
swtick.py用 Python 的稳定排序,宿主侧tk_cmp以 (type, id) 为主键、采集次序为稳定化次序(diag.c),保证同键单位两侧次序一致。 - 每个单位依次并入:
- 9 个 u32:type, cls, id, mode, act, x, y, xf, yf;
- 2 个 u32:rlo, rhi;
- 仅 t = 4:5 个 u32:iq, is_lo, is_hi, iflags, iinit;
- 仅 t = 0/1/4:u32 项数,再逐项 8 字节原样。
- 第 6 格(全局种子)= 偏移基并入 8 字节
[G+0x1a8](u32 lo + u32 hi 原样)。注意这里就是建局种子落地的位置:建局函数0x5d0bd0以节点为基址写入+0x1b8,换算成 G 基址恰好是G + 0x1a8。 - 输出为 16 位小写十六进制字符串(
%016x)。
swtick.py中h[5] = fnv(h[5], gs)处理全局种子、struct.pack('<9I', ...)/'<II'/'<5I'分别折叠 9 个 u32、2 个种子 u32 与物品附加 5 个 u32,与ticks_hash的f9/f2/it/st折叠逐字节对应(diag.c)。
确定性:逐拍对拍的前提
两边要逐拍相同,输入必须相同。文档明确四个维度,全部有环境变量或脚本支撑:
- 建局种子:建局函数
0x5d0bd0读u32 [main + 0x20a0074];该值 ≠0xffffffff时种子 = {该值, 666},否则取系统时钟。两边须在建局前写同一个值:真值侧是驱动脚本的--game-seed N,宿主DM_GAME_SEED=N。宿主实现见 main.c:game_seed_maybe直接写 main 数据段、不剪口,放在各模块 init_array 之后、nnMain 之前以免被全局构造函数覆盖;写前断言原值等于0xffffffff,构建号不符即报红中止。 - 建局时刻:建局时读一次系统时间。真值侧是驱动脚本的
--fix-time <秒>,宿主DM_FIXED_TIME=<秒>。宿主侧还有一个细节:只有确定性对拍(DM_FIXED_TIME)会把时区钉成 UTC(setenv("TZ", "UTC", 1),main.c),因为同一份回放在东八区本机与东九区的 mini 上必须换出同样的日历时刻。 - 存档:两边各用同一份标准存档(
$DM_ROOT/data/switch/save_ref_warlock)的新副本开局;存档内容指纹用 dm/verify/savesha.sh 算,跑前记下。savesha.sh对用户存档目录按MANIFEST.sha256的同一格式(sha 0000000000000001/<相对路径>逐行、按路径排序)出清单并打印清单本身的 sha256,.lock不计,可直接与data/switch/<存档>/MANIFEST.sha256比对。 - 输入:客户端发给服务端的输入一律以真值录下的命令包序列为准(格式见 command_packet_format.zh-CN.md)。命令包与系统类消息(
0x6d心跳、0x6f进度等)都要按原拍号注入原字节,宿主自己产生的同类消息丢弃(进局时的0x67加入照常放行)。系统类消息到达的拍号取决于客户端快慢,其中0x6f决定服务端何时插入同步,属于时序相关输入,不注入的话逐拍状态对不上。
输入注入在宿主侧由拍入口观测门统一编排:tick_gate(diag.c)"先注入本拍命令包,再取状态哈希"——if (g_rp) c2s_inject(regs[0])在前、if (g_tkfp) ticks_hash(regs)在后,保证哈希里包含本拍命令包的效果,与真值口径一致。
真值侧:swtick.py 与 GDB 客户端
swtick.py是仓库内唯一随附的真值读取端,用法:swtick.py [--port 55571] [--ticks N] [--out 文件] [--full],默认端口 55571、默认 25 拍、默认输出到 stdout、--full时除每拍一行外再按排序后次序逐单位输出{"t": 拍号, "u": {…}}明细(宿主侧DM_TICKS_FULL=1输出同构明细,diag.c)。
它的工作循环体现了与模拟器打交道的全部关键细节:
- 通过模块表找
Game模块,基址 +TICK_ENTRY(0x5d3bc0)下软件断点; - 每次停止后先核对 PC 是否为拍入口,非真命中则继续等待(连续 50 次停在非入口即退出);
- 同拍号去重、断档记录、
step_over越过入口断点(否则继续后又停在同拍); - 结束时向 stderr 打印
{"重复报告": dup, "断档": gaps},有断档则非零退出。
其底层是dm/verify/oracle/swgdb.py——Ryujinx GDB stub 的最小客户端(读/写内存、读寄存器、软件断点、继续/冻结),协议细节来自实测:stub 一次只服务一个客户端;0x03冻结全部客体线程并回停止包;c先回 OK,命中断点时再异步来停止包。wait_stop自动切线程、_stash按线程去重、step_over本线程走过断点这三条规矩(在 command_packet_format.zh-CN.md 中有更详细的说明)都实现在这个客户端里。
宿主侧:DM_TICKS 环境变量族
宿主侧全部由环境变量驱动,入口在ticks_install(diag.c):
| 环境变量 | 作用 |
|---|---|
DM_TICKS=<路径> | 打开逐拍状态哈希,输出到指定 JSONL 文件 |
DM_TICKS_N=<拍数> | 记满指定拍数即正常退出(_exit(0)),用于控制一局录制长度 |
DM_TICKS_FULL=1 | 额外输出逐单位明细(对应swtick.py --full) |
安装时机与产物约束:ticks_install在宿主装载阶段(main.c的host_preload中probe_install之后)被调用;若产物没有拍入口观测门OBS_TICK(需 r8 起的产物),带DM_TICKS/DM_STATE/DM_C2S_REPLAY/DM_C2S_LOG启动会直接die。拍入口观测门本身也被产品缺省挂载:即使不带任何测试变量,UIKit 底栏数字与难度条落地也在拍入口完成(tick_gate_product),这部分与哈希录制无关但说明观测门是多用途的。
录制期间ticks_hash还做了两件工程性保障:一是静态缓冲与单线程约束——拍入口只在一条客体线程上跑,tick_gate用原子比较交换记录持有线程,换线程即abort(),防止 static 缓冲被竞争写乱;二是记录reads计数并从读拍号算起,与swtick.tick_line首读同口径,保证两侧"读取次数"可比。
仓库内的消费方:逻辑闸门与验收
逐拍状态哈希在仓库内被两道闸门直接消费:
rulegate.sh [拍数=400] [产物=rules]:宿主用rules产物回放一局真值输入,逐拍 6 格状态与真值的ticks.jsonl对拍(对拍脚本selfcmp.py属于模拟器驱动脚本那一组,不随仓库提供);真值局目录由DM_RULE_TRUTH指定,须含meta.json、c2s.jsonl、ticks.jsonl;末行RULEGATE OK|RED。logicself.sh [产物=touch] [拍数=230]:产物与它自己的基线比——回放同一局真值输入,把逐拍的单位数与哈希同仓库内的 dm/verify/logicself/touch.json 比,用来发现"改了不该影响逻辑的东西、逻辑却变了";LOGICSELF_REBASE=1重录基线,且连跑两遍、两遍一致才写入。
验收链accept.sh的第 ② 项(dm/tools/host_savecmp.sh)则以更严格的口径综合使用本格式:宿主回放一局真值的输入、保存并退出,要求6 格状态逐拍一致 + 存档逐字节一致 + 阴性对照成立(真值存档与起始存档必须不同);第 ⑤ 项再叠加DM_TEST_LAG=400(在拍 400 入口人工睡一下制造"补拍积压"),验证冻结/补拍改动后仍落在预期拍上。真值局目录本身由meta.json(种子、时刻、起始存档指纹等)、ticks.jsonl、c2s.jsonl、uimsg.jsonl、events.jsonl、save_after/、shots/组成,种子与时刻即录制时通过--game-seed、--fix-time定下的那两个值。
从格式到判据:为什么六格哈希够用
理解这套格式的关键是它的设计目标:不是精确重现任何单个单位,而是给每一拍打一个紧凑、确定、两侧同算法的"状态指纹"。六格哈希把五类单位数量与全局种子放进同一行,任何单位增删、坐标漂移、属性变化都会反映到对应格的哈希;bad计数又把内存读取失败与类型错位这类"格式本身失效"的情形显式暴露出来,避免把读错的指针静默当成正常状态。配合拍号连续性与确定性输入(种子、时刻、存档、命令包)四重约束,逐拍相等即可作为"游戏逻辑走得一模一样"的高置信判据——这正是整个 d2r-mobile 验证体系以ticks.jsonl为地基的原因。
参考实现索引
- 格式规范:dm/verify/tick_state_format.zh-CN.md(本文主题文档,英文版 tick_state_format.md)
- 真值侧读取端:dm/verify/oracle/swtick.py、GDB 客户端 dm/verify/oracle/swgdb.py
- 宿主侧实现:dm/host/diag.c(
ticks_install、tick_gate、ticks_hash、tk_fnv/tk_cmp)、dm/host/main.c(game_seed_maybe、DM_FIXED_TIME时区钉定) - 输入侧格式:dm/verify/command_packet_format.zh-CN.md
- 存档指纹:dm/verify/savesha.sh
- 消费方与运行前提:dm/verify/README.zh-CN.md、基线文件 dm/verify/logicself/touch.json
注意运行边界:真值依赖的模拟器驱动脚本与对拍脚本selfcmp.py不随仓库提供,依赖真值的闸门只能在与仓库配套的工作区($DM_ROOT,默认~/dm-work,存放游戏文件、构建产物与真值数据)中运行;仅凭仓库本身可完整运行的是本格式的宿主侧采集(DM_TICKS)与格式层面的自我一致性检查。
【免费下载链接】d2r-mobile
Open-source iOS / macOS compatibility layer for Diablo II: Resurrected with the Reign of the Warlock expansion. Runs your own Switch copy natively on iPhone, iPad and Mac — full touch controls, 60 fps, new endgame modes. Bring your own game files. 开源的《暗黑破坏神 II:狱火重生》术士君临 iOS / macOS 兼容层
相关推荐
AI-For-Beginners 开发者环境搭建与协作指南:基于 AGENTS.md 的完整上手手册
AI For Beginners 开发者环境搭建与协作指南:基于 AGENTS.md 的完整上手手册 本指南以 AI For Beginners 仓库根目录的
wp-calypso ExPlat SDK 中的 GrowthBook 派生代码解析:FNV-1a 哈希、分桶与条件求值
wp calypso ExPlat SDK 中的 GrowthBook 派生代码解析:FNV 1a 哈希、分桶与条件求值 导读 本文聚焦 WordPress.c
前端CMS掌握Android Camera2Basic状态机:5个核心状态轻松理解拍照全流程
掌握Android Camera2Basic状态机:5个核心状态轻松理解拍照全流程 Android Camera2Basic是Google提供的相机基础示例项目
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考