Skip to content

Latest commit

 

History

History
130 lines (76 loc) · 7.78 KB

ARCHITECTURE.md

File metadata and controls

130 lines (76 loc) · 7.78 KB

设计取舍

这份文档记录"为什么这样分层",以及几个被反复踩过的坑。改代码前先读,能省一轮返工。

1. 为什么是"证据流水线"而不是"一键还原"

一键还原的问题不是慢,是不可判定:产出的 C# 看起来永远对,因为你没有独立的信息去反驳它。这条流水线把"生成"和"判定"分开:

证据(native / metadata / 字面量) → 卡片 → 候选实现 → 判定(锚点/标记/残留/编译)

判定用的信息不来自候选实现,而来自 native。这是整个设计的地基。

2. 为什么所有中间产物都是明文 JSON

  • Agent 能直接读、能 grep、能按需切片;
  • 人能在没有工具的情况下核对;
  • 任何阶段都能单独重跑,不需要"从第 3 步重来";
  • 不引入 SQLite 之类的东西,避免工具链版本差异导致的不可复现。

代价是 methods.jsonl 会比较大(20 万方法 ≈ 100 MB),可以接受;语料索引额外做了 TSV 缓存(见 §6)。

3. 精简(reduce)为什么按"可达性"而不是按行过滤

反编译输出里一个调用能占十行参数,按行删调用会把 &_, v9, v10, ... 留下来;类初始化守卫是一个多行 if 块,必须整体删除。

所以做法是:先语句化(括号深度 0 处的 ; 切分,{/} 独立成 token),建块树,然后只保留包含业务语句的子树。守卫因为包住了业务语句而自动保留,纯管道块整体消失。

结果是 500 行 → 10~60 条,且和原 C# 接近一一对应。

4. 什么算"业务"

三个信号:业务类型调用、字段写入、字符串字面量。

难点是判断"业务类型"。硬编码 System_|UnityEngine_|... 前缀是不可移植的——每个项目引入的第三方库都不同。工具改成用 dump.cs 的程序集归属:symbols.json 里每个 mangled 类型都带 target: true/false,调用落在非目标程序集就是框架。前缀列表只作为"索引里查不到的类型"(主要是泛型实例化)的兜底。

5. 证据块的边界必须按"横幅行"匹配

早期实现用 "UNRESTORED EVIDENCE" in line 判断,结果机械翻译头里的散文

// Original C evidence preserved in UNRESTORED EVIDENCE block above.

会重新打开证据块,把后面所有语句吞掉。一次事故里 190 个成员因此丢了 throw / return,多出 190 个 CS0161。

现在边界是:

^\s*//[\s\-=*]*UNRESTORED EVIDENCE\b

只认注释横幅,不认正文提及。任何"包含即匹配"的写法都是同一个 bug。

6. 语料索引为什么要缓存

4 万个 .c、50 万+ 函数,扫一遍 6 分钟。缓存文件是 TSV(name \t relpath \t addr),用"文件数 + 最新 mtime"做失效判据——便宜,且能抓住所有有意义的重新导出。

缓存只存"名字 → 文件",函数正文按需读取并做 LRU(上限 64 个文件),所以内存不会随语料线性增长。

7. image base 用投票,不用猜

dump.cs 的 RVA 是镜像相对地址,native 反编译里的地址是 VA,差一个 image base。工具用 script.json 的托管→native 映射(其次 mangled 名匹配)算出 va - rva 的众数;票数不到一半就返回 0 并在 join.json 里写明。宁可少对齐,不可错对齐——错一个 base 会把每个方法都指向别人的函数体,而且看起来完全正常。

8. 编译诊断为什么要去重

dotnet build -v q 会把同一条错误打印两遍(编译器一次、MSBuild 摘要一次)。按行计数会凭空翻倍,把"没变"读成"退步"。parse_output 按 (file, line, code, message) 去重,并单独记录 MSBuild 自己报的总数作为交叉验证。

9. 门禁为什么只看语法类错误

