Skip to content

Commit 31c99dc

Browse files
bfirshclaude
andcommitted
Add support for mapper 9 (MMC2)
MMC2 is used exclusively by Mike Tyson's Punch-Out!! and features tile-triggered CHR bank switching with two independent 4KB latches. The PPU rendering pipeline now calls latchAccess() during BG and sprite tile fetches so the mapper can monitor pattern table addresses, matching real hardware behavior. Unused sprite slot dummy fetches (tile $FF in 8x16 mode) are simulated to correctly reset latch 1 between scanlines. Fixes #430 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5d51ece commit 31c99dc

File tree

3 files changed

+237
-5
lines changed

3 files changed

+237
-5
lines changed

src/mappers/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Mapper3 from "./mapper3.js";
55
import Mapper4 from "./mapper4.js";
66
import Mapper5 from "./mapper5.js";
77
import Mapper7 from "./mapper7.js";
8+
import Mapper9 from "./mapper9.js";
89
import Mapper11 from "./mapper11.js";
910
import Mapper34 from "./mapper34.js";
1011
import Mapper38 from "./mapper38.js";
@@ -27,6 +28,7 @@ export default {
2728
4: Mapper4,
2829
5: Mapper5,
2930
7: Mapper7,
31+
9: Mapper9,
3032
11: Mapper11,
3133
34: Mapper34,
3234
38: Mapper38,

src/mappers/mapper9.js

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import Mapper0 from "./mapper0.js";
2+
3+
// MMC2 (PNROM / PEEOROM)
4+
// Used exclusively by Mike Tyson's Punch-Out!! (and Punch-Out!!).
5+
// Features tile-triggered CHR bank switching: two independent 4 KB CHR latches
6+
// automatically swap between two banks when the PPU fetches specific tiles ($FD/$FE).
7+
// PRG: 8 KB switchable at $8000, three 8 KB fixed banks at $A000-$FFFF.
8+
// See https://www.nesdev.org/wiki/MMC2
9+
class Mapper9 extends Mapper0 {
10+
static mapperName = "MMC2";
11+
12+
constructor(nes) {
13+
super(nes);
14+
15+
// PRG bank register ($A000-$AFFF): selects 8 KB bank at $8000
16+
this.prgBank = 0;
17+
18+
// CHR bank registers: each pattern table half has two possible banks,
19+
// selected by the corresponding latch state ($FD or $FE).
20+
this.chrBankFD0 = 0; // $B000: CHR bank for $0000 when latch0 = $FD
21+
this.chrBankFE0 = 0; // $C000: CHR bank for $0000 when latch0 = $FE
22+
this.chrBankFD1 = 0; // $D000: CHR bank for $1000 when latch1 = $FD
23+
this.chrBankFE1 = 0; // $E000: CHR bank for $1000 when latch1 = $FE
24+
25+
// Latch states: $FD or $FE, one per pattern table half.
26+
// Both initialize to $FE on power-up.
27+
this.latch0 = 0xfe;
28+
this.latch1 = 0xfe;
29+
}
30+
31+
write(address, value) {
32+
if (address < 0x8000) {
33+
super.write(address, value);
34+
return;
35+
}
36+
37+
// Only the top nibble matters for register selection
38+
switch (address & 0xf000) {
39+
case 0xa000:
40+
// $A000-$AFFF: PRG bank select (bits 3-0 select 8 KB bank at $8000)
41+
this.prgBank = value & 0x0f;
42+
this.load8kRomBank(this.prgBank, 0x8000);
43+
break;
44+
45+
case 0xb000:
46+
// $B000-$BFFF: CHR bank for $0000 when latch0 = $FD
47+
this.chrBankFD0 = value & 0x1f;
48+
this._updateChr0();
49+
break;
50+
51+
case 0xc000:
52+
// $C000-$CFFF: CHR bank for $0000 when latch0 = $FE
53+
this.chrBankFE0 = value & 0x1f;
54+
this._updateChr0();
55+
break;
56+
57+
case 0xd000:
58+
// $D000-$DFFF: CHR bank for $1000 when latch1 = $FD
59+
this.chrBankFD1 = value & 0x1f;
60+
this._updateChr1();
61+
break;
62+
63+
case 0xe000:
64+
// $E000-$EFFF: CHR bank for $1000 when latch1 = $FE
65+
this.chrBankFE1 = value & 0x1f;
66+
this._updateChr1();
67+
break;
68+
69+
case 0xf000:
70+
// $F000-$FFFF: Mirroring (bit 0: 0=vertical, 1=horizontal)
71+
if (value & 0x01) {
72+
this.nes.ppu.setMirroring(this.nes.rom.HORIZONTAL_MIRRORING);
73+
} else {
74+
this.nes.ppu.setMirroring(this.nes.rom.VERTICAL_MIRRORING);
75+
}
76+
break;
77+
}
78+
}
79+
80+
// Load the correct CHR bank into $0000 based on latch0 state.
81+
_updateChr0() {
82+
let bank = this.latch0 === 0xfd ? this.chrBankFD0 : this.chrBankFE0;
83+
this.loadVromBank(bank, 0x0000);
84+
}
85+
86+
// Load the correct CHR bank into $1000 based on latch1 state.
87+
_updateChr1() {
88+
let bank = this.latch1 === 0xfd ? this.chrBankFD1 : this.chrBankFE1;
89+
this.loadVromBank(bank, 0x1000);
90+
}
91+
92+
// Called by the PPU when pattern table memory is accessed.
93+
// Updates the CHR latches based on the tile being fetched.
94+
// The latch switches AFTER the data has been read, so the
95+
// tile at $FD/$FE itself is rendered with the old bank.
96+
// See https://www.nesdev.org/wiki/MMC2#Latch_0_($0000-$0FFF)
97+
latchAccess(address) {
98+
// Only reload CHR banks when the latch state actually changes.
99+
// The same trigger tile may appear on many consecutive scanlines (e.g. a
100+
// column of $FD tiles in the nametable), and redundantly calling
101+
// loadVromBank on every fetch would copy 4 KB of VRAM each time.
102+
if (address === 0x0fd8) {
103+
// Latch 0 triggers on exactly $0FD8
104+
if (this.latch0 !== 0xfd) {
105+
this.latch0 = 0xfd;
106+
this._updateChr0();
107+
}
108+
} else if (address === 0x0fe8) {
109+
// Latch 0 triggers on exactly $0FE8
110+
if (this.latch0 !== 0xfe) {
111+
this.latch0 = 0xfe;
112+
this._updateChr0();
113+
}
114+
} else if (address >= 0x1fd8 && address <= 0x1fdf) {
115+
// Latch 1 triggers on $1FD8-$1FDF
116+
if (this.latch1 !== 0xfd) {
117+
this.latch1 = 0xfd;
118+
this._updateChr1();
119+
}
120+
} else if (address >= 0x1fe8 && address <= 0x1fef) {
121+
// Latch 1 triggers on $1FE8-$1FEF
122+
if (this.latch1 !== 0xfe) {
123+
this.latch1 = 0xfe;
124+
this._updateChr1();
125+
}
126+
}
127+
}
128+
129+
loadROM() {
130+
if (!this.nes.rom.valid) {
131+
throw new Error("MMC2: Invalid ROM! Unable to load.");
132+
}
133+
134+
// Load first switchable 8 KB PRG bank at $8000
135+
this.load8kRomBank(0, 0x8000);
136+
137+
// Load the last three 8 KB PRG banks fixed at $A000-$FFFF
138+
let lastBank8k = (this.nes.rom.romCount - 1) * 2 + 1;
139+
this.load8kRomBank(lastBank8k - 2, 0xa000);
140+
this.load8kRomBank(lastBank8k - 1, 0xc000);
141+
this.load8kRomBank(lastBank8k, 0xe000);
142+
143+
// Load CHR-ROM
144+
this.loadCHRROM();
145+
146+
// Load Battery RAM (if present)
147+
this.loadBatteryRam();
148+
149+
this.nes.cpu.requestIrq(this.nes.cpu.IRQ_RESET);
150+
}
151+
152+
toJSON() {
153+
let s = super.toJSON();
154+
s.prgBank = this.prgBank;
155+
s.chrBankFD0 = this.chrBankFD0;
156+
s.chrBankFE0 = this.chrBankFE0;
157+
s.chrBankFD1 = this.chrBankFD1;
158+
s.chrBankFE1 = this.chrBankFE1;
159+
s.latch0 = this.latch0;
160+
s.latch1 = this.latch1;
161+
return s;
162+
}
163+
164+
fromJSON(s) {
165+
super.fromJSON(s);
166+
this.prgBank = s.prgBank;
167+
this.chrBankFD0 = s.chrBankFD0;
168+
this.chrBankFE0 = s.chrBankFE0;
169+
this.chrBankFD1 = s.chrBankFD1;
170+
this.chrBankFE1 = s.chrBankFE1;
171+
this.latch0 = s.latch0;
172+
this.latch1 = s.latch1;
173+
}
174+
}
175+
176+
export default Mapper9;

src/ppu/index.js

Lines changed: 59 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -880,6 +880,10 @@ class PPU {
880880
}
881881

882882
triggerRendering() {
883+
// Guard against recursion from mapper latch bank switches during rendering.
884+
// When the PPU is already rendering and a latch-triggered loadVromBank calls
885+
// triggerRendering, we must not re-enter the rendering loop.
886+
if (this._inRendering) return;
883887
if (this.scanline >= 21 && this.scanline <= 260) {
884888
// Render sprites, and combine:
885889
this.renderFramePartially(
@@ -893,6 +897,7 @@ class PPU {
893897
}
894898

895899
renderFramePartially(startScan, scanCount) {
900+
this._inRendering = true;
896901
if (this.f_spVisibility === 1) {
897902
this.renderSpritesPartially(startScan, scanCount, 1);
898903
}
@@ -917,11 +922,16 @@ class PPU {
917922
this.renderSpritesPartially(startScan, scanCount, 0);
918923
}
919924

925+
this._inRendering = false;
920926
this.validTileData = false;
921927
}
922928

923929
renderBgScanline(bgbuffer, scan) {
924930
let baseTile = this.regS === 0 ? 0 : 256;
931+
// Base address for pattern table fetches (used for mapper latch triggers).
932+
// On real hardware, the PPU puts this address on its bus when fetching tile
933+
// data, and mappers like MMC2 monitor these fetches.
934+
let baseAddr = this.regS === 0 ? 0x0000 : 0x1000;
925935
let destIndex = (scan << 8) - this.regFH;
926936

927937
this.curNt = this.ntable1[this.cntV + this.cntV + this.cntH];
@@ -939,11 +949,34 @@ class PPU {
939949
let imgPalette = this.imgPalette;
940950
let pixrendered = this.pixrendered;
941951
let targetBuffer = bgbuffer ? this.bgbuffer : this.buffer;
952+
let mmap = this.nes.mmap;
942953

943954
let t, tpix, att, col;
944955

956+
this._inRendering = true;
957+
958+
// Simulate unused sprite slot dummy fetches from the previous scanline.
959+
// On real hardware, the PPU fetches patterns for 8 sprites per scanline
960+
// during cycles 257-320. Unused slots fetch tile $FF. In 8x16 sprite
961+
// mode, tile $FF selects pattern table $1000 (bit 0 = 1) with top-half
962+
// tile $FE. The high-plane byte fetch at $1FE8 triggers MMC2/MMC4
963+
// latch 1 → $FE, resetting it before the next scanline's BG fetches.
964+
// Without this, latch 1 can stay at $FD from a previous BG trigger tile,
965+
// causing sprite corruption (e.g. in Punch-Out!!'s crowd).
966+
// See https://www.nesdev.org/wiki/MMC2
967+
if (this.f_spriteSize === 1) {
968+
mmap.latchAccess(0x1fe8);
969+
}
970+
945971
for (let tile = 0; tile < 32; tile++) {
946972
if (scan >= 0) {
973+
// Look up nametable tile index (needed for both rendering and mapper
974+
// latch access even when tile data is cached).
975+
let tileIndex = nameTable[this.curNt].getTileIndex(
976+
this.cntHT,
977+
this.cntVT,
978+
);
979+
947980
// Fetch tile & attrib data:
948981
if (this.validTileData) {
949982
// Get data from array:
@@ -955,11 +988,7 @@ class PPU {
955988
att = attrib[tile];
956989
} else {
957990
// Fetch data:
958-
t =
959-
ptTile[
960-
baseTile +
961-
nameTable[this.curNt].getTileIndex(this.cntHT, this.cntVT)
962-
];
991+
t = ptTile[baseTile + tileIndex];
963992
if (typeof t === "undefined") {
964993
continue;
965994
}
@@ -996,6 +1025,16 @@ class PPU {
9961025
}
9971026
}
9981027
}
1028+
1029+
// Mapper latch access: simulate the PPU's pattern table high byte
1030+
// fetch. On real hardware, the PPU reads the high plane byte at
1031+
// (baseAddr + tileIndex*16 + fineY + 8), and MMC2/MMC4 monitor
1032+
// this address to trigger CHR bank switches. The latch updates
1033+
// AFTER the fetch, so the current tile is rendered with the old
1034+
// bank (correct, since we already read from ptTile above) and
1035+
// subsequent tiles will use the new bank.
1036+
// See https://www.nesdev.org/wiki/MMC2
1037+
mmap.latchAccess(baseAddr + tileIndex * 16 + this.cntFV + 8);
9991038
}
10001039

10011040
// Increase Horizontal Tile Counter:
@@ -1006,6 +1045,7 @@ class PPU {
10061045
this.curNt = this.ntable1[(this.cntV << 1) + this.cntH];
10071046
}
10081047
}
1048+
this._inRendering = false;
10091049

10101050
// Tile data for one row should now have been fetched,
10111051
// so the data in the array is valid.
@@ -1033,6 +1073,7 @@ class PPU {
10331073

10341074
renderSpritesPartially(startscan, scancount, bgPri) {
10351075
if (this.f_spVisibility === 1) {
1076+
let mmap = this.nes.mmap;
10361077
for (let i = 0; i < 64; i++) {
10371078
if (
10381079
this.bgPriority[i] === bgPri &&
@@ -1044,6 +1085,7 @@ class PPU {
10441085
// Show sprite.
10451086
if (this.f_spriteSize === 0) {
10461087
// 8x8 sprites
1088+
let sprBaseAddr = this.f_spPatternTable === 0 ? 0x0000 : 0x1000;
10471089

10481090
this.srcy1 = 0;
10491091
this.srcy2 = 8;
@@ -1089,9 +1131,17 @@ class PPU {
10891131
this.pixrendered,
10901132
);
10911133
}
1134+
1135+
// Mapper latch: simulate PPU's sprite pattern table fetch.
1136+
// Use fineY=0 (high byte at +8), matching the first scanline row.
1137+
mmap.latchAccess(sprBaseAddr + this.sprTile[i] * 16 + 8);
10921138
} else {
10931139
// 8x16 sprites
10941140
let top = this.sprTile[i];
1141+
// 8x16 sprites select their pattern table via bit 0 of the tile
1142+
// index: odd tile numbers use $1000, even use $0000.
1143+
let sprBaseAddr = (top & 1) !== 0 ? 0x1000 : 0x0000;
1144+
let topTileNum = top & 0xfe;
10951145
if ((top & 1) !== 0) {
10961146
top = this.sprTile[i] - 1 + 256;
10971147
}
@@ -1149,6 +1199,10 @@ class PPU {
11491199
i,
11501200
this.pixrendered,
11511201
);
1202+
1203+
// Mapper latch: simulate fetches for both halves of 8x16 sprite.
1204+
mmap.latchAccess(sprBaseAddr + topTileNum * 16 + 8);
1205+
mmap.latchAccess(sprBaseAddr + (topTileNum + 1) * 16 + 8);
11521206
}
11531207
}
11541208
}

0 commit comments

Comments
 (0)