Skip to content

feat: consume the project's symbol map - #8

Merged
macabeus merged 56 commits into
mainfrom
symbol-maps
Aug 2, 2026
Merged

feat: consume the project's symbol map#8
macabeus merged 56 commits into
mainfrom
symbol-maps

Conversation

@macabeus

@macabeus macabeus commented Jul 31, 2026

Copy link
Copy Markdown
Owner

What this delivers

asmlift can now read the project's own symbol map from one decomp.yaml key — tools.asmlift.elf — and use it to spell the code the way the project spells it. This is asmlift's analogue of m2c's --context, held to the same boundary: the map carries exactly what the project's build outputs declare, never authored knowledge.

Three channels feed the map, each used when the ELF carries it:

  • .symtab — names for globals and functions (gInputState instead of *(u16 *)0x03004668).
  • DWARF — declaration shapes (array/struct/pointer, signedness, volatile/const) that drive typed spellings like gSym.field and bare gSym[i], and signatures for the functions the ELF compiled from C: callee arity drives call-argument recovery, the most common decline cause without it. The target's own definition-derived DWARF is deliberately never consumed — you are decompiling name, so name's compiled facts are the answer, not an input.
  • .debug_macinfo — address-cast macro names (#define gCounter (*(u16 *)0x03001234)), which no symbol table can carry, recovered from the ELF alone. The macro spelling matches the numeric literal-pool word the original build has where an extern would emit a relocated one — which is also why macro names are exempt from the numeric-pool veto.

Every map fact is a ranked lever, never an override: the named spelling and its raw-address sibling are both enumerated and the byte-diff picks the winner. Four layers, in dependency order:

  • core — the map seam (symbols.ts, inert without a map), the Thumb numeric-pool promotion, declaration-shape spellings, the global-address spellings that survive a project's own headers ((u32)&gSym, signedness-aware compares, cast-then-add pointer globals), strict-mode declines that name their gaps, and candidate declaration synthesis (each scored candidate compiles in a world it declares itself; the compile world is probed, not configured).
  • clitools.asmlift.elf, the symbol-map provider (alias policy, capability gates), macro-name adoption.
  • bench — pinned project checkouts (pnpm bench setup), vendored maps with full provenance on every row, reproduction scripts that load the same map and score in the same context the harness did, dropped-candidates recorded rather than swallowed, rows scored as if their function were still INCLUDE_ASM, and a fidelity gate that verifies the map, the pin, and the bundle it certifies.
  • web — a Provenance accordion per row, a with symbols filter, and a playground Symbols pane that ranks with the map in-browser.

On the klonoa side (merged separately in the project repo), the reference integration is make asmlift-elf: agbcc -g supplies shapes and signatures from the project's own compiler (byte-neutral, sha-verified), and a macro-only sidecar graft supplies the #define names. The CLI READMEs document the elf: key and how to produce the ELF, adversarially reviewed for overclaims.

The numbers — honestly

675 rows (483 synthetic + 192 real; all 192 real rows run symbol-fed, with provenance published).

match nonmatch declined noncompile failed
asmlift 343 168 162 2 0
m2c 340 189 22 123 1

The headline match count is unchanged vs main (343 vs 340), and that is deliberate. Exactly four rows move:

  • snowboardkids2:func_80037FE0_38BE0 — nonmatch → match, on the cast-then-add pointer-global spelling.
  • kleod:GameUpdatematch → nonmatch 3, traded knowingly: the manifest carried callee prototypes no real user of that project could have (kleod has 210 of 1,196 functions decompiled, so its ELF cannot supply them either). Dropping them costs one match and makes the number mean what it appears to mean.
  • marioparty3:GWBoardRecordGet and snowboardkids2:func_80014440_15040 — declined → nonmatch: the addr-intify spelling turns "no output at all" into a scored, compilable candidate.
  • m2c is byte-for-byte unchanged.

Measured in a clean A/B (same code, map passed vs omitted, all 192 real rows): the map alone changes the winning source on 8 rows, gains 1 match, and its reach is 46 distinct symbols referenced. The value of this branch is mostly not the scoreboard — it is named, typed, honest output where there were raw addresses, and a callee-signature channel that grows as a project decompiles more of itself.

Gates

  • pnpm bench regression: 0 lost, 0 missing, 0 gained, 0 other flips (675 committed rows).
  • pnpm bench fidelity: 1350 script runs — 1350 ok, 0 warn, 0 fail. Every published reproduction script re-run and held against its row; all six vendored maps verified byte-for-byte against a re-derive from each checkout's ELF. Caveat, stated plainly: CI runs fidelity checkout-free and warns rather than failing on what it cannot verify — the map verification above is a local result.
  • The full offline suite (968 tests) is green against the published dependency, not a local build.

@gba-kit/debug-info — resolved

The upstream work this branch needed (gba-kit PR #5) is merged and 0.4.0 is published; this branch depends on ^0.4.0 from the registry, with no link overlay. The closing proof: pnpm bench vendor against the published tarball re-derives all six vendored maps byte-identically — the dataset is reproducible from a clean clone.

Defense-in-depth stays: the provider gates on capability by key presence, not version label. An under-capable package (one that exports variableShape but never sets the cv/signedness facts) makes the map silently partial — every volatile MMIO global loses its qualifier — which is the plausible-but-wrong class this project refuses, so the provider throws and the CLI exits 66 rather than emitting a degraded map.

Reviewing the history

53 commits, each green on pnpm install --frozen-lockfile + typecheck/lint/format + the offline suites, with two documented exceptions: the branch's base is a tree-identical rewrite of the exploratory read-elf branch (tag read-elf-identity carries the proof), and one commit (feat(bench): reproduction scripts reproduce the SYMBOL-FED row…) introduces tests that assert on the committed results.json and are red until the results commit lands at the end — the tests travel with the code they describe rather than being back-dated to look green; that commit's body says so.

macabeus and others added 30 commits July 31, 2026 02:37
…act register-offset lowering

The optional address→symbol map (`symbols.ts`): a plain `Map<address, SymbolInfo[]>`
carrying what a project's ELF already knows — `.symtab` names and, where a DWARF
types-sidecar is linked in, declaration shapes. Threaded through `DecompileOptions.symbols`
into the frontend only; every consumer is opt-in and the seam is INERT without a map.

  • `lookupSymbol` is kind-aware two-probe: an exact hit wins, else the Thumb odd-address
    mask resolves a code pointer (`Func|1`) to its function. An exact DATA hit at an odd
    address beats the masked code hit — nothing guesses.
  • `lookupInterior` attributes an address strictly inside a SIZED data symbol as
    `&gSym + K`. Unsized symbols never attribute.
  • Thumb numeric-pool PROMOTION: a pool-loaded word the map knows becomes the named
    global's `gaddr` — the same node the `.word gSym` symbol-pool path already emits, so
    everything downstream is the existing named-global machinery. A promoted CODE symbol
    carries `code: true` and spells `(u32)Name`, not `&Name` (defect G of the dogfood
    report: the `&` form compiles but is a different, non-matching spelling).
  • JSON codec (`symbolMapToJson`/`symbolMapFromJson`) so a map can be vendored.

Ships with an independent pre-existing FRONTEND BUG fix that this seam's tests are what
surfaced: `parseAddr` silently dropped the index register of `[rB, rX]` addressing and
lowered the access as `[rB]` — a silent miscompile, and Thumb-1 `ldrsh` exists ONLY in the
register-offset form, so every `ldrsh` went through it. Loads and stores now lower the
address exactly as `rB + rX`. (Merged here rather than split off because the regression
tests live in `symbols.test.ts` and cannot travel alone.)

Inertness is pinned: no map ⇒ byte-identical raw-literal output; an empty map ⇒ the same
bytes as no map at all; an unmapped address stays a raw literal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pells like the source did

With a symbol map carrying DECLARATION SHAPES (the DWARF types-sidecar half), a global's
access spells the way the source spelled it, not the way a folded literal reads:

  • struct global + constant byte offset ⇒ the named field, `gSym.field` (`Expr.field`
    gains `dot: true`, since the base is a `var`, not an index);
  • array global ⇒ the BARE name indexed, `gSym[i]`, uncast — the spelling the klonoa
    dogfood proved agbcc needs for ROM tables. Its element type is registered on the new
    `SFn.globals` (typed for the legalization env, NEVER declared by a backend — the
    project's own headers declare it, like every other global asmlift emits).

Match is EXACT or nothing: a field must match offset AND width, an array element must
match the access width. Anything else falls through to today's honest cast forms — never
a guessed field name, never a named element at a wrong address.

The map also OVERRIDES the usage-based scalar/aggregate inference: a project-declared
`extern u16 tbl[]` seen through a single offset-0 access used to classify scalar and emit
the bare `tbl` — which a project declaring it as an array rejects as an incomplete-type
assignment. Independently, the inference itself is fixed: ANY arithmetic on a symbol's
address now marks it aggregate, not just an add feeding a load/store base directly, so a
pointer-walk loop (`p = &g + 2; do { *p++ … }`) no longer classifies `g` scalar.

The address folds look THROUGH a width-32 integer cast (`(u32)&gSym`, the spelling the
next commit introduces for value-context escapes), so every access that CAN spell a named
element still does. A NARROWING cast (`(u8)&gSym`) is a different VALUE and never folds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…aders

A bare `&gSym` reaching a VALUE context is unspelled: its C type is whatever the PROJECT
declares, which asmlift cannot know. Emitting it verbatim is byte-inexact (`&gSym + K`
element-scales K by `sizeof(gSym)`) or ill-formed (`&gSym & K`), and the old guard
DECLINED the whole function rather than emit it. Three closures, all spelling the integer
math the asm actually did:

  • ARITHMETIC: every `addr` operand of a binary op intifies to `(u32)&gSym` — byte-exact
    under any project declaration. The deref folds see through the cast (previous commit),
    so no named element/field spelling is lost; only a genuine escape keeps it.
  • COMPARISON: the compare's SIGNEDNESS lives in the operand types (icmp_ult and icmp_slt
    both spell `<`), so leaving `&gSym` untyped lets the project's declaration pick the
    compare the compiler emits — silently byte-inexact whenever it disagrees with the asm.
    The cast AGREES with the opcode: `(u32)&gSym` for unsigned (and sign-agnostic ==/!=),
    `(s32)&gSym` for signed.
  • POINTER-shaped globals: the map deliberately does not model the pointee (`void *`),
    so `gPtr + K` scales by 1 here and by whatever the project's header says in the world
    the user recompiles in. CAST-THEN-ADD — `(u8 *)gPtr + K` — is the same address in
    EVERY world; add-then-cast `(u8 *)(gPtr + K)` is right in exactly one of them, and
    folding K into the deref index re-scales it by the ACCESS width. Under operators C
    rejects for pointers, the cell spells `(u32)gPtr`.

`assertDerefsTyped` keeps both rules as REGRESSION detectors: nothing emits a bare `addr`
under `+`/`-` or under a comparison anymore, so one arriving there is a lowering bug and
declines loudly rather than shipping wrong bytes.

The splat test that pinned the old DECLINE now pins the spelling: `(u32)&GwPlayer + 24`,
and the bare element-scaling form must be absent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e across modes

Annotate mode already spells each unresolvable value as `ASMLIFT_ERROR("<reason>", …)`.
Strict mode dropped the reason on the floor: the `?` sentinel travelled to the boundary
and `assertResolved` reported an anonymous "unresolved value", so the two modes described
the same input differently.

`structure()` now records every gap reason in strict mode and throws a `StructureError`
naming them — "1 unresolvable value(s) in 'clzlive' — unmodelled instruction 'clz'" — the
same text annotate's markers carry. That matters beyond ergonomics: the benchmark stores
annotate markers on its rows while the published reproduction scripts run the CLI in
strict mode, and `bench fidelity` holds one against the other.

`assertResolved` stays exactly where it was, as the backstop for any OTHER producer of a
`?` — pinned by a new test that hands it a hand-built SFn.

`StructureError` joins the DESIGNED loud-failure classes in the contract-invariant corpus,
so the decline stays loud-by-construction rather than by remembering a whitelist.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ares what it names

A candidate that names project globals only compiles where the project's headers are.
That made the ranked scoring world dependent on a headers wrapper, and made a published
reproduction unrunnable outside a checkout. Candidates now carry their own declarations.

  • `l3/symbol-refs.ts` — walk a final SFn body and collect the map-derived VALUE
    references it actually contains (call targets excluded; post-DCE, so a dead reference
    is not declared).
  • `core/declare.ts` (browser-pure, so the webapp's wasm scorer prepends the SAME text
    the Node/objdiff scorer does) — renders one `extern` per ref: struct globals get a
    `struct N { … };` with pad fields seated at exact offsets, arrays get `T name[]`,
    scalars their exact width, pointers `void *` (load/store/compare of the 4-byte cell
    are identical for any object-pointer type), code refs a prototype.
  • `renderStructDecl` in cfamily.ts is now THE struct-declaration spelling — the
    backend's recovered structs and this synthesis share it, so they cannot drift.
    `cDeclare` learns the pointer declarator (`void *p`).
  • `rank.ts` derives each spelling's refs at the exact point the candidate comes into
    existence — right where its source is emitted. No pipeline stage caches refs (SFn has
    no such field), so a future l3 pass that rewrites the tree cannot leave a stale ref
    behind: whatever tree reaches emit is the tree the refs describe, by construction.
    Refs are collected for EVERY spelling variant including `/raw-globals`, whose tree
    still names pool/reloc-derived globals and needs them declared just the same.
  • `bareGlobalAccessFacts` supplies the width/signedness authority for NAME-ONLY map
    symbols (symtab-only projects such as marioparty3, where the map knows no shape): a
    fact is recorded only for a symbol accessed EXCLUSIVELY at offset 0, with ONE width
    and ONE load signedness. Interior offsets, address arithmetic, or any width/sign
    conflict record nothing — those spellings go through `&gSym` casts, where every object
    declaration is address-identical. `extern u16 g;` assembles to `sh` where a guessed
    u32 would assemble to `sw`, so guessing here is exactly the wrong-bytes class.
  • `rankBy` passes the whole candidate to the scorer, so a scoring layer can reach its
    refs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`tools.asmlift.elf` in `decomp.yaml` names the project's built ELF (relative to the config);
`symbols-provider.ts` turns it into the core `SymbolMap`. Names + addresses + kind come from
`.symtab` (always); declaration shapes come from the DWARF types-sidecar the project links
in, joined BY NAME (the sidecar's variable DIEs carry name→type, the symtab carries
name→address). No sidecar ⇒ names-only, which is still the whole numeric-pool promotion.

Explicit config, so an unreadable/absent ELF is a LOUD input error (exit 66), never a
silent names-less run — the provider's throw is converted at the CLI boundary.

Alias policy, because addresses are NOT unique in real projects: every symbol at an address
is kept, ordered so `[0]` is the canonical pick — header-declared (DIE-joined) names first,
`sub_08xxxxxx`-style placeholder names (real symbols, but names no header declares, so
emitting one produces non-compiling output) last, then by name. Deterministic.

Facts are kept only when the DWARF has them: a null size, a null signedness, a bitfield
member (whose read width never equals a field size) all stay ABSENT rather than guessed —
absence falls through to the honest cast spellings.

Adds the `@gba-kit/debug-info` dependency (pnpm-lock updated in the same commit so the
install stays reproducible).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…prelude is PROBED, not configured

The scoring seam now forwards each candidate's synthesized declaration block (core
`declare.ts`, via `Candidate.symbolRefs`) to the compiler. A candidate that names project
globals therefore compiles in a world it declares itself — no headers wrapper needed, and a
published reproduction script is runnable outside any checkout.

Which world applies is PROBED, never configured: `compileFromCommand` compiles a tiny probe
carrying the same declaration vocabulary synthesis emits (signed narrow member, interior and
tail pads, volatile member, pointer member, volatile scalar, const array, void prototype —
itself rendered by `renderDeclarations`, so a world that accepts the probe accepts any real
block). A template that injects the project's own headers rejects it on the C89
duplicate-typedef collision, and the prelude AND the declarations are dropped together for
every candidate — headers own everything. A template that accepts it keeps both. The verdict
is cached per compiler instance: 1 probe + N candidates, not 2N. A template where BOTH probe
variants fail is broken, not a world signal: the prelude is kept and the real candidate
compile throws the template's own error.

This removes the `prelude:` config flag entirely. It was a lever a user had to know to set,
and it could not express the declarations half at all.

`cli/declare.ts` is now a one-line re-export of the core renderer (which moved to core so
the browser scorer prepends the same text), preserving the historical import path.

Verified end-to-end by a new checkout-gated A/B matching suite: the same klonoa functions
scored through the self-declared world and through the project's headers wrapper produce
byte-identical objects.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dataset only — no code. Lands ahead of the harness commits that read it, so each of those
is green on its own.

  • `symbols.json.gz` for all six real projects, derived at vendor time from the ELF each
    project's own `decomp.yaml` names. Symbol-map content is project METADATA (ldscript +
    header declarations), vendorable where the ELF itself (game code) is not:
      af             32411 addresses,    89 shaped
      kleod           1168 addresses,   442 shaped
      marioparty3    22116 addresses,   902 shaped
      pokeemerald    41008 addresses, 24539 shaped
      sa3            12182 addresses,   102 shaped
      snowboardkids2  5452 addresses,    27 shaped
    All six carry struct-member signedness; af/kleod/pokeemerald also carry `volatile`
    members. `shaped` counts symbols whose declaration shape the DWARF types-sidecar knew;
    the rest are names-only, which is still the numeric-pool promotion.
  • Every manifest pins its benchmark fork and integration branch (`repo` = GitHub
    owner/name, `branch` = `asmlift-benchmark`), plus the `elfMake` target that derives the
    symbols ELF where the project needs one (af, marioparty3, pokeemerald, sa3,
    snowboardkids2 — kleod's ordinary build already produces it).
  • kleod's `sourceUrl`s move from `macabeus/kl-eod-decomp` to the repo's new home,
    `Dream-Atelier/kl-eod-decomp` (same commits — the old links 404).
  • kleod's TUs and contexts are re-vendored through the pinned branch, so its PROVENANCE
    describes the tree the maps came from. pokeemerald drops `GetGameStat` from the index.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The harness used to read whatever the maintainer's sibling checkouts happened to be. That
makes the vendored dataset's provenance unverifiable: a WIP tree produces blobs no one else
can reproduce. `bench setup` now materializes a HARNESS-OWNED workspace under the gitignored
`apps/benchmark/checkouts/`:

  • clone each real project's pinned fork at its `asmlift-benchmark` branch (submodules
    included; ssh submodule URLs rewritten to https), copy baseroms in from the sibling user
    checkout when present, and run each project's preparation recipe
    (`project-setup.ts`: agbcc builds, venvs, splat splits, generated sources);
  • `--build` runs every project's full build through its own byte-compare gate, plus its
    `elfMake` symbol-ELF target.

