ADR, ROADMAP: D58 lands; D62 to D64 carry what is left

doc/adr-decompiler-rewrite.md gets "D2's spelling order" (one pass in reverse postorder, why a pass and
not a prediction, forward edges, the late edge promise, the gate and its measured table) and the
forward-edge rule in "Merges"; doc/adr-structure-dominator-tree.md no longer ledgers D58. ROADMAP D58
is done with the measured numbers (the order sweep 0, 0, 0 against 0, 120, 187 with the knobs alone;
13 of 2,508 functions move in the natural order, +9 sites; 0pack +48 and pumasim +16 residual sites
over 23,213 functions, none rendered to refused; instructions retired within -0.12% to +0.13%);
the old row moves verbatim to doc/roadmap-history.md. New rows, numbered after the guards' D61: D62
(a callee's result presence, r2ssa states, r2types joins, r2dec reads; the exact repro bash 0x61520 and
the negative test for pumasim png_free), D63 (any rendered forward copy, conditional on the certificate
agreeing a gap traps) and D64 (Values immutable once spelled).

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01456qmqCGpCWJkrg6JUZNNM
This commit is contained in:
Priyanshu KumarandClaude Sonnet 5.5 committed 2026-10-23 16:23:28 +05:30
1 parent 29f7a0d26b
commit f2256c1aaa
4 files changed
+98 -11

No files matched your search

