# asmdiff ELF input mode — checkpoint 2026-07-07

Handoff for a fresh session in THIS repo (`~/esp-idf/tools/asmdiff`). Feature is
scoped and estimated but zero code is written. Origin: a zero-overhead-loop (ZOL)
audit in `~/esp-idf/S3-Amysynth` (report: `S3-Amysynth/docs/zol-audit-2026-07-07.md`)
where single-TU `-S` output could not show post-LTO reality — the audit had to
hand-roll awk over `objdump -d`. This mode makes that reusable.

## State

Design agreed with the user, sized at ~250-350 lines in `asmdiff.py` plus
~200-300 lines of tests (~20% repo growth), judged in line with the tool's
direction ("what did the compiler do" gains "what actually shipped after LTO").
No branch created. The awk prototypes lived in an ephemeral
session scratchpad — everything needed from them is restated below.

**2026-07-07 later session: item 1 (objdump parser / ZOL identifier) DONE.**
`extract_functions_objdump()` + `_relabel_objdump()` in asmdiff.py (after
`extract_functions`), TDD'd via `TestObjdumpExtract` (12 tests, fixture
`OBJDUMP_ASM`) in test_asmdiff.py; full suite green except the pre-existing
env-dependent `TestShortAliases.test_p_is_pair` failure (a global
~/.config/asmdiff.toml with target esp32s3 short-circuits it — fails at HEAD
too). Validated against the real S3-Amysynth ELF (1682 functions parsed):
ZOL counts match the fixtures exactly (render_lut 1, filter_process 6,
render_lut_cub 0, amy_fill_buffer 3; LTO-inlined trio absent). Branch-span
counts read HIGHER than the audit's (filter_process 4 vs 3,
amy_fill_buffer 11 vs 5) — explained, not a bug: the audit awk counted only
conditional backedges, while `loop_spans()` counts any backward label
reference incl. unconditional `j` latches (top-tested loops), same as it
does on -S output. Spans containing a conditional backedge match the audit
exactly (3 and 5).

**2026-07-07 third session: items 2-4 DONE — feature complete.**
- Longcall resolver (`_resolve_longcalls`, `L32R_VALUE`, `XTENSA_STORES`,
  `TestLongcallResolver` 9 tests): nearest write to the call register in an
  8-insn window decides; annotated l32r resolves, unannotated l32r or a
  non-store overwrite (e.g. l32i fetching a function pointer from a
  just-loaded struct — a REAL false positive caught on the amysynth ELF,
  where `amy_fill_buffer` briefly reported data symbol `amy_global` as a
  callee) leaves it indirect. `render_partial` now reports `__divsf3` etc.
- CLI (`is_elf` magic-bytes, `derive_objdump` gcc→objdump swap,
  `run_objdump`, `run_elf`, `--filter REGEX`, `--objdump PATH`,
  `TestElfMode` 14 tests): named fns get listing + table, --filter adds
  table-only rows, bare ELF errors with a hint, unknown names get difflib
  close-match suggestions, --pair/--across/--layout rejected. Deriving
  objdump resolves config targets with `want_db=False` (new param on
  build_matrix/target_command) so a target's `compile_commands = true`
  no longer triggers db discovery when nothing is compiled — found by
  running against the real config.
- Docs: module docstring (five modes + ELF usage line), README section
  "ELF input: what actually shipped after LTO" (incl. the ZOL kill rules),
  command-reference rows, a pointer from "Why not objdump by hand", and a
  narrowed indirect-call limitation. Example toml untouched (no config
  keys added).
- Suite: 182 tests, sole failure the pre-existing env-dependent
  `test_p_is_pair`. End-to-end validated on the real ELF via config-derived
  objdump (glob → esp-15.2.0 toolchain). Nothing committed.

## What the mode does

`asmdiff build/firmware.elf render_lut render_lut_cub` (or `--filter 'render_'`)
disassembles the linked ELF via the toolchain's objdump and feeds each function
through the EXISTING analyzers and renderers — same summary table
(`function / insns / calls / loop spans`, ZOL spans marked by `_LEND`-style
labels), no compile matrix.

Why it must exist: LTO fully inlines functions ("symbol from plugin" in the
linker map) — in the motivating audit, `stereo_reverb` / `parametric_eq_process` /
`delay_line_in_out` had no symbols at all; their loops lived inside
`amy_fill_buffer`. Only the ELF can answer "did this loop ship as ZOL".

## Reuse map (verified against asmdiff.py @ 1298 lines)

Unchanged, reused verbatim:
- `analyze()` (line ~530) — insns + calls from "cleaned asm lines" (mnemonic +
  operands, labels end with `:`). Format-agnostic.
