Releases: ejfkdev/dae
Release list
v0.1.11
修复循环头栈溢出守卫被整条丢弃(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
修复五个「产物给出错误值」的缺陷,外加流式落盘与并行渲染
这一版的主线是正确性:五个缺陷都不是「少一行」,而是产物读起来会算错。全部由「拿源码对照产物」和「逐类审计真实应用」发现,既有门禁一个都没报——产物照样过 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丢掉目标寄存器(渲染成裸调用,对目标的写消失);arm64sbcs被 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
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 fromasm/appear as statement addresses indart/. 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— acondFlagargument 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 ofclassifyemitreturn 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 StatefulWidgetresolves toSceneBuilder. It feedsfrida.js'ssidfield and the ancestor grouping intext/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 ppanddae findrefsquery 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 missingif (amount <= 0) throw ArgumentError(...)— that Code object is 4 instructions, and the immediate is0x32= 50. It is the constant-specialised copy ofa.deposit(50)frommain;50 > 0is known at compile time, so the compiler legitimately folded the guard away.- A
Greeterclass absent from a variant's class table —nmfinds 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 recoversGreeterat 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 daeOr grab a binary for Linux / macOS / Windows (x64 and arm64) below.
dae v0.1.8: 50x faster decompilation, a 20-command progressive CLI
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:
liftrebuilt the whole object-pool map once per function — 122,064 entries on a real app, rebuilt 15,082 times on the sample above — andStructurerowned itsRolesby value, soemit_functiondeep-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 sameliftpath.mask_regsrebuilt its register-alias table on every instruction — clone the profile aliases, appendpp/thrand nine hardcoded pairs, stable-sort by descending key length, then onereplace_wordper pair, each allocating aStringand rescanning the whole text. ~1M instructions × 20 pairs ≈ 20M allocations. The table is now built once;liftwent 1.95 s → 0.63 s.sort_by_keydoes not cache its key.callgraphsorted edges withsort_by_key(|a| (…, a.to_text.clone())), so it allocated aStringper 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, andfindrefs <bin> kind NAMEfor an object kind (the/* TypeArguments */word that appears indart/).members— methods and fields in one search;callees— the other direction of the call graph, with columns identical tocallersso the two read side by side.pp/objs/stubs— the object layer becomes queryable. Each renders through the same function that writestext/pp.txt,text/objs.txtandtext/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 anout_dir.- Scope filters —
--exclude-lib PATTERN,--no-sdk(URLs starting withdart:),--app(alsopackage:flutter). Decided by the library's original URL, not by guessing from the mangled name:dart:coremangles todart_core, and a package nameddart_core_extrawould 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.txtno longer prints a fabricated constant. Its first line was hardcodedpool heap offset: 0x10f000080, carried over from the Python reference implementation — which also hardcodes it. blutter computes that value aspool_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 readsunavailablewith 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 infoexits 1 on parse drift instead of printing the drift and returning 0, and its drift message no longer prints a wrapped u64 class id (18446744073709551553for −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::Serializefor ref order and flags encoding, then re-baseliningfrida.jsand 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.txtcarries 122,064 resolved entries on a real app, andfindrefsnow queries it — it is just not projected into the tool scripts. text/pp.txtdoes 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.txtescapes;pp.txtdoes not).findrefsescapes correctly and is ...
v0.1.7
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_growand_platform_memmove— i.e. roughly oneformat!("... // {addr:#x}")per statement, ~1M of them. Two allocation reductions aimed at that (replace_wordslice copies, pre-sized buffers) were verified byte-identical but produced no measurable gain; the fix that would is convertingrender_op's call sites fromformat!towrite!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.txtcarries 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_COLLAPSEDwith 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 daeOr grab a binary for Linux / macOS / Windows (x64 and arm64) below.
v0.1.6
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
verifiedprofile): 70,996 table entries, 0 warnings, 1,808 functions at 94.9% structured,dart analyze0 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.sois a 21-byteCSOSplaceholder inside the APK itself (confirmed withunzip -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.2sample: 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_wordcopied its input one byte at a time viaout.push(b[i] as char)— a char conversion plus a UTF-8 encode per byte. It now moves whole slices withpush_str, andsubst_regsskips 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 daeOr grab a binary for Linux / macOS / Windows (x64 and arm64) below.
v0.1.5
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_assignableThe 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 analyzeerrors 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=1while still analysing cleanly, so "0 errors" alone does not prove health.hello_2.12.4at 1,212 andhello_2.18.1at 27 are an order of magnitude apart, so the floor cannot misfire. - a sample that produces no
.dartat all must be listed inSCORECARD_NO_ADDRESS_LAYER— an explicit registry, not afiles == 0escape, because the latter would let a future collapse-to-nothing pass silently. It holdshello_2.7.2andhello_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.6goes from 2 files / 229 functions to 15 / 1,240 now that its layout is right.hello_2.18.1stays 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.6alone 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 twodatastream.hencoding rules that are easy to invert and cost hours:Write<T>is a varint unlesssizeof(T) == 1, andWriteUnsignedmarks 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), andCargo.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 daeOr grab a binary for Linux / macOS / Windows (x64 and arm64) below.
v0.1.4
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.
TypeParameterwritesint32 + 3× uint8up to 2.19,int32 + 2× uint16 + uint8in 3.0, and2× uint16 + uint8from 3.2 on. (Write<uint8_t>is a genuine raw byte;Write<uint16_t>/Write<int32_t>go throughRaw<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 insideif (kind != kFullAOT)in 2.18, so AOT snapshots carry one trailing varint from 2.18 on, not two.SubtypeTestCache:num_inputs/num_occupiedwere 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 analyzeon a missing directory exits 64 with usage text containing noerror -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_analyzedtest.dart_validasserted only that the error count was zero, so a regression that emitted nothing while exiting 0 scoredfiles=0, errors=0and 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 testswallows the notice — a fresh clone reported a green suite having measured almost nothing.DAE_REQUIRE_GATES=1 cargo test --releaseturns 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-lineif/else. Detected loops on CHSI went 1173 → 1200 andgotoLabelfallbacks 4987 → 4961. PoolEntry.typwas aStringholding one of four values — one heap allocation per pool entry, 105,214 of them on Lark. It is aCopyenum now.- Measured with old and new binaries interleaved under contention, minimum of three: CHSI
--decompile160.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
Functionlayout its parse collapses; the previous layout only scored better because an extra varint was compensating for a second, still-unlocated error. It is registered inKNOWN_COLLAPSEDwith 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_variablewarnings (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 — thetyperef is already read and discarded, andtype_cidsalready 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 daeOr grab a binary for Linux / macOS / Windows (x64 and arm64) below.
v0.1.3
Highlights
- Field names where they can be proved — the snapshot's surviving
Fieldobjects 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 analyzeerrors 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 honestgotoLabel+ NOTE header. - Progressive mode — list first (
dae libs / classes / functions / strings / fields / largest / callers / disasm), then decompile one class, method or library.dae getclass Footakes 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 headerswhile, exitsbreak/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 keepgotoLabel. - 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]becomesx0 = "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_FutureListenergetters,_Uri.pathas the 5th declared field, a generic class's unboxed bitmap,Error._stackTraceat word 1). The second route reads implicit getter/setter names (kind6/7) whose body touches exactly one field; hand-written accessors are excluded because their names lie (get:_ignoreErrorreads_state). - Address fixes for appended snapshots (Mach-O
LC_NOTE, and the 2.12–2.14dart compile exetrailer → 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:
cselfolds throughcmp,brkis 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/--funcalso produce a filtered export (object-layer dumps stay complete).
Gates (run on every change)
tests/dart_valid.rs— realdart 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
Highlights
- Per-target struct headers —
DartThreadfrom a version × architecture layout table,DartObjectPoolgenerated 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.handida_script/ida_dart_struct.hare now generated per target instead of a fixed blutter template:DartThreadcomes from a 24-version × arm64/x64 layout table (profiles/struct/, compiled from the Dart VM);DartObjectPoolis built from the target's own object-pool entries (offset =0x10 + 8·i, matchingpp.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.txtalso move undertext/.
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
doneline. --version/ help show a cleanvX.Y.Z(follows the CI release tag, no-N-gHASHdev 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.
.gitattributesmarksprofiles/sdk/andprofiles/struct/vendored so GitHub reports Rust (was JSON/C header)..gitignoreexcludes 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).