+6 -3
View File
@@ -115,10 +115,13 @@ Decisions
| D55 | Wall time: the listing answers from the walked body (`pdf`, `afb`, `afi` 4 to 5 times slower than radare2 on the bench protocol), and D50's pool design (threads over a `Send` database with shared `Held` summaries, or processes) is decided before D50 starts (gzdoom decompile-all 758.7 s serial against Kuna's 155 s wall at ten workers); the bench records RSS and a decompile-all column; `ax` and gzdoom enter `TIMED` | Q, LX |
| D56 | The product surface has dates: user facts (rename, retype, comment, save) as query-database inputs with a `Basis` (ADR first), `j` on every verb, an embedded agent skill with a test that every verb it names exists, a thin MCP server, an agent-task bench of about 30 questions before A, an i386 equivalence target, cited Windows API rows, library identification by structural hashes, demangling as a `Symbol` hint | V, S1, A, I, P7 |
| D57 | The loop reaches the field and the repository: a merge cadence (a cap of 8 open stacked PRs, a merge queue, bottom-first on green), a red nightly opens an issue within 24 hours, the bench lives in the tree and its nightly builds the branch tip, artifacts leave contabo, a release binary per tag with an opt-in friction record, a fuzz and sanitizer gate for untrusted input, and a quarterly field note on IDA, Binary Ninja, Ghidra, angr, Kuna, rizin and radare2 | GT, maintainer |
| D58 | A rendering decision depends on facts, not on the order placement writes blocks. Landed: a callee's declaration is its first call's in address order among the calls that state one (`Values::decide_prototypes`, `O(calls log callees)`), so pumasim `0x003b6b20`'s `png_free` stays `void` with 23 residual sites whichever exit is written first. Left, next: whether a name reads as a value or a NeverAssigned residual is the text written so far (`Values::read_name`, the `assigned` set), and no write order makes it right for a loop-carried merge whose entry copy is a gap, because its latch copy is written after every header-side read; the owner is a two-phase emission in `render::values` that knows which definitions and edge copies render before any read (definite assignment, not a set of statements written), gated by an engine test that gives the same verdict under every block write order. Repro: 0pack `pdd @ 0x00476982` (`Obfuscator::ELF::RegisterManager::get_by_str`), residual sites 7 at the base and at the chains head and 9 at the stack's head (its author measured 7 before and 9 at the sole-exit commit on the branch alone), because the inlined exit is written before the latch copy `rsp_8 = rsp_10`: `rsp_8 + 8` became `r2sleigh_residual_u64(2) + 8` and `rsp_24 = rsp_8` became `rsp_24 = r2sleigh_residual_u64(3)`, the proof line's never-assigned set (`RAX_5`, `RSP_8`) unchanged; the entry edge into the loop is the `ValuesNotRendered` gap at `0x4769b1`, so the text before the sole-exit commit (`e2401bdf` on the stack) read `rsp_8` after the loop on a zero-trip path where nothing assigns it, and the new verdict is the conservative one there. Also next: a callee's result is a program fact, so a call that discards it should not spell the callee `void` and a call that reads it `uint64_t` (the disagreement is `png_free`'s two sites that overwrite `rax` against five that do not), because the first call in address order can be the minority form and the calls that lose become gaps whose result's readers read NeverAssigned (pumasim `0x40c140`: `QPointArray::setPoint` 14 disagreeing sites against 46 once the first call by address declares it, `0x34c050`: `QGDict::look_ptr` declared `void` leaves 6 more never-assigned reads; 0pack `0x66d2b0`, `0x66f040` and `0x670e80`: `el::base::MessageBuilder::operator<<` declared `void` by a first call that drops its result, 5 disagreeing sites against 15); and the certificate asks nothing of a copy's statements (a copy that drops one passes `I_S` and the ledger, found by mutating `tails::copies`; conditional on a per-occurrence statement check that blocks written in parts do not break) The skeptic's measurements at the stack's head (2026-10-23): residual verdicts still depend on the write order, identically at the base (reversing the loop exits' write order changes the ledger of 119 of 2,436 functions, reversing the merge children's 183; bash `0x3c2b0` 0 to 3 gaps, `0x26760` 1,026 to 1,152; `0x85d70` 232 to 231 residual with no new evidence), while callee declarations no longer do (0 of 11,091 differ under both permutations, 2 and 4 at the base); the address-order declaration moves 22 of 13,093 declarations against the base (14 `void` to a value, 8 a value to `void`), 21 more obligations in 4 functions and 62 fewer in 13, and bash `0x61520` is the repro of the tie break ignoring a call that reads the result (`set_signal_handler` returns a pointer and becomes `void`, its reader `RAX_3` never assigned, 12 to 13 residual). Gate for the two-phase emission: an engine test that one verdict holds under every write order, with the permutations above as the mutants; timing: next | SD, next |
| D58 | A rendering decision depends on facts, not on the order placement writes blocks. Done (2026-10-23, [adr-decompiler-rewrite](doc/adr-decompiler-rewrite.md) "D2's spelling order"): `render::spelled::spell` spells every placed block once, in the CFG's reverse postorder, before `control::Writer` places any (`O(B + E + statements + phi inputs)`, no fixpoint), and the Writer holds no `Values` (`crates/r2dec/tests/architecture.rs`); a merge reads as a variable only when every forward edge into its block renders its copy (`Values::owed`; a back edge, a self copy and a gap pay nothing; an edge that retreats in reverse postorder in an irreducible region is read on its promise and the function is spelled again if that copy gaps, `spell_function`: 43 of 2,508 functions hold one, 1 is spelled again). Gate: `census.py orders` over a `--features placement_mutants` build (O1 keeps every loop exit after its loop, O2 reverses each loop's exits, O3 each region's merges) on bash, ls-toybox and pigz: 0 differ under all three, against 0, 120 and 187 with the knobs alone; the text moves in 64, 332 and 720 functions, so the knobs are live; 1 (O2) and 7 (O3) differ only in a selection the structurer chose (`x = c ? a : b`), which reads the layout; five engine tests under the four orders, each a fresh program (the 43, 48 and 24 byte merges that fail before the pass or the rule, a merge whose copies all render, and two irreducible cycles). Natural order against the base, bash, ls-toybox and pigz: 13 of 2,508 functions move, +9 residual sites (seven bash functions' block-transfer locals are numbered in reverse postorder, bash `0x8ee40` lists its `_next` temporaries in another order, five hold a gapped forward copy: bash `0x2dd90`, `0x48140`, `0x85d70`, ls-toybox `0x392dc`, `0x501c8`); 0pack (8,437 functions) 47,850 to 47,898 residual sites (27 functions move: 26 more sites, none fewer) and pumasim (14,776) 67,628 to 67,644 (15 move: 8 more, 2 fewer: a definition the old write order read before spelling it), the biggest movers (0pack `0x4e46d0` 17 to 20, pumasim `0x3f9610` 5 to 10, `0x23b6c0` 3 to 7) each a merge with a gapped forward copy read as residual; none goes rendered to refused, the 442 census goldens and `stable_temporaries` hold byte for byte, certification is 50 rendered, 30 refused, 0 invented, dropped or panicked, as at the base. 0pack `0x476982` has 9 residual sites under all four orders (the base's 7 under O1 read `rsp_8` after a loop whose entry copy at `0x4769b1` is a gap, which nothing assigns on a zero-trip path); bash `0x3c2b0` 0, `0x26760` 227 (1,106 residual obligations) and `0x61520` 4 sites (13 obligations) under every order, `0x85d70` 44 sites (42 at the base: two `rsp_8` reads, entry copy a gap at `0x85de0`). Release `pdd` instructions retired against the base within -0.12% to +0.13% on 0pack `0x62ecf0`, `0x58e3d0`, `0x4e5f90` and pumasim `0x23fdf0`, `0x1fafb0`, `0x2312c0`, peak RSS within the run to run spread (264 to 317 MB both). Left: D62 (a callee's result presence), D63 (any rendered forward copy), D64 (`Values` immutable once spelled); the done text before this is in [doc/roadmap-history.md](doc/roadmap-history.md) | done (SD); left D62, D63, D64 |
| D59 | A condition chain whose exit is a `continue` to its own head is certified | done (2026-10-23, branch `d59-d60-guards`): `condition::exits` takes an edge to a later test as an edge of the chain's DAG and any other edge to a member (the head, an earlier test) as an exit, as a lone test always did, so the `ExitCount { found: 1 }` fault no longer reverts every chain of the function; the engine test `a_chain_whose_exit_is_a_continue_to_its_own_head_is_one_test` (a scan to a zero byte or a colon: two tests in a `for (;;)` at the base, `do { ... } while (c != 0 && c != 58)` now) and the certificate test `a_chain_whose_second_test_goes_back_to_its_head_has_the_head_as_an_exit` fail without it. Repro of the row: `R2DEC_TRACE_REFUSAL=1 r2s -q -c "aa; pdd @ 0x2e540" bash` no longer prints the revert; `fedora_32_bin_ls` `0xe690` and `0x11ef0` likewise. Over bash, ld-2.31.so, fedora_32_bin_ls, ls-toybox, pigz, bashbot O0 and static-glibc (3,765 functions) 12 change, none newly refused, no goto or residual site added; 11 move only control lines (more `&&`, `do`/`while` for `for (;;)`, fewer `break`, the statements equal) and pigz `get_header` writes one copy fewer of a two-statement tail; bash `extract_colon_unit` runs as at the base on 48 vectors; golden and stable_temporaries are unmoved. D59's program does not reach `rotate::negated`'s De Morgan branch (its continue carries a copy, so the chain is spelled on the staying side): D60 c. Over every function of 0pack and pumasim (23,213) 8 change, all the same loops, none newly refused. The DecBench mined set is not re-scored here | SD |
| D60 | The structurer's data guards each have an end to end engine test and the certificate reads what a copy says | done (2026-10-23, branch `d59-d60-guards`): (a) one engine test per latch-hoist guard, each failing with only its guard removed: L4 `a_copy_stays_on_its_arm_when_the_test_reads_the_variable_it_writes`, L2 `a_copy_stays_on_its_arm_when_code_after_the_loop_reads_what_it_writes`, L3 `a_copy_stays_on_its_arm_when_the_test_is_not_a_proven_read`; (b) `I_T` in `structure/certify.rs`: every statement the placement wrote for a block is written, and as often as any other of it (edge copies are marked statements of the block they leave, with no instruction and no place in the walk; `O(statements)`), 0 of 3,765 functions newly refused or changed, no Statement violation at the base, 23,213 functions of 0pack and pumasim change only as D59 does; with the previous skeptic's drop mutants run again through the corpus: L5 (the hoist drops its copies) 543 functions change and the certificate reverts the stage in all 543 (388 of 388 passed before), T2 (a tail copy drops its first statement) 488 change and 488 are refused (73 of 280 passed), T12 (a joined return's copy drops its first statement) 204 change and 204 are refused (144 of 144 before); (c) the De Morgan branch is reached by a unit test (`rotate::tests::a_loop_that_leaves_on_a_chain_stays_on_its_negation_with_the_connective_flipped`) and an engine test (`a_loop_that_leaves_on_a_chain_stays_on_both_tests_negated`: a chain with nothing copied on the way round), each failing with R1; (d) `owes_only_its_branch`, `NotInert` and the certificate's `inert` parameter are deleted: forced to `true` the fact moved 0 of 3,765 renderings and a condition holds a load in none, the text decides who may be a member, and a member's lost statement is now a Statement violation; (e) C1 `exact` and T3 `named` were dead and are gone, C4 asserts the chain is the one violation, T9 is equal (it changes which arm end keeps the original's markers), V2 went with (d). The sweep again, 61 mutants: 5 no longer apply, 42 of the other 56 die to r2dec unit tests, 8 to engine tests, C3, T7 and T13 to the golden census only, and S5, S6 and T9 survived all three; S5 and S6 (`statement_role` without the never-returns evidence, at the final certificate and at a stage) now die to the engine test `an_arm_ending_in_a_call_declared_never_to_return_is_a_conjunction_that_leaves_by_it` (S5 refuses the function, S6 reverts the chain), and T9 is equal. Release `pdd` on 0pack `0x62ecf0` and pumasim `0x23fdf0`: -0.1% to +0.1% instructions retired, RSS flat, the renderings byte-identical; exit bar: workspace suite 2,977 passed, 0 failed, 8 ignored, certification 50 rendered, 30 refused, 0 invented, 0 dropped, Dylint 12, structure counts unchanged. Left: D61 | SD |
| D61 | A copy's place and text are read, not only that its statements are written, and a copy may carry a residual. (1) `I_T` counts a block's statements across its occurrences: a statement moved from one copy of a block to another, or written twice in one occurrence, leaves the counts equal, and a block of one statement has no sibling to differ from; attribute each statement to the occurrence the walk has open (`Walker::walk_observed`) and refuse a root twice in one occurrence. (2) A copy's text is not compared with its original's (`remint` clones the expressions, so only a producer that rewrites a copy differs): compare the unobserved leaves of one statement across its occurrences (`CExpr` holds `f64`, so a NaN literal needs an order that equals itself). (3) The cost D60 kept: a skipped block that holds a residual is no longer copied into two arms, so 6 of 3,288 functions gain a goto (bash `0x65bf0` 0 to 1, `0x67b10`, `0x67ca0`; ls-toybox `0x5d458`; pigz `0x4115f0`, `0x411a40`; 873 lose gotos, 15,429 to 11,914 in all), until a copy can carry a residual without counting its obligation twice. (4) Guards that only the census holds: C3 (`leaves_to` of a chain in the walk, 1 golden), T7 (the join needs two arm ends, 5 goldens) and T13 (`replace_gotos` under a switch's arms, 3 goldens) die to no unit or engine test; an engine program each. Gate: mutants that move a statement between copies and that rewrite a copy's operand, each refused by the certificate; one engine test per guard of (4) that fails with it removed (C3, T7 and T13); (3) a bash `0x65bf0` golden that reads without the goto. Owner: `r2dec` `structure/certify.rs`; (3) `render::values` for the residual's one cell; timing: (1) and (2) conditional on a producer that does either (none does), (3) and (4) next | SD |
| D61 | A copy's place and text are read, not only that its statements are written, and a copy may carry a residual. (1) `I_T` counts a block's statements across its occurrences: a statement moved from one copy of a block to another, or written twice in one occurrence, leaves the counts equal, and a block of one statement has no sibling to differ from; attribute each statement to the occurrence the walk has open (`Walker::walk_observed`) and refuse a root twice in one occurrence. D58's `Spelled` table holds each block's statements once, which is what that check reads. (2) A copy's text is not compared with its original's (`remint` clones the expressions, so only a producer that rewrites a copy differs): compare the unobserved leaves of one statement across its occurrences (`CExpr` holds `f64`, so a NaN literal needs an order that equals itself). (3) The cost D60 kept: a skipped block that holds a residual is no longer copied into two arms, so 6 of 3,288 functions gain a goto (bash `0x65bf0` 0 to 1, `0x67b10`, `0x67ca0`; ls-toybox `0x5d458`; pigz `0x4115f0`, `0x411a40`; 873 lose gotos, 15,429 to 11,914 in all), until a copy can carry a residual without counting its obligation twice. (4) Guards that only the census holds: C3 (`leaves_to` of a chain in the walk, 1 golden), T7 (the join needs two arm ends, 5 goldens) and T13 (`replace_gotos` under a switch's arms, 3 goldens) die to no unit or engine test; an engine program each. Gate: mutants that move a statement between copies and that rewrite a copy's operand, each refused by the certificate; one engine test per guard of (4) that fails with it removed (C3, T7 and T13); (3) a bash `0x65bf0` golden that reads without the goto. Owner: `r2dec` `structure/certify.rs`; (3) `render::values` for the residual's one cell; timing: (1) and (2) conditional on a producer that does either (none does), (3) and (4) next | SD |
| D62 | A callee's result presence is one fact per callee, stated by r2ssa and joined by r2types, not each call site's to spell in r2dec. r2ssa states per call site how the result register's value after the call is read: Dead, ReturnedOnly (with whether the caller's own return type is proven) or Consumed by an operation, transitively through copies and phis, once per function in `O(values + uses)`; r2types joins the site facts per callee in `CalleeResolutionFacts` (a stated prototype or the callee body's carriers outrank site evidence, then Consumed, then ReturnedOnly, then Dead); `Values::decide_prototypes` reads that join and breaks ties by address only. Not a downstream vote: a Readers vote in r2dec rebuilds an r2ssa fact, and a vote that lets the first call with a result win makes pumasim `png_free` `uint64_t`, which doc/adr-decompiler-rewrite.md D4 calls no new evidence. Repro: bash `pdd @ 0x00061520` (`ignore_signal`) declares `void set_signal_handler(uint64_t, uint64_t)` from the first call in address order, `RAX_3` is never assigned behind the call that reads it, 4 residual sites and 13 residual obligations (12 at master); the 2026-10-23 address-order declaration moved 22 of 13,093 declarations (14 `void` to a value, 8 a value to `void`), and pumasim `0x40c140` (`QPointArray::setPoint`, 14 disagreeing sites against 46), `0x34c050` (`QGDict::look_ptr`, 6 more never-assigned reads), 0pack `0x66d2b0`, `0x66f040` and `0x670e80` (`MessageBuilder::operator<<`, 5 against 15) are the same disagreement. First step: read why `callee_carrier_signature` or `Prototype::of` gives no `CallPlan.declared` at bash `0x61520` (the `staged-call-prototype` refusal evidence); where a prototype is stated every site agrees and the tie break never fires. Gate: bash `0x61520` loses its residual obligations without a new `void` to value flip elsewhere in the census, and an engine test that pumasim `png_free` (`0x003b6b20`) stays `void` with 23 residual sites; owners `r2ssa`, `r2types`, `r2dec`; timing: next | SD, next |
| D63 | A merge with one gapped forward copy and one rendered one reads residual (D58 requires every forward edge to render its copy), though a gap traps where it stands. Relaxing to any rendered copy is sound at run time only while the emitted trap and the certificate agree: `GapKind::ValuesNotRendered.ends_control()` is false and `render::traps` excludes it, so the certificate lets control go on past a gap. Relax only if the certificate model changes to say a gap ends control, and a count of merges with at least one rendered and one gapped forward copy (`R2DEC_TRACE_REFUSAL` prints `gapped-forward-copy` per edge: 426 over bash, ls-toybox and pigz at D58's landing) shows many are lost; gate: bash `0x2dd90` (`rbx_2`) and `0x48140` (`r13_4`) read residual under the rule and plain at the base, so read which of their merges mix a rendered and a gapped copy first; owners `r2ssa` (the certificate), `r2dec`; timing: conditional | SD, conditional |
| D64 | `Values` keeps interior mutability (`RefCell` scratch for rendered, residual, effect, restored, owed, broken and the pending attempt) and the D58 spelling pass is its only mutator; making the pass `&mut self` and `Values` immutable once spelled, with each attempt of `spell_function` a value rather than fresh mutable state, makes the single owner true by the type and not by the architecture test. Gate: no `RefCell` in `render::values` outside the pass; owner `r2dec` `render`; timing: deferred | SD, deferred |
The Sleigh crates (`libsla`, `libsla-sys`, `sleigh-config`) are vendored (`vendor/`).
@@ -188,7 +191,7 @@ staged merges of PR #67 (finished items land on master).
| Item | Done | Left | Exit |
|------|------|------|------|
| **D** Decompiler rewrite | D0 to D5; the old r2dec deleted | `uncompress2` and `deflateInit2_` read by hand; the rendered function named by its entry, not its loop header's `root()` (audit W44) | — |
| **SD** Structuring and readability ([structure-dominator-tree](doc/adr-structure-dominator-tree.md)) | the dominator-tree structurer; a latch that leaves a carried value alone writes no `x = x` and stages no `_next` temporary, since an edge that carries the merge's own value leaves the parallel copy (2026-10-16: 492 self-copies gone over 13 binaries, 3 bash functions lose their staging; census unmoved); r2ssa now states that edge, `forward_copies` pointing a merge source that is a copy of the merge's own output (ls-toybox 0x4cc5c's 4172) at the merge, so r2dec compares ids and its spelled-name check is deleted (2026-10-16: 0 self-copies over the same 13 binaries, 429 goldens and `pdd` on 0pack and pumasim byte-identical, instructions retired and peak RSS flat; on the scan three more `x = y` edges drop that the spelled check missed because the copy had its own name: ls-toybox `x21_4 = x21_9`, `x25_3 = x25_5`, fedora_32_bin_ls `r8_25 = r8_27`). Constant branches are decided before preparation (2026-10-17, audit D13): `SccpMode::Decide` runs the lattice on the pdd path and rewrites no operand, so the dead arm owes no obligation (`frame_dummy`, `register_tm_clones`, `__libc_csu_init`'s empty init array: 27 goldens, each a guard over link-time constants or a register the code zeroed; over the three static binaries holding 222 of the 228 sites `call to fcn_0` 223 to 132, r2dec gaps down 157 and refused functions 320 to 315 with none going rendered to refused, pinned gapped obligations 6 fewer; certification identical (50 rendered, 30 refused, 0 invented, dropped or panicked); 0pack and pumasim `pdd` byte-identical, +1.7 and +2.3% instructions retired (the pass itself: a multiply-fold hash for the SCCP sets saved 0.2%, so dense block sets would not recover it), peak RSS flat); a guard after a user operation with unstated effects (`syscall`) is never decided, since SSA has no definition of the register the kernel writes (`__access` lost its `jae __syscall_error` edge before that; the poison keeps 6 `fcn_0` sites in server-zigrefs). Next: the identity copy itself still stands for its other readers, so `forward_copies` forwards it globally (not a memory variable), with the goldens re-run; the sweep's three downstream regressions (2026-10-17): `_Unwind_Find_FDE` refused on the stack pointer's merge past a conditional call once `__dl_iterate_phdr`'s arguments were proven, because the call's own `CallUse` of the stack pointer carrier counted as a program read that kept the merge out of `frame_setup` (any frame address passed on after a conditional call refused; fixed in `collect_stack_geometry_certificate`, gate `a_stack_pointer_merged_past_a_call_to_nowhere_is_the_frame_argument_it_is`, which fails at the base); `__run_exit_handlers` gained a fourth formal and lost `uint32_t` and `uint8_t` because `convention_call_boundary` read a phi merging the arrival with a write as a write, so an unknown indirect call counted `rcx` (`merges_of_arrivals`, once per function, O(values + merge inputs); gate `registers_written_on_one_path_before_an_unknown_call_are_no_parameters`, 4 formals before and 3 after; the dead `call 0x0` arm had hidden it); `_IO_un_link` keeps 2 gaps where it had 1 and is an exposed defect, not honest precision (glibc's `_IO_un_link` and `.part.1` are both `void`, so the root is recovery proving `.part.1` returns `rax`): its tail callee now proves it returns `rax` while its own `ret` leaves `rax` the caller's, so that `ret` is an incomplete return boundary and `taint_incomplete_boundary_inputs` gaps the `test byte [rdi], 0x80` that decides it (owner `recover_interface`: an exact tail result is not the function's result where a direct return leaves the carrier unwritten, as the base's unproven callee result left it; census-wide, so its own item); the removed blocks leave no record, so `pdf` still shows the dead `call rax` that `pdd` no longer owes (a count beside `compiler_inserted_blocks`); `inst_combine` folds through the same stale `syscall` register (the certificate refuses it) and belongs in the lift's userop effects. 2026-10-22: the ADR's S2 was marked done but its legacy cleanup pass (`structure/rewrite.rs`) died with the legacy decompiler in 51b9d6a9; the writer emits `for(;;)` only (no while, do-while rotation), no `&&`/`||`, and no return into arms, which is 40.2% of scored DecBench functions ending in a goto (five ranked fixes in the ADR). Seven recoveries stack on the writer (2026-10-22, B5, doc/adr-structure-dominator-tree.md "the recoveries together"), each stage kept only while the control certificate holds and the marker table one type (`shape::Occurrences`): condition chains (`&&`/`||` where the CFG's DAG of blocks owing nothing but their branch has the test's exit function by BDD identity within 16 atoms and 512 nodes), `while`/`do-while`, the latch's copies written before its test, a return of a merge of pairwise-distinct terms written in each arm that falls to it, a terminal block of at mLine truncated
| **SD** Structuring and readability ([structure-dominator-tree](doc/adr-structure-dominator-tree.md)) | the dominator-tree structurer; a latch that leaves a carried value alone writes no `x = x` and stages no `_next` temporary, since an edge that carries the merge's own value leaves the parallel copy (2026-10-16: 492 self-copies gone over 13 binaries, 3 bash functions lose their staging; census unmoved); r2ssa now states that edge, `forward_copies` pointing a merge source that is a copy of the merge's own output (ls-toybox 0x4cc5c's 4172) at the merge, so r2dec compares ids and its spelled-name check is deleted (2026-10-16: 0 self-copies over the same 13 binaries, 429 goldens and `pdd` on 0pack and pumasim byte-identical, instructions retired and peak RSS flat; on the scan three more `x = y` edges drop that the spelled check missed because the copy had its own name: ls-toybox `x21_4 = x21_9`, `x25_3 = x25_5`, fedora_32_bin_ls `r8_25 = r8_27`). Constant branches are decided before preparation (2026-10-17, audit D13): `SccpMode::Decide` runs the lattice on the pdd path and rewrites no operand, so the dead arm owes no obligation (`frame_dummy`, `register_tm_clones`, `__libc_csu_init`'s empty init array: 27 goldens, each a guard over link-time constants or a register the code zeroed; over the three static binaries holding 222 of the 228 sites `call to fcn_0` 223 to 132, r2dec gaps down 157 and refused functions 320 to 315 with none going rendered to refused, pinned gapped obligations 6 fewer; certification identical (50 rendered, 30 refused, 0 invented, dropped or panicked); 0pack and pumasim `pdd` byte-identical, +1.7 and +2.3% instructions retired (the pass itself: a multiply-fold hash for the SCCP sets saved 0.2%, so dense block sets would not recover it), peak RSS flat); a guard after a user operation with unstated effects (`syscall`) is never decided, since SSA has no definition of the register the kernel writes (`__access` lost its `jae __syscall_error` edge before that; the poison keeps 6 `fcn_0` sites in server-zigrefs). Next: the identity copy itself still stands for its other readers, so `forward_copies` forwards it globally (not a memory variable), with the goldens re-run; the sweep's three downstream regressions (2026-10-17): `_Unwind_Find_FDE` refused on the stack pointer's merge past a conditional call once `__dl_iterate_phdr`'s arguments were proven, because the call's own `CallUse` of the stack pointer carrier counted as a program read that kept the merge out of `frame_setup` (any frame address passed on after a conditional call refused; fixed in `collect_stack_geometry_certificate`, gate `a_stack_pointer_merged_past_a_call_to_nowhere_is_the_frame_argument_it_is`, which fails at the base); `__run_exit_handlers` gained a fourth formal and lost `uint32_t` and `uint8_t` because `convention_call_boundary` read a phi merging the arrival with a write as a write, so an unknown indirect call counted `rcx` (`merges_of_arrivals`, once per function, O(values + merge inputs); gate `registers_written_on_one_path_before_an_unknown_call_are_no_parameters`, 4 formals before and 3 after; the dead `call 0x0` arm had hidden it); `_IO_un_link` keeps 2 gaps where it had 1 and is an exposed defect, not honest precision (glibc's `_IO_un_link` and `.part.1` are both `void`, so the root is recovery proving `.part.1` returns `rax`): its tail callee now proves it returns `rax` while its own `ret` leaves `rax` the caller's, so that `ret` is an incomplete return boundary and `taint_incomplete_boundary_inputs` gaps the `test byte [rdi], 0x80` that decides it (owner `recover_interface`: an exact tail result is not the function's result where a direct return leaves the carrier unwritten, as the base's unproven callee result left it; census-wide, so its own item); the removed blocks leave no record, so `pdf` still shows the dead `call rax` that `pdd` no longer owes (a count beside `compiler_inserted_blocks`); `inst_combine` folds through the same stale `syscall` register (the certificate refuses it) and belongs in the lift's userop effects. 2026-10-22: the ADR's S2 was marked done but its legacy cleanup pass (`structure/rewrite.rs`) died with the legacy decompiler in 51b9d6a9; the writer emits `for(;;)` only (no while, do-while rotation), no `&&`/`||`, and no return into arms, which is 40.2% of scored DecBench functions ending in a goto (five ranked fixes in the ADR). Seven recoveries stack on the writer (2026-10-22, B5, doc/adr-structure-dominator-tree.md "the recoveries together"), each stage kept only while the control certificate holds and the marker table one type (`shape::Occurrences`): condition chains (`&&`/`||` where the CFG's DAG of blocks owing nothing but their branch has the test's exit function by BDD identity within 16 atoms and 512 nodes), `while`/`do-while`, the latch's copies written before its test, a return of a merge of pairwise-distinct terms written in each arm that falls to it, a terminal block of at mLine truncated
### Surface (`r2s`, `r2s-tui`, `r2sleigh-cli`)
+75 -1
View File
@@ -101,6 +101,15 @@ The obligation inventory says what must be rendered, so no stage re-derives it.
seal points a merge source that is a copy, through any chain, of the merge's own
output at the merge itself, and r2dec compares ids, never spellings. Coalescing a phi web into one variable is the
partition, r2ssa's to state from `ValueLiveness` and `StorageSpans` (D2.1).
A merge reads as a variable only when every distinct placed *forward* edge into its block renders its
copy (`Values::owed`: one per forward edge spelled before the merge, set by `forward_in` from the CFG's
predecessors and `DomTree::is_back_edge`, paid once per edge by `account_copy`). A back edge, a self copy
and a gap pay nothing, so a latch copy never justifies a read at the header and one gapped forward copy
keeps every read residual. A gap traps where it stands
(`a_gap_is_written_as_the_residual_call_whose_helper_traps`), which would make "any forward copy renders"
sound at run time, but the certificate lets control go on past a gap
(`GapKind::ValuesNotRendered.ends_control()` is false) and a verdict does not lean on what the
certificate denies; the weaker rule is ledgered in "D2's spelling order".
- **Frame.** A stack object is a C byte array only where r2ssa proves the
storage is the callee's (`callee_allocation`, or a source slot that is a
local or a parameter home); a slot at or above the entry stack pointer
@@ -119,6 +128,71 @@ The obligation inventory says what must be rendered, so no stage re-derives it.
Cost: one canonicalisation, `O(terms × rules applied)`, then one pass over
the operations and their uses.
## D2's spelling order
Every verdict D2 gives (a statement rendered or a gap, a name read as a variable or as a NeverAssigned
residual, an edge copy, an extern, a local) is a function of the sealed facts and the reverse postorder
of the placed blocks. It is never a function of the order `control::Writer` composes blocks, which
the placement chooses to put an exit inside a loop or after it and a merge in this region or that.
- **One pass, then the layout.** `render::spelled::spell` walks `function.block_addrs()` (r2ssa's block
order, which is the CFG's reverse postorder, filtered by `place::is_placed`) and spells each block's
statements, then its edges (the copies of a target's merges, `transferred`, or a tail call and the
gap that stands for it), then the operand the block ends on, in the order `Writer::arms` used to:
a copy is set before the test that follows it reads. The result is a typed table (`Spelled`, by
block address) that the Writer takes once per block. The Writer holds no `Values`: it decides
regions, loops, labels and switch case grouping, and `crates/r2dec/tests/architecture.rs` keeps
`control.rs` free of `Values` and `spelled.rs` free of the placement.
- **Why a pass and not a prediction.** Whether a block's statements render is not knowable from the
facts alone: a term with no spelling, a type with no C type, a callee the symbol table shadows and an
identifier a global claimed first each decide it, and a predictor of those would be a second copy of
the spelling rules. So the order of spelling is made a fact of the CFG, and cost stays `O(B + E +
statements + phi inputs)`, one sweep, no fixpoint.
- **Forward edges are exact.** The first arrival at a merge is never over a back edge, since a back
edge's source is dominated by the merge, and every read the merge dominates follows an arrival. A
forward predecessor comes before the merge in reverse postorder where the region is reducible. In an
irreducible region an edge can be forward by dominance and still retreat in reverse postorder (a
*late* edge): its copy is spelled after the first reads of the merge it enters. The merge is not owed
that copy; it is verified. The first attempt reads the merge as a variable on the promise that the
copy renders, and `Values::broken` names every merge whose late copy gapped; `render` then spells the
function again with each of those owing one definition more than any edge pays, and stops when an
attempt breaks no promise. Each attempt adds a merge to the set, so there are at most as many attempts
as merges; over bash, ls-toybox and pigz (2,508 functions) 43 hold a late edge (280 edges) and one is
spelled again, over 0pack and pumasim (23,201 answered) 123 hold one (1,084 edges) and none is. The rule stays "every forward edge renders its copy", checked by the attempt that
stands, and the attempts are a function of the facts and the order. Owing the late copy at the early
reads instead made 36 functions read 424 more sites as residuals that the copy would have defined.
- **The gate.** `placement_mutants` (a cargo feature, off by default and absent from release builds)
permutes the placement's write order: O1 keeps every loop exit after its loop, O2 reverses each
loop's exits, O3 reverses each region's merges. `census.py orders` runs `pddj` of every function of
bash, ls-toybox and pigz under each and compares what no write order may change (the ledger's
columns, the never-assigned names, the residual sites by instruction address, cause and gap kind, the
declared names and links, and the set of statement lines apart from control). The rendered text
itself cannot be equal: O1 turns an inlined exit into a `goto`, and O2 and O3 move blocks, so the
labels and the site numbers move. The structure stages (chains, tails, latch, rotate) read text
order, copy text and never call `Values`; their shapes differ by layout by design and are not
gated, and nor is the selection pass (`if (c) x = a; else x = b;` against `x = c ? a : b;`), which
the fingerprint counts apart.
Measured (2026-10-23) over bash, ls-toybox and pigz, 2,508 functions, one mutant build per step:
| Step | O1 differ | O2 differ | O3 differ | Functions whose text moves (O1, O2, O3) |
|---|---|---|---|---|
| the knobs alone (write-order dependent) | 0 | 120 | 187 | 64, 338, 722 |
| spelled in reverse postorder | 0 | 0 | 0 | 64, 332, 720 |
| and the forward-edge rule | 0 | 0 | 0 | 64, 332, 720 |
One function under O2 and seven under O3 differ only in a selection. Against the base, the natural
order moves 13 of the 2,508 functions (+9 residual sites): the lazily declared locals of seven bash
functions' block-transfer loops are numbered in reverse postorder (`reached_5` for `reached`), bash
`0x8ee40` lists its `_next` temporaries in another order, and five hold a gapped forward copy (bash
`0x2dd90`, `0x48140`, `0x85d70`, ls-toybox `0x392dc`, `0x501c8`). 0pack `0x476982` has 9 residual
sites under all four orders: the base's 7 under O1 read `rsp_8` after a loop whose entry copy at
`0x4769b1` is a gap, so nothing assigns it on a zero-trip path, and that read is what the rule refuses.
Ledgered: ROADMAP D62 (a callee's result presence is one fact per callee, not each call site's to
spell), D63 (reading a merge as a variable when any forward copy renders, which waits on the
certificate agreeing that a gap traps) and D64 (`Values` immutable once spelled).
## D4's frame, as it is designed
The machine stack below the entry stack pointer is one region; C must see one
@@ -176,7 +250,7 @@ with an exit written in the loop a call that does not overwrite it was first (`u
first outcome, whichever block is written first, and the engine test
`a_callee_is_declared_by_its_first_call_in_address_order_whichever_block_is_written_first` holds it
under two layouts. A callee's result is a program fact the call sites should not each spell (ROADMAP
D58).
D62).
## The proof line
+7 -7
View File
@@ -331,17 +331,17 @@ was tuned to the score, and the benchmark rewards the source's spelling, which t
win the perfect count back (10 newly perfect, 1 lost) and take 1,917 gotos away. No recovery is added or dropped to
move the sum.
**Ledgered, not fixed here.** ROADMAP D58: a rendering decision must not depend on the order blocks are written
(a callee's declaration was fixed; whether a name reads as a value or as a NeverAssigned residual is still the text
written so far, which no write order makes right for a loop-carried merge whose entry copy is a gap; 0pack
`0x476982`). ROADMAP D59 (a chain whose exit is a `continue` to its own head faulted `ExitCount` and reverted every
chain of the function: bash `0x2e540`) and D60 (the certificate asked nothing of a copy's statements) are closed in
"2026-10-23" below.
**Ledgered, not fixed here.** ROADMAP D59 (a chain whose exit is a `continue` to its own head faulted `ExitCount` and
reverted every chain of the function: bash `0x2e540`) and D60 (the certificate asked nothing of a copy's statements)
are closed in "2026-10-23: the guards, and what a copy says", and D61 holds what they leave. ROADMAP D58 was ledgered
here and has landed since: whether a statement renders and whether a name reads as a value or as a NeverAssigned
residual are spelled once, in reverse postorder, before this section's placement writes anything ("D2's spelling
order" in adr-decompiler-rewrite.md), so none of this section's write orders reaches a verdict.
**Left**, by when:
- Next: the assignment before a copied or joined return, folded at the owner that spells the edge copy
(`return a;`); the exit edge's copies after a loop whose only exit is the latch (about 140 loops); D58 and D61.
(`return a;`); the exit edge's copies after a loop whose only exit is the latch (about 140 loops); D61.
- Conditional: the four-statement bound and the skipped-block-first order were chosen on the mined set, so they move
with a measurement on projects the analysis did not mine; the unguarded join (+17, 2 lost), only with a CFG fact that
separates `return a || b` from two returns; a `while` whose exit arm is not a bare `break` (34 loops), once the exit
+10
View File
@@ -563,3 +563,13 @@ Standing debt
- `seal_body_proven_interface` rewrites the format parameter after the build (C1).
- x87 80-bit floats refuse ([floating-point](adr-floating-point.md)).
- Partition-first's conservatism (read closure, `CallRestore`, return carrier, call-use reads) and the semantic-kernel ADR's rewrite against F2 and D fold into D's ADR.
Amendments after 2026-10-14
---------------------------
Rows whose done work left [ROADMAP.md](../ROADMAP.md) later, kept verbatim with the date they left.
### D58, as it stood before the emission landed (2026-10-23, base `8cff06fe`)
| D58 | A rendering decision depends on facts, not on the order placement writes blocks. Landed: a callee's declaration is its first call's in address order among the calls that state one (`Values::decide_prototypes`, `O(calls log callees)`), so pumasim `0x003b6b20`'s `png_free` stays `void` with 23 residual sites whichever exit is written first. Left, next: whether a name reads as a value or a NeverAssigned residual is the text written so far (`Values::read_name`, the `assigned` set), and no write order makes it right for a loop-carried merge whose entry copy is a gap, because its latch copy is written after every header-side read; the owner is a two-phase emission in `render::values` that knows which definitions and edge copies render before any read (definite assignment, not a set of statements written), gated by an engine test that gives the same verdict under every block write order. Repro: 0pack `pdd @ 0x00476982` (`Obfuscator::ELF::RegisterManager::get_by_str`), residual sites 7 at the base and at the chains head and 9 at the stack's head (its author measured 7 before and 9 at the sole-exit commit on the branch alone), because the inlined exit is written before the latch copy `rsp_8 = rsp_10`: `rsp_8 + 8` became `r2sleigh_residual_u64(2) + 8` and `rsp_24 = rsp_8` became `rsp_24 = r2sleigh_residual_u64(3)`, the proof line's never-assigned set (`RAX_5`, `RSP_8`) unchanged; the entry edge into the loop is the `ValuesNotRendered` gap at `0x4769b1`, so the text before the sole-exit commit (`e2401bdf` on the stack) read `rsp_8` after the loop on a zero-trip path where nothing assigns it, and the new verdict is the conservative one there. Also next: a callee's result is a program fact, so a call that discards it should not spell the callee `void` and a call that reads it `uint64_t` (the disagreement is `png_free`'s two sites that overwrite `rax` against five that do not), because the first call in address order can be the minority form and the calls that lose become gaps whose result's readers read NeverAssigned (pumasim `0x40c140`: `QPointArray::setPoint` 14 disagreeing sites against 46 once the first call by address declares it, `0x34c050`: `QGDict::look_ptr` declared `void` leaves 6 more never-assigned reads; 0pack `0x66d2b0`, `0x66f040` and `0x670e80`: `el::base::MessageBuilder::operator<<` declared `void` by a first call that drops its result, 5 disagreeing sites against 15); and the certificate asks nothing of a copy's statements (a copy that drops one passes `I_S` and the ledger, found by mutating `tails::copies`; conditional on a per-occurrence statement check that blocks written in parts do not break) The skeptic's measurements at the stack's head (2026-10-23): residual verdicts still depend on the write order, identically at the base (reversing the loop exits' write order changes the ledger of 119 of 2,436 functions, reversing the merge children's 183; bash `0x3c2b0` 0 to 3 gaps, `0x26760` 1,026 to 1,152; `0x85d70` 232 to 231 residual with no new evidence), while callee declarations no longer do (0 of 11,091 differ under both permutations, 2 and 4 at the base); the address-order declaration moves 22 of 13,093 declarations against the base (14 `void` to a value, 8 a value to `void`), 21 more obligations in 4 functions and 62 fewer in 13, and bash `0x61520` is the repro of the tie break ignoring a call that reads the result (`set_signal_handler` returns a pointer and becomes `void`, its reader `RAX_3` never assigned, 12 to 13 residual). Gate for the two-phase emission: an engine test that one verdict holds under every write order, with the permutations above as the mutants; timing: next | SD, next |