7 August 2026: the last mode handler, screen transitions, and the sprite init pass
The $0C76 mode dispatch is closed with the default handler. Then the whole screen-transition path, from fade-out through scene save and restore to fade-in, is matched, two early labels are corrected, and the 1,034-byte sprite init pass Sub_E12A goes in: 2,568 new bytes in 15 functions over sessions 21 to 25.
DefaultHandler closes the mode dispatch
DefaultHandler at $C0:16DC–$18D8 (509 bytes) is the last arm of Sub_0C76's per-frame dispatch. I already knew it used dp $5D/$5E instead of $5B/$5C and wrote VRAM slots $09B2–$09B8 instead of $09CA–$09F8. Almost nothing else matches the named handlers either. Those follow a tight template (advance some $7E:3000 entries, run N passes of 4-corner writes via Sub_1B36, BRL back to $16DC). DefaultHandler is $16DC, the landing zone, so there is no tail call.
Three bit flags in dp $17 control three separate sections:
- Bit 1 (VRAM update). Clears the flag, calls
Sub_1BA7, reads the layer entry at$7E:3000,X(index dp$5D) and advances it only for the terminal mode bytes$FEor$E0. Then one 4-corner VRAM pass to$09B2/$09B4/$09B6/$09B8, and bit 1 of dp$5Fset as a done flag. The 16-bit math is the same as in the named handlers, with other sources and destinations. - Bit 5 (display-mode transition). The surprise. Clears the bit, sets dp
$38= 1, and loops:JSR $885A/JSR $00BF/ check dp$38. After the loop (dp$38= 0) it reads dp$45and dispatches to one of four transition sub-modes (values 1–4). Each calls a mode-specific renderer with DP set to$1D00for the call, adjusts dp$46, clears dp$45and tail-callsSub_EC60. Mode 3 runs three renderer passes withPHD/PLDframing and swaps dp$7C/$82between them. No named handler has anything like this. - Bit 0 (scene swap). Checked only when bit 5 is clear. Calls a probe at
$024C. Carry clear starts a full scene swap:JSL $C02C41,JSL $C10000(bank$C1init), reinstall NMI/IRQ, reset dp$17/$18, call three post-init helpers. Carry set takes a quick path: flip bits in$17/$18andBRLto the$00EBfragment (JSR $881E+JSR $00DE+BRL $EC60).
Sub_1BA7 ($C0:1BA7–$1BAA, 4 bytes) sends an SPC audio command:
Sub_1B90 uses dp $FC for the same argument. Sub_1BA7 loads $FB instead and jumps into Sub_1B90 at its second instruction, skipping only LDA $FC. A new Sub_1B90_body label marks that spot; labels emit no bytes, so Sub_1B90 is unchanged. $FB versus $FC presumably picks a different command from the SPC tables, which fits a different kind of layer transition.
$885A, deferred. The bit-5 loop calls it every frame until dp $38 clears. The disassembler traced over 2,700 unique instructions from it before hitting the sweep limit, into a large tree across bank $C0 ($7612, $77E4, $79CF, $7BA9 and many more). It is a major frame-work routine and is in the bank map as Identified/deferred. Sub_EC60 stays the always-deferred post-VBlank worker. DefaultHandler tail-calls it for sub-modes 1, 2 and 3 (raw BRL offsets $D49B, $D472 and $D3FE), and mode 4 reaches it with two JSRs.
| Handler | Status |
|---|---|
| ModeE6_Handler | Matched |
| ModeEC_Handler | Matched |
| ModeEE_Handler | Matched |
| ModeFA_Handler | Matched |
| ModeFC_Handler | Matched |
| DefaultHandler | Matched |
| Sub_1B36 (leaf callee) | Matched |
| Sub_1B90 (leaf callee) | Matched |
| Sub_1BA7 (leaf callee) | Matched |
| Sub_EC60 (black-box) | Identified, always-deferred |
| Sub_885A (black-box) | Identified, deferred |
The mode-dispatch part of the $0C76 cluster is complete. Two subsystems are now fully closed: the sprite renderer cluster (fourteen functions, sessions 7–17) and the $0C76 mode dispatch (thirteen functions, sessions 18–21).
The screen-transition manager
Past DefaultHandler's last byte ($18D8) sit routines that are not part of the Sub_0C76 tree.
Sub_18D9 ($C0:18D9–$1984, 172 bytes) is the outer gate for screen fades and mode transitions, called from the startup sequence at $C0:00A1. It reads two flags:
$00F0bit 0: Start, newly pressed (corrected later:$00F0is the newly-pressed button word the pad reader writes, not a BG mode 5 flag)$00F6bits 0, 6 and 7: a second mode-control byte
If $00F0 bit 0 is set (with dp $11 zero and dp $1F non-zero), it pauses: it halves the brightness in dp $19 and loops a frame at a time, calling JSR $EC60 and JSL $FDC2C1, until Start is pressed again, then restores the brightness. (Corrected later: this is the Start pause, first read as a fade loop.) A "busy" flag at $0407 is written $FF at the top of every pass and reset to zero on exit. Then: if $00F6 bit 0 is set it calls Sub_1985. If $00F6 bit 6 is clear, it calls Sub_1ADF when dp $62 is non-zero. If bit 6 is set, it takes a long reinit path: the fade-down loop again, then $0B4E, $011B, JSL $C28000, $0B4E, $0B64, $0B75, DP=$0100, $01A5, $1A03, $B192, Sub_2824, and $0407 cleared. That is a full screen transition in this engine: fade out, tear down hardware state, reinit everything, run the post-transition fade.
Sub_1985 ($C0:1985–$19C6, 66 bytes) is the narrower mode-5 probe, called from Sub_18D9 at $191D. It checks:
- dp
$26: if non-zero, XOR with$03, store and return (this flips between two palette modes) $7F0000: if below$49, bail- dp
$1F, dp$62, dp$10in turn: any non-zero returns
If every guard passes, it runs the same fade-to-black loop (dp $19 down, JSL $FDC2C1, JSR $EC60), then calls $0B4E and $011B. There is no RTS. It falls straight into Sub_19C7, the mode-5 hardware reinit, so calling Sub_1985 when a reinit is needed runs Sub_19C7 on the way out. The first estimate said 322 bytes because tracing without knowing Sub_19C7 was already matched counts 66 + 60 bytes of Sub_19C7 and more. Seeing org $C019C7 / Sub_19C7: in the source made the boundary obvious.
Sub_1ADF ($C0:1ADF–$1B35, 87 bytes) is a pure leaf that runs an on-screen menu cursor, depending on dp $62:
$62 = 1: if dp$63is not negative and not$04, copy it to dp$66and set dp$63=$04. Then, if$00F6bit 7 is set, zero dp$34(a word write).$62 = 2: read joypad$00F7. Bit 2 (up) increments dp$63, clamped at dp$65; bit 3 (down) decrements it, floored at dp$64; bit 7 of$00F8(Start) sets dp$62=$03and returns. Both clamps useBCS.- anything else: the same "check
$63, update$66, set$63=$04" block as$62 = 1.
The fade-loop label bug. The first make diff had two wrong bytes, at 0x001957 and 0x0019C0: the BRA offsets in the fade loops of Sub_18D9 and Sub_1985, $EB (−21) instead of $E6 (−26). I had put .fade_loop after LDA #$FF / STA $0407, but the ROM re-runs those two instructions every pass. Moving the label back five bytes fixed both.
Two names that were backwards
Cross-checking against dscotton's disassembly turned up two early labels that were wrong.
EntryTable → ReentryVectors. The table at $C0:0000 was commented as "boot modes". Entry 0, BRA GameLoop_Main, is right. But entries 1–4 are three-byte BRL stubs to ScrollStepAccum, AudioDrvSync, MusicCueDispatch and AudioFadeDispatch. These are JSL re-entry points. A caller in another bank does JSL $C0:0002 (or $0005, $0008, $000B), which pushes three bytes. The BRL jumps to the real target without pushing anything, and the target's RTL returns straight to the caller. The stubs give other banks fixed entry points. Callers turn up in bank $CD ($CD:043E, $CD:0C8C) and bank $D1 ($D1:F405, $D1:F4E2), and every target ends in RTL ($6B).
FrameUpdate → LoadLocation. The routine at $C0:00F4 was commented "per-frame logic dispatch", because GameLoop_Main calls it. But GameLoop_Main is the outer loop that runs once per mode entry; the per-frame loop is GameLoop_FrameBody at $C0:0097. The routine's ten JSRs and two JSLs load a location.
Both renames reached every call site and label stub. No bytes changed (ReentryVectors $C0:0000–$000D, 14 bytes; LoadLocation $C0:00F4–$011A, 39 bytes), and make diff was clean.
Saving and restoring the scene
Sub_18D9's reinit path left four callees. Estimates were Sub_011B ~138 bytes (calls $0918, also called from Sub_0C76), Sub_01A5 unknown (calls $0905, also called from Sub_0C76), Sub_1A03 ~160 bytes (calls $597D), Sub_B192 ~32 bytes (BRL to $E935). All four are matched, plus three smaller callees:
Sub_011B($C0:011B–$01A4, 138 bytes) saves the scene context to$7F:1Dxx. It skips the save if dp$29is zero. It callsSub_0918to copy a 5,344-byte ($14E0) WRAM block from$00:0920to$7F:2000withMVN, then copies scene table pointers and scroll positions to$7F:1D09–$1D24.Sub_01A5($C0:01A5–$024B, 167 bytes) is the full location-engine init. It callsSub_0905(the reverse copy), runs an audio tick (SEP #$10/JSL $FDC2C1/REP #$10/JSL $FDC1EE), sets OAM base pointers ($7D=$0900,$7F=$0770,$7B=$08A0), calls eight scene-setup helpers, restores the scroll registers, callsSub_595C, and calls$E12Adepending on a flag.Sub_0905andSub_0918($C0:0905–$0917and$C0:0918–$092A, 19 bytes each) are theMVNpair. In the opcode bytes the destination bank comes first:Sub_0918(save) is$54 $7F $00, from$00to$7F;Sub_0905(restore) is$54 $00 $7F. The disassembler also got theLDX/LDYsizes wrong here, assuming 8-bit X; the entry state is 16-bit X, soLDX #$2000is three bytes.Sub_1A03($C0:1A03–$1AAB, 169 bytes) restores the scene after a transition. It compares the current palette colors ($7E:2980–$2982) with the cached dp$94–$96; if they match, it returns early. Otherwise it callsSub_597Dfor each of seven active sprite slots, updates the cache, copies scene table pointers back from$7F:1D09/$0F/$15, and restores dp$AB/$AC/$ADfrom$7F:1D1B/$1C/$1D. The addresses matchSub_011B's exactly: the pair is closed.Sub_B192($C0:B192–$B1AF, 30 bytes) zeroes the$1Bxxtable. It sets DP to$1B00, loads a loop count from$7F:2000, zeroes 16-bit words at$00,Y(Y steps by 2, count down to zero), restores DP and tail-callsSub_E935withBRL. The operand is the raw offset$3783, applied from the address after theBRL. I couldn't make the hand arithmetic land on$E935, butmake diffpasses with that raw offset.Sub_E935($C0:E935–$E951, 29 bytes) sets DP to$0B00and stores$80into dp$C0–$C7, which is WRAM$0BC0–$0BC7.$80means "no sprite" in the sprite system. This links back toSub_E9E2/Sub_E9FF/Sub_EA1F, which search$0BC0and mark slots with$80.
The full transition, now byte-exact end to end:
The nine steps of Sub_18D9's long path, in the order the code calls them. The orange steps are the save and restore pair built on MVN.
The MVN idiom. The only MVN instructions in matched code are in Sub_0905 (54 00 7F: $7F:2000 → $00:0920) and Sub_0918 (54 7F 00: $00:0920 → $7F:2000), called from Sub_01A5 and Sub_011B. The address pair appears nowhere else. The two routines are back to back; matched code resumes at $0B4E (InitHW), and $092B–$0B4D is uncharted. A small symmetric pair with one MVN each is a recognizable shape: if another turns up, it is probably a save/restore pair for a different workspace.
The save and restore pair: one MVN each way over the same $14E0-byte (5,344-byte) block. In the opcode the destination bank comes first.
The entity dispatchers
The scene path left three callees: Sub_595C (33 bytes), Sub_597D (92 bytes) and Sub_E12A. The first two have no external calls; they only dispatch through an indirect jump table at $5D6E.
Sub_595C ($C0:595C–$597C, 33 bytes), called from Sub_01A5 once per scene load, walks the entity list:
- In 16-bit A,
STZ $6Dclears dp$6Dand$6Ein one instruction: the sprite-slot counter and the sprite-update flag. - Load a 16-bit base pointer from
$7F:2003into X. - Loop: load the entity type from
$7F:2001,X; stop on zero. Otherwise save X to Y, compute type × 2, load the handler from($5D6E + type×2),JSRto it, repeat.
Before the JSR the entity pointer is in Y; after it, the loop reads $7F:2001,X with the current X. So each handler must return with X pointing at the next entity.
Sub_597D ($C0:597D–$59D8, 92 bytes), called from Sub_1A03 once per active sprite slot (slot index in dp $6D), does the same for one slot's sub-list, found at $7F:2001 + slot×16 (four ASLs in 16-bit mode). After the terminator it:
- steps X past the terminator and stores the pointer at
$1180 + slot, recording where the slot's list ends - zeroes one 16-bit entry for the slot in each of eight arrays at
$7F:0580,$7F:0600,$7F:0680,$7F:0700,$7F:0780,$7F:0800,$7F:0880and$7F:0900(stride$0080: eight parallel attribute tables) - stores
$07(three flags set) at$1C00 + slot, perhaps a "slot ready" or type-flags start state; not clear yet
The $5D6E table. Both functions use it. The first words are $5F74, $5F6E, $5FB6, $607E, ...: pointers to entity-type handlers in bank $C0, at least 30 before the first value that isn't obviously code. They are a future frontier. make diff was clean on the first pass; both org blocks went after Sub_1A03's RTS, and the two JSR $595C and seven JSR $597D call sites now use the labels.
Sub_E12A: the sprite init pass
The first look at Sub_E12A reported "829+ instructions". That was the whole reachable subgraph. Its own body ends at the first RTS, at $E533: 1,034 bytes, $E12A–$E533. Its only two external calls go to $E534 and $E687, right after it:
$E534ends at$E686(341 bytes) and calls$E739,$E7BC,$E83Fand$E8C2, each about 131 bytes, each a tight copy loop from a different ROM bank into WRAM.$E687ends at$E738(178 bytes) and dispatches through internal jump targets.
In all, about 2,370 bytes in seven routines from $E12A to $E934 (1,034 own body + ~1,336 callee cluster). I deferred it, matched the entity dispatchers, and then came back to it the same evening as the head of its own arc.
It runs once per sprite slot during scene load, called from Sub_01A5 when dp $AE holds a valid slot. Four phases:
- Pointers. Builds two indirect-long pointers from four descriptor arrays (
$1200,$1280,$1300,$1380, indexed by slot):[$CD]into$7F:XXXX, the sprite-data table (palette source), and[$D3]intobank($D5):XXXX, the scene-data table (tile layout source).$0D80+slotgets$3800(the WRAM staging base), dp$D0tracks the running WRAM write address, and$0F01+slotis cleared (a sprite-state flag). Early instructions set$D2=$7Fand write$2181(WMADDL). - The 96-entry WRAM fill. After setting
$2183=$01and$2181=$3800, it reads 96 16-bit words from[$D3]+Y(Y from 0, two bytes per pass). Bit 14 picks the path: clear →JSR Sub_E687(one WMDATA write), set →JSR Sub_E534(multi-tile fill). The inner loop at$E173does this test against$4000. The callee writes what it needs at the current address, and$D0advances by$0020on every pass except the first. - DMA to VRAM. Channel 7: source
$7F:3800, size$0C00, destination VRAM word$0400, port$2118(VMDATA,BBAD7=$18), modeDMAP7=$01(CPU→PPU, word increment),VMAIN=$80. It programs$4370–$4375and triggersMDMAEN, copying 3,072 bytes. - OAM staging. About 800 bytes. A lookup from
$1700+slotgives an offset into$7F:4BC0, and the code fills 24 groups of 8 bytes (stride$08, bases$4BC2,$4BCA,$4BD2, …,$4C7A). Each group gets, from[$D3]+$C0on: +0 a signed 8-bit value, +1 its sign extension ($FFor$00, viaBPL/LDA #$FF/BRA/LDA #$00, 24 times), +2 a raw 8-bit value. Y walks$C0to$EF. Then two constant passes: tile indexes at +4 ($40, $42, …$4Efor groups 1–8,$60, $62, …$6Efor 9–16,$80, $82, …$8Efor 17–24, as 24LDA #$xx / STA.l $7F4BCn,Xpairs) and attribute$22at +5 (oneLDA #$22and 24STAs).$22=%00100010: in the sprite renderer's attribute format, bit 5 is priority and bits 1–0 the palette, so priority 1 and palette 2.
It is big because it is fully unrolled: the 24-group fill alone is 24 × ~14 bytes = ~336 bytes. It runs once per slot at scene load, and on a 2.68 MHz CPU the loop overhead over 24 iterations is not trivial, so speed won over size. make diff passed on the first assembly; the local labels (.sp01–.sp24, .se01–.se24, .next_iter, .first_iter, .big_fill, .dma) resolved cleanly.
| Block | Range | Bytes | Status |
|---|---|---|---|
Sub_E12A |
$E12A–$E533 |
1034 | Matched |
Sub_E534 |
$E534–$E686 |
341 | Identified |
Sub_E687 |
$E687–$E738 |
178 | Identified |
Sub_E739 |
$E739–$E7BB |
131 | Identified |
Sub_E7BC |
$E7BC–$E83E |
131 | Identified |
Sub_E83F |
$E83F–$E8C1 |
131 | Identified |
Sub_E8C2 |
$E8C2–$E934 |
115 | Identified |
The day in numbers
| Session | Routine | Address | Bytes | Running total quoted |
|---|---|---|---|---|
| 21 | DefaultHandler |
$C0:16DC–$18D8 |
509 | |
| 21 | Sub_1BA7 |
$C0:1BA7–$1BAA |
4 | 10,697 (52 functions) |
| 22 | Sub_18D9 |
$C0:18D9–$1984 |
172 | |
| 22 | Sub_1985 |
$C0:1985–$19C6 |
66 | |
| 22 | Sub_1ADF |
$C0:1ADF–$1B35 |
87 | 11,022 (55) |
| 23 | Sub_011B |
$C0:011B–$01A4 |
138 | |
| 23 | Sub_01A5 |
$C0:01A5–$024B |
167 | |
| 23 | Sub_0905 |
$C0:0905–$0917 |
19 | |
| 23 | Sub_0918 |
$C0:0918–$092A |
19 | |
| 23 | Sub_1A03 |
$C0:1A03–$1AAB |
169 | |
| 23 | Sub_B192 |
$C0:B192–$B1AF |
30 | |
| 23 | Sub_E935 |
$C0:E935–$E951 |
29 | 11,593 (62) |
| 24 | Sub_595C |
$C0:595C–$597C |
33 | |
| 24 | Sub_597D |
$C0:597D–$59D8 |
92 | 11,718 (64) |
| 25 | Sub_E12A |
$C0:E12A–$E533 |
1,034 | 12,752 (65) |
| Total | 2,568 |
Session totals: 513, 325, 571, 125 and 1,034 bytes. Bank $C0 ended the day at 12,281 bytes. (The quoted function count goes from 32 after session 19 to 52 after session 21; the notes don't say why.)
Next
Sub_E534 (341 bytes) and Sub_E687 (178 bytes), then the four bank-copy routines, to close the Sub_E12A arc. The broader $881E callee tree is still waiting.