Kajar notes of ChronoRET, rebuilding Chrono Trigger one matched routine at a time

25 July 2026: a disassembler and the first bank sweeps

No new matches today. Instead the project got the tools it needs to scale: a recursive-descent disassembler, sweeps of banks $00, $01 and $02, CI, a pre-commit hook, progress tracking and a hardware register include file.

The disassembler

Reading ROM bytes from a hex dump works for a 22-byte routine. It does not work for a 60 KB bank. So I wrote tools/disasm.py, a recursive-descent 65816 disassembler. It starts from known entry points (such as $FF00 or $C000), follows the code, and tracks which bytes it has already decoded.

The hard part on the 65816 is that instruction sizes depend on the current M and X flags. LDA is 2 bytes when M=1 (8-bit A) and 3 bytes when M=0 (16-bit A). The disassembler has to follow every SEP and REP to know the current mode.

Eight bytes from MainInit decoded twice: as four 2-byte instructions with M=1, and as three instructions with M=0

Why the disassembler must track SEP and REP: the same eight bytes from MainInit split into different instructions depending on the M flag. Only one reading is what the CPU runs.

Two bugs came up:

  • Every second instruction skipped. The trace loop checked the visited set after decoding the first byte of an instruction, which had already marked it visited. Checking before decoding fixed it, and explains why early traces found only half the instructions.
  • XCE stopped the trace. XCE switches between emulation and native mode, and it was on the list of "terminators". It doesn't stop execution: it changes mode and falls through. It is off the list now.

Bank sweeps

With the disassembler working, I traced banks $00, $01 and $02:

Bank Entry points Instructions traced
$00 Reset ($FF00) + NMI/IRQ ~12,000
$01 Cross-bank call targets from $00 ~10,000
$02 Cross-bank call targets ~5,300

A sweep matches nothing. It maps which functions exist and where, so there is a list of entry points to choose from. Bank $01 has about 10,000 instructions, which points to substantial engine code. Bank $02 is smaller. The disassembly output is saved for reference.

CI and the pre-commit hook

A GitHub Actions workflow runs make diff on every pull request. It needs the ROM, which can't be committed, so the ROM is stored base64-encoded as a GitHub Actions secret (ROM_B64). The workflow decodes it, builds and checks the diff.

A pre-commit hook (tools/pre-commit) runs the same diff locally before each commit, so mistakes never reach the history.

Progress tracking

tools/progress.py scans asm/ for matched regions and reports how many bytes are matched. For now it uses org plus db/dw statements as the signal for data sections. Instruction-only banks are counted separately and verified by the diff.

Hardware register names

asm/hardware.inc is a large include file with every SNES PPU, CPU, DMA and APU register as a named equate, such as INIDISP = $2100 and NMITIMEN = $4200. With it, the assembly reads like documentation instead of raw hex.

Next

MainInit at $FD:C000: 215 bytes of CPU and PPU register setup, called straight from the reset stub.

← Back to devlog