Skip to content

Releases: ejfkdev/dae

v0.1.11

Choose a tag to compare

@github-actions github-actions released this 28 Sep 16:23
v0.1.11
ffe8487

修复循环头栈溢出守卫被整条丢弃(arm64)

这一版只有一处代码改动,但它修的是控制流语义:产物此前会让读者误判函数在每次循环迭代都做了栈检查。

缺陷

Dart 把栈溢出检查放在循环头,而真正的循环条件在下一个块:

块10(循环头): ldr BARRIER,[THR,#0x48]; cmp SP,BARRIER; b.ls <handler>   ← 守卫
块11:           cmp r1, #4; b.ge <exit>                                  ← 循环条件
0x4bbdec:       b 0x4bbdac                                               ← 回边指向循环头

loop_shape 的兜底臂返回 succ(h, 0),也就是分支目标,于是 out-of-line 的溢出处理块被当成了循环体入口。产物变成:

改前:  while (true) {
         BARRIER = mem(THR, 0x48); // 0x4bbdac
         sub_0x4c3c40();           // 0x4bbe30     ← 每圈无条件调用溢出 stub
         if (x1 >= 4) { break; }

两条语句地址相差 0x64,正是「把远端处理块内联到了加载后面」的痕迹;守卫的 if 因为循环头路径 continue 跳过整个分支处理而彻底消失。溢出 stub 于是从「仅 SP <= BARRIER 时调用」变成「每圈无条件调用」。

改后:  while (true) {
         BARRIER = mem(THR, 0x48); // 0x4bbdac
         if (SP <= BARRIER) {
           sub_0x4c3c40(); // 0x4bbe30
         }
         if (x1 >= 4) { break; }

判别依据

关键是区分「头块的分支是循环条件」与「头块的分支只是守卫」。用的是侧块的形状,不是循环归属:溢出处理块的形态是 bl <stub>; b <落空块>,即它的无条件跳转目标正好等于头块的落空后继(is_rejoin_side_block)。成立时头块的分支就是守卫,于是兜底臂改为从落空边进入循环体,并在 body 顶部把守卫补发成 if。

第一版用「分支目标在循环外」判别,失败了:处理块 b 回循环内,被循环检测标成 in_loop,判据恒假;结果它在别处挪动了 15 个 if 而目标缺陷一点没修,已完整撤回(撤回后 material_3_demo 1011 个文件与改动前逐字节一致)。教训写进了 docs/DECOMPILER.md:结构化率 7 语料全不变 + 所有门禁全绿,仍不足以说明改动是对的——必须直接去看目标实例的产物。

验证

项 结果
sample_arm64 无守卫处 113 → 0(有守卫 758 → 875,if ( 5445 → 5562)
Reqable(arm64 真机商业应用) 守卫 1218 处 / 无守卫 0 处(改前是 1149 / 69,69 处全部补回,与独立审计的数字精确吻合)
结构化率 7 语料 逐一完全不变:1060/115、1047/127、1051/116、1063/156、13947/1135、1716/92、1073/115
dart analyze material_3_demo 全量 0 错误、Reqable 全量 0 错误、T4_blank(真机安卓、压缩指针)0 错误
非 dart/ 产物 逐字节一致
性能 material_3_demo 带 --decompile 0.84–0.85 s(v0.1.10 为 1.09–1.22 s)、不带 0.45–0.47 s
门禁 65 测试全绿、full_scorecard 26 样本 / 291 文件 / 24 253 函数 / 0 错误、regress_all 25/25、check_profiles 47/47、clippy 0

棘轮门禁 stack_check_guards_do_not_regress 的上限已收到 0,从此强制保持。

另得到一个否定结论:empty_if_without_else_does_not_grow 仍是 109、没有跟着下降,说明那 139 个「无 else 的空 if 丢分支边」与本次不同源,是另一个根因(已在文档中记录,含三个尚未试过的判别方向)。

发版前抽查

22 条子命令逐条冒烟测试全部 rc=0 且输出非空(arrays/maps 是刻意不提供的子命令——它们是 text/ 下的单列 dump,grep text/arrays.txt 就够,CLI 会把 dae arrays X 读成 dae <bin> <out> 快捷形并报「读不到名为 arrays 的文件」,这是正确行为)。

v0.1.10

Choose a tag to compare

@github-actions github-actions released this 28 Sep 13:05
v0.1.10
ffd2a11

修复五个「产物给出错误值」的缺陷,外加流式落盘与并行渲染

这一版的主线是正确性:五个缺陷都不是「少一行」,而是产物读起来会算错。全部由「拿源码对照产物」和「逐类审计真实应用」发现,既有门禁一个都没报——产物照样过 dart analyze、结构化率不变。

正确性

  • arm64 cset/csetm 整条消失。 lift_one 用裸条件码拼 (ne) ? 1 : 0(非法 Dart),而 nest_block 对 Expr::Text 不做待定值替换、pending 又按目标寄存器建键,于是紧随的同寄存器赋值把它整条覆盖。净效果是静默的错误值:源码 int get rank => this == Level.low ? 0 : 1 被内联成 cmp; cset x2,ne; lsl x2,x2,#1,产物只剩 x2 = x2 << 1,而 x2 还是八条指令前的插值数组长度 4。修复后是 x2 = ((x1 != BARRIER) ? 1 : 0) << 1。另外 NEST_MAX_DEPTH 挡住折叠时改为落地而不是留在 pending 里等着被覆盖。
  • 寄存器名匹配退化成子串匹配,捏造出 ppmem(...) 并吞掉 1411 个 store。 Dart arm64 的池指针 PP 物理名是 x27,而位移文本 #0x27 里含子串 x27,于是 stur x17, [x3, #0x27] 被判成池加载:store 变成赋值、写操作彻底消失,且 Expr::Pool 渲染成 pp[0x27] 再经出口 sanitizer 变成凭空的标识符 ppmem(0x27)。诊断指纹是纯前缀相关:16 种偏移全以 0x27 开头、mem(..., 0x27*) 零幸存。改成词边界匹配后 1411 → 0,并顺带恢复了一批被假池索引挡住的字符串字面量。
  • tst 被渲染成相等比较,写屏障快慢路径语义反转。 tst a,b; b.eq 的真值是 (a & b) == 0,产物却写 BARRIER == HEAP——运行期两寄存器几乎不可能全等,等于宣称「每次都要过写屏障」;且第三段移位修饰 lsr #32 被整个丢掉。436 → 0,正确形态 1303 处。x86 的 test eax, 0x20 + je 同样中招。
  • wN 与 xN 被当成两个独立变量。 它们是同一物理寄存器的两个视图:写 wN 会清零 xN 高 32 位。两个方向都错过——写 wN 后读 xN 的陈旧读 3590 → 16;blr LR; tbz w0,#4 里的 w0 从未被赋值、条件在对 null 求值,这类 1690 → 0(位号 < 32 时 w/x 的第 k 位恒等,所以位测试直接用 64 位名是精确的,不需要掩码)。
  • raw 反汇编注释块越过函数边界:lift 有意多看 16 字节,stmts 一直按地址裁剪而 raw 漏了,于是每个函数尾部印上最多四条下一个函数的指令。裁剪后 dart/ 反而小 2.5–3.5%。
  • getclass/getmethod/decompile -o FILE.dart 命中多库时产物非法:逐库拼接前导声明会让 mem/memSet 等占位函数重复定义,Reqable 随机 100 个类里 45 个中招、2823 个 dart analyze 错误。现在按合并后的正文重算一份前导(只保留第一份不行:它会把别的库定义的函数声明成 dynamic,撞成同一个 duplicate_definition)。修复后 0 错误。
  • x86 adc/sbb 丢掉目标寄存器(渲染成裸调用,对目标的写消失);arm64 sbcs 被 x86 两操作数路径处理(丢掉第三个操作数、且目标兼作操作数)。

指令覆盖

x86 SSE 标量浮点 addsd/subsd/mulsd/divsd(含 ss)——x64 上所有 double/float 算术都走它,此前因为是两操作数形态而全部落成 // unmapped;另有 comisd/ucomisd 归入 cmp 族、cmov<cc>、arm64 cinc/cinv/cneg、inc/dec、cdq/cqo、x86 setcc、arm64 adcs/sbcs。

未映射行数:material_3_demo 3 → 0、T4_blank 34 → 9、hello_3.13.0 161 → 142(剩下 121 条是指令前缀 rep/std/cld/lock,正确修法是与后续指令合并成 memcpy 语义,而不是单独映射前缀;把它们改标成 note 能让数字掉 85% 而信息量为零,所以没做)。

性能

交替 A/B,material_3_demo(15 082 函数):--decompile 3.04 s → 1.09–1.22 s、峰值 RSS 181–200 → 178–184 MB;不带它 0.56–0.58 s → 0.55 s、RSS 139–142 → 124–130 MB。真机微博(9 MB、19 053 反编译函数 / 163 万语句)4.51 s / 251 MB → 2.12 s / 211 MB。

两处改动:产物流式落盘(asm 原本把每个库攒进一个容量低估约 2 倍的 String;render 原本一次返回全部 505 份文件共 63.2 MB),以及按库并行渲染。并行不改变产物一个字节——文件名与「每个入口地址归哪个库发射」都由一趟顺序预扫描先定死,1011 个文件在 1/8 线程下 diff -rq 完全相同。默认并发是 n_threads()(核数、上限 8),DAE_DEC_THREADS=N 可覆盖;在 6 性能核 + 12 能效核的机器上超过 8 线程反而更慢更费内存。

两处做了又撤回的改动

都记在 docs/DECOMPILER.md,因为它们比成功的那些更有信息量:

  • 把立即数字面量折进待定值:int - dynamic 的静态类型是 num,而 num 没有 <</&/|,直接打破 dart analyze。
  • 把 tbz xN, #0 还原成 isSmi(xN):依据是 profile 的 heap_object_tag/smi_mask,Reqable 上 1169+58 处、残留形态归零、analyze 0 错误、压缩指针安卓语料同样生效——每项指标都说成功。但 n.isEven ? 'even' : 'odd' 编译出来也是测第 0 位(操作数是未装箱 int),产物于是变成 if (isHeapObject(w1)) { "odd" } else { "even" } = 编造语义,比朴素但正确的 w1 & (1 << 0) != 0 更糟,而且只有对照源码才发现。判据由此明确:只有一对一的形态映射才叫还原;一个形态对应多个语义时,命名就是编造。

一处看起来像回归、其实是修复生效的地方

text/fields.txt 少了一行(634 → 633,_SyncStarIterator._current)。原因是访问器推断路径会跑完整的 lift,而它的健全性护栏是「恰好一个字段偏移」。那个 setter 有两处字段访问,第二处位移是 0x27——在旧版被上面的子串 bug 吞成池读取、不算字段访问,于是护栏通过并写出了那一行。子串 bug 修好后第二处被正确识别,护栏正确地拒绝推断。佐证是那个「setter」开头是两次加载而非 stur,根本不是朴素字段 setter 的形状。所以那一行是一个 bug 抵消了另一个 bug 的盲区才产出的。

验证

62 个测试全绿;full_scorecard 26 个样本 / 291 个文件 / 24 253 个函数,dart analyze 0 错误;regress_all 25/25;check_profiles 47/47(含 21 份压缩指针变体);clippy 0。Reqable 全量产物 1955 个文件同样 0 错误。

新增四条门禁,每条都用旧二进制负测过(证明不是空过):cset_instructions_materialize_as_ternaries(基线 8 指令 / 0 三元式 / 8 个函数不合格)、x86_setcc_materializes_as_ternary(基线 9 条全部 unmapped)、w_register_write_aliases_x_register(基线 290 处陈旧读)、no_register_substring_false_positives_in_output、bit_test_conditions_use_the_64bit_view。另加强了一条既有门禁:condflag_only_wraps_bare_condition_codes 原先只查「含空格或运算符」,放过了 condFlag("isSmi(w0)"),现在要求参数必须是 1–3 个纯小写字母的裸条件码——门禁判据要按形状写,不要按已见过的坏样子枚举。

dae v0.1.9: decompiler output that stopped lying by omission

Choose a tag to compare

@github-actions github-actions released this 28 Sep 00:15
v0.1.9
613c67d

Four defects, all found the same way: decompile our own example programs and read the result against the source. Every one was invisible to the existing gates — the output still passed dart analyze, structuring ratios and address self-consistency were unchanged, and the regression archives stayed byte-identical. Three of the four made the pseudocode omit something rather than say something wrong, which is the failure mode no validity check can catch.

1. Statements were silently discarded

nest_block folded register assignments into pending values, inlined them at their use sites, and landed the rest at block end. On notes and compares it did pending.clear() — threw them away — while calls, stores, branches and returns flushed them. push/pop are notes, and they are exactly where call-argument preparation ends.

On the x64 example corpus, fib compiles to:

mov rcx, rax      ; rcx = n
sub rcx, 1        ; rcx = n - 1
push rcx          ; argument
call fib

The first two folded into rcx = rax - 1 and were then discarded at the push. The decompiled body showed fib() with no argument and no line anywhere mentioning n - 1 — for a function whose entire meaning is recursing on n−1 and n−2. It did not count as unmapped either: the instruction was recognised, only its result was dropped.

Now flushed, matching every other barrier. Measured on a 15,082-function Flutter app: dart/ grows 1,998,350 → 2,165,737 lines (+8.4%), recovering parameter loads, argument setup and epilogues. fib now reads:

rax = mem(FP + 0x10); // 0x5e36e   ← the parameter n, previously absent
if (rax < 2) {
} else {
  rcx = rcx - 1; // 0x5e37f        ← fib(n-1)'s argument, previously absent
  fib() /* 0x5e35c */;
  rcx = rcx - 2; // 0x5e392        ← fib(n-2)'s argument, previously absent
  fib() /* 0x5e35c */;
  ...
}

DecompileStats did not move at all, because it is computed before nest_block — which is precisely why the run summary could not see this. A narrower variant (flush only on notes, +3.0% lines) was measured and rejected: it still dropped parameter loads cleared at a compare.

2. condFlag wrapped conditions that were already valid Dart

cbz/cbnz/tbz/tbnz build a complete boolean expression at lift time, but every branch then went through fold_cond, which matches on mnemonics and falls back to condFlag("{mnem}"). So a real condition came out as a string inside a placeholder that always returns false. From a purpose-built stress sample (switch + ternary):

// before                                     // after
if (condFlag("x2 == 0")) {                    if (x2 == 0) {
  x0 = "zero"; return x0; }                     x0 = "zero"; return x0; }
if (condFlag("w1 & (1 << 0) != 0")) {         if (w1 & (1 << 0) != 0) {
  x0 = "odd"; } else { x0 = "even"; }           x0 = "odd"; } else { x0 = "even"; }

The after column reads directly as the source's n.isEven ? 'even' : 'odd'. 14,886 → 2,841 occurrences; the remainder are genuine bare condition codes (vc 1782, vs 293, eq 162, ne 90, hs 5, lo 3), which do need the placeholder. Unrecognised bare tokens still go through fold_cond, so if (eq) can never reach the output.

3. Scoped decompiles lost cross-library call names

The entry→name map was built from the emission set, so --lib, --app, getclass, getmethod and getlib degraded every call into another library to sub_0x…. In testing_app (a Flutter sample whose source is in the repo), Favorites.remove is two lines:

void remove(int itemNo) {
  _favoriteItems.remove(itemNo);
  notifyListeners();
}

A full decompile rendered them correctly as GrowableList_remove() and ChangeNotifier_notifyListeners(). Under --lib testing_app the same two became sub_0x8a1b8() and sub_0x6d60() — the two calls that carry the method's entire meaning were the ones lost, and "just the app's own code" is what --app advertises. The names were always in the snapshot (dae callees resolved both), so this was a projection gap, not missing data. Named call targets in scoped output, measured on two independent artifacts: 26.6% → 54.0% on testing_app with --lib, and 26.9% → 42.9% (6,380 → 10,180 named calls) on a real 7.5 MB Android build with --app.

4. dae classes listed top-level functions as nameless classes

Each library's functions with no owning class were emitted as a row with an empty name and cid -. Empty sorts first, so dae classes x | head -1 | cut -f3 returned "" — and fed that empty string into whatever came next. Now skipped, with the count line stating how many were skipped and where they still appear (dae functions, dae members).

The same line now states the scope that misled us while investigating: this command lists only classes that own at least one function, while text/classes.txt lists every Class record. Those differ a lot — 3,256 → 3,047 rows on a real Android app (209 nameless rows removed, nothing else), and 2,177 vs 3,358 on a Flutter sample, the gap being classes whose methods were fully inlined or tree-shaken.

Compatibility

On both a Mach-O arm64 Flutter app and a real Android compressed-pointer build, every difference between v0.1.8 and v0.1.9 is inside dart/: 503 of 503 and 867 of 867 changed files, zero outside it. ida_script/, r2_script/, frida.js, asm/, text/ and callgraph.dot are byte-identical, and the export summary matches item for item. Both the full and the --app-scoped decompile output still analyse at 0 errors.

New gates, each negative-tested

  • decompiled_body_covers_instruction_addresses — how many real instruction addresses from asm/ appear as statement addresses in dart/. 70.1% before → 78.3% after, floor 0.75, chosen between the two so a revert fails. Reverting reports 129/184 and fails.
  • condflag_only_wraps_bare_condition_codes — a condFlag argument containing a space or a comparison operator is a failure. Reverting lists the offending expressions and fails.
  • scoped_decompile_keeps_cross_library_call_names — a scoped decompile's anonymous-call set must be a subset of the full decompile's. Corpus-independent. Reverting reports "30 call targets became anonymous" and fails.

The first two exist because the defect they guard was invisible to everything else: validity gates cannot see omission, and regress_all runs unfiltered so it never exercised the scoping path.

Still open, now measured rather than assumed

  • Statement order does not follow address order — 21,826 sites, 2.74% of statements, 36.1% of functions. It cannot be fixed by sorting on address: a folded expression is only correct because it appears before the statement it folded, so reordering would double-count. Fixing it means choosing between liveness analysis to suppress redundant landings, and dropping folding entirely.
  • Return values are mostly not recovered — bare return; is 95.6% of returns (17,238 vs 790). Not "never": both case branches of classify emit return x0;. The figure is dominated by void functions and epilogues.
  • Calls still show no arguments. This was attempted before and reverted: without a verified per-ABI clobbered-register table there is no real liveness, so a register written before an earlier call gets passed off as this call's argument. Item 1 above at least makes the argument setup visible as its own statements.
  • The superclass chain is wrong (unchanged from v0.1.8, where it was first documented): App extends StatefulWidget resolves to SceneBuilder. It feeds frida.js's sid field and the ancestor grouping in text/objs.txt, so both are unreliable and are marked at their source sites.
  • Object-pool names are still not projected into the IDA/r2 scripts (blutter emits ~52,700 pp.*). The data exists; dae pp and dae findrefs query it.

Two things that looked like bugs and were not

Both settled by external truth rather than by inspection, because the honest answer was not the obvious one:

  • Account.deposit's missing if (amount <= 0) throw ArgumentError(...) — that Code object is 4 instructions, and the immediate is 0x32 = 50. It is the constant-specialised copy of a.deposit(50) from main; 50 > 0 is known at compile time, so the compiler legitimately folded the guard away.
  • A Greeter class absent from a variant's class table — nm finds zero Greeter/greet symbols out of 1,555, i.e. fully inlined and tree-shaken, with only the name left in the string table for stack traces. The same toolchain's x64 corpus recovers Greeter at cid 205, so x64 class recovery is fine.

The general rule this release kept running into: read dae disasm before judging the decompiler. Several apparent omissions were the compiler's doing, and one apparent class-recovery failure was our own query hitting the wrong data source.

Verified

57 tests under DAE_REQUIRE_GATES=1, plus every ignored release gate · dart_valid full scorecard: 26 artifacts, 0 dart analyze errors · app_truth against real demo source: classes 98.8%/100%, literals 95.4%/97.4%, source-file→library 18/18 and 21/23 · regress_all 25/25 byte-identical · check_profiles 47/47 · clippy 0 · 41-check CLI comparison against the v0.1.8 binary.

Install

cargo install dae-rs          # crate name is dae-rs; the binary is `dae`
brew install ejfkdev/tap/dae
scoop bucket add ejfkdev https://github.com/ejfkdev/scoop-bucket; scoop install dae

Or grab a binary for Linux / macOS / Windows (x64 and arm64) below.

dae v0.1.8: 50x faster decompilation, a 20-command progressive CLI

Choose a tag to compare

@github-actions github-actions released this 27 Sep 21:52
v0.1.8
3b4cc18

The last release listed "decompiler wall-clock is not improved, and no speedup is claimed" as its top open item. That item is closed: --decompile is ~50× faster, and the CLI grows from 12 subcommands to 20.

Speed

Interleaved A/B against v0.1.7 on the same machine, two rounds each (the host carries unrelated load, so single runs are not evidence):

workload v0.1.7 v0.1.8
material_3_demo (15,082 functions) --decompile 106.8 / 108.7 s 1.91 / 2.35 s ~50×
real Android arm64 app (compressed pointers) --decompile 175.2 / 178.1 s 2.74 / 2.75 s ~64×
material_3_demo, export only 1.49 / 1.43 s 0.54 / 0.50 s ~2.8×
peak RSS 206–232 MB 179–211 MB −27…−47 MB

None of this came from a faster algorithm; it came from stopping three rebuilds of run-invariant data:

  • lift rebuilt the whole object-pool map once per function — 122,064 entries on a real app, rebuilt 15,082 times on the sample above — and Structurer owned its Roles by value, so emit_function deep-cloned that same map again per function. Both now borrow. This is the ~33× and it also explains why export got 2.8× faster: field recovery runs through the same lift path.
  • mask_regs rebuilt its register-alias table on every instruction — clone the profile aliases, append pp/thr and nine hardcoded pairs, stable-sort by descending key length, then one replace_word per pair, each allocating a String and rescanning the whole text. ~1M instructions × 20 pairs ≈ 20M allocations. The table is now built once; lift went 1.95 s → 0.63 s.
  • sort_by_key does not cache its key. callgraph sorted edges with sort_by_key(|a| (…, a.to_text.clone())), so it allocated a String per comparison: 96,904 edges × O(log N) ≈ 1.6M clones. Plus an O(n²) name lookup in the stubs exporter.

A single-pass alias lookup is not equivalent to sequential replacement in general, because the replacements cascade: the arm64 profile maps x29 → fp and x30 → lr (lowercase) and the hardcoded tail maps fp → FP, lr → LR, so x29 reaches the output as FP. The new table resolves each key by simulating the chain in the original pair order — graph reachability would be wrong here, since it also fires on a value that equals an earlier key, which sequential replacement never re-applies.

Two changes are kept but credited with nothing: capstone's .detail(true) → .detail(false) (no detail API is used anywhere, so it is strictly less work, but the A/B showed no measurable gain), and the callgraph fix's effect on wall time (it bought memory and removed 1.6M allocations; sorting 96k edges was already fast).

CLI: 20 subcommands on a declarative tree

get oriented   info · libs · classes · functions · largest
find things    strings · fields · members · findrefs · callers · callees
object layer   pp · objs · stubs
decompile      getclass · getmethod · getlib · decompile
low level      disasm
full export    export        (and `dae <binary> <out_dir>` stays as an equivalent shortcut)

Parsing moved to clap. The reason is not fashion — the hand-rolled parser was written twice over, and that shape is where the sibling tool's CLI bugs come from: an -o flag that was dead code because an earlier loop bailed on unknown options first, a subcommand that took a flag's value as its input path, and one that parsed --dex then dropped it. With flags declared once and positionals parsed independently, those three cannot occur. Shared options live in one struct flattened into every command; seven commands share one <binary> [pattern> shape and five share <binary> <name>, so their semantics cannot drift apart.

New in this release:

  • findrefs <bin> string TEXT — every code site that loads a given string literal from the object pool, and findrefs <bin> kind NAME for an object kind (the /* TypeArguments */ word that appears in dart/).
  • members — methods and fields in one search; callees — the other direction of the call graph, with columns identical to callers so the two read side by side.
  • pp / objs / stubs — the object layer becomes queryable. Each renders through the same function that writes text/pp.txt, text/objs.txt and text/stubs.txt, so "what the artifact shows" and "what the query returns" cannot disagree.
  • decompile <bin> — decompile without writing any other artifact, to stdout by default. This is the only way to pipe a whole app's pseudocode; a full export requires an out_dir.
  • Scope filters — --exclude-lib PATTERN, --no-sdk (URLs starting with dart:), --app (also package:flutter). Decided by the library's original URL, not by guessing from the mangled name: dart:core mangles to dart_core, and a package named dart_core_extra would look the same. On a real Flutter app the three levels measure 505 libraries / 15,796 functions → 489 / 11,016 (--no-sdk) → 56 / 765 (--app).

Function names now also accept the all-dots lib.Class.method form that callers/callees/findrefs/call_edges.txt print, so one command's output feeds straight into the next. That was broken, not merely missing: piping findrefs's from column into dae disasm reported "nothing matched".

Query latency on a 15,796-function app: info 53 ms, objs 43 ms, pp 59 ms, stubs 62 ms, members 68 ms, getclass 82 ms, decompile --app 185 ms, findrefs 209 ms, callees 311 ms.

Correctness fixes

  • pp.txt no longer prints a fabricated constant. Its first line was hardcoded pool heap offset: 0x10f000080, carried over from the Python reference implementation — which also hardcodes it. blutter computes that value as pool_addr − heap_base; dae parses the snapshot stream and has neither the pool's image address nor a heap base. The same constant printed for macOS and Android, compressed and uncompressed pointers, so it was wrong for at least some of them. It now reads unavailable with the reason, and a gate fails if a number ever comes back. This is the one place v0.1.8's output differs from v0.1.7's.
  • dae info exits 1 on parse drift instead of printing the drift and returning 0, and its drift message no longer prints a wrapped u64 class id (18446744073709551553 for −63).
  • Usage errors exit 2, matching the documented convention (0 ok / 1 runtime error / 2 usage error). They used to exit 1, conflating the two. The message is clap's precise diagnosis plus one bilingual line pointing at dae help — clap has no i18n, so that is the honest trade-off.

Output compatibility

Across all 11 commits since v0.1.7, the whole artifact tree is byte-identical to v0.1.7 except that one pp.txt line. Verified with diff -rq on both a Mach-O arm64 Flutter app (1,011 files including all 489 under asm/ and 505 under dart/) and a real Android compressed-pointer build, and the export summary — every count, including the decompiler's blocks/statements/structured/unstructured/unmapped figures — matches item for item. dae export <bin> <out> and the dae <bin> <out> shortcut produce identical trees.

Known limitation: the superclass chain is wrong

Documented now rather than newly broken. Analyzer::parent_of does not resolve superclasses correctly. Measured against an app's own source, which is on disk:

source says dae resolves
App extends StatefulWidget (cid 2285) SceneBuilder (cid 1142)
BrightnessButton extends StatelessWidget (cid 2115) ParagraphBuilder (cid 1057)
_AppState extends State<App> _MixinApplication163&…

The class names and libraries in the same output are correct, so cid → name is fine; only the super hop is wrong. Probing all 13 Class-cluster refs found that only positions 9 and 11 resolve through type_cids at all, and 11 is also wrong — so either the superclass is not among those refs, or the Type cluster's (flags >> 4) & cid_tag_mask decode is. The second is the likelier suspect, because it would explain getting a valid cid that is systematically the wrong one.

Two shipped artifacts consume this chain and are therefore unreliable, both now marked at their source sites: frida.js's sid field (measured: {id:2316,name:"App",…,sid:1142} where the real super is 2285) and the ancestor grouping inside text/objs.txt (the field values themselves are readable; the inheritance grouping is not). Neither was silently changed — altering frida.js bytes means re-validating 25 regression archives, which is its own piece of work.

A hierarchy command was built and then withdrawn: it produced the wrong chain, and a wrong inheritance chain is worse than none. The full diagnosis is left in src/cli.rs so nobody has to redo it.

Also deliberately not provided: findrefs field (compiled code carries no symbolic field reference, only a bare displacement, so matching on displacement would report unrelated [x, #0x18] as hits — guessing, not querying), findrefs type (a pool entry's description is Kind: content, and that prefix is the object category, not a type name), and a manifest-derived --app package name (a Dart snapshot has no manifest).

Still open

  • The superclass chain, above — fixing it needs the Dart SDK's Class::Serialize / AbstractType::Serialize for ref order and flags encoding, then re-baselining frida.js and the regression archives.
  • Object-pool names are still absent from the IDA/r2 scripts (blutter emits ~52,700 pp.* flags). The data exists — text/pp.txt carries 122,064 resolved entries on a real app, and findrefs now queries it — it is just not projected into the tool scripts.
  • text/pp.txt does not escape values, so a pool string containing a real newline spills across two lines and breaks the file's own one-entry-per-line format (text/strings.txt escapes; pp.txt does not). findrefs escapes correctly and is ...
Read more

v0.1.7

Choose a tag to compare

@github-actions github-actions released this 27 Sep 16:14
v0.1.7
158f703

The export side was silently dropping 86% of the function names it could have emitted. This release fixes that, and cuts peak memory on the export path by 9%.

Recovered names: 6× more

entry_for rejected any function whose instructions-table index fell below first_entry_with_code, on the theory that those entries were dispatch stubs with no function body. That reading was wrong. The SDK is explicit about the field — it is "the first Instructions object which is going to have Code object associated with it", recorded so the runtime can "reduce the binary search space when searching specifically for the code object". Entries below it belong to discarded Code objects: under dwarf_stack_traces_mode Dart drops the Code wrapper to save space, but the machine code stays in the image — the program has to run — and the serializer merely omits their payload_info.

Every real shipping app builds with dwarf stack traces on, so this hit all of them:

artifact table entries named functions libraries classes
Reqable (android) 57,960 2,164 → 13,371 496 → 1,734 1,141 → 3,567
Lark 飞书 79,327 5,262 → 25,183 1,418 → 3,194 2,868 → 6,306
Reqable.app (macOS) 70,996 2,358 → 17,319 564 → 1,955 1,285 → 4,262
ChatGLM 30,782 → 27,517 1,211 4,603
CHSI 学信网 19,752 → 17,438 875 3,256
Weibo 微博 22,623 → 19,807 750 3,671

All at 0 warnings. The IDA and radare2 scripts are what actually felt this: for Android Reqable, addNames.py goes 1,766 → 11,296 lines and addNames.r2 7,602 → 37,344. Class and library counts now land at or above aotopsy's on the same artifacts (CHSI 3,256 vs 3,819 classes; Weibo 3,671 vs 4,232).

Address validity was checked independently, not assumed. Of the 9,530 newly reachable entries on Android Reqable, 95.5% begin with stp x29, x30, [x7, #-16]! (Dart's arm64 prologue) against 82.7% for the already-validated set, 98.2% of their first instructions also occur in the validated set, and none is out of bounds. Decompiler output is unchanged apart from improving: Lark still emits exactly 3,517 function blocks / 20,335 blocks / 110,881 statements / 3,374 structured / 1 unmapped line, with named direct calls up 6,974 → 7,130.

No regression risk on the existing corpus: every hello sample builds with no-dwarf_stack_traces_mode, so their first_entry_with_code is 0 and the code path is untouched — regress_all stays 25/25 byte-identical and ground_truth stays 6,963/6,963 addresses at 89.4% naming.

Memory: export peak −9%

Every text/ file and both tool scripts were built as one String and then handed to fs::write, with capacity guessed as rows × bytes-per-row. Guessing high wastes resident memory; guessing low reallocs and memmoves repeatedly. They are now streamed through a BufWriter, so peak holds one 8 KB buffer per file instead of the whole file — and the per-row byte-count constants are gone rather than maintained.

Measured on Reqable.app (26 MB), old and new binaries back to back:

plain export peak RSS   163 -> 148 MB, and again 161 -> 146 MB     (-15 MB, -9%)
whole output tree       byte-identical (diff -rq over every file)
--decompile output      byte-identical

The --decompile peak did not measurably move: base ranged 172–196 MB and streamed 184–191 MB over four runs, so the spread exceeds the effect. That path's peak is dominated by the decompiler's own structures, not these buffers. An earlier "172 vs 191" reading was noise, not a regression — said plainly because the honest result is one path improved, the other did not.

Still open, and why

  • Decompiler wall-clock is not improved in this release, and no speedup is claimed. Profiling a debug-symbol build attributes it to format_inner, Formatter::pad/pad_integral, RawVecInner::finish_grow and _platform_memmove — i.e. roughly one format!("... // {addr:#x}") per statement, ~1M of them. Two allocation reductions aimed at that (replace_word slice copies, pre-sized buffers) were verified byte-identical but produced no measurable gain; the fix that would is converting render_op's call sites from format! to write! into reused buffers, which is a real refactor rather than a tweak. Timing on the verification host was also unusable — load average 16, the same binary varying 35% between runs.
  • Object-pool names are still absent from the IDA/r2 scripts (blutter emits ~52,700 pp.* flags). dae has the data — text/pp.txt carries 122,064 resolved entries for Reqable.app — it just is not projected into the tool scripts yet. This is the remaining export-side gap.
  • Dart 2.18.1 remains unusable (registered in KNOWN_COLLAPSED with the measurement showing both candidate layouts are unhealthy).

Verified

48 tests under DAE_REQUIRE_GATES=1 · full scorecard 26 artifacts / 0 dart analyze errors · regress_all 25/25 byte-identical · check_profiles 47/47 · app_truth 98.8%/100% class recovery against real demo source · clippy 0.

Install

cargo install dae-rs          # crate name is dae-rs; the binary is `dae`
brew install ejfkdev/tap/dae
scoop bucket add ejfkdev https://github.com/ejfkdev/scoop-bucket; scoop install dae

Or grab a binary for Linux / macOS / Windows (x64 and arm64) below.

v0.1.6

Choose a tag to compare

@github-actions github-actions released this 27 Sep 14:59
v0.1.6
b98df69

A verification release: the claims now rest on real applications with known source, not only on artifacts we built ourselves.

New gate: tests/app_truth.rs

Every existing truth gate ran on our own inputs — source_truth compiles a 160-line fixture, ground_truth diffs against .symtab on hello-world samples. This one decompiles real applications whose source is known and judges the output against that source, which is the only check that can catch a recovery chain rotting at scale.

Corpus: locally built flutter-samples apps (DAE_DEMO_ROOT, defaulting to a sibling checkout) — material_3_demo at 5,107 source lines and animations at 2,108. Measured, and asserted with a 0.90 floor:

check material_3_demo animations
public classes/mixins/enums in lib/ recovered 85/86 = 98.8% 35/35 = 100%
source string literals present in the output 292/306 = 95.4% 111/114 = 97.4%
source files mapping to a recovered library 18/18 = 100% 21/23 = 91%

The single missed type is enum Value { first, second } — its library is in the output, so the enum was tree-shaken rather than misparsed. The two unmapped animations files are examples nothing references.

The fast half (plain export + source comparison) takes ~2 s and runs in the normal suite; the --decompile + dart analyze half is #[ignore]d at 247 s for both. Negative-tested by raising the class floor to 1.01, which fails and names Value.

Also measured on shipping apps

  • Reqable.app (macOS arm64, 26 MB, Dart 3.3.4, a verified profile): 70,996 table entries, 0 warnings, 1,808 functions at 94.9% structured, dart analyze 0 errors.
  • material_3_demo: 15,082 functions, 985,900 statements, 92.5% structured, 3 unmapped lines, 0 errors, 94.7% of direct calls resolved to a name.
  • animations: 11,102 functions, 696,396 statements, 92.5% structured, 3 unmapped lines, 0 errors.

Added to the five Android builds already matching aotopsy exactly (57,960 / 79,327 / 30,782 / 19,752 / 22,623, all at 0 warnings).

What is out of scope, stated plainly

Of the 41 APKs in the local corpus exactly 8 ship a lib/arm64-v8a/libapp.so. Three of those eight are not standard Flutter AOT snapshots, and dae says so rather than guessing:

  • WeChat — its libapp.so is a 21-byte CSOS placeholder inside the APK itself (confirmed with unzip -l, not an extraction mistake). The real payload lives elsewhere.
  • DingTalk — features string carries enable_aion + llvm_compiler: a vendor fork that replaced the Dart AOT compiler with an LLVM backend. Its snapshot version hash matches no known SDK, so detection falls back to a low-confidence structural probe.
  • Tonghuashun — genuine Dart 2.7.2, and it behaves identically to the reference hello_2.7.2 sample: strings and the object layer export, but the instruction table is recoverable neither from the snapshot header nor from Code-cluster text offsets, so there are no function addresses. That is the documented ≤2.9 ceiling, not an artifact-specific failure.

Two allocation reductions — and an honest null result

Both verified byte-identical over 985,900 statements:

  • replace_word copied its input one byte at a time via out.push(b[i] as char) — a char conversion plus a UTF-8 encode per byte. It now moves whole slices with push_str, and subst_regs skips the call entirely when the operand text cannot contain the register.
  • Three output buffers grew from zero (per-function body, per-function disassembly comment, per-library file); all three are now sized from the statement / instruction / function counts.

Neither produced a measurable speedup, and we are not claiming one. Profiling a debug-symbol build attributed the time to format_inner, Formatter::pad / pad_integral, RawVecInner::finish_grow and _platform_memmove — i.e. the ~1M format!("... // {addr:#x}") calls, not the register substitution first suspected. Interleaved A/B on animations gave 67.7 / 61.5 s before and 63.1 / 67.7 s after: the same binary varies by more than the effect on a host carrying unrelated load. The changes stay because they are strictly less work and provably output-identical. The real fix — replacing those format! calls with write! into reused buffers across render_op — is a larger refactor, deliberately not attempted in a release commit.

Docs

README and DECOMPILER carry the real-app tables above and the out-of-scope classification. The gates list now names app_truth first, since it is the strongest one.

Verified

48 tests under DAE_REQUIRE_GATES=1 · full scorecard 26 artifacts / 0 errors · regress_all 25/25 byte-identical · check_profiles 47/47 · clippy 0 · Lark / Weibo / Reqable.app still at 79,327 / 22,623 / 70,996 table entries.

Install

cargo install dae-rs          # crate name is dae-rs; the binary is `dae`
brew install ejfkdev/tap/dae
scoop bucket add ejfkdev https://github.com/ejfkdev/scoop-bucket; scoop install dae

Or grab a binary for Linux / macOS / Windows (x64 and arm64) below.

v0.1.5

Choose a tag to compare

@github-actions github-actions released this 27 Sep 01:14
v0.1.5
6a498b8

A correctness and gate-integrity release. No new artifact support — this one fixes a bug v0.1.4 shipped and closes the hole that let it ship.

Fixed: string literals folded into arithmetic

A pool string literal was carried as a pending value and substituted into later expressions, so an address computation rendered as

rbx = mem((" fib(20)=") + rdx*8 + 0x17);   // String + int → argument_type_not_assignable

The register holds the address of the pool slot; the literal is what lives at that address. Substituting one for the other is a category error in every context, not just arithmetic — Dart merely happened to reject this one. Literals are now left alone when the operand text contains arithmetic; they still appear on the line that loads them (which is where they belong), and non-arithmetic uses still substitute.

This was the corpus's only dart analyze error, and it was a regression introduced in v0.1.4 by the Function layout fix: the same sample scored 0 errors before that change. The full corpus is back to 0 errors across 26 artifacts / 291 files / 24,253 functions, with file and function counts unchanged — nothing was suppressed to get there.

Fixed: the gate that should have caught it

full_scorecard ran every artifact in the corpus and then only printed the total. So the summary line read dart analyze 错误 1 while the suite stayed green — dart_valid's asserted corpus is three samples, and this test was #[ignore]d and informational. "The output compiles" is the claim this project leads with, so the test that actually measures it now asserts it:

  • total dart analyze errors must be 0
  • at least 20 samples must be present, so a missing or moved artifacts directory cannot score a vacuous zero
  • every sample that produced output must recover ≥ 400 functions. A drifted parse collapses to libraries=1 / classes=1 while still analysing cleanly, so "0 errors" alone does not prove health. hello_2.12.4 at 1,212 and hello_2.18.1 at 27 are an order of magnitude apart, so the floor cannot misfire.
  • a sample that produces no .dart at all must be listed in SCORECARD_NO_ADDRESS_LAYER — an explicit registry, not a files == 0 escape, because the latter would let a future collapse-to-nothing pass silently. It holds hello_2.7.2 and hello_2.10.4, which have no instruction table by design.

Negative-tested: raising the floor to 2,000 fails on hello_2.12.4 with the reason printed.

Docs re-measured, not carried over

  • Decompiler scorecard synced to a fresh full run. hello_2.19.6 goes from 2 files / 229 functions to 15 / 1,240 now that its layout is right. hello_2.18.1 stays in the table at 27 functions with a pointer to the collapse registry — hiding it is what let it sit unnoticed.
  • Comparison vs aotopsy: Lark and Weibo move from "open" to their real numbers, so all five Android builds now match aotopsy's instructions-table entry counts exactly (57,960 / 79,327 / 30,782 / 19,752 / 22,623), and their decompile results are listed. The naming-agreement figure is corrected 90.6% → 89.4% with the reason stated: the ground-truth corpus grew from three samples to six, and hello_2.19.6 alone went from 558 to 1,081 agreeing names. The 2026-09-26 "before the fix" table is kept as history.
  • Profile spec gains a section on fields whose presence depends on the version (TypeParameter, Function, SubtypeTestCache, FfiTrampolineData, Type — with the boundary version for each), plus the two datastream.h encoding rules that are easy to invert and cost hours: Write<T> is a varint unless sizeof(T) == 1, and WriteUnsigned marks the terminator, not the continuation bytes (the inverse of ULEB128).
  • README tagline and Features now mention the decompiler and mobile support; the decompiler had a section but no feature bullet. Repo description and topics updated to match (android, decompiler, compressed-pointers), and Cargo.toml's description likewise — that one reaches crates.io with this release.

Adjudicated: Dart 2.18.1

Both candidate Function layouts were measured against .symtab, and neither is healthy: 1 trailing varint gives libraries=1 / classes=1 / 63 functions, 2 gives libraries=1 / classes=2 / 629 functions, against ~15 / ~320 / ~1,300 for a working sample. The source is unambiguous — 2.18.1's WriteFill diffs empty against 2.19.6, whose 1-varint layout is confirmed by both .symtab and aotopsy's entry count — so the source-correct layout stays and 2.18.1's second error remains unlocated. Keeping the 2-varint variant would freeze a compensating mistake into the profile and misdirect whoever finds the real one. The measurement is recorded in the registry so nobody has to redo it.

2.18.1 should be treated as unsupported. 2.15 / 2.16 / 2.17 and 2.19+ are fine.

Verified

47 tests under DAE_REQUIRE_GATES=1 · full scorecard 26 artifacts / 0 errors · regress_all 25/25 byte-identical · check_profiles 47/47 · clippy 0 · all five real-device Android artifacts still at their exact entry counts with 0 warnings.

Install

cargo install dae-rs          # crate name is dae-rs; the binary is `dae`
brew install ejfkdev/tap/dae
scoop bucket add ejfkdev https://github.com/ejfkdev/scoop-bucket; scoop install dae

Or grab a binary for Linux / macOS / Windows (x64 and arm64) below.

v0.1.4

Choose a tag to compare

@github-actions github-actions released this 26 Sep 23:02
v0.1.4
46becc3

Real-device Android support

This is the release that makes dae work on the artifacts people actually reverse: compressed-pointer arm64 libapp.so from shipping Flutter apps. v0.1.3 was tagged before that work was committed, so the published binaries did not have it — this release does.

21 compressed-pointer SDK profile variants (2.13.4 → 3.14β) ship embedded and are selected automatically from the snapshot's own features string; no new flags, no guessing. Compressed builds differ structurally, not just in pointer width: they have no ROData clusters at all (strings, PcDescriptors, CodeSourceMap and CompressedStackMaps become filled clusters), instance field slots are counted in pointer-width units so the slot count is nfo − 2, the VM-side string cluster omits the canonical-set trailer that the isolate-side one writes, and the data image stays 64-aligned.

Verified against five real Android apps. The metric is the instructions-table entry count, which is the strongest available check when a binary carries no symbols — and it matches aotopsy exactly on every one:

App Dart Table entries dart analyze errors
Reqable 3.3.4 57,960 —
Lark (飞书) 3.6.1 79,327 0
ChatGLM 3.11.6 30,782 —
CHSI (学信网) 3.7.2 19,752 —
Weibo (微博) 2.19.6 22,623 0

All five at warnings 0. Lark and Weibo were also decompiled end to end: 3,517 and 19,053 function blocks, 95.9% and 91.1% structured, 1 unmapped line each, and the emitted Dart analyses with zero errors (the remaining diagnostics are unused_local_variable / dead_code warnings, see below).

Five parse bugs fixed, each against SDK source

Four of these came from the profile generator remapping the 3.3.4 baseline onto older versions by class id alone, which silently carried fields those versions never wrote:

  • ObjectPool resync discarded a correct value. The resync heuristic fires above 100,000 entries — a threshold chosen for drifted 2.10–2.14 samples. Lark's 25.6 MB build has a genuine 105,214-entry pool, so the heuristic threw it away and guessed 5,640. The alloc and fill passes each read that length independently, so when they agree the position is provably sound; resync now stands down in that case.
  • TypeParameter writes int32 + 3× uint8 up to 2.19, int32 + 2× uint16 + uint8 in 3.0, and 2× uint16 + uint8 from 3.2 on. (Write<uint8_t> is a genuine raw byte; Write<uint16_t>/Write<int32_t> go through Raw<2>/Raw<4> into a varint.) Reading a varint where a raw byte sits is fatal, because the signed-varint reader only stops at a byte with the high bit set.
  • Function: packed_fields_ moved inside if (kind != kFullAOT) in 2.18, so AOT snapshots carry one trailing varint from 2.18 on, not two.
  • SubtypeTestCache: num_inputs/num_occupied were added in 3.2.0.
  • FfiTrampolineData: ffi_function_kind_ was added in 3.2.0.

Gates that cannot pass without measuring

A gate that reports success when it never looked at anything is worse than no gate, and this repo had three:

  • dart analyze on a missing directory exits 64 with usage text containing no error - lines. Both analyze-based gates parsed that as "0 errors" and passed. They now cross-check their parse against the exit code (0/1/2 ⇒ none, 3 ⇒ at least one) and require dart's own summary line, and each carries a *_rejects_directory_it_never_analyzed test.
  • dart_valid asserted only that the error count was zero, so a regression that emitted nothing while exiting 0 scored files=0, errors=0 and passed. Every corpus must now produce files and functions.
  • Five of the six gate files consume gitignored corpora and skip themselves when those are absent, while cargo test swallows the notice — a fresh clone reported a green suite having measured almost nothing. DAE_REQUIRE_GATES=1 cargo test --release turns any such skip into a hard failure.

New gate: tests/source_truth.rs compiles tests/fixtures/truth.dart with the local dart, decompiles it, and checks the result against the source — the first gate whose input is source code rather than a self-consistency property or a .symtab diff. DAE_TRUTH_ANDROID=1 runs the same battery on a compressed-pointer arm64 build.

Also new: a collapse detector. A drifted parse used to announce itself only as libraries=1 / classes=1 with warnings=0; hello_2.18.1.aot sat at classes=2 (healthy is ~320) unnoticed. FUNC_FLOOR now fails any corpus sample recovering fewer than 400 functions, with KNOWN_COLLAPSED as an explicit registry.

Where a change could be adjudicated, it was adjudicated against evidence dae did not produce — .symtab, or aotopsy's counts — never against dae's own regression archives. Adding hello_2.19.6 to the ground-truth corpus shows why: 630 → 1,318 recovered functions, 558 → 1,081 name agreements, addresses 100% both ways.

Performance and memory

  • Dominator computation stored a full dominator set per block — O(n²) memory, ~2.6M set nodes for the 1608-block functions in this corpus — and rebuilt the predecessor list on every fixpoint iteration. It is now a standard Cooper–Harvey–Kennedy immediate-dominator array, O(n). That also fixed a real bug: intersecting with an unreachable predecessor's {self} set crushed a block's dominators and hid back edges, so loops were emitted as straight-line if/else. Detected loops on CHSI went 1173 → 1200 and gotoLabel fallbacks 4987 → 4961.
  • PoolEntry.typ was a String holding one of four values — one heap allocation per pool entry, 105,214 of them on Lark. It is a Copy enum now.
  • Measured with old and new binaries interleaved under contention, minimum of three: CHSI --decompile 160.4s → 157.9s and peak RSS 205 → 196 MB; Lark plain export 155 → 152 MB. Modest in percentage terms — the durable win is that a pathological function can no longer allocate quadratically.
  • 83 clippy warnings → 0.

Known limitations

  • Dart 2.18.1 is not usable. With the source-correct Function layout its parse collapses; the previous layout only scored better because an extra varint was compensating for a second, still-unlocated error. It is registered in KNOWN_COLLAPSED with that reason rather than left as a quietly low score. 2.15/2.16/2.17 and 2.19+ are fine.
  • WeChat 2.15.0, Tonghuashun 2.7.2 and DingTalk (custom engine) still report 0 table entries.
  • Call sites show no arguments, so the registers a caller sets up look like dead stores — that is the bulk of the unused_local_variable warnings (30,027 on Weibo). Rendering them is not honest yet: an argument register may have been written before an intervening call, and dae has no verified per-ABI clobber list to prove liveness. Attempted and reverted; the source-truth fixture caught it.
  • Everything is still dynamic. Field and return types are recoverable — the type ref is already read and discarded, and type_cids already maps a type ref to a class id — but that is not wired up yet.

Install

cargo install dae-rs          # crate name is dae-rs; the binary is `dae`
brew install ejfkdev/tap/dae
scoop bucket add ejfkdev https://github.com/ejfkdev/scoop-bucket; scoop install dae

Or grab a binary for Linux / macOS / Windows (x64 and arm64) below.

v0.1.3

Choose a tag to compare

@github-actions github-actions released this 26 Sep 12:48
v0.1.3
6917b5c

Highlights

  • Field names where they can be proved — the snapshot's surviving Field objects plus implicit accessor symbols, rendered as attributed comments: x0 = mem((local_0), 0x17); /* _FutureListener.result (off 0x18) */. The two sources are independent and agree on 40 of 41 entries (39/39 on x64), with zero conflicts.
  • The decompiler's output is valid Dart — dart analyze errors went 680,515 → 0 on a real Flutter app (412 files, 10,245 functions), and 0 across all 27 corpora. 87–92% of functions come out with structured control flow; the rest keep an honest gotoLabel + NOTE header.
  • Progressive mode — list first (dae libs / classes / functions / strings / fields / largest / callers / disasm), then decompile one class, method or library. dae getclass Foo takes 0.03 s against 1.9 s for a full export; stdout is the data channel, so it pipes.

What's changed

Decompiler (--decompile)

  • Structure, not goto. Dominators find the natural loops (back edge = header dominates its tail), diamonds become if/else, loop headers while, exits break/continue. Dart AOT merges identical function bodies, so those shared chunks are adopted; forward shared tails are duplicated; backward jumps to a non-header are genuinely irreducible loops and keep gotoLabel.
  • The output compiles. Machine syntax is rewritten (mem/memSet/memRead2/callIndirect/gotoLabel), names are sanitised into identifiers (mixin-application class names contain &), and each file opens with a pseudo-runtime preamble that states where the machine layer ends and Dart begins.
  • Object-pool constants are inlined: ldr x0, [PP, #0x17f8] becomes x0 = "Hello" /* pp+0x17f8 */ (1,922 literals on the Flutter app).
  • Field names, when provable. host_offset_or_field_id_ is a Smi and Smis are merged into the Mint cluster, so the Mint integer is the field's word index → byte offset = word × word_size → machine displacement = offset − 1. Pinned down four ways (four _FutureListener getters, _Uri.path as the 5th declared field, a generic class's unboxed bitmap, Error._stackTrace at word 1). The second route reads implicit getter/setter names (kind 6/7) whose body touches exactly one field; hand-written accessors are excluded because their names lie (get:_ignoreError reads _state).
  • Address fixes for appended snapshots (Mach-O LC_NOTE, and the 2.12–2.14 dart compile exe trailer → inner ELF). These had been decompiling the wrong bytes while every name-based metric stayed green; the gate now carries a prologue-rate floor (51–58% broken vs 91–100% correct).
  • Instructions: csel folds through cmp, brk is a terminator, frame/barrier instructions become // frame: comments rather than pretending to be data flow; unmapped lines dropped to single digits on most corpora and the count is printed in the run summary.

New outputs and commands

  • text/fields.txt + dae fields <binary> [pattern] — class, field, source (rec = snapshot, accessor = symbol-derived) and byte offset.
  • text/stubs.txt — instruction-table entries with no Code object (1,982 on the app, 1,295 named from their prologues), and allocation-stub names used at call sites.
  • call_edges.txt + callgraph.dot — direct call edges and indirect call sites.
  • dae info / libs / classes / functions / strings / fields / largest / callers / disasm / getclass / getmethod / getlib; --lib/--class/--func also produce a filtered export (object-layer dumps stay complete).

Gates (run on every change)

  • tests/dart_valid.rs — real dart analyze, zero errors expected.
  • tests/decompiler_shape.rs — brace balance per file, statement termination, structured-rate floor, and address self-consistency.
  • tests/field_names.rs — the two field-name routes must agree, zero conflicts, and every annotation in the output must exist in the recovered table (the no-fabrication rule).
  • tests/cli.rs — stdout purity and ASCII-only artifacts; tests/ground_truth.rs — differential against the binaries' own .symtab.

Numbers

arm64 sample Flutter app (412 files)
dart analyze errors 0 0 (was 680,515)
functions fully structured 89.2% 92.7%
named fields recovered 61 367
annotated field accesses 218 438

All 25 SDK-version regression samples still match their archived object-layer output byte for byte.

Install

cargo install dae-rs                             # crates.io
brew install ejfkdev/tap/dae                     # macOS / Linux (Homebrew)
scoop install dae                                # Windows (scoop bucket)

Or download the binary for your platform below (Windows/macOS/Linux × x64/arm64; x64 builds are UPX-compressed).

v0.1.2

Choose a tag to compare

@github-actions github-actions released this 02 Sep 02:56
v0.1.2
e39723b

Highlights

  • Per-target struct headers — DartThread from a version × architecture layout table, DartObjectPool generated from the analyzed binary's own object pool
  • Six new text inventories — strings, libs, classes, functions, arrays, maps — grouped under text/
  • Cleaner CLI — absolute output path, internal diagnostics hidden by default, version string follows the release tag

What's changed

Exports

  • r2_script/r2_dart_struct.h and ida_script/ida_dart_struct.h are now generated per target instead of a fixed blutter template:
    • DartThread comes from a 24-version × arm64/x64 layout table (profiles/struct/, compiled from the Dart VM);
    • DartObjectPool is built from the target's own object-pool entries (offset = 0x10 + 8·i, matching pp.txt).
  • Dropped the blutter MIT header from generated struct files — they are now first-party output, not a vendored template.
  • Added six text dumps: text/strings.txt, text/libs.txt, text/classes.txt, text/functions.txt, text/arrays.txt, text/maps.txt; pp.txt / objs.txt also move under text/.

CLI

  • Prints the absolute output directory (not the relative path as typed).
  • Hides VM/ISO header stats, string/class/function counts and the instruction-table summary by default — re-enable with DART_AOT_VERBOSE=1.
  • Removed the duplicated output path from the final done line.
  • --version / help show a clean vX.Y.Z (follows the CI release tag, no -N-gHASH dev suffix); help now lists the full output set and the supported range/architecture (Dart 2.7–3.14β; Mach-O/ELF/PE × x64/arm64).

Docs / metadata

  • Rewrote README (English/Chinese) in a tighter style and documented the new outputs.
  • .gitattributes marks profiles/sdk/ and profiles/struct/ vendored so GitHub reports Rust (was JSON/C header).
  • .gitignore excludes the local-only struct field reference tables (profiles/struct/*/*.json).

Install

cargo install dae-rs                                  # crates.io
brew install ejfkdev/tap/dae                          # macOS (Homebrew tap)

Or download the binary for your platform below (Windows/macOS/Linux × x64/arm64; x64 builds are UPX-compressed).