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.