Skip to content

Compile pristine SMB disassembly with static control-flow and stack analysis - #8

Merged
nathsou merged 12 commits into
mainfrom
codex/remove-disassembly-edits
Sep 27, 2026
Merged

nathsou merged 12 commits into
mainfrom
codex/remove-disassembly-edits

Conversation

@nathsou

@nathsou nathsou commented Sep 19, 2026 •

Copy link
Copy Markdown
Owner

Compiles the unmodified SMB disassembly (src/smb.asm is byte-identical to the original file). No routine markers, rewritten control flow or name-based special cases remain. The recovered structure comes from static analysis.

What changed

  • Assembler and byte image. Resolves the source into one image at the original addresses, covering all 151 documented opcodes. The assembled 32 KiB PRG matches the ROM byte for byte.
  • Recursive decoding. Starts from the reset/NMI vectors and follows real successors, including instructions that overlap (.db $2c BIT tricks) and code inside data directives. A constant-propagation pass prunes branches that can never be taken.
  • Callable regions. Recovered from the CFG. Shared tails become their own functions when tail-calling them can't create recursion; otherwise, as with shared loops, they are copied locally. Loops stay inside one C function.
  • Inline dispatch. The helper after a jsr is proven by evaluating it as ordinary 6502 code over partially known state. It doesn't rely on the helper's instruction shape: the index can be in A, X or Y, the table can be .db or .dw, and the exit can be JMP (ind) or an RTS jump. The table ends where the inline data ends. Indices outside the proven set trap at runtime.
  • Guest stack. JSR return addresses are materialized on the guest stack and checked when the routine returns. A static stack contract rejects return-address tricks it can't prove safe.
  • Flags. Interprocedural C/Z/N/V liveness. Compares folded into branches become if (a < 0x8)-style conditions.
  • Structuring. Single-entry regions become loops and if/else blocks. Everything else stays a goto.
  • Runtime. Bus writes are dispatched by address range, native forms exist for every documented addressing mode, and V/D/I and PHP/PLP are handled.

Fails closed on unsupported code

These produce a compile error rather than guessed code:

  • unresolved indirect jumps, e.g. separate lo/hi tables;
  • RTS jumps through addresses pushed from elsewhere;
  • a call that returns into inline data its helper wasn't proven to read;
  • loops that only an interrupt can end (Wait: lda flag / beq Wait). NMI is delivered only at a jmp * idle loop, so these would otherwise hang.

Generated C (vs 2143a91)

Before After
goto 977 607
do/while loops 0 105
direct register compares 0 291
functions 644 537

Original names and comments are kept. Routine comments sit above each function, the disassembly header is the file header, and inverted branches are marked Original branch:.

Calls read as CALL(GameRoutines, 0xaef5);. By default this keeps the original return address on the guest stack and checks it on return. -DNATIVE_CALLS (make … EXTRA_CFLAGS=-DNATIVE_CALLS) turns it into a plain C call.

Validation

  • python3 tests/check_codegen.py --rom runs:
    • 35 MoonBit tests;
    • a deterministic-regeneration check;
    • native CPU/bus semantic checks, including exhaustive ADC/SBC;
    • an independently authored NROM-128 program that is compiled and executed natively in both call modes;
    • a replay of all 7,987 recorded frames in both call modes (hash 1633679932, unchanged).
  • CI runs the ROM-free part of this suite and the WASM build.

Limits

  • Still an assembly-source recompiler: it needs a disassembly in this dialect, and it uses source labels to decide where data tables end.
  • Only games that run in NMI from a jmp * main loop are supported.
  • Rendering is frame-based and has an optional SMB status-bar adapter.
  • Follow-ups are in docs/recompiler-migration.md: resumable foreground execution, split-table/pointer analysis, mirrored code views, and ROM input with optional metadata.

🤖 Generated with Claude Code

@nathsou nathsou self-assigned this Sep 19, 2026
@nathsou nathsou changed the title Recover control flow from unmodified SMB assembly Compile pristine SMB disassembly with static control-flow and stack analysis Sep 19, 2026
@nathsou
nathsou marked this pull request as ready for review September 19, 2026 21:27
nathsou and others added 3 commits September 20, 2026 14:54
- Replace the JumpEngine-shaped dispatcher interpreter with a general 6502
  evaluator over partially known state. The index may be in A, X or Y; the
  helper may exit through JMP (ind) or an RTS-based jump; branches on the
  proven path become runtime guards. Table extent comes from the inline data
  after the call (up to the next label/instruction), not from .dw contiguity.
- A call that returns into inline data without a proven helper is now a clear
  error instead of decoding the table as code. RTS jumps through data pushed
  by the routine report an unsupported computed jump.
- Reject loops that can repeat without changing memory, I/O, stack or any
  consumed register: under the frame-based NMI model they would hang.
- Emit JSRs as CALL(routine, return_address).
- Extend the NROM-128 fixture with an X-indexed, byte-table, RTS-based
  dispatcher executed natively.

SMB output is unchanged apart from CALL and the dispatch default; all 18
dispatch sites recover the same targets and the replay hash is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Promote the labelled head of a tail shared by several routines to its own
  function when the tail call cannot recurse. Extra label copies in SMB drop
  from 214 to 40 (remaining ones are shared loops); gotos 696 -> 607.
- Emit comments that precede a routine's entry label above its C function,
  and the source's leading comments as the code.c file header.
- CALL(routine, return_address) becomes a plain C call with -DNATIVE_CALLS
  (EXTRA_CFLAGS in the Makefile). The suite checks the replay hash and the
  NROM fixture in both modes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@nathsou
nathsou merged commit 801da3d into main Sep 27, 2026
4 checks passed
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