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.