语法类错误(CS1002/CS1513/CS1519/CS1525/CS1026…)会让编译器中止语义分析,所以只要它们不为 0,其他数字都没有意义。反过来,总错误数上升常常是好事:新写的 body 让编译器开始检查之前根本没解析到的代码。

所以门禁是:syntax_errors == 0 且 residue == 0。总数只作趋势参考。

10. 为什么工具不内置模型调用

模型传输(端点、重试、成本、缓存、批处理)是另一个维度的问题,且变化极快。工具的边界划在"卡片出去、补丁回来":

  • 卡片是自包含 JSON,任何模型/任何框架都能消费;
  • 补丁是纯数据,apply 不关心它从哪来;
  • 判定不信任补丁来源,只信任证据。

这样换模型、换供应商、换批处理策略都不需要改工具,也不会把凭据塞进仓库。

11. 刻意不做的事

不做 原因
自动生成 C# 没有证据的生成只会产出"看起来对"的代码,而它会被当成完成
自动删证据块清队列 行为不变,只是把待办藏起来
一个 native 函数映射多个托管方法时强行选一个 泛型共享/ICF 折叠是真实存在的歧义
从 AssemblyVersion 推断 NuGet 包版本 两者无关,推出来的引用会编译过但语义错
在工具内驱动 IDA/Ghidra 导出格式差异太大,且需要 GUI 许可;消费导出结果是更稳的边界

12. 虚调用不是管道

Hex-Rays 把虚调用展开成四行、且把接收者写两遍:

((void (__fastcall *)(struct Text_o *, System_String_o *, const MethodInfo *))
  beforeLevelText->klass->vtable._75_set_text.methodPtr)(beforeLevelText, v69, ...);

早期把 ->vtable 一律当管道过滤,于是 set_text、set_sprite 这类真正的 UI 行为在精简视图里全部消失——而这恰恰是界面代码的主要内容。

现在的规则分两层:

  • 识别:->klass->vtable._NN_name.methodPtr 里的 name 就是托管方法名,机械还原成 recv->name(args)(不是推断,是改写反编译器的固定写法);只有 GetType/Equals/MoveNext/SetResult 这类每个对象都有的槽位算管道。
  • 计分:虚调用算业务,且不再套框架过滤。理由是它改的是"我们手上这个对象"的状态,与该方法定义在哪个程序集无关;被过滤的框架噪音是 List.Add、string.Concat 那种直呼形态。

13. 无花括号分支必须和它的 else 配对

Hex-Rays 对单语句分支省略花括号,于是 if (c) X; 和 else Y; 变成两条平铺语句,else 到达时已经找不到它的 if——精简视图里就会出现一个没有条件的 else,读者只能猜。

所以语句化阶段就要拆:把 if (c) X 拆成头 if (c) 与体 X,tree() 让"头"吃掉紧随的一个 token 作为分支体,_emit_chain() 再把 if/else if/else 作为一个整体输出。

两条附带规则:

  • 分支体没有锚点时显式写 // no anchors in this branch,而不是留空——空分支和"没有这个分支"是两件事;
  • 条件里读了字段的守卫(if (!x->fields._item) goto LABEL_78;)即使体里没有锚点也保留,它是 native 的空值检查。

同理,LABEL_26: 这类标签没有 ;,会和后一条语句粘成一个 token,导致那条语句永远拿不到分——所以标签也要单独拆出来。

14. 生成卡片和校验必须用同一套锚点

卡片会把 StringLiteral_27938 解析成真正的字符串再算锚点;如果 verify 从原始证据直接算,锚点里留的就是 StringLiteral_27938 本身,于是照着卡片写对的 body 会一条字面量都命中不了,覆盖率 6/7 变 4/7,被判 LOW_COVERAGE。

一次真实回归:手写 body 命中全部四个调用、两个日文字面量,仍被判 0.57。现在 verify 在精简前先做同样的 resolve_literals。原则是:凡是"判定"用到的派生量,必须和"出题"时用的是同一条路径,否则工具会稳定地冤枉正确答案。