吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 181|回复: 0
上一主题 下一主题
收起左侧

[分享] 从 919 个伪 C 函数里挖出 DOOM II,然后在浏览器里跑起来

[复制链接]
跳转到指定楼层
楼主
top777 发表于 2026-10-15 21:45 回帖奖励

一句话结果:一个纯 HTML + 原生 JS、零依赖的 DOOM II 引擎,读你自己手上的 DOOM2.WAD,
能跑 MAP01–MAP32,怪物有从二进制里挖出来的真状态机(帧、tics、受伤、死亡、尸体、复活、追踪弹、
火墙、boss 死亡过关),音乐是 WAD 里的 MUS 事件流经自写 OPL2 核心渲染,音效是 WAD 里的真采样。

这篇写的是过程,尤其是那些踩过的坑——我认为坑比结论更值得分享。


一、起因:工具在目标面前碰壁

我原本在做 REA(一个本地逆向工具)的适配工作,顺手把 DOOM2.EXE 丢给它:

$ node rea.mjs analyze DOOM2.EXE --json
{"code":"provider_unavailable",
 "rejections":[{"provider_id":"ghidra","code":"target_format_unsupported",
   "reason":"Windows Ghidra P0 accepts PE targets only."},
  {"provider_id":"hopper","code":"unsupported_host"},
  {"provider_id":"ida","code":"not_configured"}]}

DOOM2.EXE 是 MZ + DOS/4GW LE(16 位实模式加载器 + 32 位保护模式镜像),不是 PE。
Windows 上 REA 的 Ghidra 通道只接受 PE,这是它的声明边界,不是故障。

于是换路:直接用本机的 Ghidra 12.1.4 加开源扩展 ghidra-lx-loader。结果又撞墙:

java.lang.NoSuchMethodError:
  ghidra.program.model.data.DataTypeComponentImpl.setFieldName(java.lang.String)

上游只有针对 Ghidra 12.0.1 的构建,12.1 改了 API。于是我下载 master 源码、
用 javac 对着本机 12.1.4 的 157 个 jar 重编译(27 个源文件 → 33 个 class),替换 jar,
再补上 master 新增的 Watcom 语言定义(x86-watcom.ldefs + watcom.cspec)。

于是:

INFO  Using Loader: Linear Executable (LE-Style DOS)
INFO  Using Language/Compiler: watcom:LE:32:default:watcom
INFO  REPORT: Analysis succeeded        → 920 个函数

小坑先记一个:javac 的 @argfile 里反斜杠是转义符。我把源码路径转义了、漏了 -cp 那一行,
表现是"所有 ghidra 包都不存在"——classpath 被静默吃掉了。另外 Ghidra 脚本文件不能带 BOM,
带 BOM 会报 illegal character: '\ufeff'。


二、伪 C 只是副产品,真正值钱的是"表"

拿到 1MB 伪 C 之后,我先做了三件让代码可读的事:

  1. 全部 920 个函数的调用约定从 unknown 批量改成 Watcom 的 __watcall(extraout_ECX/EDX 从 2494 处降到 1824 处);
  2. 把 "被 134 个调用点调用的中止函数" 认成 I_Error 并标 noreturn;
  3. 按消息文本/特征字符串做了 36 个有证据支撑的命名(D_DoomMain、R_InitTextures、
    W_GetNumForName、P_StartButton…)。

但真正让复刻成为可能的是把表挖出来。因为这些表决定了游戏行为:

表 规模 用途
states[] 967 × 28 字节 每个动画状态的 sprite/帧/tics/下一状态/动作
mobjinfo[] 138 × 92 字节 每种怪物的血量/速度/半径/痛感/反应/音效
sprnames[] 138 个指针 状态里的 sprite 索引 → 名字
S_sfx[] 112 个名字 音效索引 → lump 名
GENMIDI.OP2 175 个乐器 每个乐器的 FM 参数

三、两个关键技巧

技巧一:用指针数组给地址映射"自校准"

LE 头跟规范对不上(有 4 字节错位,e32_data_pages_offset 读出 0),
按"页面在文件里顺序排列"去推算 _entry(0x50b48) 得到的字节像代码但不是入口——
说明 object page table 不是顺序映射。硬解头文件这条路我放弃了。

换办法:sprnames[] 是一串指针,而它指向的字符串在内存里是连续的。
我先在文件里找到那 138 个 4 字符 sprite 名字的字符串块,再用
"第一个指针值 − 字符串块的地址"得到一个常量偏移:

文件偏移 = 运行时地址 + 0x88014        ← 用 138 个指针逐条验证:138/138 命中

