diff --git a/src/mappers/mapper1.js b/src/mappers/mapper1.js index 47e52436..193d15a9 100644 --- a/src/mappers/mapper1.js +++ b/src/mappers/mapper1.js @@ -31,6 +31,7 @@ class Mapper1 extends Mapper0 { // Register 3: this.romBankSelect = 0; + this.romBankSelectValid = true; } write(address, value) { @@ -100,6 +101,9 @@ class Mapper1 extends Mapper0 { // VROM Switching Size: this.vromSwitchingSize = (value >> 4) & 1; + if (this.usesSuromPrgBanking() && this.romBankSelectValid) { + this.applySuromPrgBanking(); + } break; case 1: @@ -132,6 +136,9 @@ class Mapper1 extends Mapper0 { } } + if (this.usesSuromPrgBanking() && this.romBankSelectValid) { + this.applySuromPrgBanking(); + } break; case 2: @@ -156,45 +163,84 @@ class Mapper1 extends Mapper0 { break; default: { - // Select ROM bank: - // ------------------------- - let bank; - let baseBank = 0; + this.romBankSelect = value & 0xf; + this.romBankSelectValid = true; - if (this.nes.rom.romCount >= 32) { - // 1024 kB cart - if (this.vromSwitchingSize === 0) { + if (this.usesSuromPrgBanking()) { + this.applySuromPrgBanking(); + } else { + // Select ROM bank: + // ------------------------- + let bank; + let baseBank = 0; + + if (this.nes.rom.romCount >= 32) { + // 1024 kB cart + if (this.vromSwitchingSize === 0) { + if (this.romSelectionReg0 === 1) { + baseBank = 16; + } + } else { + baseBank = + (this.romSelectionReg0 | (this.romSelectionReg1 << 1)) << 3; + } + } else if (this.nes.rom.romCount >= 16) { + // 512 kB cart if (this.romSelectionReg0 === 1) { - baseBank = 16; + baseBank = 8; } - } else { - baseBank = - (this.romSelectionReg0 | (this.romSelectionReg1 << 1)) << 3; - } - } else if (this.nes.rom.romCount >= 16) { - // 512 kB cart - if (this.romSelectionReg0 === 1) { - baseBank = 8; } - } - if (this.prgSwitchingSize === 0) { - // 32kB - bank = baseBank + (value & 0xf); - this.load32kRomBank(bank, 0x8000); - } else { - // 16kB - bank = baseBank * 2 + (value & 0xf); - if (this.prgSwitchingArea === 0) { - this.loadRomBank(bank, 0xc000); + if (this.prgSwitchingSize === 0) { + // 32kB + bank = baseBank + (value & 0xf); + this.load32kRomBank(bank, 0x8000); } else { - this.loadRomBank(bank, 0x8000); + // 16kB + bank = baseBank * 2 + (value & 0xf); + if (this.prgSwitchingArea === 0) { + this.loadRomBank(bank, 0xc000); + } else { + this.loadRomBank(bank, 0x8000); + } } } + break; } } } + isSurom() { + return this.nes.rom.romCount === 32 && this.nes.rom.vromCount === 0; + } + + usesSuromPrgBanking() { + return this.isSurom() && this.vromSwitchingSize === 0; + } + + applySuromPrgBanking() { + // SUROM uses CHR-RAM, so CHR bank 0 bit 4 is available as the outer + // PRG-ROM address bit. Each outer page contains sixteen 16 KB banks. + const pageBase = this.romSelectionReg0 << 4; + const selectedBank = pageBase + this.romBankSelect; + + if (this.prgSwitchingSize === 0) { + // Modes 0 and 1 select an aligned 32 KB pair. Keep the calculation in + // 16 KB units so the outer page is applied exactly once. + const firstBank = selectedBank & ~1; + this.loadRomBank(firstBank, 0x8000); + this.loadRomBank(firstBank + 1, 0xc000); + } else if (this.prgSwitchingArea === 0) { + // Mode 2 fixes the first bank of the active page at $8000. + this.loadRomBank(pageBase, 0x8000); + this.loadRomBank(selectedBank, 0xc000); + } else { + // Mode 3 fixes the last bank of the active page at $C000. + this.loadRomBank(selectedBank, 0x8000); + this.loadRomBank(pageBase + 15, 0xc000); + } + } + // Returns the register number from the address written to: getRegNumber(address) { if (address >= 0x8000 && address <= 0x9fff) { @@ -250,6 +296,7 @@ class Mapper1 extends Mapper0 { s.romSelectionReg0 = this.romSelectionReg0; s.romSelectionReg1 = this.romSelectionReg1; s.romBankSelect = this.romBankSelect; + s.romBankSelectValid = this.romBankSelectValid; s.regBuffer = this.regBuffer; s.regBufferCounter = this.regBufferCounter; return s; @@ -265,6 +312,9 @@ class Mapper1 extends Mapper0 { this.romSelectionReg0 = s.romSelectionReg0; this.romSelectionReg1 = s.romSelectionReg1; this.romBankSelect = s.romBankSelect; + // Older states contain romBankSelect but never updated it after register + // writes. Preserve their restored CPU windows until register 3 is written. + this.romBankSelectValid = s.romBankSelectValid === true; this.regBuffer = s.regBuffer; this.regBufferCounter = s.regBufferCounter; } diff --git a/test/mappers.spec.js b/test/mappers.spec.js index 89789401..38fb5843 100644 --- a/test/mappers.spec.js +++ b/test/mappers.spec.js @@ -1,6 +1,7 @@ import assert from "node:assert/strict"; import { describe, it, beforeEach } from "node:test"; import Mappers from "../src/mappers/index.js"; +import NES from "../src/nes.js"; import NameTable from "../src/ppu/nametable.js"; import Tile from "../src/tile.js"; @@ -80,6 +81,52 @@ function populateMockRom(mockNes) { } } +function createMapper1Rom(prgBankCount, chrBankCount = 0) { + const headerSize = 16; + const prgBankSize = 16384; + const chrBankSize = 8192; + const data = new Uint8Array( + headerSize + prgBankCount * prgBankSize + chrBankCount * chrBankSize, + ); + + data.set([0x4e, 0x45, 0x53, 0x1a]); // "NES" followed by the iNES terminator. + data[4] = prgBankCount; + data[5] = chrBankCount; + data[6] = 0x10; // Mapper 1, horizontal mirroring. + + for (let bank = 0; bank < prgBankCount; bank++) { + const start = headerSize + bank * prgBankSize; + data.fill(bank, start, start + prgBankSize); + } + + return data; +} + +function loadMapper1Rom(prgBankCount, chrBankCount = 0) { + const nes = new NES({ + onFrame: function () {}, + onAudioSample: function () {}, + emulateSound: false, + }); + nes.loadROM(createMapper1Rom(prgBankCount, chrBankCount)); + return nes; +} + +function writeMmc1Register(mapper, address, value) { + // MMC1 accepts the register value least-significant bit first over five + // consecutive CPU writes. + for (let bit = 0; bit < 5; bit++) { + mapper.write(address, (value >> bit) & 1); + } +} + +function assertPrgBanks(nes, lowBank, highBank) { + assert.strictEqual(nes.cpu.mem[0x8000], lowBank); + assert.strictEqual(nes.cpu.mem[0xbfff], lowBank); + assert.strictEqual(nes.cpu.mem[0xc000], highBank); + assert.strictEqual(nes.cpu.mem[0xffff], highBank); +} + describe("Mappers", function () { let mapper = null; let mockNes = null; @@ -154,6 +201,144 @@ describe("Mappers", function () { }); }); +// --- MMC1 (Mapper 1) Tests --- +describe("MMC1 (Mapper 1)", function () { + it("preserves the established SUROM power-on mapping", function () { + const nes = loadMapper1Rom(32); + + assertPrgBanks(nes, 0, 31); + }); + + it("maps both SUROM outer pages in every PRG mode", function () { + const modes = [ + { control: 0x00, lower: [2, 3], upper: [18, 19] }, + { control: 0x04, lower: [2, 3], upper: [18, 19] }, + { control: 0x08, lower: [0, 3], upper: [16, 19] }, + { control: 0x0c, lower: [3, 15], upper: [19, 31] }, + ]; + + for (const mode of modes) { + const nes = loadMapper1Rom(32); + + writeMmc1Register(nes.mmap, 0x8000, mode.control); + writeMmc1Register(nes.mmap, 0xe000, 3); + writeMmc1Register(nes.mmap, 0xa000, 0x00); + assertPrgBanks(nes, ...mode.lower); + + writeMmc1Register(nes.mmap, 0xa000, 0x10); + assertPrgBanks(nes, ...mode.upper); + } + }); + + it("reapplies a retained PRG bank when the SUROM outer page changes", function () { + const nes = loadMapper1Rom(32); + + writeMmc1Register(nes.mmap, 0xe000, 5); + assertPrgBanks(nes, 5, 15); + + writeMmc1Register(nes.mmap, 0xa000, 0x10); + assertPrgBanks(nes, 21, 31); + }); + + it("reapplies both PRG windows when the control mode changes", function () { + const nes = loadMapper1Rom(32); + + writeMmc1Register(nes.mmap, 0xa000, 0x10); + writeMmc1Register(nes.mmap, 0xe000, 5); + assertPrgBanks(nes, 21, 31); + + writeMmc1Register(nes.mmap, 0x8000, 0x08); + assertPrgBanks(nes, 16, 21); + + writeMmc1Register(nes.mmap, 0x8000, 0x00); + assertPrgBanks(nes, 20, 21); + }); + + it("does not apply SUROM outer banking to an ordinary MMC1 image", function () { + const nes = loadMapper1Rom(8); + + assertPrgBanks(nes, 0, 7); + writeMmc1Register(nes.mmap, 0xe000, 3); + writeMmc1Register(nes.mmap, 0xa000, 0x10); + assertPrgBanks(nes, 3, 7); + + writeMmc1Register(nes.mmap, 0x8000, 0x08); + assertPrgBanks(nes, 3, 7); + }); + + it("preserves legacy high-bit banking for 64-bank MMC1 images", function () { + const nes = loadMapper1Rom(64, 1); + + writeMmc1Register(nes.mmap, 0x8000, 0x1c); + writeMmc1Register(nes.mmap, 0xa000, 0x10); + writeMmc1Register(nes.mmap, 0xc000, 0x10); + writeMmc1Register(nes.mmap, 0xe000, 2); + + assertPrgBanks(nes, 50, 63); + }); + + it("retains the existing 4 KB CHR-mode PRG mapping path", function () { + const nes = loadMapper1Rom(32); + + writeMmc1Register(nes.mmap, 0x8000, 0x1c); + writeMmc1Register(nes.mmap, 0xa000, 0x10); + writeMmc1Register(nes.mmap, 0xe000, 3); + + assertPrgBanks(nes, 19, 31); + }); + + it("requires both 32 PRG banks and CHR-RAM for SUROM outer banking", function () { + const nes = loadMapper1Rom(32, 1); + + writeMmc1Register(nes.mmap, 0xe000, 3); + writeMmc1Register(nes.mmap, 0xa000, 0x10); + assertPrgBanks(nes, 3, 31); + }); + + it("reuses the saved PRG selection through the NES state API", function () { + const rom = createMapper1Rom(32); + const source = new NES({ emulateSound: false }); + source.loadROM(rom); + writeMmc1Register(source.mmap, 0xe000, 5); + const state = source.toJSON(); + + const restored = new NES({ emulateSound: false }); + restored.loadROM(rom); + restored.fromJSON(state); + assertPrgBanks(restored, 5, 15); + writeMmc1Register(restored.mmap, 0xa000, 0x10); + + assert.strictEqual(restored.mmap.romBankSelect, 5); + assert.strictEqual(restored.mmap.romBankSelectValid, true); + assertPrgBanks(restored, 21, 31); + }); + + it("does not remap legacy states from a stale PRG selection", function () { + const rom = createMapper1Rom(32); + const source = new NES({ emulateSound: false }); + source.loadROM(rom); + writeMmc1Register(source.mmap, 0xe000, 5); + source.mmap.loadRomBank(31, 0xc000); + + const legacyState = source.toJSON(); + legacyState.mmap.romBankSelect = 0; + delete legacyState.mmap.romBankSelectValid; + + const restored = new NES({ emulateSound: false }); + restored.loadROM(rom); + restored.fromJSON(legacyState); + assertPrgBanks(restored, 5, 31); + + writeMmc1Register(restored.mmap, 0xa000, 0x10); + assert.strictEqual(restored.mmap.romBankSelectValid, false); + assertPrgBanks(restored, 5, 31); + + writeMmc1Register(restored.mmap, 0xe000, 5); + assert.strictEqual(restored.mmap.romBankSelectValid, true); + assertPrgBanks(restored, 21, 31); + }); +}); + // --- MMC3 (Mapper 4) Tests --- describe("MMC3 (Mapper 4)", function () { let mapper = null;