These clones are DISPOSABLE and freely mutated. Non-bench-owned checkouts (an
`ASMLIFT_PROJ_*` override, or the sibling WORKSPACE dir) are only ever REPORTED — setup
never mutates a tree the maintainer owns.

Checkout resolution is now ordered: `ASMLIFT_PROJ_<PROJECT>` > bench-owned checkout >
sibling `WORKSPACE/<repoDir>`. `enforceCheckoutPin` fails `bench vendor` loud when the
checkout has drifted off its pinned remote head (`ASMLIFT_ALLOW_DIRTY_CHECKOUT=1` downgrades
to a warning for WIP machines), so a vendored blob always names a commit anyone can fetch.
Manifest validation gains `repo` (owner/name — a URL must fail HERE, not mid-clone),
`branch`, and the optional `elfMake`, and `real-manifests.test.ts` pins that every project
declares them.

Also un-pins the gcc 2.7.2 toolchain from the marioparty3 checkout: `bench setup` fetches
the decompals gcc-2.7.2 + binutils-2.6 releases into `apps/benchmark/toolchains/`, which
`GCC272_TOOLCHAIN` prefers when present. Same release, no cross-project dependency; the
sibling path stays as the fallback and `ASMLIFT_GCC272_DIR` still overrides both.

Both bench-owned dirs are gitignored — they are fetched artifacts, not sources.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… as provenance