有了这个偏移,所有指针都能解析了(音效名表、乐器表、状态表里的 sprite 名)。

技巧二:用"内容特征"而不是"地址"找表

不知道表在哪,就找它的结构特征:

  • states[] 的特征是"约千条 28 字节记录里,action 槽只有几十个不同代码指针、每个重复很多次"。
    一扫就命中 0x9DA3C,而且长度正好 967(等于 DOOM II 的 NUMSTATES),
    第 0 条是 S_NULL、第 2 条是 S_PUNCH。
  • mobjinfo[] 在 states[] 之后(0xA4400),92 字节步长,138 条里 137 条自洽。
  • 音效名字符串在 8 字节槽里,但 bfg 只有 4 字节会让 8 字节栅格从 sawup 开始整体错位——
    改成 4 字节对齐立刻得到 DOOM 的原始顺序。验证方式是拿 MT_POSSESSED 的四个音效字段反查:
seesound(36) → posit1 ✅   attacksound(1) → pistol ✅
painsound(27) → popain ✅  deathsound(59) → podth1 ✅     4/4 命中

教训:用"已知答案"去反证提取结果,比"看起来合理"可靠得多。我上面每一次错位,
单看数据都是"合理"的(都是小写单词),只有交叉验证能发现。

技巧三:把查不清的未知变成无关紧要