- `loop_spans()` (~557) — backward-branch spans + Xtensa `loop*`-to-end-label
  spans. Works on the same cleaned-lines shape IF branch targets are labels.
- `CALL_RE` (~524), `inspect_table`, `file_summary_table`, `format_spans`,
  `format_calls`, `render_table`.

New code:
1. **objdump parser** (~70 lines), sibling of `extract_functions()` (~477):
   - Function header: `^([0-9a-f]+) <([^>]+)>:$`.
   - Insn line: tab-separated `addr:\thexbytes\tmnemonic\toperands` (hex bytes
     are ONE token, e.g. `004136` — do not confuse with the mnemonic).
   - Convert to cleaned lines: rewrite branch-target addresses
     (`beqz a7, 4037d176 <fn+0x82>`) into synthetic local labels (e.g. `.L82`)
     and insert the label line at the target instruction, so `loop_spans()`
     works unchanged. Xtensa `loop aN, <end-addr>` gets the same treatment;
     name the synthetic end labels `.L<off>_LEND` so the existing ZOL-vs-branch
     visual convention holds.
   - Skip data regions: headers with `+`/`-` offset forms
     (`<render_lpf_lut$constprop$0-0x1130>:` = literal pools / symbol-less
     gaps that disassemble as garbage) and bare `...` filler lines.
2. **Longcall resolver** (~25 lines, Xtensa): `-mlongcalls` turns direct calls
   into `l32r aX, <lit> (VALUE <sym>)` … `callx8 aX`. Resolve by scanning back
   ≤8 insns for the `l32r` into the same register and take the `<sym>` from its
   parenthesized annotation; rewrite the line so `CALL_RE` reports the real
   symbol instead of an indirect. Degrade gracefully to "indirect" when the
   annotation is absent (binutils format drift) — do not promise universality.
3. **objdump invocation + CLI** (~80 lines): detect an `.elf` positional (or
   ELF magic), derive objdump from the resolved cc path (swap trailing `gcc` →
   `objdump`; the resolve_cc glob machinery ~708 already finds the toolchain),
   optional `--objdump PATH` override. A whole ELF has ~1700 functions, so
   bare-ELF invocation needs `--filter REGEX` (function names remain the
   positional form). No compile, no matrix, no `-db`.
4. **Tests + docs**: golden objdump snippets, one class per feature area
   (repo convention, `python3 test_asmdiff.py`, currently 147 tests pass).
   README + module docstring + (if flags/config change) `asmdiff.example.toml`
   AND its byte-identical `EXAMPLE_CONFIG` constant — a unit test enforces the
   match. Stdlib-only, no third-party deps (hard repo rule).

## Ground-truth fixtures (from the motivating audit; ELF at
`S3-Amysynth/build/S3-Amysynth.elf`, toolchain esp-15.2.0 xtensa-esp32s3-elf)

Use as acceptance checks (re-verify against the current build — it may have
been rebuilt since):
- `render_lut` 1 ZOL loop, call-free; `filter_process` 6 ZOL + 3 branch.
- `render_lut_cub` 0 ZOL, 1 backward-branch loop, call-free (register-pressure
  loss — the interesting true-negative).
- `amy_fill_buffer` 3 ZOL + 5 branch loops, all call-free (holds the inlined
  FX pipeline).
- `render_partial` loop contains `__divsf3` longcalls that resolve via the
  l32r pattern — the longcall-resolver test case.

## ZOL domain facts (empirically verified on esp-15.2.0, -O2)

One call surviving to codegen anywhere in the loop body kills ZOL even behind a
never-taken runtime condition; early `break` kills it; spilled loop counter
(register pressure) kills it; nested loops: inner only. These belong in the
README section explaining what the mode is for.

## Hypotheses

N/A (feature work, not debugging). Open design choices, pick during TDD:
- OPEN: `.elf` detection — extension vs magic bytes (magic is safer, 4 bytes).
- OPEN: whether ELF mode with no names and no `--filter` prints the full
  ~1700-row table or errors asking for a filter (lean: error with hint).

## Next steps

1. Read this repo's `CLAUDE.md`, `README.md` (inspect-mode section), and skim
   `2026-07-06-inspect-mode.md` for how the last mode was integrated + doc'd.
2. TDD: write the objdump-parser tests first (golden snippet with a ZOL loop,
   a backward branch, a data region to skip, a longcall) — then implement the
   parser to green.
3. Longcall resolver + tests.
4. CLI routing + objdump-path derivation + tests.
5. README/docstring updates; full suite `python3 test_asmdiff.py`.
6. Validate against the real S3-Amysynth ELF using the fixtures above.

## Hardware-verify pending

None — this tool never touches hardware. Real-ELF validation (step 6) is the
equivalent gate.