`bench vendor` now also writes each project's `symbols.json.gz` (via the CLI's own ELF
provider, so the harness and a user's `asmlift --config` read the same map from the same
file); `loadManifests` reads it back and the real case provider threads it into asmlift's
half of every row. Projects without an ELF are untouched — rows run exactly as before.

The map is asmlift's analogue of m2c's `--context`, and the report must never read a mixed
table as apples-to-apples, so the schema publishes what each row actually had:

  • `symbolMap: true` — this row ran WITH the project's map;
  • `symbolsUsed` — EVERY map symbol the WINNING candidate's output references, each with
    its declaration shape pre-formatted for display ("struct Unk_03004C20 (24 B)", "u16[]",
    "scalar u8", "code"). Sorted, uncapped. Present exactly when a scored row ran with the
    map; EMPTY means the winner (e.g. a `/raw-globals` spelling) named none of it — the
    honest answer, not a missing field;
  • `candidateLabel` — which lever combination won the differ ranking.

The shape strings are formatted in the harness, so neither the schema nor the web UI learns
`SymbolInfo`'s field vocabulary. A struct-element array never masquerades as a scalar type:
only a genuine scalar width (1/2/4/8) spells `u16[]`, anything else spells
"array (N B/elem)".

The `/raw-globals` lever rides along on every map-fed row (core `rank.ts`), so the differ
referees named-vs-raw per function and a symbol-fed row can never score WORSE than the same
row without a map.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… BOTH decompilers

`(u32)&gSymbol` and `(u32)FuncName` are the CORRECT source spelling of integer arithmetic on
a link-time address — the decomp projects themselves write them. Counting them as "excess
casts" penalized asmlift for the very spelling the previous core commits introduced, which
would have made the readability metric reward the WRONG output.

The exemption is textual and disciplined:
  • the `&` forms are address spellings by construction and are exempt for any identifier;
  • the bare-identifier `(u32)Name` form is exempt only when `Name` is NOT a generic local
    (asmlift's `a0`/`v0`, m2c's `var_X`/`temp_X`/`phi_X`/`argN`/`spN` — a u32 cast of a local
    is a value truncation, still noise) and NOT a call (`(u32)F(x)` casts the RESULT).

This MOVES PUBLISHED NUMBERS on BOTH sides — m2c's readability scores change too, since
`assessQuality` is the one metric both are graded by. m2c's half of every row is content-
cached and the quality heuristic runs INSIDE that cached computation without being part of
the key, so the cache version is bumped 12 → 13; without that, fixed rows would keep serving
stale scores. The Methodology page states the exemption where the metric is described.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… map-less approximation

A published script for a row measured WITH the project's symbol map used to run without it —
so the script's output could not match the row it claimed to reproduce. It now loads the
same map:

  • `bench target --project-root <dir>` resolves that checkout's `tools.asmlift.elf` and
    grafts it into the generated `decomp.yaml`, so the CLI loads the map the benchmark fed
    this function. A missing checkout or ELF warns LOUDLY and degrades to a map-less config —
    visible degradation, never a silent one.
  • The script declares a `PROJECT_PATH` placeholder naming the project's `repoDir` (not its
    GitHub repo name — kleod's differ), passes it as `--project-root`, and states the map's
    provenance: the vendored blob's repo-relative path and the sha256 of its DECOMPRESSED
    JSON (the .gz bytes vary with compressor settings; the JSON is byte-stable).
  • Real rows carry the full checkout RECIPE as comments — `git clone --branch
    asmlift-benchmark <fork>`, `make`, and the `elfMake` target where the project has one.
    Comments only: the script itself stays checkout-free, since its inputs are embedded.
  • Map-free rows say so explicitly rather than staying silent.

The generator reads the committed manifests, so it needs no checkout to emit any of this.

