Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/mappers/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import Mapper3 from "./mapper3.js";
import Mapper4 from "./mapper4.js";
import Mapper5 from "./mapper5.js";
import Mapper7 from "./mapper7.js";
import Mapper9 from "./mapper9.js";
import Mapper11 from "./mapper11.js";
import Mapper34 from "./mapper34.js";
import Mapper38 from "./mapper38.js";
Expand All @@ -25,6 +26,7 @@ export default {
4: Mapper4,
5: Mapper5,
7: Mapper7,
9: Mapper9,
11: Mapper11,
34: Mapper34,
38: Mapper38,
Expand Down
176 changes: 176 additions & 0 deletions src/mappers/mapper9.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import Mapper0 from "./mapper0.js";

// MMC2 (PNROM / PEEOROM)
// Used exclusively by Mike Tyson's Punch-Out!! (and Punch-Out!!).
// Features tile-triggered CHR bank switching: two independent 4 KB CHR latches
// automatically swap between two banks when the PPU fetches specific tiles ($FD/$FE).
// PRG: 8 KB switchable at $8000, three 8 KB fixed banks at $A000-$FFFF.
// See https://www.nesdev.org/wiki/MMC2
class Mapper9 extends Mapper0 {
static mapperName = "MMC2";

constructor(nes) {
super(nes);

// PRG bank register ($A000-$AFFF): selects 8 KB bank at $8000
this.prgBank = 0;

// CHR bank registers: each pattern table half has two possible banks,
// selected by the corresponding latch state ($FD or $FE).
this.chrBankFD0 = 0; // $B000: CHR bank for $0000 when latch0 = $FD
this.chrBankFE0 = 0; // $C000: CHR bank for $0000 when latch0 = $FE
this.chrBankFD1 = 0; // $D000: CHR bank for $1000 when latch1 = $FD
this.chrBankFE1 = 0; // $E000: CHR bank for $1000 when latch1 = $FE

// Latch states: $FD or $FE, one per pattern table half.
// Both initialize to $FE on power-up.
this.latch0 = 0xfe;
this.latch1 = 0xfe;
}

write(address, value) {
if (address < 0x8000) {
super.write(address, value);
return;
}

// Only the top nibble matters for register selection
switch (address & 0xf000) {
case 0xa000:
// $A000-$AFFF: PRG bank select (bits 3-0 select 8 KB bank at $8000)
this.prgBank = value & 0x0f;
this.load8kRomBank(this.prgBank, 0x8000);
break;

case 0xb000:
// $B000-$BFFF: CHR bank for $0000 when latch0 = $FD
this.chrBankFD0 = value & 0x1f;
this._updateChr0();
break;

case 0xc000:
// $C000-$CFFF: CHR bank for $0000 when latch0 = $FE
this.chrBankFE0 = value & 0x1f;
this._updateChr0();
break;

case 0xd000:
// $D000-$DFFF: CHR bank for $1000 when latch1 = $FD
this.chrBankFD1 = value & 0x1f;
this._updateChr1();
break;

case 0xe000:
// $E000-$EFFF: CHR bank for $1000 when latch1 = $FE
this.chrBankFE1 = value & 0x1f;
this._updateChr1();
break;

case 0xf000:
// $F000-$FFFF: Mirroring (bit 0: 0=vertical, 1=horizontal)
if (value & 0x01) {
this.nes.ppu.setMirroring(this.nes.rom.HORIZONTAL_MIRRORING);
} else {
this.nes.ppu.setMirroring(this.nes.rom.VERTICAL_MIRRORING);
}
break;
}
}

// Load the correct CHR bank into $0000 based on latch0 state.
_updateChr0() {
let bank = this.latch0 === 0xfd ? this.chrBankFD0 : this.chrBankFE0;
this.loadVromBank(bank, 0x0000);
}

// Load the correct CHR bank into $1000 based on latch1 state.
_updateChr1() {
let bank = this.latch1 === 0xfd ? this.chrBankFD1 : this.chrBankFE1;
this.loadVromBank(bank, 0x1000);
}

// Called by the PPU when pattern table memory is accessed.
// Updates the CHR latches based on the tile being fetched.
// The latch switches AFTER the data has been read, so the
// tile at $FD/$FE itself is rendered with the old bank.
// See https://www.nesdev.org/wiki/MMC2#Latch_0_($0000-$0FFF)
latchAccess(address) {
// Only reload CHR banks when the latch state actually changes.
// The same trigger tile may appear on many consecutive scanlines (e.g. a
// column of $FD tiles in the nametable), and redundantly calling
// loadVromBank on every fetch would copy 4 KB of VRAM each time.
if (address === 0x0fd8) {
// Latch 0 triggers on exactly $0FD8
if (this.latch0 !== 0xfd) {
this.latch0 = 0xfd;
this._updateChr0();
}
} else if (address === 0x0fe8) {
// Latch 0 triggers on exactly $0FE8
if (this.latch0 !== 0xfe) {
this.latch0 = 0xfe;
this._updateChr0();
}
} else if (address >= 0x1fd8 && address <= 0x1fdf) {
// Latch 1 triggers on $1FD8-$1FDF
if (this.latch1 !== 0xfd) {
this.latch1 = 0xfd;
this._updateChr1();
}
} else if (address >= 0x1fe8 && address <= 0x1fef) {
// Latch 1 triggers on $1FE8-$1FEF
if (this.latch1 !== 0xfe) {
this.latch1 = 0xfe;
this._updateChr1();
}
}
}

loadROM() {
if (!this.nes.rom.valid) {
throw new Error("MMC2: Invalid ROM! Unable to load.");
}

// Load first switchable 8 KB PRG bank at $8000
this.load8kRomBank(0, 0x8000);

// Load the last three 8 KB PRG banks fixed at $A000-$FFFF
let lastBank8k = (this.nes.rom.romCount - 1) * 2 + 1;
this.load8kRomBank(lastBank8k - 2, 0xa000);
this.load8kRomBank(lastBank8k - 1, 0xc000);
this.load8kRomBank(lastBank8k, 0xe000);

// Load CHR-ROM
this.loadCHRROM();

// Load Battery RAM (if present)
this.loadBatteryRam();

this.nes.cpu.requestIrq(this.nes.cpu.IRQ_RESET);
}

toJSON() {
let s = super.toJSON();
s.prgBank = this.prgBank;
s.chrBankFD0 = this.chrBankFD0;
s.chrBankFE0 = this.chrBankFE0;
s.chrBankFD1 = this.chrBankFD1;
s.chrBankFE1 = this.chrBankFE1;
s.latch0 = this.latch0;
s.latch1 = this.latch1;
return s;
}

fromJSON(s) {
super.fromJSON(s);
this.prgBank = s.prgBank;
this.chrBankFD0 = s.chrBankFD0;
this.chrBankFE0 = s.chrBankFE0;
this.chrBankFD1 = s.chrBankFD1;
this.chrBankFE1 = s.chrBankFE1;
this.latch0 = s.latch0;
this.latch1 = s.latch1;
}
}

export default Mapper9;
64 changes: 59 additions & 5 deletions src/ppu/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -878,6 +878,10 @@ class PPU {
}

triggerRendering() {
// Guard against recursion from mapper latch bank switches during rendering.
// When the PPU is already rendering and a latch-triggered loadVromBank calls
// triggerRendering, we must not re-enter the rendering loop.
if (this._inRendering) return;
if (this.scanline >= 21 && this.scanline <= 260) {
// Render sprites, and combine:
this.renderFramePartially(
Expand All @@ -891,6 +895,7 @@ class PPU {
}

renderFramePartially(startScan, scanCount) {
this._inRendering = true;
if (this.f_spVisibility === 1) {
this.renderSpritesPartially(startScan, scanCount, 1);
}
Expand All @@ -915,11 +920,16 @@ class PPU {
this.renderSpritesPartially(startScan, scanCount, 0);
}

this._inRendering = false;
this.validTileData = false;
}

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

this.curNt = this.ntable1[this.cntV + this.cntV + this.cntH];
Expand All @@ -937,11 +947,34 @@ class PPU {
let imgPalette = this.imgPalette;
let pixrendered = this.pixrendered;
let targetBuffer = bgbuffer ? this.bgbuffer : this.buffer;
let mmap = this.nes.mmap;

let t, tpix, att, col;

this._inRendering = true;

// Simulate unused sprite slot dummy fetches from the previous scanline.
// On real hardware, the PPU fetches patterns for 8 sprites per scanline
// during cycles 257-320. Unused slots fetch tile $FF. In 8x16 sprite
// mode, tile $FF selects pattern table $1000 (bit 0 = 1) with top-half
// tile $FE. The high-plane byte fetch at $1FE8 triggers MMC2/MMC4
// latch 1 → $FE, resetting it before the next scanline's BG fetches.
// Without this, latch 1 can stay at $FD from a previous BG trigger tile,
// causing sprite corruption (e.g. in Punch-Out!!'s crowd).
// See https://www.nesdev.org/wiki/MMC2
if (this.f_spriteSize === 1) {
mmap.latchAccess(0x1fe8);
}

for (let tile = 0; tile < 32; tile++) {
if (scan >= 0) {
// Look up nametable tile index (needed for both rendering and mapper
// latch access even when tile data is cached).
let tileIndex = nameTable[this.curNt].getTileIndex(
this.cntHT,
this.cntVT,
);

// Fetch tile & attrib data:
if (this.validTileData) {
// Get data from array:
Expand All @@ -953,11 +986,7 @@ class PPU {
att = attrib[tile];
} else {
// Fetch data:
t =
ptTile[
baseTile +
nameTable[this.curNt].getTileIndex(this.cntHT, this.cntVT)
];
t = ptTile[baseTile + tileIndex];
if (typeof t === "undefined") {
continue;
}
Expand Down Expand Up @@ -994,6 +1023,16 @@ class PPU {
}
}
}

// Mapper latch access: simulate the PPU's pattern table high byte
// fetch. On real hardware, the PPU reads the high plane byte at
// (baseAddr + tileIndex*16 + fineY + 8), and MMC2/MMC4 monitor
// this address to trigger CHR bank switches. The latch updates
// AFTER the fetch, so the current tile is rendered with the old
// bank (correct, since we already read from ptTile above) and
// subsequent tiles will use the new bank.
// See https://www.nesdev.org/wiki/MMC2
mmap.latchAccess(baseAddr + tileIndex * 16 + this.cntFV + 8);
}

// Increase Horizontal Tile Counter:
Expand All @@ -1004,6 +1043,7 @@ class PPU {
this.curNt = this.ntable1[(this.cntV << 1) + this.cntH];
}
}
this._inRendering = false;

// Tile data for one row should now have been fetched,
// so the data in the array is valid.
Expand Down Expand Up @@ -1031,6 +1071,7 @@ class PPU {

renderSpritesPartially(startscan, scancount, bgPri) {
if (this.f_spVisibility === 1) {
let mmap = this.nes.mmap;
for (let i = 0; i < 64; i++) {
if (
this.bgPriority[i] === bgPri &&
Expand All @@ -1042,6 +1083,7 @@ class PPU {
// Show sprite.
if (this.f_spriteSize === 0) {
// 8x8 sprites
let sprBaseAddr = this.f_spPatternTable === 0 ? 0x0000 : 0x1000;

this.srcy1 = 0;
this.srcy2 = 8;
Expand Down Expand Up @@ -1087,9 +1129,17 @@ class PPU {
this.pixrendered,
);
}

// Mapper latch: simulate PPU's sprite pattern table fetch.
// Use fineY=0 (high byte at +8), matching the first scanline row.
mmap.latchAccess(sprBaseAddr + this.sprTile[i] * 16 + 8);
} else {
// 8x16 sprites
let top = this.sprTile[i];
// 8x16 sprites select their pattern table via bit 0 of the tile
// index: odd tile numbers use $1000, even use $0000.
let sprBaseAddr = (top & 1) !== 0 ? 0x1000 : 0x0000;
let topTileNum = top & 0xfe;
if ((top & 1) !== 0) {
top = this.sprTile[i] - 1 + 256;
}
Expand Down Expand Up @@ -1147,6 +1197,10 @@ class PPU {
i,
this.pixrendered,
);

// Mapper latch: simulate fetches for both halves of 8x16 sprite.
mmap.latchAccess(sprBaseAddr + topTileNum * 16 + 8);
mmap.latchAccess(sprBaseAddr + (topTileNum + 1) * 16 + 8);
}
}
}
Expand Down