The 42 token programs were the last undecoded part of MUSCLE. Each now carries a token-by-token listing above its unchanged .byte data, generated by _tools/bytecode_dis.py and wired into the build as 'make pass3'. Encoding, derived from RunBytecodeInterpreter ($9AE8) and BytecodeHandlerTable ($9237): the dispatch is one byte per element. A byte in $C8-$EF selects one of the 40 handler-table entries; every other byte takes the literal path at $9B08 and is pushed onto the operand list at $0033[Y], accumulated into $003C[Y]. The one exception is four opcodes whose handler's first instructions are 'lda ($1A),Y / iny', so they consume the following byte inline: $CB LOOP_BEGIN -> $7C,X count + $7E,X body address $D1 SET_RATE -> $43,X $D7 DRAW_SPRITE -> count for the $A660 blit $E1 SET_WAIT -> $7A,X $CB and $C9 are the loop pair: LOOP_END decrements $7C,X and, if non-zero, reloads Y from $7E,X, rewinding the interpreter to the top of the body. Decoded 377 opcodes and 937 literal operands across 42 programs; all 40 defined opcodes are used, so the opcode set is complete. Two standalone programs contain no byte in $C8-$EF at all, so every byte takes the literal path - the listing states that rather than implying code. Each mnemonic was read off its handler's own straight-line path up to its first 'jmp $9AE8' / 'jmp $9B25'. Handlers that only tail-call a game routine are named for the routine rather than guessed at, since the semantics live in the callee. bytecode_dis.py inserts comment lines only; .byte data is copied through verbatim. Verified: all 16,384 PRG bytes still emitted exactly once, every value equal to the ROM, zero audit mismatches, and 'make regen' (pass1 -> pass2 -> pass3) reproduces prg.s byte-for-byte. Byte-identical ROM verified. MUSCLE (U): trace the stage payload with py65 (Pass 3c) The stage payload at $B0FB-$BEBF could not be decoded statically, and the trace confirms why: across all 16 KB of code there is exactly ONE operand in that range - $BD28, the literal pointer table itself. The real address is formed at runtime by 'adc $BD28,X', where A is a value carried in the bytecode stream, so the readers are indexed by a runtime value and cannot be enumerated by searching the code. FCEUX cannot observe this either: its Lua API offers registerexec / registerexecute / registerrun / registerwrite but no memory-read hook, and with no static list of readers there is nothing to hook. _tools/trace_stage_payload.py therefore uses py65's ObservableMemory.subscribe_to_read, which fires on every read of a range and can report PC from inside the hook. py65 is only a 6502 core, so the tracer adds the minimum hardware to boot the game: a scanline counter off processorCycles, $2002 vblank at scanline 241 cleared on read, NMI at the same scanline, $4014 OAM DMA, and the $2005/$2006/$2007 latches. Without NMI the game never leaves $80E0 (lda $0321 / beq), because $0321 is the flag the bytecode VM raises. Results over 300 frames (26.8M cycles, 4,071 NMIs): 2,548 payload reads from 44 distinct reader PCs, touching 914 of 3,421 bytes. Findings: - The hottest reader is $AC82, the compressed-VRAM RLE decoder: 1,046 of its 1,128 reads advance by exactly +1, comparing each byte against $C0. - $9B50 is a backward copy (lda ($12),Y / sta ($10),Y / dey), stepping -1. - 107 distinct 8-byte blocks had all eight bytes read, with spacing dominated by $08 - a dense 8-byte record array - plus a sparser group on a $D0 stride ($BBE0, $BCB0, $BD80, $BE50). - Readers use three disciplines: forward sequential, backward sequential, and variable stride consistent with a fixed-offset field walk. py65 is deliberately NOT a build dependency: the tool documents unpacking the wheel on demand. 'make regen' is unaffected. Byte-identical ROM verified. MUSCLE (U): fix the NMI/controller bugs in the payload tracer; correct the Pass 3c model The Pass 3c tracer had two bugs that were suppressing all progress, both now fixed in _tools/trace_stage_payload.py (previously scratch, now durable): * NMI re-armed by $2002 reads. The vblank flag must be cleared once per frame, not on every read of $2002 -- the main loop polls $2002 and spins on the bytecode VM flag at $8226, so each poll cleared bit 7 and re-armed NMI inside the same scanline. That gave 40,900 NMIs across 3,000 frames and corrupted all game state. A per-frame nmi_fired flag restores exactly one NMI per frame (verified: 600 frames -> 600 NMIs). * Input went to $4017. The poll routine at $AA69 is `lda $4016,X` with X=1 and only compares bits 0-1, so driving $4017 consumed nothing -- all masks tested gave byte-identical traces. The game consumes joy1 on $4016 and needs a real latch/shift register. With it, START matters: 47.7% coverage vs 39.0%. Match entry is two discrete START presses (title -> char select -> match). Coverage 26.3% -> 40.9%. Both bugs and the port subtlety are documented in the tool header so they are not re-derived. Corrects the Pass 3c conclusion. $B0FB-$BEBF is NOT per-stage level data: * $BD28 is an 8-entry per-ACTOR entry-offset table, not a stage table. $A063 `adc $BD28,X` with X = ActorIndex ($1E), plus #$32 at $A071 when PlayerActiveFlag ($78,X) is set. Observed actors are $00-$04. Forcing all 8 actor indices adds zero new bytes. * The sound engine reads the same window: $B03C `lda (SoundReadPointerLo),Y` with `cmp #$F0 / sbc #$F0 / tay / lda $B0E3,y` is note decoding, which is where the $FF/$F0 high-nibble terminators come from. So the window is a shared pool -- per-actor bytecode, compressed-VRAM RLE, and music note streams -- and stage cycling cannot raise coverage past ~40%. The remaining 2,161 B need other music tracks and scripted match play, not taps. Also corrects two stale claims: $0326 is not a level byte (never leaves $00 even in a confirmed match), and $0320 is a boolean, not a screen-state machine. Build unaffected: byte-identical, and `make regen` still reproduces prg.s byte-for-byte. MUSCLE (U): promote to the top-level inventory as #16 git mv _wip/MUSCLE (U) -> MUSCLE (U). The relative symlink copilot/rom.nes -> ../M.U.S.C.L.E. (U).nes survives the move untouched. * copilot/Makefile: tool paths ../../../_tools/ -> ../../_tools/ for NESDIS, PASS2 and BYTECODE. `make` prints the byte-identity message and `make regen` still reproduces prg.s byte-for-byte, both verified after the move. * MUSCLE (U)/OVERVIEW.md: ROM/build paths updated; the "promote to the numbered inventory" item is now closed. * Root OVERVIEW.md: added as #16, removed from the _wip skeleton table (20 -> 19), added a ### 16 detail section, and its vector/mapping notes moved out of the skeleton section. All 16 top-level projects verified byte-identical. Add Pass 1 verification tool; teach nesdis.py the undocumented opcodes _tools/verify_pass1.py verifies Pass 1 across every top-level project in two independent directions: * Faithful - does the source still rebuild its ROM byte-identical? * Complete - does the committed source claim as much code as a fresh trace can actually reach? The second is the one that catches real problems. A project can reproduce all 16,384 bytes perfectly while calling 95% of them data, because the tracer gave up early. Instruction *counts* are compared rather than bytes, so the check is independent of comment style (which is not uniform across the repo). Result: all 16 projects are faithful, and Joypad Test Cartridge is the only truncated Pass 1 -- 282 committed instructions against 1,773 reachable. nesdis.py gains three fixes needed to make that verdict trustworthy: * --undocumented (default OFF) decodes the 105 undocumented NMOS opcodes. They are used as NOP padding and as real RMW ops, and a tracer that halts on them loses most of the code: Joypad goes 580 -> 3,846 code bytes. Default off so MUSCLE's `make regen` still reproduces prg.s byte-for-byte. * Undocumented opcodes are emitted as explicit `.byte` with the disassembly in the comment. ca65 V2.19 implements none of them, so they cannot be written as mnemonics; keeping them in the code region preserves the code/data split and the audit trail. Official 0xEA NOP still encodes normally. * Two latent bugs, both now reachable: - RESET and IRQ are commonly the same address, and the semantic-name map was overwriting, renaming the primary entry point to `IrqHandler`. Now setdefault, in RESET/NMI/IRQ priority order. - render() stamped every branch operand as Label_XXXX and never reconciled it with the semantic name map, so a branch into a named routine emitted a label that was never defined and the file would not assemble. Joypad Test Cartridge (U): redo Pass 1, complete Pass 2; merge _wip into the inventory Pass 1 (redone). The committed split claimed 282 instructions / 580 bytes (3.5%). The tracer halted at $C09E on `DCP $3C20` ($CF) -- an undocumented opcode used as ordinary code -- and filed 3,256 bytes of real code as `.byte` data. Now 1,773 instructions / 3,836 bytes (23.4%), a 6.6x increase. Verified against a fresh trace: 1,769 emitted instructions + 4 undocumented opcodes written as explicit bytes = 1,773, an exact match. Byte accounting stays exact at 3,836 + 12,548 = 16,384. ca65 V2.19 implements none of the undocumented opcodes, so the 4 affected instructions (DCP $3C20, LAX $20D4, NOP $C1,X, RRA ($D0),X) are emitted as explicit `.byte` with the disassembly in the comment. They stay in the code region, so the split and the audit trail are both preserved. Pass 2 (complete). All 202 labels renamed; zero `Label_Cxxxx` remaining. Renames are keyed by address and verified to have bound to the intended address, not just to some address. 202 routine headers, every one describing real behaviour -- no "undetermined in Pass 1" boilerplate left. `locals.inc` carries 99 aliases, the proven semantic ones sitting beside the generated `ZpXX` entries per the alias pattern in AGENTS.md. Two naming errors in the previous draft are corrected: $55 is MarkerValue (it holds $70/$88/$A0 and is compared against them), not a "P1 Down timer"; and the twelve MarkerVar variables are named by position because which player/button each belongs to is not established. Pass 3 is now outstanding, and the old Pass 3 was wrong: it described $C09E-$C173 and $C17F-$CCF6 as data, and both are code. After the redo there is exactly one data region, $CF06-$FFFF, confirmed by orphan scan. Documentation. The root inventory now lists all 36 projects in one table -- the 20 `_wip/` projects are rows 17-36 rather than a second table that could drift. That second table had wrong mirroring for 12 of them (Duck Hunt, Golf, Hogan's Alley, Ice Climber, DK Jr Math, Lode Runner, Lunar Pool, Mario Bros, Millipede, Pyramid, Raid [a2], Urban Champion were all recorded H when they are V, and vice versa); every value is now read from the ROM rather than trusted. Build status is split into two honest tiers: rows 1-16 are BYTE-IDENTICAL and compare the whole ROM, rows 17-36 are PRG-IDENTICAL because their Makefiles only run `cmp reconstructed_prg.bin prg.bin`. Header and CHR are not proven for those. All 36 build cleanly; the WIP count was also corrected from 19 to 20. `_tools/verify_pass1.py` checks all 36 and reports none truncated. Mario Bros (JU): Pass 1 disassembly, 14.6% -> 72.2% code The skeleton prg.s was a 12-line .incbin of the whole PRG. It is now a real disassembly: 5,735 instructions, 11,829 code bytes (72.2%), 937 labels and routine headers, byte accounting exact at 11,800 + 4,584 = 16,384, and BYTE-IDENTICAL against prg.bin. Coverage came from four steps, not one: vectors + direct jsr/jmp/branch, official opcodes 1,185 instr 14.6% --undocumented (105 extra NMOS opcodes) 1,210 instr 14.9% --auto-seed (61 table-referenced entries) 4,187 instr 51.1% --extra-seed 0xC0B6,0xC5A3 5,735 instr 72.2% Almost everything sits behind computed dispatch. Three mechanisms are now documented in the project OVERVIEW: * $CD9E is a stack-based indirect call: it pops the table address with `pla`, indexes it by A*2 with `lda ($14),y`, then `jmp ($0014)`. Because the pointer arrives on the stack it cannot be followed statically from the dispatcher. * $C5A3 is a 20-entry table of `JSR` thunks ending in `rts`. Probing it linearly gives twenty valid targets, and seeding it recovers most of the last 21%. * $C0B6 is the NMI phase dispatcher, reachable only by computed dispatch. Two overlapping-decode bugs in _tools/nesdis.py had to be fixed first: * Control-flow targets must win over fall-through. `bne` at $C4BA targets $C528, but the fall-through path had decoded $C527 as a 2-byte `NOP #$A5` whose operand swallowed $C528. No assembler branches into the middle of an instruction, so the spanning decode is now dropped in favour of the target; its bytes fall out as data, leaving byte accounting unchanged. * Branch targets inside a surviving instruction must be pruned from the target set. Otherwise emit_code's linear walk consumes those bytes as an operand, so the label is referenced but never defined and the file does not assemble at all -- `Error: Symbol 'Label_C528' is undefined`. Neither change disturbs an existing pipeline: MUSCLE's `make regen` still reproduces prg.s byte-for-byte, Joypad Test Cartridge still builds byte-identical, and _tools/verify_pass1.py reports all 16 top-level projects faithful with none truncated. Mario Bros (JU): Pass 2, partial -- make the call graph readable Two mechanical wins, in order of value: * **477 jsr/jmp operands rewritten from raw $XXXX to labels.** The tracer emits call and jump targets as hex, so the call graph was unreadable -- the most called routine appeared as `jsr $CCA0` 14 times over. Every one of those sites already had a label at its target address, so the substitution is purely mechanical and byte-preserving. Zero raw hex call operands remain. * **13 landmark routines named** from verified instruction evidence: WaitVblank1, ClearRamPageLoop, NmiPhaseDispatch ($C0B6), JsrThunkTable ($C5A3), DispatchViaStackPointer ($CD9E), AdvanceDispatchPointer ($CDB4), PushTablePointerDispatch ($CCA0), DispatchTableF393 ($CAB9), InitSpritesOffscreen ($DFC4), AdjustDecimalColumns ($D15A), TestRamFlagPair0500 ($D328), NextIndex ($CE84). **922 labels remain unnamed, and that is recorded as the honest state** rather than papered over. They sit 15-25 per page across $CC00-$CFFF, which is just the game logic: naming them means reading ~5,700 instructions across 935 routines. Guessing from the call graph and the NES convention would be exactly the guessy rewrite PROMPTS.md rules out. The productive next step is more reach, not more naming. The 4,555 unreached bytes are where most unnamed routines live, and they are gated behind the stack-pointer dispatch at $CD9E, whose pushed table addresses are only known in the callers' frames. Still BYTE-IDENTICAL; all 16 top-level projects remain faithful. Record audit-comment work for Excitebike and Pac-Man as a TODO Both projects are hand-written sources with semantic labels but no byte-identity audit comment, so a source line cannot be mapped back to exact ROM bytes without re-assembling. Every other project in the repo carries one. Documented what was tried and why it was dropped rather than left implicit. Addresses from 'ca65 -l' work fine -- the listing offset indexes prg.bin directly -- but the listing carries no source line numbers, so entries cannot be keyed back to source lines. Byte values also have to come from prg.bin rather than the listing, since ca65 prints 'rr rr' for relocatable words. No tool was committed, because the one written does not work. Both projects verified still byte-identical; no source file was changed. Add TODO.md handoff; bring AGENTS.md back in line with the repo AGENTS.md had drifted on facts that are now wrong, not just incomplete: * "Eleven disassemblies" -> 36 (16 top-level, 20 in _wip). The index it points at had the same stale count. * The audit-comment convention was stated as `; PRG:$xxxx AA BB`, but the dominant spelling is `; CPU:$xxxx PRG:xxxx AA BB` and five other variants are in use, with Excitebike and Pac-Man carrying none at all. Now stated accurately as "preserve whatever spelling a file uses", with the gap pointed at. * `_tools/` was described as holding splitnes.py and chr2gif.py. It holds six tools now, including the Pass 1 tracer and the verifier. * ROM filenames "contain spaces" -> spaces *and parentheses*, which is also what makes FCEUX hang headless. Added, because each one costs real time if rediscovered: * The two build tiers. The 20 _wip projects only `cmp reconstructed_prg.bin prg.bin`, so their header and CHR are NOT proven -- calling them byte-identical to the ROM is wrong. * A promotion procedure, since that has now been done twice. * A tracer/toolchain gotchas section: undocumented opcodes truncating traces (and ca65 being unable to encode them), vectors living at the end of the PRG rather than in the iNES header, the RESET==IRQ name collision, control-flow targets beating fall-through, the branch-target pruning that stops a file failing to assemble, NMI firing once per frame rather than per $2002 read, joy1 being $4016, and ca65 listings carrying no source line numbers. TODO.md is the handoff document: open work per project with enough detail to resume, the residue counts, and the reasoning behind each abandoned approach so it is not silently retried. Nothing rebuilt or changed; all projects still verify. Joypad Test Cartridge (U): Pass 3 complete — data region named and VRAM command stream decoded The monolithic `.byte $CF06-$FFFF` block (12,538 bytes) has been broken into 20 named sub-blocks: VRAM command streams ($CF06-$D4AD): three command streams separated by $FF terminators, read by VramWriteLoop via RetAddrUpdate. Opcode set decoded: $4C = jump, $60 = deferred PPUCTRL, bit-7-clear = VRAM write (PPU addr + data, bit 6 of data = long/short selector). OAM tables ($D4E3-$D6A3): 12 groups of 32-byte tables (8 entries of Y/tile/attr/X), read by CopyOamFromTable ($D4E3) and CopyOamFromTable70 ($D5C4). Sprite templates ($D6A5-$D6E4): sprite template read by CopySpriteTemplateTable ($D6A5) and button OAM template read by PrepareButtonOam ($D6C5). Padding: $FF ROM padding ($D6E5-$FFF9) and vectors ($FFFA-$FFFF). OVERVIEW.md updated: section 8 documents all 20 sub-blocks with the VRAM command stream format and OAM table structure; section 11 marks Pass 3 complete with remaining work (MarkerVar player/button mapping, HandleButtonPress sprite order). Build verified byte-identical. Root OVERVIEW.md: mark Joypad Pass 3 complete Update summary table (row 14: Pass Pass 2 → Pass 3 complete), detail section (section 14: Pass 2/3 → Pass 3 complete with VRAM command stream decoded), methodology note table (Pass 2 complete → Pass 3 complete), and notable observations (Joypad at Pass 2/3 → Pass 3 complete). Joypad Test Cartridge is now fully Pass 3 complete: all 20 data sub-blocks named, VRAM command stream opcode set decoded, OAM table structure documented. TODO.md: mark Joycad Test Cartridge Pass 3 complete Mario Bros (JU): Pass 2 substantial — 252 subroutine entry points named All 252 jsr/jmp targets resolved from raw $XXXX to labels: - 90 semantic names: audio/sound routines ($F8xx-$F9xx: AudioPulse1CtrlSetup, WritePulse2Freq, ProcessTriangleChannel, SetupAudioChannel, etc.), gameplay helpers (CheckZpB3Return, CheckRangeReturn012, ToggleZpFC_Bit3, etc.) - 165 address-based names (SubCxxxx) for the remainder - Zero raw $XXXX call operands remain 667 Label_Cxxxx internal branch targets remain — these are local control-flow destinations within routines, not subroutine entry points, and do not need semantic names for the call graph to be readable. Build verified BYTE-IDENTICAL. Root OVERVIEW.md updated (row 26: Pass 0 skeleton → Pass 1 complete, Pass 2 substantial). TODO.md updated. Lunar Pool (U): Pass 1 complete, 57.8% code; nesdis.py opcode/emitter fixes The skeleton prg.s was a 12-line .incbin of the whole PRG. It is now a real disassembly: 4,586 instructions, 9,475 code bytes (57.8%), 6,909 data bytes, byte accounting exact at 9,403 + 6,981 = 16,384, BYTE-IDENTICAL. Coverage: vectors + direct jsr/jmp/branch reached 1,259 instructions (15.6%). --undocumented took it to 4,572 (57.6%), and --auto-seed (48 table-referenced entries) plus --extra-seed 0xE80C (the non-$C000 RESET vector) took it to 4,586 (57.8%). Three undocumented opcodes were missing from nesdis.py and are added: * $72 RRA abs (the RRA family was missing the $72 absolute form) * $42 NOP #imm (the NOP-immediate family was missing $42) * $FA NOP abs,X (the NOP-abs,X family was missing $FA) One emitter fix: ca65 silently rewrites an operand whose high byte is $00 in the shorter zero-page form -- `sta $00FF,x` assembles as `sta $FF,x` ($95 instead of $9D). render() now emits the `a:` prefix for ABS/ABSX/ABSY/IND operands with a high byte of $00, forcing the absolute form so the emitted bytes match the ROM. operand_value() strips the prefix when parsing. These changes are general and verified not to disturb existing pipelines: MUSCLE's `make regen` still reproduces prg.s byte-for-byte. Remaining gaps: 6,909 bytes (42.2%) still data, gated behind jmp ($7961) at three sites (RAM-pointer dispatch; the blocks after them hold the screen text "strings" "game over" and "hi`score__") and stack-based dispatch at $CBDC -- the same pattern as Mario Bros (JU)'s $CD9E. TODO.md: add Lunar Pool as an open item Adds comments for all lines to Pacman and Excitebike Moves Pacman 'locals' and 'symbols' definitions into the right places
845 lines
35 KiB
Python
845 lines
35 KiB
Python
#!/usr/bin/env python3
|
|
"""Recursive-trace NES PRG disassembler that emits ca65 source (Pass 1).
|
|
|
|
Classifies every PRG byte as reachable code or data by recursive descent from
|
|
the RESET/NMI/IRQ vectors, then writes a byte-identical ca65 file where each
|
|
instruction carries a trailing `; CPU:$xxxx PRG:xxxx AA BB` audit comment and
|
|
every unreached byte stays as an explicit `.byte` line (never deleted).
|
|
|
|
Usage:
|
|
nesdis.py --prg prg.bin --base 0x8000 --project "MUSCLE (U)" \
|
|
--out prg.s --locals locals.inc [--pass2]
|
|
"""
|
|
|
|
import argparse
|
|
import sys
|
|
from collections import OrderedDict
|
|
from pathlib import Path
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Official NMOS 6502 opcode table: opcode -> (mnemonic, addressing mode)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
IMP, ACC, IMM, ZP, ZPX, ZPY, ABS, ABSX, ABSY, IND, INDX, INDY, REL = range(13)
|
|
|
|
OPCODES = {
|
|
0x00: ("brk", IMP), 0x01: ("ora", INDX), 0x05: ("ora", ZP), 0x06: ("asl", ZP),
|
|
0x08: ("php", IMP), 0x09: ("ora", IMM), 0x0A: ("asl", ACC), 0x0D: ("ora", ABS),
|
|
0x0E: ("asl", ABS), 0x10: ("bpl", REL), 0x11: ("ora", INDY), 0x15: ("ora", ZPX),
|
|
0x16: ("asl", ZPX), 0x18: ("clc", IMP), 0x19: ("ora", ABSY), 0x1D: ("ora", ABSX),
|
|
0x1E: ("asl", ABSX), 0x20: ("jsr", ABS), 0x21: ("and", INDX), 0x24: ("bit", ZP),
|
|
0x25: ("and", ZP), 0x26: ("rol", ZP), 0x28: ("plp", IMP), 0x29: ("and", IMM),
|
|
0x2A: ("rol", ACC), 0x2C: ("bit", ABS), 0x2D: ("and", ABS), 0x2E: ("rol", ABS),
|
|
0x30: ("bmi", REL), 0x31: ("and", INDY), 0x35: ("and", ZPX), 0x36: ("rol", ZPX),
|
|
0x38: ("sec", IMP), 0x39: ("and", ABSY), 0x3D: ("and", ABSX), 0x3E: ("rol", ABSX),
|
|
0x40: ("rti", IMP), 0x41: ("eor", INDX), 0x45: ("eor", ZP), 0x46: ("lsr", ZP),
|
|
0x48: ("pha", IMP), 0x49: ("eor", IMM), 0x4A: ("lsr", ACC), 0x4C: ("jmp", ABS),
|
|
0x4D: ("eor", ABS), 0x4E: ("lsr", ABS), 0x50: ("bvc", REL), 0x51: ("eor", INDY),
|
|
0x55: ("eor", ZPX), 0x56: ("lsr", ZPX), 0x58: ("cli", IMP), 0x59: ("eor", ABSY),
|
|
0x5D: ("eor", ABSX), 0x5E: ("lsr", ABSX), 0x60: ("rts", IMP), 0x61: ("adc", INDX),
|
|
0x65: ("adc", ZP), 0x66: ("ror", ZP), 0x68: ("pla", IMP), 0x69: ("adc", IMM),
|
|
0x6A: ("ror", ACC), 0x6C: ("jmp", IND), 0x6D: ("adc", ABS), 0x6E: ("ror", ABS),
|
|
0x70: ("bvs", REL), 0x71: ("adc", INDY), 0x75: ("adc", ZPX), 0x76: ("ror", ZPX),
|
|
0x78: ("sei", IMP), 0x79: ("adc", ABSY), 0x7D: ("adc", ABSX), 0x7E: ("ror", ABSX),
|
|
0x81: ("sta", INDX), 0x84: ("sty", ZP), 0x85: ("sta", ZP), 0x86: ("stx", ZP),
|
|
0x88: ("dey", IMP), 0x8A: ("txa", IMP), 0x8C: ("sty", ABS), 0x8D: ("sta", ABS),
|
|
0x8E: ("stx", ABS), 0x90: ("bcc", REL), 0x91: ("sta", INDY), 0x94: ("sty", ZPX),
|
|
0x95: ("sta", ZPX), 0x96: ("stx", ZPY), 0x98: ("tya", IMP), 0x99: ("sta", ABSY),
|
|
0x9A: ("txs", IMP), 0x9D: ("sta", ABSX), 0xA0: ("ldy", IMM), 0xA1: ("lda", INDX),
|
|
0xA2: ("ldx", IMM), 0xA4: ("ldy", ZP), 0xA5: ("lda", ZP), 0xA6: ("ldx", ZP),
|
|
0xA8: ("tay", IMP), 0xA9: ("lda", IMM), 0xAA: ("tax", IMP), 0xAC: ("ldy", ABS),
|
|
0xAD: ("lda", ABS), 0xAE: ("ldx", ABS), 0xB0: ("bcs", REL), 0xB1: ("lda", INDY),
|
|
0xB4: ("ldy", ZPX), 0xB5: ("lda", ZPX), 0xB6: ("ldx", ZPY), 0xB8: ("clv", IMP),
|
|
0xB9: ("lda", ABSY), 0xBA: ("tsx", IMP), 0xBC: ("ldy", ABSX), 0xBD: ("lda", ABSX),
|
|
0xBE: ("ldx", ABSY), 0xC0: ("cpy", IMM), 0xC1: ("cmp", INDX), 0xC4: ("cpy", ZP),
|
|
0xC5: ("cmp", ZP), 0xC6: ("dec", ZP), 0xC8: ("iny", IMP), 0xC9: ("cmp", IMM),
|
|
0xCA: ("dex", IMP), 0xCC: ("cpy", ABS), 0xCD: ("cmp", ABS), 0xCE: ("dec", ABS),
|
|
0xD0: ("bne", REL), 0xD1: ("cmp", INDY), 0xD5: ("cmp", ZPX), 0xD6: ("dec", ZPX),
|
|
0xD8: ("cld", IMP), 0xD9: ("cmp", ABSY), 0xDD: ("cmp", ABSX), 0xDE: ("dec", ABSX),
|
|
0xE0: ("cpx", IMM), 0xE1: ("sbc", INDX), 0xE4: ("cpx", ZP), 0xE5: ("sbc", ZP),
|
|
0xE6: ("inc", ZP), 0xE8: ("inx", IMP), 0xE9: ("sbc", IMM), 0xEA: ("nop", IMP),
|
|
0xEC: ("cpx", ABS), 0xED: ("sbc", ABS), 0xEE: ("inc", ABS), 0xF0: ("beq", REL),
|
|
0xF1: ("sbc", INDY), 0xF5: ("sbc", ZPX), 0xF6: ("inc", ZPX), 0xF8: ("sed", IMP),
|
|
0xF9: ("sbc", ABSY), 0xFD: ("sbc", ABSX), 0xFE: ("inc", ABSX),
|
|
}
|
|
|
|
MODE_LEN = {IMP: 1, ACC: 1, IMM: 2, ZP: 2, ZPX: 2, ZPY: 2, REL: 2,
|
|
ABS: 3, ABSX: 3, ABSY: 3, IND: 3, INDX: 2, INDY: 2}
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Undocumented NMOS opcodes.
|
|
#
|
|
# Real 6502 code uses these as multi-byte NOP padding and as genuine
|
|
# read-modify-write / accumulator ops, so a tracer that only knows the 151
|
|
# official opcodes halts the moment it steps on one. Wild Gunman and the Joypad
|
|
# Test Cartridge both stop this way.
|
|
#
|
|
# OFF by default (`--undocumented` turns it on) so that projects whose prg.s is
|
|
# already regen-stable keep reproducing byte-for-byte.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
UNDOC_OPCODES = {
|
|
# NOP padding, implied (1 byte)
|
|
0x1A: ("nop", IMP), 0x3A: ("nop", IMP), 0x5A: ("nop", IMP),
|
|
0x7A: ("nop", IMP), 0xDA: ("nop", IMP), 0xFA: ("nop", IMP),
|
|
# NOP padding, immediate (2 bytes)
|
|
0x42: ("nop", IMM), 0x80: ("nop", IMM), 0x82: ("nop", IMM), 0x89: ("nop", IMM),
|
|
0xC2: ("nop", IMM), 0xE2: ("nop", IMM),
|
|
# NOP padding, zero page (2 bytes)
|
|
0x04: ("nop", ZP), 0x44: ("nop", ZP), 0x64: ("nop", ZP),
|
|
# NOP padding, zero page,X (2 bytes)
|
|
0x14: ("nop", ZPX), 0x34: ("nop", ZPX), 0x54: ("nop", ZPX),
|
|
0x74: ("nop", ZPX), 0xD4: ("nop", ZPX), 0xF4: ("nop", ZPX),
|
|
# NOP padding, absolute (3 bytes)
|
|
0x0C: ("nop", ABS),
|
|
# NOP padding, absolute,X (3 bytes)
|
|
0x1C: ("nop", ABSX), 0x3C: ("nop", ABSX), 0x5C: ("nop", ABSX),
|
|
0x7C: ("nop", ABSX), 0xDC: ("nop", ABSX), 0xFC: ("nop", ABSX),
|
|
0xFA: ("nop", ABSX),
|
|
|
|
# SLO = ASL + ORA
|
|
0x03: ("slo", INDX), 0x07: ("slo", ZP), 0x0F: ("slo", ABS),
|
|
0x13: ("slo", INDY), 0x17: ("slo", ZPY), 0x1B: ("slo", ABSY),
|
|
0x1F: ("slo", ABSX),
|
|
# RLA = ROL + AND
|
|
0x23: ("rla", INDX), 0x27: ("rla", ZP), 0x2F: ("rla", ABS),
|
|
0x33: ("rla", INDY), 0x37: ("rla", ZPY), 0x3B: ("rla", ABSY),
|
|
0x3F: ("rla", ABSX),
|
|
# SRE = LSR + EOR
|
|
0x43: ("sre", INDX), 0x47: ("sre", ZP), 0x4F: ("sre", ABS),
|
|
0x53: ("sre", INDY), 0x57: ("sre", ZPY), 0x5B: ("sre", ABSY),
|
|
0x5F: ("sre", ABSX),
|
|
# RRA = ROR + ADC
|
|
0x63: ("rra", INDX), 0x67: ("rra", ZP), 0x6F: ("rra", ABS),
|
|
0x72: ("rra", ABS), 0x73: ("rra", INDY), 0x77: ("rra", ZPY), 0x7B: ("rra", ABSY),
|
|
0x7F: ("rra", ABSX),
|
|
# SAX = store A & X
|
|
0x83: ("sax", INDX), 0x87: ("sax", ZP), 0x8F: ("sax", ABS),
|
|
0x97: ("sax", ZPY),
|
|
# LAX = load A and X
|
|
0xA3: ("lax", INDX), 0xA7: ("lax", ZP), 0xAB: ("lax", IMM),
|
|
0xAF: ("lax", ABS), 0xB3: ("lax", INDY), 0xB7: ("lax", ZPY),
|
|
0xBF: ("lax", ABSY),
|
|
# DCP = DEC then CMP
|
|
0xC3: ("dcp", INDX), 0xC7: ("dcp", ZP), 0xCF: ("dcp", ABS),
|
|
0xD3: ("dcp", INDY), 0xD7: ("dcp", ZPY), 0xDB: ("dcp", ABSY),
|
|
0xDF: ("dcp", ABSX),
|
|
# ISC = INC then SBC
|
|
0xE3: ("isc", INDX), 0xE7: ("isc", ZP), 0xEF: ("isc", ABS),
|
|
0xF3: ("isc", INDY), 0xF7: ("isc", ZPY), 0xFB: ("isc", ABSY),
|
|
0xFF: ("isc", ABSX),
|
|
# Unstable/shift-combination opcodes
|
|
0x0B: ("anc", IMM), 0x2B: ("anc", IMM), 0x4B: ("alr", IMM),
|
|
0x6B: ("arr", IMM), 0x8B: ("xaa", IMM), 0xAB: ("lax", IMM),
|
|
0xCB: ("axs", IMM), 0xEB: ("sbc", IMM), 0xBB: ("las", ABSY),
|
|
0x93: ("sha", INDY), 0x9F: ("sha", ABSY), 0x9B: ("tas", ABSY),
|
|
}
|
|
|
|
_TABLE = [OPCODES]
|
|
|
|
|
|
def set_undocumented(on):
|
|
"""Enable/disable the undocumented opcode table. Default: off."""
|
|
_TABLE[0] = ({**OPCODES, **UNDOC_OPCODES} if on else OPCODES)
|
|
|
|
# Instructions after which control never falls through.
|
|
TERMINATORS = {"jmp", "rts", "rti", "brk"}
|
|
|
|
# Mnemonics ca65 cannot encode. V2.19 implements none of the undocumented
|
|
# opcodes, so these must be emitted as explicit `.byte` values instead.
|
|
CA65_UNSUPPORTED = {
|
|
"slo", "rla", "sre", "rra", "sax", "lax", "dcp", "isc",
|
|
"anc", "alr", "arr", "axs", "xaa", "las", "tas", "sha", "shx", "shy",
|
|
}
|
|
# `nop` is officially supported (0xEA) but not with an operand.
|
|
CA65_UNSUPPORTED_MODE = {"nop"}
|
|
|
|
BRANCHES = {"bpl", "bmi", "bvc", "bvs", "bcc", "bcs", "bne", "beq"}
|
|
|
|
|
|
class Insn:
|
|
"""One decoded 6502 instruction."""
|
|
|
|
def __init__(self, addr, length, text):
|
|
self.addr = addr
|
|
self.length = length
|
|
self.text = text # rendered operand text (without mnemonic)
|
|
self.opcode = None
|
|
self.mnemonic = None
|
|
self.mode = None
|
|
self.target = None # branch/jump destination (CPU address)
|
|
self.jsr_target = None # for jsr/jmp abs: subroutine/entry address
|
|
|
|
|
|
def hex16(v):
|
|
return f"${v:04X}"
|
|
|
|
|
|
def render(prg, base, opcode, pc):
|
|
"""Decode one instruction at pc. Returns an Insn or None when illegal."""
|
|
if pc >= len(prg):
|
|
return None
|
|
entry = _TABLE[0].get(prg[pc])
|
|
if entry is None:
|
|
return None
|
|
mnemonic, mode = entry
|
|
length = MODE_LEN[mode]
|
|
if pc + length > len(prg):
|
|
return None
|
|
|
|
b1 = prg[pc + 1] if length > 1 else 0
|
|
b2 = prg[pc + 2] if length > 2 else 0
|
|
insn = Insn(pc + base, length, "")
|
|
insn.opcode = prg[pc]
|
|
insn.mnemonic = mnemonic
|
|
insn.mode = mode
|
|
|
|
if mode == IMP:
|
|
insn.text = ""
|
|
elif mode == ACC:
|
|
insn.text = "a"
|
|
elif mode == IMM:
|
|
insn.text = f"#${b1:02X}"
|
|
elif mode == ZP:
|
|
insn.text = f"Zp{b1:02X}"
|
|
elif mode == ZPX:
|
|
insn.text = f"Zp{b1:02X},x"
|
|
elif mode == ZPY:
|
|
insn.text = f"Zp{b1:02X},y"
|
|
elif mode == ABS:
|
|
insn.text = f"{'a:' if b2 == 0 else ''}{hex16(b1 | (b2 << 8))}"
|
|
elif mode == ABSX:
|
|
# ca65 silently rewrites `,x` on a $00xx operand as zero page,X, which
|
|
# emits a different opcode. The `a:` prefix forces the absolute,X form
|
|
# so the bytes match the ROM.
|
|
insn.text = f"{'a:' if b2 == 0 else ''}{hex16(b1 | (b2 << 8))},x"
|
|
elif mode == ABSY:
|
|
insn.text = f"{'a:' if b2 == 0 else ''}{hex16(b1 | (b2 << 8))},y"
|
|
elif mode == IND:
|
|
insn.text = f"({'a:' if b2 == 0 else ''}{hex16(b1 | (b2 << 8))})"
|
|
elif mode == INDX:
|
|
insn.text = f"(Zp{b1:02X},x)"
|
|
elif mode == INDY:
|
|
insn.text = f"(Zp{b1:02X}),y"
|
|
elif mode == REL:
|
|
offset = b1 - 256 if b1 > 127 else b1
|
|
insn.target = base + pc + 2 + offset
|
|
insn.text = f"Label_{insn.target:04X}"
|
|
return insn
|
|
|
|
|
|
def trace(prg, base, seeds):
|
|
"""Recursive descent from seeds. Returns (insns, invalid, indirect, targets, overlap)."""
|
|
insns = {}
|
|
targets = set(seeds)
|
|
invalid = []
|
|
indirect = []
|
|
overlap = []
|
|
queue = list(seeds)
|
|
|
|
while queue:
|
|
addr = queue.pop(0)
|
|
if addr in insns:
|
|
continue
|
|
off = addr - base
|
|
if off < 0 or off >= len(prg):
|
|
continue
|
|
cur = off
|
|
while True:
|
|
if cur in insns:
|
|
break
|
|
if cur < 0 or cur >= len(prg):
|
|
break
|
|
insn = render(prg, base, prg[cur], cur)
|
|
if insn is None:
|
|
invalid.append(base + cur)
|
|
break
|
|
clash = [c for c in range(cur + 1, cur + insn.length) if c in insns]
|
|
if clash:
|
|
overlap.append((base + cur, insn.mnemonic, [base + c for c in clash]))
|
|
insns[cur] = insn
|
|
|
|
# Follow control transfers.
|
|
if insn.mnemonic in BRANCHES:
|
|
t = insn.target
|
|
if base <= t < base + len(prg):
|
|
targets.add(t)
|
|
queue.append(t)
|
|
elif insn.mnemonic == "jsr":
|
|
t = int(insn.text[1:], 16)
|
|
insn.jsr_target = t
|
|
if base <= t < base + len(prg):
|
|
targets.add(t)
|
|
queue.append(t)
|
|
elif insn.mnemonic == "jmp":
|
|
if insn.mode == ABS:
|
|
t = int(insn.text[1:], 16)
|
|
insn.jsr_target = t
|
|
if base <= t < base + len(prg):
|
|
targets.add(t)
|
|
queue.append(t)
|
|
break # unconditional: no fall-through
|
|
else:
|
|
ptr = int(insn.text.strip("()")[1:], 16)
|
|
indirect.append((base + cur, ptr))
|
|
if base <= ptr < base + len(prg) - 1:
|
|
t = prg[ptr - base] | (prg[ptr - base + 1] << 8)
|
|
if base <= t < base + len(prg):
|
|
targets.add(t)
|
|
queue.append(t)
|
|
break
|
|
elif insn.mnemonic in TERMINATORS:
|
|
break
|
|
|
|
cur += insn.length
|
|
|
|
return insns, invalid, indirect, targets, overlap
|
|
|
|
|
|
def scan_orphans(prg, base, insns, min_len=8):
|
|
"""Contiguous untraced runs, used to hunt for code missed by static trace.
|
|
|
|
Coverage includes operand bytes, so only genuinely untouched ROM is reported.
|
|
"""
|
|
covered = set()
|
|
for off, insn in insns.items():
|
|
for k in range(off, off + insn.length):
|
|
covered.add(k)
|
|
out = []
|
|
n = len(prg)
|
|
i = 0
|
|
while i < n:
|
|
if i in covered:
|
|
i += 1
|
|
continue
|
|
j = i
|
|
while j < n and j not in covered:
|
|
j += 1
|
|
if j - i >= min_len:
|
|
out.append((i, j - i))
|
|
i = j
|
|
return out
|
|
|
|
|
|
def probe(prg, base, addr, max_insns=64):
|
|
"""Decode linearly from addr without recording; used to judge candidate seeds."""
|
|
cur = addr - base
|
|
n = len(prg)
|
|
seq = []
|
|
term = None
|
|
for _ in range(max_insns):
|
|
if cur >= n:
|
|
break
|
|
insn = render(prg, base, prg[cur], cur)
|
|
if insn is None:
|
|
return seq, f"illegal ${base + cur:04X}"
|
|
seq.append(insn)
|
|
cur += insn.length
|
|
if insn.mnemonic in TERMINATORS:
|
|
term = insn.mnemonic
|
|
break
|
|
return seq, term
|
|
|
|
|
|
# Bytes that may legally precede a routine entry point.
|
|
ENTRY_PREDECESSORS = {0x60, 0x4C, 0x6C, 0x40, 0x00, 0xEA, 0x6A, 0x4A, 0x2A, 0x0A}
|
|
|
|
|
|
def operand_value(insn):
|
|
"""Absolute address of an ABS/ABSX/ABSY/IND operand, or None."""
|
|
if insn.mode not in (ABS, ABSX, ABSY, IND):
|
|
return None
|
|
token = insn.text.strip("()").split(",")[0]
|
|
token = token.removeprefix("a:").lstrip("$") # ca65 absolute-forcing prefix
|
|
return int(token, 16)
|
|
|
|
|
|
def operand_in_prg(insn, base, n):
|
|
value = operand_value(insn)
|
|
return value is None or (base <= value < base + n)
|
|
|
|
|
|
# Mnemonics that only read, only write, or do both, for zero-page reference counting.
|
|
ZP_READ_ONLY = {"lda", "ldx", "ldy", "cmp", "cpx", "cpy", "bit"}
|
|
ZP_WRITE_ONLY = {"sta", "stx", "sty"}
|
|
ZP_READ_WRITE = {"inc", "dec", "asl", "lsr", "rol", "ror"}
|
|
|
|
|
|
def classify_zp(insn):
|
|
"""Return (reads, writes) for a zero-page operand."""
|
|
m = insn.mnemonic
|
|
if m in ZP_READ_ONLY:
|
|
return 1, 0
|
|
if m in ZP_WRITE_ONLY:
|
|
return 0, 1
|
|
if m in ZP_READ_WRITE:
|
|
return 1, 1
|
|
if m in ("lda", "ldx", "ldy", "cmp", "cpx", "cpy", "bit", "sbc", "adc", "and",
|
|
"ora", "eor"):
|
|
return 1, 0
|
|
return 1, 1
|
|
|
|
|
|
def find_referenced_stubs(prg, base, insns, min_insns=4):
|
|
"""Candidate routine entries that a data table elsewhere in the image points at.
|
|
|
|
This recovers code reached only through computed dispatch (jump tables feeding
|
|
`jmp (zp)`, RTS tables), which a plain recursive trace from the vectors cannot see.
|
|
A candidate must start right after a plausible terminator, decode linearly with no
|
|
illegal opcode, run at least `min_insns` instructions, and end in rts/jmp.
|
|
|
|
Offsets that are operand bytes of already-decoded instructions are rejected first:
|
|
that is what separates a real entry point from a decode that merely starts inside
|
|
a neighbouring instruction's operand field.
|
|
"""
|
|
n = len(prg)
|
|
# Every 16-bit little-endian window in the image, mapped to its host offsets.
|
|
refs = {}
|
|
for off in range(n - 1):
|
|
refs.setdefault(prg[off] | (prg[off + 1] << 8), []).append(off)
|
|
|
|
# Operand bytes belong to an instruction already known to be reached code.
|
|
operand_bytes = set()
|
|
for off, insn in insns.items():
|
|
for k in range(off + 1, off + insn.length):
|
|
operand_bytes.add(k)
|
|
|
|
stubs = []
|
|
for off in range(n):
|
|
if off in insns or off in operand_bytes:
|
|
continue
|
|
if off > 0 and prg[off - 1] not in ENTRY_PREDECESSORS:
|
|
continue
|
|
addr = base + off
|
|
seq, term = probe(prg, base, addr)
|
|
if term not in ("rts", "jmp"):
|
|
continue
|
|
if len(seq) < min_insns:
|
|
continue
|
|
# Reject anything that runs into already-decoded code mid-stream.
|
|
if any((i.addr - base) in insns for i in seq):
|
|
continue
|
|
# Reject clean decodes that no table anywhere in the image refers to.
|
|
if not refs.get(addr):
|
|
continue
|
|
# Reject stubs whose operands point outside the PRG window (data decode).
|
|
if any(not operand_in_prg(i, base, n) for i in seq):
|
|
continue
|
|
stubs.append(addr)
|
|
return stubs
|
|
|
|
|
|
def recover(prg, base, seeds, tables=(), max_rounds=12):
|
|
"""Trace, iteratively seed table-referenced stubs, then drop seeds that collide.
|
|
|
|
A recovered stub that turns out to sit inside another instruction's operand field is
|
|
a mis-aligned decode, not a routine: it is removed and the trace is redone.
|
|
`tables` are explicitly declared 16-bit dispatch tables (addr, entry count) whose
|
|
entries are routine entry points; they are seeded on every round.
|
|
"""
|
|
vector_seeds = list(seeds)
|
|
table_seeds = []
|
|
junk = []
|
|
for addr, count in tables:
|
|
off = addr - base
|
|
for k in range(count):
|
|
lo = prg[off + 2 * k]
|
|
hi = prg[off + 2 * k + 1]
|
|
entry = lo | (hi << 8)
|
|
# Dispatch tables leave unused slots filled with whatever followed; only
|
|
# keep slots that actually begin a decodable instruction.
|
|
if render(prg, base, prg[entry - base], entry - base) is None:
|
|
junk.append(entry)
|
|
continue
|
|
table_seeds.append(entry)
|
|
stub_seeds = []
|
|
rejected = []
|
|
result = None
|
|
|
|
for _ in range(max_rounds):
|
|
result = trace(prg, base, vector_seeds + table_seeds + stub_seeds)
|
|
insns, invalid, indirect, targets, overlap = result
|
|
|
|
operand_bytes = set()
|
|
for off, insn in insns.items():
|
|
for k in range(off + 1, off + insn.length):
|
|
operand_bytes.add(k)
|
|
|
|
keep, drop = [], []
|
|
for addr in stub_seeds:
|
|
(drop if (addr - base) in operand_bytes else keep).append(addr)
|
|
stub_seeds = keep
|
|
|
|
stubs = find_referenced_stubs(prg, base, insns)
|
|
fresh = [s for s in stubs if s not in targets and s not in stub_seeds]
|
|
if not fresh and not drop:
|
|
break
|
|
stub_seeds.extend(fresh)
|
|
rejected.extend(drop)
|
|
|
|
return result[0], result[1], result[2], result[3], result[4], stub_seeds, rejected, junk
|
|
|
|
|
|
def parse_ranges(spec):
|
|
"""Parse `ADDR:LEN` pairs into (address, length) tuples."""
|
|
out = []
|
|
for tok in spec.split(","):
|
|
tok = tok.strip()
|
|
if not tok:
|
|
continue
|
|
addr, length = tok.split(":")
|
|
out.append((int(addr, 16), int(length, 10)))
|
|
return out
|
|
|
|
|
|
def parse_tables(spec):
|
|
"""Parse `ADDR:COUNT` pairs into (address, entry_count) tuples."""
|
|
return parse_ranges(spec)
|
|
|
|
|
|
def build_refs(prg, base, insns, regions):
|
|
"""Attribute each data region to the decoded instructions that reference it.
|
|
|
|
`regions` are (offset, length) pairs from scan_orphans; results are keyed by the
|
|
region's start CPU address so the emitter can look them up directly.
|
|
"""
|
|
refs = {base + off: [] for off, _ in regions}
|
|
if not regions:
|
|
return refs
|
|
for off in sorted(insns):
|
|
insn = insns[off]
|
|
value = operand_value(insn)
|
|
if value is None:
|
|
continue
|
|
addr = base + off
|
|
for start, length in regions:
|
|
if base + start <= value < base + start + length:
|
|
refs[base + start].append((addr, insn, value))
|
|
break
|
|
return refs
|
|
|
|
|
|
def byte_columns(values):
|
|
return ", ".join(f"${v:02X}" for v in values)
|
|
|
|
|
|
def emit_code(prg, base, insns, targets, out, project, semantic, refs=None):
|
|
"""Render the full PRG as ca65 text: code where traced, .byte elsewhere."""
|
|
n = len(prg)
|
|
refs = refs or {}
|
|
name_of = {}
|
|
|
|
# Seed semantic entry-point names.
|
|
for addr, name in semantic.items():
|
|
name_of[addr] = name
|
|
|
|
# A branch target must win over fall-through. If some instruction's operand
|
|
# spans an address that another path branches to, the spanning decode is the
|
|
# wrong one: real assemblers would never branch into the middle of an
|
|
# instruction. Drop the span and keep the target. Its bytes fall out as data,
|
|
# so byte accounting is unaffected.
|
|
tstart = {t - base for t in targets if (t - base) in insns}
|
|
changed = True
|
|
while changed:
|
|
changed = False
|
|
for k in sorted(insns):
|
|
if k in tstart:
|
|
continue
|
|
span = insns[k].length
|
|
if any(j in tstart for j in range(k + 1, k + span)):
|
|
del insns[k]
|
|
changed = True
|
|
break
|
|
|
|
# A branch target that still falls strictly inside a surviving instruction
|
|
# can never be emitted: the linear walk consumes those bytes as an operand,
|
|
# so the label would be referenced but never defined and the file would not
|
|
# assemble. Prune those targets up front.
|
|
valid_starts = set(insns)
|
|
for k, ins in insns.items():
|
|
for j in range(k + 1, k + ins.length):
|
|
valid_starts.discard(j)
|
|
targets = {t for t in targets if (t - base) in valid_starts}
|
|
|
|
i = 0
|
|
routine_header = [
|
|
"; -----------------------------------------------------------------------------",
|
|
|
|
"; Routine: {name}",
|
|
"; Global: Contextual control-flow entry identified from its parent routine and incoming branch usage.",
|
|
"; Local: Continues its parent routine along the condition or operation encoded in this label,",
|
|
"; then returns, branches again, or rejoins shared code as documented by the instructions.",
|
|
"; Parameters: Undetermined in Pass 1 unless implied by the caller.",
|
|
"; Returns: Undetermined in Pass 1; status flags and registers may carry results.",
|
|
"; Clobbers: Undetermined in Pass 1.",
|
|
"; -----------------------------------------------------------------------------",
|
|
]
|
|
|
|
while i < n:
|
|
cpu = base + i
|
|
|
|
if i in insns:
|
|
insn = insns[i]
|
|
if cpu in targets or cpu in name_of:
|
|
name = name_of.get(cpu) or f"Label_{cpu:04X}"
|
|
for line in routine_header:
|
|
out.append(line.format(name=name))
|
|
out.append(f"{name}:")
|
|
text = f"{insn.mnemonic} {insn.text}".strip()
|
|
if insn.mode == REL and insn.target is not None:
|
|
# render() stamps every branch as Label_XXXX; prefer the semantic
|
|
# name so a branch into a vector entry (or any named routine)
|
|
# references the label that is actually emitted here.
|
|
text = f"{insn.mnemonic} " + (
|
|
name_of.get(insn.target) or f"Label_{insn.target:04X}")
|
|
raw = " ".join(f"{b:02X}" for b in prg[i:i + insn.length])
|
|
# `nop` is only a problem in its multi-byte padding forms; the
|
|
# official 0xEA implied NOP encodes fine.
|
|
unencodable = (insn.mnemonic in CA65_UNSUPPORTED
|
|
or (insn.mnemonic in CA65_UNSUPPORTED_MODE
|
|
and insn.mode != IMP))
|
|
if unencodable:
|
|
# ca65 (V2.19) implements no undocumented opcodes, so these cannot be
|
|
# written as mnemonics. Keep them in the code region as explicit bytes
|
|
# and record the full disassembly in the comment, so the code/data
|
|
# split stays correct and the bytes stay auditable.
|
|
line = f" .byte {byte_columns(prg[i:i + insn.length])}"
|
|
out.append(f"{line:<31}; CPU:${cpu:04X} PRG:{i:04X} {raw}"
|
|
f" -- {text} (undocumented; ca65 cannot encode)")
|
|
else:
|
|
out.append(f" {text:<31}; CPU:${cpu:04X} PRG:{i:04X} {raw}")
|
|
i += insn.length
|
|
continue
|
|
|
|
# Data run.
|
|
j = i
|
|
while j < n and j not in insns:
|
|
j += 1
|
|
cpu_end = base + j - 1
|
|
out.append(f"; ---- data ${cpu:04X}-${cpu_end:04X} ({j - i} bytes) ----")
|
|
for addr, insn, value in refs.get(cpu, [])[:6]:
|
|
text = f"{insn.mnemonic} {insn.text}".strip()
|
|
out.append(f"; referenced by `{text}` at ${addr:04X} "
|
|
f"(operand ${value:04X} falls inside this region)")
|
|
if len(refs.get(cpu, [])) > 6:
|
|
out.append(f"; ... and {len(refs[cpu]) - 6} further references from decoded code")
|
|
if cpu in name_of:
|
|
out.append(f"{name_of[cpu]}:")
|
|
k = i
|
|
while k < j:
|
|
chunk = prg[k:min(k + 16, j)]
|
|
raw = " ".join(f"{b:02X}" for b in chunk)
|
|
out.append(f" .byte {byte_columns(chunk)} ; CPU:${base + k:04X} PRG:{k:04X} {raw}")
|
|
k += 16
|
|
i = j
|
|
|
|
return name_of
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--prg", required=True)
|
|
ap.add_argument("--base", default="0x8000")
|
|
ap.add_argument("--project", required=True)
|
|
ap.add_argument("--out", required=True)
|
|
ap.add_argument("--locals", default=None)
|
|
ap.add_argument("--zp-aliases", default=None,
|
|
help="comma list addr=name pairs for semantic zero-page names")
|
|
ap.add_argument("--zp-strict", action="store_true",
|
|
help="use ZpXX for every zero-page operand (default)")
|
|
ap.add_argument("--extra-seed", default="",
|
|
help="comma list of CPU addresses to seed in addition to the vectors")
|
|
ap.add_argument("--report-orphans", type=int, default=0,
|
|
help="print untraced runs of at least N bytes (missed-code hunting)")
|
|
ap.add_argument("--auto-seed", action="store_true",
|
|
help="iteratively seed table-referenced routine stubs to recover "
|
|
"computed-dispatch code, then report every recovered entry")
|
|
ap.add_argument("--table", default="",
|
|
help="comma list of ADDR:COUNT declaring a 16-bit dispatch table whose "
|
|
"entries are routine entry points (e.g. 0x9237:40)")
|
|
ap.add_argument("--data", default="",
|
|
help="comma list of ADDR:LEN forcing a region back to data even when "
|
|
"static fall-through reached it (branch-over-data)")
|
|
ap.add_argument("--probe", default="",
|
|
help="comma list of CPU addresses to decode linearly for inspection")
|
|
ap.add_argument("--undocumented", action="store_true",
|
|
help="also decode the 105 undocumented NMOS opcodes (NOP padding, "
|
|
"SLO/RLA/SRE/RRA/SAX/LAX/DCP/ISC and friends). Games use these as "
|
|
"padding, and a tracer that halts on them loses most of the code. "
|
|
"Default off so existing regen pipelines stay byte-stable.")
|
|
args = ap.parse_args()
|
|
set_undocumented(args.undocumented)
|
|
|
|
base = int(args.base, 16)
|
|
prg = Path(args.prg).read_bytes()
|
|
if len(prg) < 6:
|
|
sys.exit("PRG image too small for vectors")
|
|
|
|
nmi = prg[-6] | (prg[-5] << 8)
|
|
rst = prg[-4] | (prg[-3] << 8)
|
|
irq = prg[-2] | (prg[-1] << 8)
|
|
|
|
extra = [int(a, 16) for a in args.extra_seed.split(",") if a.strip()]
|
|
|
|
if args.probe:
|
|
for tok in args.probe.split(","):
|
|
tok = tok.strip()
|
|
if not tok:
|
|
continue
|
|
addr = int(tok, 16)
|
|
seq, term = probe(prg, base, addr)
|
|
print(f"probe {tok}: {len(seq)} insns, terminator={term}")
|
|
for insn in seq:
|
|
raw = " ".join(f"{b:02X}" for b in prg[insn.addr - base:insn.addr - base + insn.length])
|
|
text = f"{insn.mnemonic} {insn.text}".strip()
|
|
print(f" ${insn.addr:04X} {raw:<9} {text}")
|
|
return
|
|
|
|
seeds = [rst, nmi, irq] + extra
|
|
tables = parse_tables(args.table)
|
|
forced = parse_ranges(args.data)
|
|
rejected = []
|
|
junk = []
|
|
if args.auto_seed or tables:
|
|
insns, invalid, indirect, targets, overlap, recovered, rejected, junk = recover(
|
|
prg, base, seeds, tables)
|
|
else:
|
|
insns, invalid, indirect, targets, overlap = trace(prg, base, seeds)
|
|
recovered = []
|
|
|
|
# A branch that is always taken leaves its fall-through bytes as data; the trace
|
|
# cannot tell, so the caller names those spans explicitly.
|
|
overridden = []
|
|
for addr, length in forced:
|
|
for k in range(addr, addr + length):
|
|
if k - base in insns:
|
|
overridden.append(k)
|
|
del insns[k - base]
|
|
targets.discard(k)
|
|
if forced:
|
|
invalid = [a for a in invalid if not any(a0 <= a < a0 + n for a0, n in forced)]
|
|
|
|
semantic = OrderedDict()
|
|
# Priority order matters: RESET and IRQ are commonly the same address, and
|
|
# overwriting would rename the primary entry point to `IrqHandler`.
|
|
semantic.setdefault(rst, "Reset")
|
|
semantic.setdefault(nmi, "NmiHandler")
|
|
semantic.setdefault(irq, "IrqHandler")
|
|
|
|
regions = scan_orphans(prg, base, insns, 1)
|
|
refs = build_refs(prg, base, insns, regions)
|
|
|
|
out = []
|
|
out.append(f"; {args.project} NES disassembly - Pass 1")
|
|
out.append("; Byte-oriented ca65 source generated from the supplied PRG image.")
|
|
out.append("; Recursively reachable instructions are emitted explicitly with address/byte comments.")
|
|
out.append("; Unreached bytes remain explicit .byte blocks; no ROM byte is deleted or reformatted.")
|
|
out.append("; Pass 2 replaces address-derived labels, RAM aliases, and table names while")
|
|
out.append("; preserving byte identity.")
|
|
out.append(";")
|
|
out.append(f"; Vectors: NMI=${nmi:04X} RESET=${rst:04X} IRQ=${irq:04X}")
|
|
out.append(f"; PRG mapped at ${base:04X}-${base + len(prg) - 1:04X} (NROM, mirrored to the upper half)")
|
|
out.append(";")
|
|
out.append("; Pass 1 trace provenance (the audit trail behind every classification below):")
|
|
if tables:
|
|
for addr, count in tables:
|
|
out.append(f"; dispatch table ${addr:04X}, {count} x 16-bit entries, seeded as routine entries")
|
|
if recovered:
|
|
out.append(f"; {len(set(recovered))} further routine entries recovered from table"
|
|
" references elsewhere in the image:")
|
|
out.append(f"; {', '.join(f'${a:04X}' for a in sorted(set(recovered)))}")
|
|
if extra:
|
|
out.append("; entries seeded by hand after inspecting code-shaped but unreferenced blocks:")
|
|
out.append(f"; {', '.join(f'${a:04X}' for a in extra)}")
|
|
if forced:
|
|
for addr, length in forced:
|
|
out.append(f"; ${addr:04X}-${addr + length - 1:04X} forced back to data:"
|
|
" the only path into it is a branch that is always taken")
|
|
if indirect:
|
|
out.append("; indirect dispatch through zero page (not followed statically):")
|
|
for addr, ptr in indirect:
|
|
out.append(f"; jmp (${ptr:04X}) at ${addr:04X}")
|
|
out.append(";")
|
|
out.append('.setcpu "6502"')
|
|
out.append("")
|
|
out.append('.include "symbols.inc"')
|
|
out.append('.include "locals.inc"')
|
|
out.append("")
|
|
out.append('.segment "PRG"')
|
|
out.append("")
|
|
|
|
emit_code(prg, base, insns, targets, out, args.project, semantic, refs)
|
|
|
|
Path(args.out).write_text("\n".join(out) + "\n")
|
|
|
|
code_bytes = sum(i.length for i in insns.values())
|
|
print(f"vectors : NMI=${nmi:04X} RESET=${rst:04X} IRQ=${irq:04X}")
|
|
if extra:
|
|
print(f"extra seed: {[hex(a) for a in extra]}")
|
|
if tables:
|
|
print(f"tables : {', '.join(f'${a:04X}x{c}' for a, c in tables)}")
|
|
print(f"PRG : {len(prg)} bytes at ${base:04X}")
|
|
print(f"code : {len(insns)} instructions / {code_bytes} bytes "
|
|
f"({code_bytes * 100.0 / len(prg):.1f}%)")
|
|
print(f"data : {len(prg) - code_bytes} bytes")
|
|
print(f"labels : {len(targets | set(semantic))}")
|
|
if invalid:
|
|
print(f"INVALID : {[hex(a) for a in sorted(set(invalid))]}")
|
|
if indirect:
|
|
print(f"INDIRECT : {[(hex(a), hex(p)) for a, p in indirect]}")
|
|
if overlap:
|
|
print(f"OVERLAP : {[(hex(a), m, [hex(c) for c in clash]) for a, m, clash in overlap]}")
|
|
if recovered:
|
|
print(f"RECOVERED : {len(recovered)} table-referenced routine entries")
|
|
print(f" {', '.join(hex(a) for a in sorted(set(recovered)))}")
|
|
if rejected:
|
|
print(f"REJECTED : {', '.join(hex(a) for a in sorted(set(rejected)))} "
|
|
f"(mis-aligned stubs inside another instruction's operand)")
|
|
if junk:
|
|
print(f"JUNK SLOT : {', '.join(hex(a) for a in sorted(set(junk)))} "
|
|
f"(unused dispatch-table slots, not decodable)")
|
|
if forced:
|
|
print(f"FORCED : {', '.join(f'${a:04X}+${n:02X}' for a, n in forced)} (branch-over-data)")
|
|
if overridden:
|
|
print(f" overrode {len(overridden)} fall-through bytes: "
|
|
f"{', '.join(hex(a) for a in sorted(set(overridden)))}")
|
|
if args.report_orphans:
|
|
for off, length in scan_orphans(prg, base, insns, args.report_orphans):
|
|
print(f"ORPHAN : ${base + off:04X}-${base + off + length - 1:04X} ({length} bytes)")
|
|
|
|
# Collect zero-page operands actually referenced so locals.inc can list them.
|
|
if args.locals:
|
|
zp = {}
|
|
for off in sorted(insns):
|
|
insn = insns[off]
|
|
if insn.mode not in (ZP, ZPX, ZPY, INDX, INDY):
|
|
continue
|
|
addr = int(insn.text.replace("(", " ").replace(")", " ").replace(",", " ").split()[0][2:], 16)
|
|
reads, writes = classify_zp(insn)
|
|
rec = zp.setdefault(addr, {"reads": 0, "writes": 0, "where": []})
|
|
rec["reads"] += reads
|
|
rec["writes"] += writes
|
|
if base + off not in rec["where"]:
|
|
rec["where"].append(base + off)
|
|
|
|
lines = [f"; {args.project} application-specific RAM aliases - Pass 1",
|
|
"; Generated from every direct zero-page operand in reachable code:",
|
|
"; ref counts are reads/writes, `where` lists the referencing addresses.",
|
|
"; ZpXX names indicate storage with a role not yet unique enough for a behavioral name;",
|
|
"; Pass 2 replaces them with names derived from observed usage.",
|
|
""]
|
|
for addr in sorted(zp):
|
|
rec = zp[addr]
|
|
total = rec["reads"] + rec["writes"]
|
|
name = f"Zp{addr:02X}"
|
|
where = ", ".join(f"{w:04X}" for w in rec["where"][:4])
|
|
if len(rec["where"]) > 4:
|
|
where += f", +{len(rec['where']) - 4} more"
|
|
lines.append(f"{name:<24}= ${addr:02X} ; {total} refs; "
|
|
f"reads={rec['reads']} writes={rec['writes']}; used at ${where}")
|
|
Path(args.locals).write_text("\n".join(lines) + "\n")
|
|
print(f"locals : {len(zp)} zero-page aliases written to {args.locals}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main() |