KNOWN RED: `apps/benchmark/test/repro-scripts.test.ts` fails from HERE until the results
commit at the end of this branch. Three tests are red now — one because the ctx-world
scoring it pins lands in the next commit, two because they enumerate `symbolMap` rows from
the COMMITTED `results.json`, which is regenerated last (the dataset must move before the
code that asserts on it, and the results must be produced by the finished code). The tests
travel with the code they describe rather than being back-dated to look green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t` materializes the rung the EVAL used

Two scoring-world defects, both of which made a published script grade in a different world
than the harness did.

1. The typedef guard was ALL-OR-NOTHING. A vendored context can own SOME of the typedef
   family (af's header-less manifests vendor just `typedef short s16;`). Adding the whole
   prelude then re-typedefs that one name — a C89 hard error that makes EVERY candidate
   noncompile — while adding none leaves `u8`/`u32`/… undeclared, the same noncompile from
   the other side. `ctxTypedefPrelude` now keeps exactly the names the context lacks, PER
   NAME. It lives in `compile/util.ts` as ONE definition shared by the scoring path
   (`compile/real.ts`) and the reproduction path (`decomp-config.ts`), because a drift
   between them IS the bug.

2. `bench target` materialized the RICHEST context rung unconditionally. Scoring escalates
   cheapest → richest and stops at the first rung that compiles, and a richer context can
   REJECT what a poorer one accepts (a project prototype vs. the candidate's
   implicitly-declared call). So the script could be handed a world with no scorable
   candidate at all. It now REPLAYS the ladder against the row's published winning source
   (`resolveScoringPrelude`, 1–3 candidate compiles) and materializes the rung that source
   actually compiles in, as `ctx.i` next to `target.o`, with the generated compile command
   concatenating it ahead of every candidate. Only a SCORED row's source pins a rung —
   declined/noncompile/failed rows have no source that compiles anywhere, so they take the
   richest rung directly instead of burning three compiles to get there.

The CLI needs no new flag for any of this: its prelude probe sees a context-injecting
template and drops its typedefs and synthesized declarations on its own.

Still red until the results commit: two `repro-scripts.test.ts` tests that enumerate
`symbolMap` rows from the committed `results.json` (see the previous commit). The third,
which pins the ctx-world scoring note, goes GREEN here.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rtifies

`bench fidelity` re-runs every published reproduction script and holds its output against
the row. Three things it was not verifying, each of which could certify a lie:

  • THE BUNDLE. The asmlift scripts invoke `packages/cli/dist/asmlift.mjs`, a GITIGNORED
    esbuild bundle. Certifying published scripts against a stale (or absent) bundle silently
    tests old code — exactly the drift this gate exists to catch. Fidelity now rebuilds it
    first (~20ms when up to date), loud on failure.
  • THE MAP. `symbol-drift.ts` re-derives each project's map from its checkout's ELF and
    compares it to the vendored blob (by sha256 of the decompressed JSON, plus a per-symbol
    diff when they differ), so a symbol-fed row is only certified when the vendored map still
    IS what the checkout derives. Without a checkout it says so explicitly rather than
    passing in silence.
  • THE PIN. Each project's checkout is checked against its pinned remote head before its
    rows are certified — the published rows claim provenance from that branch. Missing
    checkouts WARN (CI runs checkout-free), they do not fail.

`materialize` also fills the scripts' `PROJECT_PATH` placeholder with the checkout resolved
exactly as vendor resolves it, and is exported so the substitution is unit-tested offline.
`--project`/`--only` filters make spot-checks cheap on a 1350-script gate.

The warn class is re-stated honestly: real-tier divergence used to be excused as "scores
outside the project context", which is no longer true — the scripts now score INSIDE the
row's vendored context by construction, so a divergence is environment skew. Still a visible
WARN rather than a hard fail, so one machine quirk cannot block publish.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…w warned

A `noncompile` row's stored `source` is the ANNOTATE-mode emission: marker-free, but it does
not compile. The reproduction script runs the CLI in SCORING mode, which never gets a
scorable object and exits 1 with EMPTY stdout — so comparing stdout to the row's source
always "diverged", and every noncompile row produced a warn that meant nothing.

What must reproduce for such a row is the FAILURE. Fidelity now asserts exit 1 plus the
`no scorable candidate for '<sym>'` signature and leaves stdout alone. Synthetic noncompile
rows FAIL on divergence, real ones warn — the same tier split every other class uses.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ually had

The symbol map is an INPUT, exactly like m2c's context, so the report must let a reader see
which rows had it and what it contributed — otherwise with-map and without-map rows read as
one experiment.

  • Each row's detail consolidates every input into ONE collapsed `Provenance` accordion at
    the bottom: the prototype hints asmlift received, the context m2c received, the symbol
    map's state, every map symbol the WINNING candidate references with its declaration
    shape, and the candidate label that won the ranking. Collapsed because it is evidence,
    not the headline.
  • A `with symbols` filter in the Explorer, on the URL (`?symbols=with`) like every other
    filter, so a filtered view is shareable.
  • Symbol-fed rows' script panel gains a `PROJECT_PATH` input, persisted per project, that
    fills the placeholder in the copyable script — the drawer is keyed by row so it re-reads
    its storage when the selection changes.
  • Methodology states the map's boundary explicitly: it carries what the project's ldscript
    and headers declare and nothing authored, both spellings are candidate-ranked so the map
    can reveal capability but never mask a regression, and map rows are flagged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A Symbols pane accepts the map as JSON (the same shape `symbols.json` vendors), parsed with
loud per-entry errors rather than a silent partial map. It feeds the whole pipeline:

  • decompile and the traced tower both take it (`trace.ts` gains the `symbols` knob so
    TraceOptions keeps decompile() parity — pinned by a test asserting the named lift dump
    AND byte-identical source);
  • in-browser candidate ranking enumerates and scores WITH it, prepending each candidate's
    synthesized declarations (the SAME core renderer the cli scorer uses) after the typedef
    prelude. agbcc-wasm compiles bare candidates with no project headers, so this scorer is
    ALWAYS the self-declared world — no probe arbitration needed;
  • the map rides the permalink channel, omitted when empty so existing links are unchanged;
  • a worked preset example, and Pipeline notes explaining what the map changed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`apps/web` has its own tsconfig (JSX, DOM libs, its own path mappings) and was never
typechecked by the root script — only by `pnpm --dir apps/web build`, which nothing in the
default loop runs. A type error in the report or the playground reached CI as a build
failure or not at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…m2c 340 (unchanged)

The generated artifact, deliberately last: `results.json` plus the two staged web copies.

  asmlift  match 344  nonmatch 167  declined 162  noncompile 2  failed 0
  m2c      match 340  nonmatch 189  declined  22  noncompile 123  failed 1

Movement against `main`, row by row (0 rows added or removed):

  • +1 MATCH: `snowboardkids2:func_80037FE0_38BE0` flips nonmatch → match, on the
    cast-then-add pointer-global spelling.
  • 0 LOST. No row regressed, and no nonmatch got a worse score (8 improved, 0 worse).
  • 2 rows move declined → nonmatch: `marioparty3:GWBoardRecordGet` and
    `snowboardkids2:func_80014440_15040`. Both declined on "interior pointer arithmetic on
    the global address", which the addr-intify spelling now emits as legal byte-exact C —
    so both went from NO output to a scored candidate. An improvement, counted honestly as
    a category change rather than folded into the headline.
  • noncompile stays 2 (`kleod:CopyBGScrollTiles`, `kleod:UpdateHUDCounterDisplay`) —
    unchanged, not fixed by this branch.
  • m2c's match count is unchanged at 340. Its READABILITY scores move (the cast-metric
    address exemption applies to both decompilers), which is why the m2c cache version was
    bumped in that commit.

192 of the 675 rows (every real-tier row) ran with a project symbol map.

This also turns `apps/benchmark/test/repro-scripts.test.ts` green: the two tests that
enumerate `symbolMap` rows from the committed results have rows to enumerate from here on.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…o silently partial maps

The provider gated shape recovery on `typeof types.variableShape === 'function'` and said, in
a comment, that an older @gba-kit/debug-info "degrades to names-only". Both were wrong.

The published 0.3.0 DOES export `variableShape` — it just never sets the 0.4 facts. Verified
against the installed tarball: no branch of its `variableShape` emits a `volatile`/`const`
key, and its `StructMember` has no `signed`. So the method check passes, shapes come back,
and the emitted map is PARTIAL in exactly the load-bearing places: every volatile MMIO global
loses the qualifier its correct declaration needs, and every struct member is synthesized at
a guessed signedness — which changes the bytes a load compiles to. Plausible-but-wrong
output, produced silently. That is the one class this project refuses.

The gate is now a CAPABILITY PROBE by KEY PRESENCE, on the first shaped variable and the
first struct member, and it THROWS (the CLI already converts a provider throw to exit 66,
naming the ELF and the required version). Key presence, not a version comparison, because a
version comparison is provably wrong here in both directions: the local 0.4-capable build of
this package is itself labelled 0.3.0 — this repo's own working install is a link to it —
while the registry's 0.3.0 carries the same label and none of the facts.

For the same reason the dependency RANGE is left at `^0.3.0` rather than bumped to `^0.4.0`:
0.4.0 is unpublished (the registry has 0.2.0 and 0.3.0 only), so the bump would make
`pnpm install --frozen-lockfile` unresolvable for every job in CI, and it could not express
the requirement anyway — the 0.4-capable build advertises 0.3.0. The runtime probe is the
enforcement; a 0.3-only install now refuses loudly the moment `tools.asmlift.elf` is used,
instead of quietly emitting a degraded map.

Also pins the two pure POLICIES the provider had no coverage for — the alias order (which of
several symbols sharing an address becomes the canonical, compilable pick) and the
`sub_08xxxxxx` placeholder shape — in an offline suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The `decomp.yaml` reference still documented `prelude`, a key that no longer exists (the
typedef prelude is probed, not configured), and did not document `elf` at all — the key that
turns on the whole symbol map.

Replaces the `prelude` row with `elf`, and states the probe under the scoring rules where a
reader looks for it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Text-only. All three survived the removal of the `symbolMapFellBack` field, whose "never
worse" fallback pass was retired once the core lowering learned to spell every map-induced
escape legally.

  • `eval/asmlift.ts`: the Phase-1 comment had been half-overwritten, leaving a sentence
    fragment starting mid-clause ("spells all known map-induced escapes legally — …") and
    referring to a schema field that is no longer historical-only. Restored, and it now says
    the true thing: the annotate-mode decompile runs exactly ONCE, with the map, because
    there is no fallback pass left to arbitrate.
  • `Methodology.tsx` and `FunctionDetail.tsx`: both listed "fell back" as a symbol-map
    provenance state the UI can show. It cannot — the state does not exist, and neither
    component has a branch for it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`results.json` (plus the two staged web copies) regenerated by `pnpm bench run && pnpm bench
