跳到主内容
快讯直播
AI智模界
教程

反编译老 FPS:智能体流水线从导出到编译通过

这篇能做出什么

目标很具体:拿一款有年头的第一人称射击游戏(本文以 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、导出脚本、进度文件、对拍脚本打包成一个模板仓库,换一个目标二进制就能复用。反编译一个游戏最难的部分从来不是某个函数,而是这套能反复跑的流程。

AI 生成本文由 AI 基于公开信息自动生成,仅供参考。