GENMIDI.OP2 在文件里的字段偏移我试了五条路都没定下来(值域校准不收敛、加载器只是转发给 DMX、
#OPL_II# 头串在二进制里根本不存在、36 步长扫描命中的是 malloc、nibble 拆分扫描命中字符串拷贝)。

最后的解法不是继续猜,而是换接口:DOOM 公开源码的 genmidi.h 给了字段的位布局,
我按它把乐器字节直接打包成 OPL2 寄存器写进自写的 YM3812 核心:

0x20 = tremolo<<7 | freq      0x40 = scale<<6 | level
0x60 = attack<<4 | decay      0x80 = sustain<<4 | release      0xE0 = waveform

于是"文件里第几字节是什么"这个问题消失了——因为它对结果不再有影响。


四、引擎侧:让真表驱动行为

有了表,怪物就不是"我写的 AI",而是状态机的执行器:

  • 每个状态给出 sprite + 帧字母 + 存活 tics + 下一状态 + 动作函数指针;
  • 我按使用频次/位置把 74 个动作指针命名(A_Chase 唯一且被 16 只怪共享、A_FaceTarget 被 17 只共享…),
    再在 JS 里实现约 26 类动作;
  • 受伤走真 pain 链、死亡走真 death 链,尸体停在 tics=-1 的终态永久保留——
    这一点是"像不像 DOOM"的关键之一,DOOM 的战场会留下尸体。

实测一条链(POSS 僵尸):

spawn 174 / see 176(A_Chase) / pain 187 / death 189
死亡链: POSSH t5 → POSSI t5(A_Scream) → POSSJ t5 → POSSK t5 → POSSL t-1
实机:   被打死的 imp 走 TROOJ(t8) → TROOL(t6) → TROOM(t-1) → 停尸

五、踩坑清单(我觉得这段最有价值)

  1. GUI 全流程卡死(这是另一个项目里的启动器,同一个思路照进这里):
    同步读子进程管道会堵死 UI 线程;而且"先读完 stdout 再读 stderr"会永久死锁——
    子进程把进度写到 stderr 塞满管道后就阻塞,stdout 永不 EOF,父进程则卡在等 stdout。
    两个进程都停在 0% CPU,看着像"卡死"其实谁也动不了。正解:两个流都异步、交替排空。
  2. Math.min(x, undefined) = NaN 传染:开门时我算了"最高邻接天花板"却没存进 thinker,
    门扇区的 ceilH 变 NaN,读出来在 JSON 里是 null,我一开始还以为是"IO 错误"。
  3. 单位搞错 35 倍:把 DOOM 的"每 tic 移动 2 单位"当成"每秒 2 单位",门开一次要 34 秒。
  4. "全黑"其实是"贴图被错误地抑制了":我为了区分"实心墙",把这类墙归成 blocking 后只画中贴图。
    而 MAP07 的中央毒液坑是"地板 32 被 112 高的台地包围",它两侧天花板同高 → 可见开口为 0 →
    被归成 blocking → 而它的贴图在下贴图槽 → 什么都不画。修完之后顺带把 MAP31 的重复绘列
    从 51406 降到 320、fps 从 4 涨到 48
    。
  5. 列号没裁到屏幕内:大厅的墙投影远超 320 列,我往 pl.top[x] 写入越界索引——
    JS 的类型化数组会静默丢弃,表现是"visplane 一个像素都没有"。
  6. NaN 污染的迷惑表现:自写 OPL2 核心第一次运行输出"静默"。真相是未初始化的算子字段是
    undefined → 混音变 NaN;而检测代码 if (NaN > peak) 恒假,所以 peak 显示 0、
    rms 显示 null(JSON 里 NaN 就是 null)——两个数字都指向"没声音",实际是"全是 NaN"。
  7. 两处补丁撞车:我给 FM 音色打补丁时删掉了 const voice = ... 那一行,
    后来加"混音预算"的补丁又引用了 voice.gain → ReferenceError 被定时器吞掉 →
    音乐看着在放、实际每个音符都抛异常。
  8. 初始化顺序 bug 被 catch 吞掉:把"统计本关物品数"写在 loadMap 之前,
    那时 state.map 还是 null → TypeError → 被外层的 try/catch 吞掉 → 游戏根本没启动,
    页面却"没有报错"。花了很久才意识到"没有报错"和"正常运行"是两回事。

六、验证方法:把"感觉"变成数字

没有真机比对时,我尽量让每件事都有可测量的证据:

想知道 办法
窗口是卡死还是慢 SendMessageTimeout(WM_NULL, SMTO_ABORTIFHUNG) + 采样进程 CPU 增量
渲染对不对 统计帧缓冲非零像素(满屏约 6.4 万)+ 截图目视
音频有没有削波 挂 AnalyserNode,读时域峰值(实测 0.446,无削波)
音乐是否真的出声 统计"当前发声声部数"(我修掉的那个 bug 就是靠这个抓到的:定时器在跑但声部恒为 0)
状态机对不对 断言状态链(POSSH t5 → … → POSSL t-1)与实机动画轨迹
提取表对不对 用已知答案反证(音效 4/4、状态条数 967、sprnames 指针 138/138)

七、成品与边界(不吹)

成品:index.html + 11 个 JS 文件,约 4000 行;加上从二进制里提取的约 65 KB 数据表。
MAP01–32 可玩,含门/升降台/地板/天花板+压碎/楼梯/传送/开关/过关、四类灯光效果、伤害地板与秘密、
拾取与七种武器(含溅射与怪物互斗)、真实状态栏+状态脸+结算画面、MUS 音乐 + OPL2 渲染、真采样音效、
高清放大与贴图平滑。渲染约 8–11 ms/帧,游戏逻辑 35 Hz 下 0.24–0.52 ms/tic(178 只怪的地图也一样)。

边界(同样是成果的一部分):

  • 3D 渲染是照 DOOM 的算法结构自写的浮点实现,不是定点原版;
  • R_InitLightTables 没定位到,光衰减用相似曲线;
  • OPL2 核心是紧凑 YM3812 结构(9×2 算子/4 波形/feedback/四段包络),没有节奏模式/ADPCM,
    包络计时是近似表,不是周期级仿真;个别乐器音色可能与真机不同;
  • states[] 的 misc1/misc2 提取出来了,但这份二进制里 967 条全是 0(vanilla 不用它);
  • 少数稀有动作(A_IconOfSin 的精确逻辑等)是等效近似;
  • WAD 不随项目分发——请用你自己合法持有的 DOOM2.WAD。

八、如果你也想复现

  1. 目标程序是什么格式,先用工具确认(我这里是 format: dos-mz + LE 镜像);
  2. 工具不支持就换工具或补工具(这次是自己重编译了 Ghidra 扩展);
  3. 先做可读性收敛(ABI、命名、类型),再做数据挖掘;
  4. 挖表优先用内容特征扫描,用指针自校准解地址映射,用已知答案反证结果;
  5. 定不下来的东西,试着换一个更低层的接口把它变成无关;
  6. 每一步都留下可复跑的证据:脚本、偏移、校验比例。

最后一句留给同样喜欢老游戏的人:这次最有意思的不是"我写了个 DOOM",而是发现自己手里那份
1994 年的可执行文件里,还完好地存着每只怪物挨打时的反应时间表。
把它们读出来,
让它们在现代浏览器里重新动起来——这大概是"复刻"这件事最浪漫的部分。

免费评分

参与人数 2吾爱币 +2 收起 理由
clcms + 1 地址呢
mazawa666 + 1 热心回复!

查看全部评分

发帖前要善用【论坛搜索】功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-10-16 07:03

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表