merge` on a clean tree, so `meta.asmlift.commit` names a commit that EXISTS on this branch.
The value it carried before was a SHA from the pre-rewrite history, which this branch
destroyed — provenance pointing at nothing.

The re-run is also the strongest available check that the rewrite preserved behaviour:
comparing row objects field by field against the previous results, **0 of 675 rows differ**.
Only `meta.generatedAt` and `meta.asmlift.commit` changed.

  asmlift  match 344  nonmatch 167  declined 162  noncompile 2  failed 0
  m2c      match 340  nonmatch 189  declined  22  noncompile 123  failed 1

`pnpm bench regression`: 0 lost, 0 missing, 0 gained, 0 other flips (675 committed rows).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…member of what it points at

`EepromSaveData->save_slot_status[arg0]` is what the snowboardkids2 source says. asmlift
matched that function byte for byte and spelled it
`((u8 *)EepromSaveData + (a0 & 255))[16]` — the same address, arrived at by arithmetic on a
loaded cell, with the member's name nowhere in it. The map knew `EepromSaveData` was a
pointer and stopped there: nothing said what it pointed AT.

@gba-kit/debug-info now reports that (`variableShape().pointee` — the target's name and size —
plus per-member array element facts), so the provider carries the pointee WHOLE: its name,
its size, and the layout that name resolves to via `di.struct()`. The capability gate grows
with it — `assertPointeeFactPresent` probes the `pointee` KEY on a pointer shape, the same
key-presence discipline as the cv-qualifier and member-signedness probes, and refuses loudly
rather than emitting a map that is silently one release short. It is the only one of the new
facts key presence CAN witness: `elemSize`/`elemSigned`/`length` are legitimately absent on a
non-array member, so no member's silence means anything. They ship together, and the comment
says so.

Core then spells, alongside the struct-dot / bare-array / cast-then-add rules:

  • a constant total offset matching a member exactly → `gPtr->member` (ARROW: the base is a
    pointer, not an index node, so the existing `field.dot` flag stays off);
  • an offset landing on an ARRAY member with a variable term scaled by its element size →
    `gPtr->member[i]`;
  • anything else → today's cast spelling, unchanged. No member is ever guessed.

BYTE-EXACTNESS is the whole argument, and every guard exists for it:

  - the base is decomposed by ptrGlobalBase, which looks through exactly the two casts the
    additive lowering itself adds (`(u8 *)gPtr`, `(u32)gPtr` — cast-then-add) and no others: a
    `(u16 *)` base would re-scale what follows;
  - a member must match the offset AND the read width AND spell the SAME C type the cast form
    rendered (`spellsAccessType` mirrors `scalarTypeForAccess`) — so a 4-byte access, which
    renders `(s32 *)` whatever the load said, may only take a SIGNED member's name, and a
    narrow access must match the member's own signedness (an s8 read is ldrb+lsl+asr where u8
    is ldrb alone). Same address, same width, same type ⇒ every operator downstream compiles
    identically;
  - an ARRAY member is EXCLUDED from the exact-match rule whatever its size — `u8 x[1]` would
    match a byte access by (offset, size) and spell `->x`, which is not an lvalue of that
    width. The same exclusion is added to the pre-existing struct-global dot rule, where the
    hazard was latent (it could only ever produce non-compiling C, so no matching row relied
    on it);
  - a variable term becomes an index only through `elementIndex`, now THE one copy of the
    byte-residual→element-index rule (extracted from `globalOf`, unchanged in behavior);
  - the indexed form registers the pointee's type in the legalization env so the printer can
    see that `gPtr->arr` already strides the access width. That registration happens ONLY on
    that path, and only when the pointee is fully known — `SFn.globals` feeds `exprCType`
    alone, never the structurer's own intify/stride rules (which key on the map shape), so it
    cannot move a cast.

Verified, not assumed: for BOTH rows whose spelling changed, the before and after sources were
compiled in the row's own scoring context and the objects compared section by section —
`.text`, `.data`, `.bss`, `.reginfo`, `.rel.text` and `.symtab` byte-identical, only `.strtab`
differing (it holds the per-compile temp filename). The full benchmark agrees: 675 rows,
0 lost, 0 missing, 0 gained, 0 other flips.

`declare.ts` follows so the SELF-DECLARED world can compile the new spelling: a pointer global
with a pointee layout now declares the padded pointee struct and types the extern
(`struct Save *gSave;`) instead of `void *`. That cannot move bytes either — the cell is 4
bytes whatever it addresses, and core makes every arithmetic stride explicit — and the klonoa
self-declared A/B suite (both worlds compiled, objects byte-compared) stays green. Array
FIELDS also stop being flattened to `u8 x[16]`: with the element facts they declare `u16 x[8]`,
which is the same layout but the only spelling under which `x[i]` addresses elements rather
than bytes. The field typing itself moves to `symbols.ts` as `symbolFieldType`, so the type the
synthesis PRINTS and the type the emitter reasons against are one rule.

Rows moved (both snowboardkids2, both readability-only):
  func_80037FE0_38BE0  match 0, casts 1 → 0 — the flagship shape, now
                       `EepromSaveData->save_slot_status[a0 & 255]`
  func_80038000_38C00  nonmatch 9 (unchanged), casts 3 → 0, quality 94 → 96 —
                       `((u8 *)EepromSaveData)[78]` → `EepromSaveData->setting_4E`, ×3

NOT covered, and why: `pokeemerald:GiveBerryPowder` was expected to be the same shape and is
not — it takes the ADDRESS of a nested member (`&gSaveBlock2Ptr->berryCrush.berryPowderAmount`)
and passes it to a call, with the pointer value already copied into a local, so there is no
access to name. The three `marioparty3` rows and `snowboardkids2:func_80038000`'s siblings that
index a pointee ARRAY OF STRUCTS (`D_800CC69C_CD29C[winId].unk_31` — index scaled by the
pointee SIZE, then a member offset) are a different rule: `gPtr[i].member`, deliberately left
to fall through rather than guessed at.

Vendored symbol maps re-derived from the same ELFs (the pointee facts are new bytes in them);
pointee coverage: pokeemerald 149/261 pointer globals, kleod 10/19, snowboardkids2 5/6,
marioparty3 5/10, af 5/9, sa3 4/11.

Gates: `npx vitest run packages/core packages/cli apps/benchmark apps/web` 634 green,
`vitest --config vitest.matching.config.ts` 276 green, `pnpm typecheck` and `prettier --check .`
clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…etter, 0 bytes move

675 rows, asmlift 344 / m2c 340 — both unchanged, as intended: the pointee rule renames what
an access IS, never what it compiles to. `bench regression` reports 0 lost, 0 missing,
0 gained, 0 other flips.

Two snowboardkids2 rows changed, both readability-only:
  func_80037FE0_38BE0  match 0 (unchanged), casts 1 → 0
                       `((u8 *)EepromSaveData + (a0 & 255))[16]`
                       → `EepromSaveData->save_slot_status[a0 & 255]`
  func_80038000_38C00  nonmatch 9 (unchanged), casts 3 → 0, quality 94 → 96
                       `((u8 *)EepromSaveData)[78]` → `EepromSaveData->setting_4E` (×3)

Both were compiled before and after in their own scoring context and byte-compared section by
section: `.text`/`.data`/`.bss`/`.reginfo`/`.rel.text`/`.symtab` identical, `.strtab` differing
only by the per-compile temp filename.

Provenance is clean (dirty:false at 893cc90) — merged on a committed tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…clares, and only where it is byte-neutral

Two adversarial reviews found the `gPtr->member` / `gPtr->member[i]` spelling (893cc90)
defective in five ways. Every finding below was reproduced before being fixed.

D1 — the INDEXED form was not byte-neutral, which was its whole justification. agbcc folds
a member's byte offset K into the load's own immediate for `((u8 *)p + i)[K]` but
materialises `base + K` into a register for `p->arr[i]` — 12 bytes against 10, an extra
`adds r1, #16` — and the two forms also disagree for u16 members, for stores, and on MIPS
gcc-2.7.2. Byte identity had only ever been verified for a member at offset 0. The gate is
now that offset, spelled as a named constant: core cannot see which target it is emitting
for, so the rule has to be the one that holds on all of them. The CONSTANT-offset form
`gPtr->member` is NOT gated — it is a single load whose member offset becomes the same load
immediate the cast form used.

The gate cost the one row that fired (the flagship `EepromSaveData->save_slot_status[a0]`,
member offset 16, reverted to its cast form). Recovering the offset-0 case it does allow
required wiring the same rule into `arrayAccess`: an off-0 access with a scaled index
recovers as an array access, not as a load with an offset, so that lowering never saw the
pointee rule at all.

D2 — `volatile` and `const` were unguarded. A volatile MEMBER was spelled `gPtr->vreg`,
making a volatile access where the cast form was plain (a different instruction sequence,
not a different spelling). A pointer-to-volatile/const POINTEE could not even be
represented, so the same source compiled to different bytes in the project world and the
self-declared one. And a store through a const pointee is a hard error where the cast form
merely cast the qualifier away. Members now decline on `volatile`, stores decline on a
const pointee or member, and the pointee's own cv-qualifiers are carried and reproduced —
on the correct side of the `*`, since `volatile struct S *g` and `struct S *volatile g`
qualify different objects.

D3/M1 — "this pointee is spellable" had THREE definitions. The constant path required only
a layout, the indexed path also a name and size, and `declare.ts` declined the whole struct
if ANY member was unsizable — so core could emit `gPtr->flag` while synthesis emitted
`extern void *gPtr;`, which is C that does not compile. The two also SELECTED differently:
the declaration keeps the first member at an offset and drops overlapping union aliases,
while the access rules matched on (offset, size), so `gPtr->half` could be named but never
declared. There is now ONE predicate — `declaredFields` (which members exist) and
`pointeeStructType` (whether anything may be spelled through this pointee at all) — and
both access paths, the pre-existing struct-global dot rule, and `declare.ts` all gate on it.

D4 — an array-of-STRUCT member shifted the synthesised layout: `elemSize` is the ELEMENT
TYPE's size, so an array of 2-byte structs declared `u16 x[n]` acquired an alignment the
real member lacks, and at an odd offset the compiler inserts padding that moves every later
member. The array spelling now requires a base-type element (`elemSigned` is the witness);
anything else declares the byte array of the member's own size, which has no alignment to
acquire. `elemSize * length` must also account for `size`, or the layout is declined whole.

D5 — a malformed layout THREW: `layout: 42` raised `layout.find is not a function` out of
`decompile()`. `SymbolMap` is public core API and the webapp accepts caller-supplied maps,
so it is validated and declined instead.

M2 — array-ness was tested three different ways, and the signedness defaults disagreed
between the arms (a signless 4-byte member typed s32 as a scalar but u32 as an array
element). One `isArrayField` now decides it everywhere, and the array arm has no default at
all: an element whose signedness the DWARF never stated is not a base type, so it takes the
byte-array spelling rather than a guess.

M4 — the capability gate could not witness what it claimed. `assertPointeeFactPresent` is
guarded by `sh.kind === 'pointer'`, so a project with only struct globals never ran it while
its comment said it stood for both facts. The witness is now positive and tracked across the
whole ELF — a pointer shape carrying `pointee`, or any member carrying `elemSize` — and
refuses at end-of-load when layouts were read but neither was ever seen.

Also: `ptrGlobalBase` returns null rather than a `{name:''}` sentinel it documented as null,
`spellsAccessType` compares against THE one copy of the access-type rule instead of
restating it, and two comments referencing a `spellPtrGlobalArith` that does not exist are
rewritten to describe the code that does.

