一句话结果:一个纯 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 之后,我先做了三件让代码可读的事:
- 全部 920 个函数的调用约定从
unknown 批量改成 Watcom 的 __watcall(extraout_ECX/EDX 从 2494 处降到 1824 处);
- 把 "被 134 个调用点调用的中止函数" 认成
I_Error 并标 noreturn;
- 按消息文本/特征字符串做了 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) → 停尸
五、踩坑清单(我觉得这段最有价值)
- GUI 全流程卡死(这是另一个项目里的启动器,同一个思路照进这里):
同步读子进程管道会堵死 UI 线程;而且"先读完 stdout 再读 stderr"会永久死锁——
子进程把进度写到 stderr 塞满管道后就阻塞,stdout 永不 EOF,父进程则卡在等 stdout。
两个进程都停在 0% CPU,看着像"卡死"其实谁也动不了。正解:两个流都异步、交替排空。
Math.min(x, undefined) = NaN 传染:开门时我算了"最高邻接天花板"却没存进 thinker,
门扇区的 ceilH 变 NaN,读出来在 JSON 里是 null,我一开始还以为是"IO 错误"。
- 单位搞错 35 倍:把 DOOM 的"每 tic 移动 2 单位"当成"每秒 2 单位",门开一次要 34 秒。
- "全黑"其实是"贴图被错误地抑制了":我为了区分"实心墙",把这类墙归成
blocking 后只画中贴图。
而 MAP07 的中央毒液坑是"地板 32 被 112 高的台地包围",它两侧天花板同高 → 可见开口为 0 →
被归成 blocking → 而它的贴图在下贴图槽 → 什么都不画。修完之后顺带把 MAP31 的重复绘列
从 51406 降到 320、fps 从 4 涨到 48。
- 列号没裁到屏幕内:大厅的墙投影远超 320 列,我往
pl.top[x] 写入越界索引——
JS 的类型化数组会静默丢弃,表现是"visplane 一个像素都没有"。
- NaN 污染的迷惑表现:自写 OPL2 核心第一次运行输出"静默"。真相是未初始化的算子字段是
undefined → 混音变 NaN;而检测代码 if (NaN > peak) 恒假,所以 peak 显示 0、
rms 显示 null(JSON 里 NaN 就是 null)——两个数字都指向"没声音",实际是"全是 NaN"。
- 两处补丁撞车:我给 FM 音色打补丁时删掉了
const voice = ... 那一行,
后来加"混音预算"的补丁又引用了 voice.gain → ReferenceError 被定时器吞掉 →
音乐看着在放、实际每个音符都抛异常。
- 初始化顺序 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。
八、如果你也想复现
- 目标程序是什么格式,先用工具确认(我这里是
format: dos-mz + LE 镜像);
- 工具不支持就换工具或补工具(这次是自己重编译了 Ghidra 扩展);
- 先做可读性收敛(ABI、命名、类型),再做数据挖掘;
- 挖表优先用内容特征扫描,用指针自校准解地址映射,用已知答案反证结果;
- 定不下来的东西,试着换一个更低层的接口把它变成无关;
- 每一步都留下可复跑的证据:脚本、偏移、校验比例。
最后一句留给同样喜欢老游戏的人:这次最有意思的不是"我写了个 DOOM",而是发现自己手里那份
1994 年的可执行文件里,还完好地存着每只怪物挨打时的反应时间表。 把它们读出来,
让它们在现代浏览器里重新动起来——这大概是"复刻"这件事最浪漫的部分。