这篇能做出什么
目标很具体:拿一款有年头的第一人称射击游戏(本文以 32 位 x86 的单机 FPS 为例),把它从二进制一路推到一个能在现代机器上 cmake --build 编译通过、并且行为对拍一致的 C/C++ 工程。
验收线有两条,必须同时成立才叫"做完":
1. 编译通过:CMake 配置成功、编译零错误、链接出可执行文件。
2. 行为对拍:同一段输入回放,原版与重建版逐帧状态哈希在约定的帧数内完全一致。第一轮只对逻辑层——坐标、速度、朝向、血量、弹药、实体数量、随机数状态;渲染层(像素级画面)放到第二轮。
之所以强调"双重验收",是因为反编译产物的编译通过太容易作弊了:把几百个未实现的函数塞成 return 0; 的空壳,一样能编译通过,一样跑不起来。对拍就是防这个的。
合规提醒:只对你自己合法持有的副本做这件事,产出物不要公开分发,是否允许反编译请先看当地法律与游戏的最终用户协议。
前置条件清单
- Ghidra 已安装,能跑
analyzeHeadless(版本以官方文档当前版本为准)。 - JDK,版本要求同样以 Ghidra 官方文档为准。
- 一个 C/C++ 工具链:GCC 或 Clang + CMake。
- 一个编码智能体:可以是终端里的 CLI 智能体,也可以是编辑器内置的。关键是它要能读写你仓库里的文件、能执行命令。
- 原版游戏的一份可运行副本,以及它的输入录制能力——很多老 FPS 引擎自带
record demo/play demo,这是对拍最省事的抓手。没有的话,退而求其次用输入注入工具。 - 一块能放中间产物的磁盘空间:伪代码导出动辄几百 MB。
第一步:用 Ghidra 无头模式批量导出伪代码
别在 GUI 里一个个函数点右键复制。写一个 Ghidra 脚本,无头跑一遍,把每个函数的反编译结果落成独立文件。这样后续智能体才能按文件、按需取用。
```bash
语言: bash
export GHIDRA_HOME=/path/to/ghidra
"$GHIDRA_HOME/support/analyzeHeadless" \
./ghidra_proj fps_re \
-import ./original/game.exe \
-scriptPath ./scripts \
-postScript ExportDecomp.py \
-deleteProject
```
导出脚本用 Ghidra 内置的 Jython 写(Ghidra 默认支持的语言,具体以官方文档为准):
```python
语言: python (Ghidra Jython 脚本)
scripts/ExportDecomp.py
import os, json
from ghidra.app.decompiler import DecompInterface
from ghidra.util.task import ConsoleTaskMonitor
OUT = os.path.expanduser("~/re/funcs")
os.makedirs(OUT, exist_ok=True)
decomp = DecompInterface()
decomp.openProgram(currentProgram)
monitor = ConsoleTaskMonitor()
fm = currentProgram.getFunctionManager()
index = []
for fn in fm.getFunctions(True):
res = decomp.decompileFunction(fn, 60, monitor)
if not res.decompileCompleted():
continue
name = fn.getName()
addr = fn.getEntryPoint().toString()
code = res.getDecompiledFunction().getC()
safe = "%s_%s.c" % (addr.replace(":", "_"), name)
with open(os.path.join(OUT, safe), "w") as f:
f.write(code)
index.append({
"addr": addr,
"name": name,
"file": safe,
"lines": code.count("\n"),
"calls": [c.getName() for c in fn.getCalledFunctions(monitor)],
})
with open(os.path.expanduser("~/re/func_index.json"), "w") as f:
json.dump(index, f, indent=1)
print("exported %d functions" % len(index))
```
跑完你会得到两样东西:一堆 .c 伪代码文件,和一份 func_index.json。后者是整个流水线的"总目录",智能体靠它定位、靠它判断规模,而不是靠脑子记。
顺手再做一次符号清洗:把 FUN_0040a1b0 这类自动名,按调用关系换成 sub_、maybe_ 前缀的可读名,写回一份 symbols.csv。这一步不用智能体,正则加调用图排序就够了。
第二步:搭仓库骨架,把规则写进常驻文件
先建目录,让每一层产物都有明确位置:
```text
re/
├─ funcs/ # Ghidra 原始伪代码(只读,不要改)
├─ gen/ # 智能体重建出的 .c/.h(可改)
├─ include/
│ └─ types.h # 全局结构体、类型定义
├─ tests/
│ └─ golden/ # 对拍基准日志
├─ third_party/
│ └─ platform/ # 平台层垫片:Win32 API 的现代实现
├─ func_index.json
├─ symbols.csv
├─ AI 词典:AGENTS.md">AGENTS.md # 智能体的行为规则
└─ CMakeLists.txt
```
AGENTS.md 是这套流水线里性价比最高的一份文件。所有会话都会读到它,所以它必须短——建议 200 行以内——但必须硬。内容大概这几类:
- 目录约定:
funcs/只读,产物写gen/。 - 翻译规范:指针宽度统一用
uint32_t表示 32 位地址;__thiscall一律显式写成(Type *this, ...);浮点常量保留原始十六进制位模式,不四舍五入。 - 明确禁止:不得删除函数、不得用空壳占位、不得为了让编译通过而修改
funcs/里的对照物。 - 每完成一个函数,必须自己跑一遍
cmake --build并贴出结果。
同一份规则不要在每个提示词里重复粘贴。写进文件,提示词里只写"按 AGENTS.md 执行"。
第三步:给任务切片,做 token 预算
反编译是个天然的"长尾 + 巨量上下文"任务。一整份伪代码塞进去,context 会爆,而且中间的注意力会稀释。做法是按函数切片,并且显式预算。
一条可用的经验分配:
| 任务类型 | 每次加载的内容 | 量级参考 |
|---|---|---|
| 单函数翻译 | 1 个伪代码文件 + 相关结构体 + AGENTS.md | 数 k token |
| 类型恢复 | 目标结构体的所有引用点(按 symbols.csv 查) | 数 k token |
| 编译报错修复 | 报错原文 + 涉及的 2~3 个文件 | 数 k token |
| 对拍分歧定位 | 分歧帧前后各若干帧的状态 + 该帧调用链 | 数 k token |
切片规则:
- 伪代码文件超过约 400 行,先让智能体只输出一份"文字版控制流摘要"——分支、循环、调用了谁——再按摘要逐段翻译。摘要比原文短一个数量级,后续几轮都只带摘要。
- 每次任务只加载一个目标函数。跨函数依赖靠
include/types.h和函数声明解决,不靠把别人也读进来。 - 每完成 10~20 个函数,主动开一个新会话,只带
func_index.json和AGENTS.md。历史对话是最贵的上下文,而且它对下一个函数毫无价值。
外部记忆用文件,不用对话历史:
```json
// 语言: json gen/progress.json
{
"done": ["00401000", "004010a0"],
"stub": ["00401330"],
"notes": {
"00401330": "依赖未恢复的结构体 Entity,先挂起"
}
}
```
第四步:先做垂直切片,别贪全
挑一个最深的调用链末端的函数开工:它不调用别人,只被调用。比如数学库里的向量归一化、随机数发生器、定时器换算。
```bash
语言: bash
找出叶子函数
python3 - <<'PY'
import json
idx = json.load(open("func_index.json"))
known = {f["name"] for f in idx}
leaves = [f for f in idx if not (set(f["calls"]) & known)]
leaves.sort(key=lambda f: f["lines"])
for f in leaves[:20]:
print(f["addr"], f["name"], f["lines"])
PY
```
然后走一遍完整闭环:翻译一个叶子函数 → 编译 → 写一个断言它的单元测试 → 通过。这个闭环打通了,剩下的就是重复。闭环没打通就批量开工,后面会返工到怀疑人生。
第五步:智能体的分工
把角色拆开,每个角色一个会话,别让一个会话既翻译又修编译:
- 翻译员:输入一个伪代码文件 +
types.h,输出gen/xxx.c。只负责"信达雅",不管编译。 - 类型工程师:专门处理结构体。把
*(int*)(param_1 + 0x24)这类偏移访问聚合成结构体字段,产出include/types.h的增量。这个角色单独设,是因为类型定义是全项目共享的,让多个会话同时改必然冲突。 - 编译医生:只拿到编译器报错原文和相关文件,做最小改动。禁止它改逻辑结构。
- 对拍侦探:只拿到分歧帧前后的状态日志,负责给出"哪一行可能算错了"的假设,不负责改代码。
角色之间靠文件交接,不靠对话。每次交接只传路径,不传内容。
第六步:补平台层,让链接过
老游戏会调用大量 Win32 API。现代平台上,这些调用需要一层垫片:
```c
// 语言: c
// third_party/platform/platform.h
#ifndef PLATFORM_H
#define PLATFORM_H
#include <stdint.h>
/* 用现代原语重实现的老 API 子集;签名必须与原版一致,
否则调用约定在不同架构下会错位 */
void *plat_virtual_alloc(uint32_t size);
void plat_sleep_ms(uint32_t ms);
uint32_t plat_tick_ms(void);
#endif
```
链接阶段的报错大多是"未定义符号",两类处理方式:
- 属于游戏逻辑的 → 说明那个函数还没翻译,回到第五步。
- 属于平台/系统 API 的 → 在垫片里补一个实现,签名必须和原版严格一致。
到这里跑:
```bash
语言: bash
cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo
cmake --build build -j
```
第一条验收线到这里应该过。
第七步:行为对拍,第二条验收线
这一条比编译通过重要得多。做法:
1. 在原版里录一段 demo 或输入序列,确保包含移动、射击、拾取、触发机关这几类事件。
2. 在原版和重建版里,都在每个逻辑帧末尾打一行日志:
```c
// 语言: c
/* 固定小数位,避免浮点格式化差异污染对比结果;
顺序固定,字段顺序变了等于换了哈希 */
void dump_state(FILE *f, const GameState *s, uint32_t tick) {
fprintf(f, "%u|%.4f,%.4f,%.4f|%.4f,%.4f|%d,%d,%d|%u\n",
tick,
s->pos.x, s->pos.y, s->pos.z,
s->vel.x, s->vel.y,
s->health, s->ammo, s->entity_count,
s->rng_state);
}
```
3. 两份日志逐行做 diff。第一处分歧所在的帧号,就是你的突破口。
定位分歧时按这个顺序怀疑,命中率从高到低:
- 浮点精度:原版可能用了
float累加而你用了double,或者编译器把a*b+c融合成了 FMA。关掉快速数学优化(-ffp-contract=off)再试。 - 有符号/无符号:Ghidra 的
>>在伪代码里看不出算术右移还是逻辑右移,按原指令判断。 - 结构体对齐:错一个填充字节,后面全歪。
- 未初始化内存:原版能跑是因为那页内存恰好是 0。
对拍通过的标准要提前定死,比如"前 3000 帧状态哈希完全一致"。别用"看起来差不多"。
常见坑与排错
编译过了但对拍一到第 2 帧就分歧。 十有八九是随机数发生器还没翻译,或者翻译了但种子初值不同。先去对 rng_state 这一个字段。
智能体反复"修复"同一个编译错误。 通常是它在改 funcs/ 里的对照物,而不是改 gen/。回到 AGENTS.md,把禁止项写得更硬。
上下文越来越长,回答质量断崖式下降。 说明该开会话新会话了。判断标准很简单:如果这一轮需要的文件超过三个,就说明切片没切好。
大量函数翻译完,一链接全是重定义。 说明类型工程师和翻译员并行改了同一个头文件。让 types.h 只有一个写入者。
Ghidra 导出的函数名带 _ 前缀导致链接冲突。 在 symbols.csv 里统一加项目前缀,映射时一次性处理。
反编译结果里有大量 goto。 不要硬套 while/for。先保留 goto 让编译过,对拍通过之后再考虑重构——重构和对拍要分两轮做,别混在一起。
下一步建议
编译通过和对拍通过之后,可以往三个方向走:
一是扩大对拍覆盖面。把逻辑层一致性扩展到多段 demo、多个地图、边界输入(贴墙、卡角、极速下落)。
二是渲染层重建。把原来的 DirectDraw/OpenGL 调用换成现代图形 API,这时对拍要从状态哈希切到帧缓冲哈希,并且要接受浮点光栅化带来的像素差异,改用容差比较。
三是把流水线本身产品化。把 AGENTS.md、导出脚本、进度文件、对拍脚本打包成一个模板仓库,换一个目标二进制就能复用。反编译一个游戏最难的部分从来不是某个函数,而是这套能反复跑的流程。