Vendored symbol maps re-derived against the upstream fixes (gba-kit 3b7ade0/1fc5bb1): 15
struct globals gained the layouts an anonymous `typedef struct` had been hiding (af 7,
marioparty3 3, snowboardkids2 3, pokeemerald 1, sa3 1), 8 pointer globals gained pointee
cv-qualifiers, and 5 members gained a const the format had reported to nobody.

Gates: `npx vitest run packages/core packages/cli apps/benchmark apps/web` 652 green,
`pnpm typecheck` and `prettier --check .` clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… is not byte-neutral anywhere core can tell

The previous commit gated `gPtr->arr[i]` to a member at offset 0, on the reasoning that a
nonzero offset is what agbcc refuses to fold. A full sweep of the actual codegen says that
gate is not enough, so the form goes entirely.

Measured, three targets, widths 1/2/4, loads and stores, K swept over
{0,6,16,31,32,62,64,124,128,200,4096}, comparing raw `.text` plus relocations (never `.s`):

  • agbcc, every K != 0, every width, both directions — DIFFERENT. agbcc does not
    reassociate `(base + K) + i` into `(base + i) + K`, so the arrow form emits `adds rN,#K`
    where the cast form folds K into the load's own immediate: +2 code bytes at width 1,
    and .text 16 → 20 at widths 2 and 4.
  • agbcc, K == 0, widths 2 and 4 — DIFFERENT. Same instruction count, but the commutative
    `adds` picks the other destination register (`adds r1,r1,r0` vs `adds r0,r0,r1`).
  • agbcc, K == 0, width 1 — identical for a BARE index in a function with ONE such access,
    and different for `i & 255`, `i + 1`, `i >> 2`, `i * 3`, `(u8)i`, and for a second access
    that lets the cast side CSE the base (24 bytes vs 20).

That last line is the disqualifying one. The difference is not a property of the expression
being spelled — it depends on the index's shape and on what the REST of the function does, and
a spelling rule decides one expression at a time. There is no local gate that makes the form
safe. Both MIPS targets do accept it at every K, but core is target-agnostic by construction:
it cannot condition on the compiler it is emitting for.

The CONSTANT-offset form `gPtr->member` stays, and is now measured rather than argued:
identical on all three targets across 536 pairs — widths 1/2/4, loads and stores, every offset
up to 4096 (including past Thumb's immediate range, where both spellings materialise the
offset identically), under multi-member, across-a-call and in-a-loop shapes. Unlike the
indexed form, it composes.

Cost: `snowboardkids2:func_80037FE0_38BE0` reverts to `((u8 *)EepromSaveData + (a0 & 255))[16]`
— the row the original feature was built for. It is a readability loss on a row that matched
either way, and the alternative is a spelling whose bytes we cannot vouch for.

With the form gone, `pointeeStructType` no longer needs to build an env type (only the indexed
spelling required the legalization entry), so the shared gate is now `pointeeFields` — the
members a pointee may be named through, or null.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ndexed form's removal

Four sites still described `gPtr->member[i]` as something core emits: the SymRenderCtx env
callback (whose pointee registration is gone — only the bare `gSym[i]` spelling still needs
it), symbolFieldType's contract, memAccess's pointee branch, and a declare.ts test comment.
The declare.ts pointer arm also claimed qualifiers bind to the VARIABLE, which is now only
half the rule — the pointee carries its own, spelled on the other side of the `*`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…row reads worse, 0 bytes move

675 rows, asmlift 344 / m2c 340 — unchanged. `bench regression`: 0 lost, 0 missing, 0 gained,
0 other flips.

ONE row's output moved, and it moved backwards, which is the honest outcome of dropping the
indexed spelling:

  snowboardkids2:func_80037FE0_38BE0  MATCH → MATCH, quality 100 → 100, casts 0 → 1
    was  `EepromSaveData->save_slot_status[a0 & 255]`
    now  `((u8 *)EepromSaveData + (a0 & 255))[16]`

That is the row the pointee feature was built for. It still matches — the arithmetic spelling
is what it matched with before the feature existed — but it no longer reads as the source
does. The measurement that took the spelling away is in the previous commit.

The CONSTANT-offset spelling is untouched and keeps its row:
`snowboardkids2:func_80038000_38C00` still spells `EepromSaveData->setting_4E` three times
(casts 0, quality 96), which is the form measured identical on all three targets.

The other 160 changed rows differ only in the repro script's recorded sha256 of the vendored
symbol map — af, marioparty3, pokeemerald, sa3 and snowboardkids2 all re-derived their maps
against the upstream fixes (kleod's map was unaffected, and its 32 rows are byte-identical).

Provenance clean (dirty:false at 6e4805c) — merged on a committed tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…the crash it was added to prevent

`declaredFields` validated each member inside the loop that walks the SORTED list, so the
comparator read `.offset` off an unvalidated entry first. `layout: [null, null]` therefore still
threw `Cannot read properties of null (reading 'offset')` out of `decompile()` — the exact
failure the guard exists to stop.

The original test missed it because every malformed case it tried held ONE element, and
`Array.prototype.sort` never invokes the comparator on a one-element array. The cases are now
multi-entry as well, which is where the comparator actually runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
macabeus and others added 23 commits August 1, 2026 00:56
675 rows, asmlift 344 / m2c 340; `bench regression` 0 lost, 0 missing, 0 gained, 0 other
flips. No row's output changed against the previous results commit — the layout-validation
fix only reaches malformed layouts, which no vendored map contains. Re-run so the published
provenance names the tip it was actually produced at (dirty:false at cc0619e).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
M4's witness had no test: it is the one rule that cannot be checked per variable, so it is the
one most likely to rot. Pins all three arms — witnessed passes regardless of layout count,
unwitnessed-with-layouts refuses (and names how many it read), and an ELF with no layouts is
never refused, since nothing in it could depend on the facts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
675 rows, asmlift 344 / m2c 340; regression 0 lost / 0 missing / 0 gained / 0 other flips.
A test-only commit cannot move a row; re-run so the published provenance names the tip.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…cident

When a symbol-map candidate and its `/raw-globals` sibling compile to the
same bytes, the reader should get `gCounter` rather than a bare address.
That already happened — enumerateCandidates emits the named variant first
and Array#sort is stable — but it was the emergent product of two unrelated
decisions, either of which could be changed without anyone noticing the
spelling had flipped.

rankBy now carries each candidate's enumeration index and breaks a score
tie on it explicitly, and two tests pin the pair: an exact tie is won by
the candidate that names the global, and a strictly better raw spelling
still wins (bytes are never overridden by the preference).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every benchmark row is a function someone already decompiled, so the project
ELF carries facts about it that a user mid-decomp cannot have. Scoring against
those measures the harness, not the tool: it flatters any feature that reads
them and transfers nothing to the real flow the dogfood reproduces.

`asIfUndecompiled` rebuilds the map as that user's ELF gives it — stripping the
row's own DEFINITION-derived facts while KEEPING its name, because an
INCLUDE_ASM function still has a .symtab entry. Callee facts, globals and
struct layouts stay; those are what transfers.

Measured: 0 of 96 agbcc rows move. The shipped map is leakage-free today, as
expected — it carries nothing DWARF-derived about code symbols. The seam
matters for what comes next: signatures, locals and register locations are all
definition-derived, and DEFINITION_DERIVED_KEYS is where they get declared.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…numerically

agbcc emits a literal-pool word symbolically exactly when the source named a
linker symbol, and numerically when it did not (an address-cast macro,
`*(vu16 *)0x4000130`). So a numeric word sitting beside a symbolic one in the
same function is numeric by the source's CHOICE, and promoting it to a map name
spells something the source never wrote.

The witness is per-function and required: a linked-ROM disassembly resolves
every relocation to a number, and reading "numeric" as "unnamed" there would
disable the map for the users who need it most. A pool that names nothing
external is therefore left alone rather than read as evidence.

MEASURED REACH ON THE BENCHMARK: ZERO. All 96 agbcc rows are byte-identical
with and without this. Six mapped numeric pool words exist (GameUpdate,
FreeAllDecompBuffers x2, RollRandomLevelVariant x2, StreamCmd_SetBGScroll) but
every one sits in a kleod function whose pool names no external symbol, so the
witness never fires. The rule is kept because it is sound and guards a real
class; it is not kept because it bought anything here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rankBy drops a candidate whose scorer throws so a broken spelling cannot sink a
sibling that compiles — correct, but it dropped them SILENTLY: the error went
into a `lastScoreErr` that each iteration overwrote, the eval kept only `.best`,
and the row published a clean win with `compileErrors: null` over a hidden
failure. A scoring harness must not hide that a spelling was refused.

rankBy now returns `dropped` (label + first diagnostic line) and the eval
publishes it as `droppedCandidates`. The webapp's async ranking loop is the
same code by hand, so it gains both the drop record and the explicit
enumeration-order tie-break it was relying on sort stability for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Struct members carried `pointer: true` and nothing else, so synthesis declared
every one `void *`. The klonoa header says `u16 *pBufBg0Tilemap` — and pointer
arithmetic scales by the pointee, so asmlift's `gUnk_03004790.pBufBg0Tilemap -
4` addressed `ptr-4` where the source addressed `ptr-8`. Wrong code, not a
spelling preference, and it was invisible because the winning candidate on that
row is `/raw-globals`.

The fact comes from @gba-kit/debug-info's new pointee member facts and is
carried to `symbolFieldType`; a non-base-type target still declares `void *`,
which is address-identical for any object pointer. Synthesis for
gUnk_03004790 now reproduces the header exactly: `void *`, `u16 *`, `u16 *`,
`u8 *`.

Also: `symbolsByName` silently kept whichever entry a colliding name iterated
last, applying one address's layout to another's global. A disagreeing name now
degrades to NAME-ONLY — the name survives so the reference stays declarable,
only the facts that could be wrong are withheld. Measured across the six
vendored maps: 250 duplicate names, of which 5 disagree and 1 (`sMenu`) carries
a shape. The audit's "sa3 228 / InitSprite at 16 addresses" was two mistakes —
228 is pokeemerald's, and InitSprite's 16 entries are identical, so they never
conflicted.

Maps re-vendored: pointee widths now typed on 3/35 kleod, 8/20 sa3 and 670/6161
pokeemerald pointer members. Benchmark effect: 0 of 96 agbcc rows move.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Some projects declare a fixed RAM cell with `extern`, others with
`#define gCounter (*(u16 *)0x03001234)`. The two are not interchangeable in the
bytes: an extern makes agbcc emit a RELOCATED pool word, the macro a NUMERIC
one. A target showing the numeric word can therefore only be matched by the
macro spelling — and no `.symtab` carries these names, since a macro is not a
symbol.

`addressCastMacros` recognizes exactly `#define NAME (*(T *)0xADDR)` and
refuses everything else: any volatile alias (`vu8` — dropping the qualifier
changes both bytes and semantics), any type spelling outside u8/s8/u16/s16/
u32/s32, two names at one address (REG_VCOUNT vs REG_VCOUNT_L differ in width),
and one name at two addresses. `bench vendor` extracts them with `cpp -dD`
under the project's own include flags, so scoping is the compiler's, not a
header glob's.

The macro joins the map as the canonical name and declares itself — the header's
body verbatim, never an `extern`. Two compile seams had to learn that macro
defines survive into the HEADERS world as well: a duplicate `#define` with an
identical body is legal C, and a PREPROCESSED context has no macros at all, so
dropping them turned a macro-named candidate into `undeclared identifier`.
`makeRealCompile` also never bound its `declarations` argument at all.

MEASURED on 96 agbcc rows: 3 kleod rows gain a real name at an IDENTICAL score
(VBlankHandlerMinimal `gIMEAcknowledge`, ProcessHBlankWait `gBuffer_52A4`,
DmaSpriteToObjVram `gGfxStreamBuffer` — all previously raw addresses). 93
unchanged, 0 regressions, 0 matches gained or lost. Readability, not score.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pokeemerald builds with `agbcc -g`, so its ELF carries 15,678 subprogram
signatures — 98.9% of its functions. Those become `FnProto` entries for names
the caller did not already state, which in the flow that matters means the
CALLEES: the function a user is decompiling is still INCLUDE_ASM and has no
signature in their ELF at all, so its own is never available (and P3a's filter
strips it here, so it can never be scored either).

Measured on pokeemerald's 32 rows, UN-HINTED — no manifest prototypes, the path
a real user is on:

  without DWARF signatures:  10 match, 17 nonmatch, 4 declined, 1 noncompile
  with    DWARF signatures:  11 match, 17 nonmatch, 4 declined, 0 noncompile

+1 match (StringAppend 4 → 0), +1 row that now compiles at all
(GiveBerryPowder), 0 regressions. On the HINTED path the manifest already
supplies these facts, so all 96 agbcc rows are unchanged — which is the point:
this closes part of the gap between the hinted benchmark and the un-hinted
dogfood, and claims nothing on the scoreboard.

kleod and sa3 contribute 0 signatures: their sidecars are modern-gcc types-only
grafts with no subprogram DIEs. Reaching them needs `agbcc -g` in those
projects' own builds.

An unspellable parameter drops the whole entry rather than shortening the list:
a partial list would be read for its LENGTH and hand back the right arity with
the wrong widths.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The map has two sources now — the ELF (names + shapes) and the project headers
(address-cast macro names) — but the fidelity gate re-derived it from the ELF
alone, so every macro-bearing project reported permanent drift and `bench
fidelity` failed outright on kleod.

`buildVendoredMap` is now the single builder both call. Verified: kleod's map
re-derives to the vendored hash and its 64 reproduction scripts run clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The veto refused every map name for a pool word the target spells numerically.
But the property that actually contradicts a numeric word is RELOCATION: an
`extern` name makes agbcc emit a relocated pool word, while an address-cast
macro expands to that same numeric literal. So a macro spelling is compatible
with the evidence by construction — and is in fact the spelling the numeric
word is evidence FOR, since klonoa's true sources reach these cells through
exactly such macros.

Latent today (no row has both a firing witness and a macro name) but load-
bearing for any future widening of the witness: measured, a witness widened to
`bl` targets would fire on 23 of 32 kleod rows and suppress `gIMEAcknowledge`
and `gCollisionMapPtr` — two of the three names the macro channel just won,
one of which the reference source uses verbatim.

Interior attribution keeps the veto without the exemption: `&gSym + K` is
extern-shaped, hence relocated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… names

A published source that spells `gCollisionMapPtr` compiles only where that
macro is defined, but `bench target` materialized a scoring context built from
the vendored (preprocessed, macro-free) project context — so the script the
benchmark publishes could not build the source the benchmark published.

`macroDefinesUsedBy` selects the defines for exactly the macro names a source
uses, and they are now (1) prepended to every rung of the ladder REPLAY, or
resolveScoringPrelude would fail every rung and record the wrong one, and (2)
written into the materialized ctx.i so the reproduction is self-contained.

Not yet exercised end to end: the committed results.json predates the macro
channel, so no published source names a macro. The `bench fidelity` proof comes
with the regeneration that publishes them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ng headers

The address-cast macro channel read the project's headers with `cpp -dD`, using
the benchmark manifest's include flags. That worked only inside the benchmark —
a CLI user's decomp.yaml declares no header list — and it gave the map a SECOND
source, which promptly broke the fidelity gate (it re-derived from the ELF alone
and saw permanent drift).

Both projects' sidecars now carry a .debug_macinfo table, so the provider reads
the macros from the same ELF `elf:` already points at. `bench vendor` loses its
header-preprocessing path and the drift gate goes back to plain loadSymbolMap:
one source, one builder, and the feature works for any project whose ELF carries
the section — no new configuration.

Maps re-derive identically (kleod 36 macro names, sa3 1) and all 96 agbcc rows
are unchanged. pokeemerald drops from 1 macro name to 0: it has no types-sidecar
at all (it uses agbcc -g directly), so nothing there emits macro info. No row
referenced it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A manifest proto mixed two different kinds of fact: what the row's own function
is declared as, and what its CALLEES are. The first is something a real user
always has — they are writing the .c. The second they never hand-write, so
carrying it flattered the tool: it supplied for free exactly the fact this work
is trying to source from the ELF.

31 callee entries dropped (kleod 23, pokeemerald 5, sa3 3; the three non-agbcc
projects had none). Measured against the published baseline, on the agbcc rows:

  pokeemerald  unchanged — its DWARF signatures cover every dropped callee
  sa3          unchanged
  kleod        GameUpdate match -> nonmatch 3, ReadKeyInput 40 -> 43,
               Decompress 12 -> 10

kleod is the honest cost: only 210 of its 1,196 functions are decompiled, so
its ELF cannot supply the callee signatures either, and the hints were encoding
knowledge no user of that project would have. -1 match, and the number now
means what it appears to mean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`prependC` carried callee declarations on 17 rows — the same class of fact just
removed from `proto`. Unlike `proto` it is baked into the TARGET TU, so removing
one can change the bytes the row is matched against; each was tested by
rebuilding the target both ways and comparing object hashes.

  5 rows  DIFFER — the declaration changes the target's own codegen
          (af:search_position_angleY, af:spolar2world, pokeemerald:Cos2,
          snowboardkids2:func_80035F80_36B80, snowboardkids2:func_8002A390_2AF90)
  5 rows  the target does not COMPILE without it (snowboardkids2)
  7 rows  byte-identical target

Of the 7 safe ones, 6 are dropped with zero effect on any row. The 7th —
kleod:RollRandomLevelVariant — is KEPT, and it is the interesting one: removing
`u32 thunk_sub_080002A0(void);` "improved" it 27 -> 23, but only by letting a
semantically wrong call (`thunk_sub_080002A0(&gGameFlagsPtr)`, an argument to a
no-argument function) compile through C89 implicit declaration. The prototype is
what catches asmlift's arity defect; without it the row publishes a better score
on wrong code and the dropped-candidate record disappears.

Net: 64/64 rows unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`bench fidelity` caught a real asymmetry: the harness scores each row with
`asIfUndecompiled` applied, but the reproduction script it publishes ran the
CLI against the raw ELF — so the script saw the row's OWN signature and
OUTPERFORMED the row it was supposed to reproduce. Measured on kleod:

  StreamCmd_SetBGScroll   leakage-free nonmatch 2  |  unfiltered MATCH
  UpdateFadeEffect        leakage-free nonmatch 4  |  unfiltered nonmatch 2

The CLI now applies the same filter, and the reason is a product rule rather
than a harness detail: you are decompiling `name`, so `name`'s own compiled
facts are the answer, not an input. A project that already decompiled it
carries its signature in that ELF; consuming it makes the output depend on
already having the result, and it is a fact no user with an `INCLUDE_ASM`
function can have. Globals, struct layouts and callee signatures all survive.

Also reverts the prependC callee-prototype drop (59d119a). That was measured
neutral for asmlift and shipped on that basis, but regenerating the full
benchmark showed it is NOT neutral for m2c — `prependC` is the SHARED compile
context, not an asmlift input, and removing it cost m2c a match
(PutFirstMemBlockHeader) plus four other row changes. Removing context that
leaves asmlift untouched and only hurts the other decompiler is exactly the
asymmetry this harness must not ship; the drop was conditional on costing
nothing, and it cost something.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
First publish since the ELF round. `bench fidelity` is 1350/1350 ok, 0 warn,
0 fail; `bench regression` reports exactly one flip, and it is intended.

  asmlift  kleod:StreamCmd_SetBGScroll   nonmatch 20 -> nonmatch 2
  asmlift  kleod:Decompress              nonmatch 12 -> nonmatch 10
  asmlift  kleod:ReadKeyInput            nonmatch 40 -> nonmatch 43
  asmlift  kleod:GameUpdate              match       -> nonmatch 3   (LOST)

The two improvements are agbcc -g on klonoa: its own DWARF now supplies 439
declaration shapes and 210 function signatures where a types-sidecar gave 442
and none. The two regressions are the manifest keeping only the row's OWN
signature — kleod has 210 of 1,196 functions decompiled, so its ELF cannot
supply the callee facts the dropped hints did. That is the trade: one match for
a number that means what it appears to mean.

m2c is byte-for-byte unchanged (340/189/123/22/1), which is the point of
reverting the prependC drop before publishing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…w to build the ELF

Root README: the quick-start decomp.yaml example gains the elf: key with a
two-line why, deferring the how to the CLI README.

CLI README: a "The symbol map: elf" section — the three channels (.symtab
names, DWARF shapes+signatures, .debug_macinfo macro names) as a table with
what each buys, and a producing-the-ELF guide that stays flexible: point at
the ELF you already link, add -g when the era compiler can emit DWARF, fall
back to a modern-gcc types-sidecar grafted with objcopy when it cannot, and
-gdwarf-2 -g3 -gstrict-dwarf for the one macro form that survives a graft.
The kleod asmlift-elf target is linked as the worked example carrying all
three. The decomp.yaml table row now defers to the section instead of
describing a types-sidecar-only world.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An adversarial pass refuted three sentences of 45ca32b against the code, and
the fixes are all in the direction of claiming less:

- "names are byte-neutral" was the OPPOSITE of rank.ts's own design comment:
  the named spelling is itself a ranked lever (extern names flip pool words
  numeric->relocated and can change regalloc), which is why the raw-address
  sibling candidate exists. The paragraph now says that, and says what stays
  raw (unmapped addresses, MMIO).
- "stops emitting raw addresses" overclaimed on two axes: only mapped
  addresses get names, and a mapped one still renders raw when the raw
  sibling byte-wins. Now: "names what the map covers".
- "a missing channel degrades to the row above" was not the mechanism (the
  channels are independent; missing macros degrade to raw, not to DWARF) and
  contradicted "independent" in the same sentence. Now: fewer names, never a
  guess.
- ".symtab (always there)": a stripped ELF has none — now "any unstripped
  link has one".
- "signature of EVERY compiled function" softened: the provider degrades
  gracefully without the capability, and name collisions drop facts.
- kleod "sha-verified on every build": make asmlift-elf itself runs no sha
  check — the default make goal does; attributed correctly.
- root README list had a category error (signatures are consumed, not named;
  macro names are adopted, not assigned) — reworded.
- two duplicated clauses cut.

Held up under attack: exit-66 contract, gCtx.frameCounter dot spelling,
arity-drives-recovery, the numeric-pool macro fact, the graft-shadowing
warning, the -gdwarf-2 -g3 -gstrict-dwarf reasoning, and the GitHub anchor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
kleod: 26 addresses drop the confident wrong size 1-element size that agbcc's
unsized-extern encoding (upper_bound 0) had put on them — gSineTable,
gEntityInfo and friends now carry no size, which is what their DWARF actually
determines.

pokeemerald: 6,701 addresses GAIN their real sizes (gMonStillFrontPic_*
4 -> 1080, gMonIcon_* 1 -> 1024, ...). The DW_FORM_flag fix made the
prefer-definition rule live for the first time on DWARF-2, so the defining
DIE's initializer-sized bounds now beat the header declaration's [1]. The two
sWhiteoutRespawn entries that claimed 4 and 16 GiB are gone with it.

af, marioparty3, sa3, snowboardkids2: byte-identical maps (their sidecars are
modern-gcc decl TUs, which never hit the agbcc encodings); timestamp-only
PROVENANCE churn reverted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
asmlift 343 vs m2c 340, unchanged. `bench regression`: 0 lost, 0 missing,
0 gained, 0 flips across all 675 committed rows. The four debug-info producer
fixes turn out to be measurement-neutral on today's dataset: kleod's 26
dropped phantom sizes sat on 2-28-byte interior windows no row ever pooled
into, and none of pokeemerald's 32 rows pools an unnamed address inside the
6,701 ROM tables that just gained real sizes (the sWhiteoutRespawn 4/16 GiB
trap had likewise never fired in published output — it was armed, not
sprung).

What does change in the published artifact: the 64 kleod+pokeemerald repro
scripts carry the new map sha in their preamble — the drift gate's witness —
and two kleod rows' droppedCandidates re-record their compile error under a
fresh tmpdir path. `bench fidelity`: 1350/1350 ok, 0 warn, 0 fail.

That tmpdir path is worth a follow-up: droppedCandidates embeds
/var/folders/... paths in error strings, so those rows can never be
byte-stable across regenerations.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…e overlay

gba-kit 0.4.0 is on npm, so the pnpm-link overlay to a local checkout is gone
and the workspace resolves the registry tarball. This closes the
reproducibility hole the elf-for-agbcc plan called out: the vendored maps
already carried facts (pointee members, signatures, macro names) only the
unpublished build produced, so `bench vendor` could not re-derive them
anywhere but this machine.

Proof, run against the published package: all six vendored maps re-derive
BYTE-IDENTICALLY (0 changed), and the full offline suite passes (692 + 276).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@macabeus macabeus changed the title feat: the project's symbol map — ELF names + DWARF declaration shapes, end to end feat: the project's symbol map — ELF names, DWARF shapes & signatures, and macro names Aug 2, 2026
@macabeus macabeus changed the title feat: the project's symbol map — ELF names, DWARF shapes & signatures, and macro names feat: consume the project's symbol map Aug 2, 2026
macabeus and others added 3 commits August 2, 2026 14:02
…face their Error line

The example's asm was hand-written in UAL (`adds r1, r1, r2`, `lsls`) under
`.code 16`, a dialect agbcc never emits and one GNU as REJECTS in divided
mode ("instruction not supported in Thumb16 mode"). The decompile pipeline
accepted it — asmlift's parser reads both spellings — so the example looked
fine everywhere except the ranking pane, the one consumer that runs the text
through a real assembler. Respelled in agbcc's own divided forms and given
the `.align 2, 0` its literal pool was missing (PC-relative-safety warnings).
Verified: assembles clean, decompiles with the paired map to the exact
showcase output (gCounter, gState.timer, gBlendModeTable[a0], (u32)DoThing),
and the playground ranking pane now ranks instead of warning.

The warning was also self-truncating: firstLine() surfaced GNU as's
"in.s: Assembler messages:" BANNER and dropped the diagnostics after it,
which is why the UI trailed off into nothing. It now prefers the first
`Error:` line, so the next failure of this kind names its line and
instruction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…mbolic pools

The hand-written example could never rank clean: its literal pool was NUMERIC
words, and a candidate that names a global as an extern emits a RELOCATED
pool word — so the raw-address sibling was always byte-closer and the
showcase example displayed raw casts under a "closest candidate" banner.
That is the ranked-lever design working as documented; the example's
construction fought it.

The example is now REAL agbcc -O2 output for the function it demos, with
symbolic pool words — what any decomp's compiled .s looks like — obtained as
a fixpoint: the C asmlift decompiles it to recompiles to this exact
instruction stream. In the playground the named candidate now scores 0
("byte-exact match") and the Source pane shows the map-shaped output:

    gState.timer = gCounter + gState.timer + gBlendModeTable[a0];

The map's role shifts honestly with it: the names ride the asm (as in a real
project), and what the MAP adds is the declaration shapes — struct layout for
the .timer field, elemSize for the array indexing, scalar signedness. The
map JSON now carries those facts explicitly (signed/elemSigned), and the
comments quote the actual with/without-map outputs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
aec56de rewrote the preset (real agbcc output, symbolic pools) but missed
this pin — it still asserted the numeric-pool world where no name could
appear without the map. With symbolic pool words the names ride the asm, so
the inertness contract shifts from "no names" to "no SHAPES": both branches
now pin the exact source — shaped with the map (gState.timer,
gBlendModeTable[a0]), shapeless casts without it — which is a stronger pin
than the toContain probes it replaces.

Caught by CI, not locally: the redesign commit ran prettier and tsc but not
the web suite. The pin lives in apps/web/test/, which my pre-commit grep for
the preset's consumers (apps/web/src only) did not cover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@macabeus
macabeus merged commit ed33699 into main Aug 2, 2026
2 checks passed
@macabeus
macabeus deleted the symbol-maps branch August 2, 2026 13:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant