| export interface GameGeniePatch { | ||
| addr: number; | ||
| value: number; | ||
| wantskey: boolean; | ||
| key?: number; | ||
| } | ||
| export class GameGenie { | ||
| patches: GameGeniePatch[]; | ||
| enabled: boolean; | ||
| onChange: (() => void) | null; | ||
| setEnabled: (enabled: boolean) => void; | ||
| addCode: (code: string) => void; | ||
| addPatch: (addr: number, value: number, key?: number) => void; | ||
| removeAllCodes: () => void; | ||
| applyCodes: (addr: number, value: number) => number; | ||
| decode: (code: string) => GameGeniePatch | null; | ||
| encodeHex: ( | ||
| addr: number, | ||
| value: number, | ||
| key?: number, | ||
| wantskey?: boolean, | ||
| ) => string; | ||
| decodeHex: (s: string) => GameGeniePatch | null; | ||
| encode: (addr: number, value: number, key?: number, wantskey?: boolean) => string; | ||
| } |
+1
-0
| export * from "./src/nes"; | ||
| export * from "./src/controller"; | ||
| export * from "./src/browser"; | ||
| export * from "./src/gamegenie"; |
+7
-5
| { | ||
| "name": "jsnes", | ||
| "version": "2.0.0", | ||
| "version": "2.1.0", | ||
| "description": "A JavaScript NES emulator", | ||
| "homepage": "https://github.com/bfirsh/jsnes", | ||
| "author": "Ben Firshman <ben@firshman.com>", | ||
| "main": "src/index.js", | ||
| "main": "dist/jsnes.js", | ||
| "exports": { | ||
| ".": { | ||
| "types": "./index.d.ts", | ||
| "import": "./src/index.js", | ||
| "require": "./dist/jsnes.js", | ||
| "default": "./src/index.js" | ||
@@ -38,8 +40,8 @@ } | ||
| "eslint-config-prettier": "^10.1.8", | ||
| "eslint-webpack-plugin": "^5.0.2", | ||
| "eslint-webpack-plugin": "^6.0.0", | ||
| "prettier": "^3.6.2", | ||
| "terser-webpack-plugin": "^5.3.10", | ||
| "typescript": "^5.8.3", | ||
| "typescript": "^6.0.2", | ||
| "webpack": "^5.100.2", | ||
| "webpack-cli": "^6.0.1" | ||
| "webpack-cli": "^7.0.2" | ||
| }, | ||
@@ -46,0 +48,0 @@ "overrides": { |
+5
-4
@@ -17,3 +17,3 @@ # JSNES | ||
| ```html | ||
| <script type="text/javascript" src="https://unpkg.com/jsnes/dist/jsnes.min.js"></script> | ||
| <script type="text/javascript" src="https://unpkg.com/jsnes@2/dist/jsnes.min.js"></script> | ||
| ``` | ||
@@ -29,3 +29,3 @@ | ||
| <div id="nes" style="width: 512px; height: 480px"></div> | ||
| <script src="https://unpkg.com/jsnes/dist/jsnes.min.js"></script> | ||
| <script src="https://unpkg.com/jsnes@2/dist/jsnes.min.js"></script> | ||
| <script> | ||
@@ -59,2 +59,5 @@ var browser = new jsnes.Browser({ | ||
| A complete embedding example is in the `example/` directory. You can try it by running `npx serve .` in the repository root and opening [http://localhost:3000/example/nes-embed](http://localhost:3000/example/nes-embed). | ||
| ### React | ||
@@ -119,4 +122,2 @@ | ||
| A complete embedding example is in the `example/` directory. | ||
| ## API Reference | ||
@@ -123,0 +124,0 @@ |
@@ -72,4 +72,6 @@ import Controller from "../controller.js"; | ||
| handleKeyPress = (e) => { | ||
| e.preventDefault(); | ||
| if (this.keys[e.keyCode]) { | ||
| e.preventDefault(); | ||
| } | ||
| }; | ||
| } |
@@ -94,9 +94,6 @@ // AudioWorklet processor code, inlined as a string so it can be loaded via | ||
| getSampleRate() { | ||
| if (!window.AudioContext) { | ||
| return 44100; | ||
| if (this.audioCtx) { | ||
| return this.audioCtx.sampleRate; | ||
| } | ||
| let myCtx = new window.AudioContext(); | ||
| let sampleRate = myCtx.sampleRate; | ||
| myCtx.close(); | ||
| return sampleRate; | ||
| return 44100; | ||
| } | ||
@@ -103,0 +100,0 @@ |
+30
-0
@@ -5,5 +5,27 @@ export type ButtonKey = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; | ||
| state: number[]; | ||
| baseA: number; | ||
| baseB: number; | ||
| turboA: boolean; | ||
| turboB: boolean; | ||
| turboToggle: boolean; | ||
| buttonDown: (key: ButtonKey) => void; | ||
| buttonUp: (key: ButtonKey) => void; | ||
| clock: () => void; | ||
| toJSON(): { | ||
| state: number[]; | ||
| baseA: number; | ||
| baseB: number; | ||
| turboA: boolean; | ||
| turboB: boolean; | ||
| turboToggle: boolean; | ||
| }; | ||
| fromJSON(state: { | ||
| state: number[]; | ||
| baseA: number; | ||
| baseB: number; | ||
| turboA: boolean; | ||
| turboB: boolean; | ||
| turboToggle: boolean; | ||
| }): void; | ||
@@ -20,2 +42,10 @@ static readonly BUTTON_A = 0; | ||
| static readonly BUTTON_TURBO_B = 9; | ||
| static readonly JSON_PROPERTIES: readonly [ | ||
| "state", | ||
| "baseA", | ||
| "baseB", | ||
| "turboA", | ||
| "turboB", | ||
| "turboToggle", | ||
| ]; | ||
| } |
+19
-0
@@ -0,1 +1,3 @@ | ||
| import { toJSON, fromJSON } from "./utils.js"; | ||
| class Controller { | ||
@@ -15,2 +17,11 @@ static BUTTON_A = 0; | ||
| static JSON_PROPERTIES = [ | ||
| "state", | ||
| "baseA", | ||
| "baseB", | ||
| "turboA", | ||
| "turboB", | ||
| "turboToggle", | ||
| ]; | ||
| constructor() { | ||
@@ -69,4 +80,12 @@ this.state = new Array(8); | ||
| } | ||
| toJSON() { | ||
| return toJSON(this); | ||
| } | ||
| fromJSON(s) { | ||
| fromJSON(this, s); | ||
| } | ||
| } | ||
| export default Controller; |
+5
-1
@@ -32,3 +32,7 @@ const LETTER_VALUES = "APZLGITYEOXUKSVN"; | ||
| addCode(code) { | ||
| this.patches.push(this.decode(code)); | ||
| const patch = this.decode(code); | ||
| if (!patch) { | ||
| throw new Error(`Invalid Game Genie code: ${code}`); | ||
| } | ||
| this.patches.push(patch); | ||
| if (this.onChange) this.onChange(); | ||
@@ -35,0 +39,0 @@ } |
+113
-6
@@ -16,2 +16,14 @@ import { copyArrayElements } from "../utils.js"; | ||
| this.joypadLastWrite = 0; | ||
| // The effective OUT0 value visible to the controller shift register. | ||
| // On the 2A03, OUT0-OUT2 are output latches that only update on APU | ||
| // clock edges (every 2 CPU cycles). Writes to $4016 on "get" cycles | ||
| // (odd CPU cycle count) update the internal register but NOT the output | ||
| // latch until the next APU clock. This distinction matters for RMW | ||
| // instructions like DEC $4016 that produce a 1-cycle strobe pulse: | ||
| // the dummy write and final write land on consecutive CPU cycles, and | ||
| // whether the pulse is visible depends on APU clock alignment. | ||
| // See https://www.nesdev.org/wiki/CPU_pin_out_and_signal_timing | ||
| this.joypadOutputBit0 = 0; | ||
| // CPU cycle at which the last $4016 write occurred (-2 = never) | ||
| this.joypadLastWriteCycle = -2; | ||
@@ -21,2 +33,7 @@ this.zapperFired = false; | ||
| this.zapperY = null; | ||
| // Set to true by mappers that need per-tile BG override (e.g. MMC5 | ||
| // ExRAM mode 1). When true, the PPU calls getBgTileData() for each | ||
| // background tile during rendering. | ||
| this.bgTileOverride = false; | ||
| } | ||
@@ -245,10 +262,50 @@ | ||
| case 0x4016: | ||
| case 0x4016: { | ||
| // Joystick 1 + Strobe | ||
| if ((value & 1) === 0 && (this.joypadLastWrite & 1) === 1) { | ||
| this.joy1StrobeState = 0; | ||
| this.joy2StrobeState = 0; | ||
| // The 2A03 output ports (OUT0-OUT2) only update on APU clock edges, | ||
| // which happen every 2 CPU cycles. A write to $4016 always updates | ||
| // the internal register immediately, but the effective output | ||
| // (joypadOutputBit0) only changes on odd-parity CPU cycles. | ||
| // This matters for RMW instructions like DEC $4016: the dummy | ||
| // write (original value) and real write (modified value) happen on | ||
| // consecutive cycles. If the dummy write lands on an APU tick | ||
| // (even) but the real write lands on a non-tick (odd), only the | ||
| // dummy write's value reaches OUT0. The AccuracyCoin controller | ||
| // strobe test verifies this behavior. | ||
| let cpu = this.nes.cpu; | ||
| let currentCycle = cpu._cpuCycleBase + cpu.instrBusCycles; | ||
| // If previous write(s) haven't been applied to the output yet | ||
| // (because they landed on odd cycles), sync them now if at least | ||
| // one APU tick has passed since then. | ||
| if (currentCycle - this.joypadLastWriteCycle > 1) { | ||
| let prevBit = this.joypadLastWrite & 1; | ||
| if (prevBit !== this.joypadOutputBit0) { | ||
| if (this.joypadOutputBit0 === 1 && prevBit === 0) { | ||
| this.joy1StrobeState = 0; | ||
| this.joy2StrobeState = 0; | ||
| } | ||
| this.joypadOutputBit0 = prevBit; | ||
| } | ||
| } | ||
| this.joypadLastWrite = value; | ||
| this.joypadLastWriteCycle = currentCycle; | ||
| // Apply to effective output only on APU tick ("put") cycles. | ||
| // After OAM DMA sync, _cpuCycleBase is always odd, so the first | ||
| // instruction cycle (_cpuCycleBase + 1) is even = "get". The 5th | ||
| // cycle of a 6-cycle RMW (dummy write) is _cpuCycleBase + 4 = odd | ||
| // = "put" = APU tick. This matches real hardware where OUT0 updates | ||
| // on "put" cycles. | ||
| if (currentCycle % 2 === 1) { | ||
| let newBit = value & 1; | ||
| if (this.joypadOutputBit0 === 1 && newBit === 0) { | ||
| this.joy1StrobeState = 0; | ||
| this.joy2StrobeState = 0; | ||
| } | ||
| this.joypadOutputBit0 = newBit; | ||
| } | ||
| break; | ||
| } | ||
@@ -269,7 +326,24 @@ case 0x4017: | ||
| // Sync any pending $4016 output that was deferred from odd-cycle writes. | ||
| // Called before reads from $4016/$4017, since reads happen on a later | ||
| // cycle and the APU clock will have ticked by then. | ||
| _syncJoypadOutput() { | ||
| let newBit = this.joypadLastWrite & 1; | ||
| if (newBit !== this.joypadOutputBit0) { | ||
| if (this.joypadOutputBit0 === 1 && newBit === 0) { | ||
| this.joy1StrobeState = 0; | ||
| this.joy2StrobeState = 0; | ||
| } | ||
| this.joypadOutputBit0 = newBit; | ||
| } | ||
| } | ||
| joy1Read() { | ||
| // Sync deferred output before checking strobe state | ||
| this._syncJoypadOutput(); | ||
| // While strobe is active ($4016 bit 0 = 1), the shift register is | ||
| // continuously reloaded, so reads always return button A's state. | ||
| // See https://www.nesdev.org/wiki/Standard_controller | ||
| if (this.joypadLastWrite & 1) { | ||
| if (this.joypadOutputBit0) { | ||
| return this.nes.controllers[1].state[0]; | ||
@@ -296,4 +370,7 @@ } | ||
| joy2Read() { | ||
| // Sync deferred output before checking strobe state | ||
| this._syncJoypadOutput(); | ||
| // While strobe is active, always return button A's state. | ||
| if (this.joypadLastWrite & 1) { | ||
| if (this.joypadOutputBit0) { | ||
| return this.nes.controllers[2].state[0]; | ||
@@ -503,2 +580,28 @@ } | ||
| // Called by the PPU before rendering background tiles for a scanline. | ||
| // Override in mappers that need per-phase CHR bank switching (e.g. MMC5, | ||
| // which uses separate CHR bank sets for sprites vs backgrounds). | ||
| onBgRender() {} | ||
| // Called by the PPU before rendering sprites. | ||
| // Override in mappers that need per-phase CHR bank switching. | ||
| onSpriteRender() {} | ||
| // Called per-tile during BG rendering when bgTileOverride is true. | ||
| // Returns {tile, attrib} to override the tile and attribute for a | ||
| // background tile, or null to use the default lookup. | ||
| // Used by MMC5 ExRAM mode 1 for per-tile CHR bank selection. | ||
| getBgTileData(/* baseTile, tileIndex, ht, vt */) { | ||
| return null; | ||
| } | ||
| // Look up a sprite pattern tile by ptTile index (0-511). | ||
| // Default: return from the PPU's current ptTile cache. | ||
| // MMC5 overrides this to look up from Set A's VROM banks directly, | ||
| // since ptTile may have BG data (Set B) loaded during BG rendering. | ||
| // This avoids calling load*VromBank (which triggers triggerRendering). | ||
| getSpritePatternTile(index) { | ||
| return this.nes.ppu.ptTile[index]; | ||
| } | ||
| toJSON() { | ||
@@ -509,2 +612,4 @@ return { | ||
| joypadLastWrite: this.joypadLastWrite, | ||
| joypadOutputBit0: this.joypadOutputBit0, | ||
| joypadLastWriteCycle: this.joypadLastWriteCycle, | ||
| }; | ||
@@ -517,2 +622,4 @@ } | ||
| this.joypadLastWrite = s.joypadLastWrite; | ||
| this.joypadOutputBit0 = s.joypadOutputBit0 || 0; | ||
| this.joypadLastWriteCycle = s.joypadLastWriteCycle ?? -2; | ||
| } | ||
@@ -519,0 +626,0 @@ } |
@@ -35,3 +35,3 @@ import Mapper0 from "./mapper0.js"; | ||
| this.loadRomBank(0, 0x8000); | ||
| this.loadRomBank(this.nes.rom.romCount - 1, 0xc000); | ||
| this.loadRomBank(0, 0xc000); | ||
@@ -38,0 +38,0 @@ // Load CHR-ROM: |
@@ -24,5 +24,2 @@ import Mapper0 from "./mapper0.js"; | ||
| // Swap in the given VROM bank at 0x0000: | ||
| let bank = (value % (this.nes.rom.vromCount / 2)) * 2; | ||
| this.loadVromBank(bank, 0x0000); | ||
| this.loadVromBank(bank + 1, 0x1000); | ||
| this.load8kVromBank(value * 2, 0x0000); | ||
@@ -29,0 +26,0 @@ } |
@@ -39,3 +39,3 @@ import Mapper0 from "./mapper0.js"; | ||
| switch (address) { | ||
| switch (address & 0xe001) { | ||
| case 0x8000: { | ||
@@ -95,7 +95,4 @@ // Command/Address Select register | ||
| default: | ||
| // Not a MMC3 register. | ||
| // The game has probably crashed, | ||
| // since it tries to write to ROM.. | ||
| // IGNORE. | ||
| // No default needed: the 0xE001 mask maps every address >= $8000 | ||
| // to one of the eight cases above. | ||
| } | ||
@@ -102,0 +99,0 @@ } |
+1150
-103
| import Mapper0 from "./mapper0.js"; | ||
| import { copyArrayElements } from "../utils.js"; | ||
@@ -8,3 +9,2 @@ // MMC5 / ExROM (EKROM, ELROM, ETROM, EWROM) | ||
| // nametable attributes, vertical split screen, and scanline-counting IRQ. | ||
| // NOTE: This implementation is incomplete (stub). | ||
| // See https://www.nesdev.org/wiki/MMC5 | ||
@@ -16,120 +16,919 @@ class Mapper5 extends Mapper0 { | ||
| super(nes); | ||
| // PRG banking | ||
| // $5100: PRG mode (0=32K, 1=16K+16K, 2=16K+8K+8K, 3=8K+8K+8K+8K) | ||
| this.prgMode = 3; // Power-on default: mode 3 (8K banks) | ||
| // $5113-$5117: PRG bank registers. Raw values written by the game. | ||
| // $5113 always maps RAM to $6000-$7FFF. | ||
| // $5114-$5116 bit 7: 0=RAM, 1=ROM. $5117 always ROM. | ||
| this.prgBankReg = new Uint8Array(5); // indices 0-4 for $5113-$5117 | ||
| this.prgBankReg[4] = 0xff; // $5117 defaults to last page (0xFF) | ||
| // PRG RAM: up to 64 KB (two 32 KB chips), banked into $6000-$7FFF. | ||
| // Also mappable into $8000-$DFFF via bank registers with bit 7 clear. | ||
| this.prgRam = new Uint8Array(0x10000); // 64 KB PRG RAM | ||
| // PRG RAM write protection: $5102 and $5103 | ||
| // Writes only enabled when $5102=%10 and $5103=%01 | ||
| // Both reset to %11 ($03) per nesdev wiki, which keeps RAM write-protected. | ||
| this.prgRamProtectA = 0x03; // $5102 | ||
| this.prgRamProtectB = 0x03; // $5103 | ||
| // CHR banking | ||
| // $5101: CHR mode (0=8K, 1=4K, 2=2K, 3=1K) | ||
| this.chrMode = 3; // Power-on default: mode 3 (1K banks) | ||
| // $5120-$5127: CHR bank set A (sprite banks) | ||
| this.chrBankA = new Uint16Array(8); | ||
| // $5128-$512B: CHR bank set B (background banks) | ||
| this.chrBankB = new Uint16Array(4); | ||
| // $5130: Upper CHR bank bits (bits 8-9 appended to bank registers) | ||
| this.chrUpperBits = 0; | ||
| // Tracks which CHR set was last written (0=A, 1=B) for $2007 access | ||
| this.lastChrWrite = 0; | ||
| // Nametable mapping: $5105 | ||
| // Each 2-bit field: 0=CIRAM A, 1=CIRAM B, 2=ExRAM, 3=Fill | ||
| this.ntMapping = new Uint8Array(4); | ||
| // ExRAM: 1 KB internal to MMC5, used for nametable/extended attributes/RAM | ||
| // $5104: ExRAM mode (0=nametable, 1=ext attributes, 2=RAM, 3=read-only) | ||
| this.exramMode = 0; | ||
| this.exram = new Uint8Array(0x400); // 1 KB | ||
| // Fill mode: $5106/$5107 | ||
| this.fillTile = 0; | ||
| this.fillAttr = 0; | ||
| // Scanline IRQ: $5203/$5204 | ||
| // The MMC5 counts scanlines by monitoring PPU nametable fetches. | ||
| // See https://www.nesdev.org/wiki/MMC5#Scanline_detection_and_scanline_IRQ | ||
| this.irqTarget = 0; // $5203: target scanline | ||
| this.irqEnabled = false; // $5204 bit 7 write: IRQ enable | ||
| this.irqPending = false; // $5204 bit 7 read: IRQ pending flag | ||
| this.inFrame = false; // $5204 bit 6 read: in-frame flag | ||
| this.irqCounter = 0; // Internal scanline counter | ||
| // Hardware multiplier: $5205/$5206 | ||
| // Write two 8-bit unsigned values, read 16-bit product immediately. | ||
| // Wiki doesn't specify power-on default; FCEUX uses 0. Using 0 as safe default. | ||
| this.multA = 0; | ||
| this.multB = 0; | ||
| // Split screen: $5200-$5202 | ||
| // Not commonly used. Basic support for register storage. | ||
| this.splitEnabled = false; // $5200 bit 7 | ||
| this.splitRight = false; // $5200 bit 6 (0=left, 1=right) | ||
| this.splitTile = 0; // $5200 bits 0-4: tile threshold | ||
| this.splitScroll = 0; // $5201: vertical scroll for split | ||
| this.splitPage = 0; // $5202: 4K CHR page for split | ||
| // Expansion audio: two pulse channels + PCM | ||
| // The MMC5 pulse channels are similar to APU square channels but lack | ||
| // sweep units and don't silence at low frequencies. | ||
| // See https://www.nesdev.org/wiki/MMC5_audio | ||
| this.pulse1 = this._initPulse(); | ||
| this.pulse2 = this._initPulse(); | ||
| this.pcmValue = 0; // $5011: raw 8-bit PCM output | ||
| this.pcmReadMode = false; // $5010 bit 0 | ||
| this.pcmIrqEnabled = false; // $5010 bit 7 | ||
| this.audioEnabled = 0; // $5015: pulse channel enable bits | ||
| // Tracks which CHR bank set is currently loaded into the PPU's pattern | ||
| // table cache. Used by onBgRender/onSpriteRender to avoid redundant | ||
| // bank switches. -1 = unknown/dirty, 0 = set A (sprites), 1 = set B (BG). | ||
| this._chrBankTarget = -1; | ||
| } | ||
| // Initialize a pulse channel state object. | ||
| // MMC5 pulse channels are like APU square channels minus the sweep unit. | ||
| _initPulse() { | ||
| return { | ||
| enabled: false, | ||
| dutyCycle: 0, // 2-bit duty | ||
| lengthHalt: false, // envelope loop / length counter halt | ||
| constantVolume: false, | ||
| volume: 0, // 4-bit volume/envelope | ||
| timer: 0, // 11-bit timer period | ||
| timerCounter: 0, | ||
| lengthCounter: 0, | ||
| envelopeCounter: 0, | ||
| envelopeDecay: 15, | ||
| envelopeStart: false, | ||
| sequencePos: 0, | ||
| }; | ||
| } | ||
| // --- CPU Read Handler --- | ||
| // Override load() to handle MMC5 register reads and banked PRG access. | ||
| load(address) { | ||
| address &= 0xffff; | ||
| if (address < 0x5000) { | ||
| // Standard read (RAM, PPU regs, APU regs, controllers) | ||
| return super.load(address); | ||
| } | ||
| // $5000-$5017: Expansion audio read-back | ||
| if (address === 0x5015) { | ||
| // Status register: bits 0-1 indicate pulse channel length counter > 0 | ||
| let val = 0; | ||
| if (this.pulse1.lengthCounter > 0) val |= 0x01; | ||
| if (this.pulse2.lengthCounter > 0) val |= 0x02; | ||
| return val; | ||
| } | ||
| if (address === 0x5010) { | ||
| // PCM IRQ status (bit 7). Reading clears the flag. | ||
| // PCM IRQ is rarely used; return 0 for now. | ||
| return 0; | ||
| } | ||
| // $5100-$5104: Write-only control registers — return open bus | ||
| if (address >= 0x5100 && address <= 0x5104) { | ||
| return this.nes.cpu.dataBus; | ||
| } | ||
| // $5105: Nametable mapping (write-only, open bus on read) | ||
| if (address === 0x5105) { | ||
| return this.nes.cpu.dataBus; | ||
| } | ||
| // $5204: Scanline IRQ status | ||
| if (address === 0x5204) { | ||
| // The in-frame flag reflects whether the PPU is actively rendering. | ||
| // Since the PPU only calls clockIrqCounter during rendering, we check | ||
| // the current PPU state to determine in-frame for reads outside rendering. | ||
| let ppu = this.nes.ppu; | ||
| let rendering = | ||
| ppu.scanline >= 20 && | ||
| ppu.scanline <= 260 && | ||
| (ppu.f_bgVisibility === 1 || ppu.f_spVisibility === 1); | ||
| if (!rendering) { | ||
| this.inFrame = false; | ||
| } | ||
| let val = 0; | ||
| if (this.irqPending) val |= 0x80; | ||
| if (this.inFrame) val |= 0x40; | ||
| // Reading $5204 acknowledges (clears) the IRQ pending flag | ||
| this.irqPending = false; | ||
| return val; | ||
| } | ||
| // $5205: Multiplier result low byte | ||
| if (address === 0x5205) { | ||
| return (this.multA * this.multB) & 0xff; | ||
| } | ||
| // $5206: Multiplier result high byte | ||
| if (address === 0x5206) { | ||
| return ((this.multA * this.multB) >> 8) & 0xff; | ||
| } | ||
| // $5C00-$5FFF: ExRAM | ||
| if (address >= 0x5c00 && address <= 0x5fff) { | ||
| // Readable in modes 2 and 3 only; otherwise open bus | ||
| if (this.exramMode >= 2) { | ||
| return this.exram[address - 0x5c00]; | ||
| } | ||
| return this.nes.cpu.dataBus; | ||
| } | ||
| // $5000-$5BFF other: expansion area, return open bus | ||
| if (address < 0x6000) { | ||
| return this.nes.cpu.dataBus; | ||
| } | ||
| // $6000-$7FFF: PRG RAM (banked via $5113) | ||
| if (address < 0x8000) { | ||
| let bank = this.prgBankReg[0] & 0x07; // 3-bit page within 64K RAM | ||
| let offset = bank * 0x2000 + (address - 0x6000); | ||
| return this.prgRam[offset & 0xffff]; | ||
| } | ||
| // $8000-$FFFF: PRG ROM/RAM (banked via $5114-$5117 and prgMode) | ||
| return this._readPrg(address); | ||
| } | ||
| // Read from banked PRG space ($8000-$FFFF). | ||
| // In modes where a region can map to RAM (bit 7 of bank reg = 0), | ||
| // reads come from prgRam. Otherwise, reads come from ROM. | ||
| _readPrg(address) { | ||
| let slot, reg, isRam, bank, base; | ||
| switch (this.prgMode) { | ||
| case 0: | ||
| // Mode 0: One 32K bank at $8000-$FFFF, controlled by $5117 | ||
| // Ignore low 2 bits for 32K alignment | ||
| reg = this.prgBankReg[4]; | ||
| bank = (reg & 0x7c) >> 2; // 32K page = bits 6-2 | ||
| return this._readPrgRom32k(bank, address - 0x8000); | ||
| case 1: | ||
| // Mode 1: Two 16K banks | ||
| // $8000-$BFFF: $5115 (can be RAM if bit 7=0) | ||
| // $C000-$FFFF: $5117 (always ROM) | ||
| if (address < 0xc000) { | ||
| reg = this.prgBankReg[2]; // $5115 | ||
| isRam = (reg & 0x80) === 0; | ||
| if (isRam) { | ||
| bank = (reg & 0x06) >> 1; // 16K RAM page | ||
| return this.prgRam[bank * 0x4000 + (address - 0x8000)]; | ||
| } | ||
| bank = (reg & 0x7e) >> 1; // 16K ROM page (ignore bit 0) | ||
| return this._readPrgRom16k(bank, address - 0x8000); | ||
| } else { | ||
| reg = this.prgBankReg[4]; // $5117 | ||
| bank = (reg & 0x7e) >> 1; // 16K ROM page | ||
| return this._readPrgRom16k(bank, address - 0xc000); | ||
| } | ||
| case 2: | ||
| // Mode 2: 16K + 8K + 8K | ||
| // $8000-$BFFF: $5115 (RAM or ROM) | ||
| // $C000-$DFFF: $5116 (RAM or ROM) | ||
| // $E000-$FFFF: $5117 (always ROM) | ||
| if (address < 0xc000) { | ||
| reg = this.prgBankReg[2]; // $5115 | ||
| isRam = (reg & 0x80) === 0; | ||
| if (isRam) { | ||
| bank = (reg & 0x06) >> 1; | ||
| return this.prgRam[bank * 0x4000 + (address - 0x8000)]; | ||
| } | ||
| bank = (reg & 0x7e) >> 1; | ||
| return this._readPrgRom16k(bank, address - 0x8000); | ||
| } else if (address < 0xe000) { | ||
| reg = this.prgBankReg[3]; // $5116 | ||
| isRam = (reg & 0x80) === 0; | ||
| if (isRam) { | ||
| bank = reg & 0x07; | ||
| return this.prgRam[bank * 0x2000 + (address - 0xc000)]; | ||
| } | ||
| bank = reg & 0x7f; | ||
| return this._readPrgRom8k(bank, address - 0xc000); | ||
| } else { | ||
| reg = this.prgBankReg[4]; // $5117 | ||
| bank = reg & 0x7f; | ||
| return this._readPrgRom8k(bank, address - 0xe000); | ||
| } | ||
| case 3: | ||
| default: | ||
| // Mode 3: Four 8K banks | ||
| // $8000-$9FFF: $5114 (RAM or ROM) | ||
| // $A000-$BFFF: $5115 (RAM or ROM) | ||
| // $C000-$DFFF: $5116 (RAM or ROM) | ||
| // $E000-$FFFF: $5117 (always ROM) | ||
| if (address < 0xa000) { | ||
| slot = 1; // $5114 | ||
| } else if (address < 0xc000) { | ||
| slot = 2; // $5115 | ||
| } else if (address < 0xe000) { | ||
| slot = 3; // $5116 | ||
| } else { | ||
| slot = 4; // $5117 | ||
| } | ||
| reg = this.prgBankReg[slot]; | ||
| base = | ||
| slot === 1 | ||
| ? 0x8000 | ||
| : slot === 2 | ||
| ? 0xa000 | ||
| : slot === 3 | ||
| ? 0xc000 | ||
| : 0xe000; | ||
| // $5117 is always ROM; $5114-$5116 use bit 7 for RAM/ROM select | ||
| if (slot < 4 && (reg & 0x80) === 0) { | ||
| bank = reg & 0x07; | ||
| return this.prgRam[bank * 0x2000 + (address - base)]; | ||
| } | ||
| bank = reg & 0x7f; | ||
| return this._readPrgRom8k(bank, address - base); | ||
| } | ||
| } | ||
| // Read a byte from PRG ROM given a 32K bank number and offset within it. | ||
| _readPrgRom32k(bank32k, offset) { | ||
| // ROM is stored as 16K banks in rom.rom[] | ||
| let bank16k = | ||
| (bank32k * 2 + Math.floor(offset / 0x4000)) % this.nes.rom.romCount; | ||
| let innerOffset = offset % 0x4000; | ||
| return this.nes.rom.rom[bank16k][innerOffset]; | ||
| } | ||
| // Read a byte from PRG ROM given a 16K bank number and offset within it. | ||
| _readPrgRom16k(bank16k, offset) { | ||
| bank16k %= this.nes.rom.romCount; | ||
| return this.nes.rom.rom[bank16k][offset]; | ||
| } | ||
| // Read a byte from PRG ROM given an 8K bank number and offset within it. | ||
| _readPrgRom8k(bank8k, offset) { | ||
| let bank16k = Math.floor(bank8k / 2) % this.nes.rom.romCount; | ||
| let innerOffset = (bank8k % 2) * 0x2000 + offset; | ||
| if (bank16k < this.nes.rom.romCount) { | ||
| return this.nes.rom.rom[bank16k][innerOffset]; | ||
| } | ||
| return 0; | ||
| } | ||
| // --- CPU Write Handler --- | ||
| write(address, value) { | ||
| // Writes to addresses other than MMC registers are handled by NoMapper. | ||
| // Standard NES write handling for addresses below $5000 | ||
| if (address < 0x5000) { | ||
| super.write(address, value); | ||
| // MMC5 monitors writes to $2000 to track 8x8 vs 8x16 sprite mode. | ||
| // This affects which CHR bank set is used for rendering. | ||
| // The PPU already parses $2000, so we just note it here. | ||
| return; | ||
| } | ||
| switch (address) { | ||
| case 0x5100: | ||
| this.prg_size = value & 3; | ||
| // $5000-$5015: Expansion audio registers | ||
| if (address >= 0x5000 && address <= 0x5003) { | ||
| this._writePulse(this.pulse1, address - 0x5000, value); | ||
| return; | ||
| } | ||
| if (address >= 0x5004 && address <= 0x5007) { | ||
| this._writePulse(this.pulse2, address - 0x5004, value); | ||
| return; | ||
| } | ||
| if (address === 0x5010) { | ||
| this.pcmReadMode = (value & 0x01) !== 0; | ||
| this.pcmIrqEnabled = (value & 0x80) !== 0; | ||
| return; | ||
| } | ||
| if (address === 0x5011) { | ||
| // Raw PCM write. Writing $00 has no effect on the output. | ||
| if (!this.pcmReadMode && value !== 0) { | ||
| this.pcmValue = value; | ||
| } | ||
| return; | ||
| } | ||
| if (address === 0x5015) { | ||
| // Expansion audio status: bits 0-1 enable pulse channels | ||
| this.audioEnabled = value & 0x03; | ||
| this.pulse1.enabled = (value & 0x01) !== 0; | ||
| this.pulse2.enabled = (value & 0x02) !== 0; | ||
| if (!this.pulse1.enabled) this.pulse1.lengthCounter = 0; | ||
| if (!this.pulse2.enabled) this.pulse2.lengthCounter = 0; | ||
| return; | ||
| } | ||
| // $5100: PRG banking mode | ||
| if (address === 0x5100) { | ||
| this.prgMode = value & 0x03; | ||
| this._syncPrg(); | ||
| return; | ||
| } | ||
| // $5101: CHR banking mode | ||
| if (address === 0x5101) { | ||
| this.chrMode = value & 0x03; | ||
| this._syncChr(); | ||
| return; | ||
| } | ||
| // $5102/$5103: PRG RAM write protection | ||
| if (address === 0x5102) { | ||
| this.prgRamProtectA = value & 0x03; | ||
| return; | ||
| } | ||
| if (address === 0x5103) { | ||
| this.prgRamProtectB = value & 0x03; | ||
| return; | ||
| } | ||
| // $5104: ExRAM mode | ||
| if (address === 0x5104) { | ||
| this.exramMode = value & 0x03; | ||
| // ExRAM mode 1 enables per-tile BG override: each ExRAM byte provides | ||
| // a 4KB CHR bank + attribute for the corresponding background tile. | ||
| this.bgTileOverride = this.exramMode === 1; | ||
| this._syncNametables(); | ||
| return; | ||
| } | ||
| // $5105: Nametable mapping | ||
| if (address === 0x5105) { | ||
| let v = value; | ||
| this.ntMapping[0] = v & 0x03; | ||
| v >>= 2; | ||
| this.ntMapping[1] = v & 0x03; | ||
| v >>= 2; | ||
| this.ntMapping[2] = v & 0x03; | ||
| v >>= 2; | ||
| this.ntMapping[3] = v & 0x03; | ||
| this._syncNametables(); | ||
| return; | ||
| } | ||
| // $5106: Fill-mode tile | ||
| if (address === 0x5106) { | ||
| this.fillTile = value; | ||
| this._syncNametables(); | ||
| return; | ||
| } | ||
| // $5107: Fill-mode attribute (bottom 2 bits) | ||
| if (address === 0x5107) { | ||
| this.fillAttr = value & 0x03; | ||
| this._syncNametables(); | ||
| return; | ||
| } | ||
| // $5113: PRG RAM bank for $6000-$7FFF | ||
| if (address === 0x5113) { | ||
| this.prgBankReg[0] = value & 0x07; | ||
| return; | ||
| } | ||
| // $5114-$5117: PRG bank registers | ||
| if (address >= 0x5114 && address <= 0x5117) { | ||
| let idx = address - 0x5113; // 1-4 | ||
| this.prgBankReg[idx] = value; | ||
| this._syncPrg(); | ||
| return; | ||
| } | ||
| // $5120-$5127: CHR bank set A (sprites / "last written" set) | ||
| if (address >= 0x5120 && address <= 0x5127) { | ||
| let reg = address - 0x5120; | ||
| this.chrBankA[reg] = (this.chrUpperBits << 8) | value; | ||
| this.lastChrWrite = 0; | ||
| this._syncChr(); | ||
| return; | ||
| } | ||
| // $5128-$512B: CHR bank set B (background) | ||
| if (address >= 0x5128 && address <= 0x512b) { | ||
| let reg = address - 0x5128; | ||
| this.chrBankB[reg] = (this.chrUpperBits << 8) | value; | ||
| this.lastChrWrite = 1; | ||
| this._syncChr(); | ||
| return; | ||
| } | ||
| // $5130: Upper CHR bank bits | ||
| if (address === 0x5130) { | ||
| this.chrUpperBits = value & 0x03; | ||
| return; | ||
| } | ||
| // $5200: Split screen control | ||
| if (address === 0x5200) { | ||
| this.splitEnabled = (value & 0x80) !== 0; | ||
| this.splitRight = (value & 0x40) !== 0; | ||
| this.splitTile = value & 0x1f; | ||
| return; | ||
| } | ||
| // $5201: Split screen Y scroll | ||
| if (address === 0x5201) { | ||
| this.splitScroll = value; | ||
| return; | ||
| } | ||
| // $5202: Split screen CHR page | ||
| if (address === 0x5202) { | ||
| this.splitPage = value & 0x3f; | ||
| return; | ||
| } | ||
| // $5203: Scanline IRQ target | ||
| if (address === 0x5203) { | ||
| this.irqTarget = value; | ||
| return; | ||
| } | ||
| // $5204: Scanline IRQ enable | ||
| if (address === 0x5204) { | ||
| this.irqEnabled = (value & 0x80) !== 0; | ||
| // If both enabled and pending, fire IRQ immediately | ||
| if (this.irqEnabled && this.irqPending) { | ||
| this.nes.cpu.requestIrq(this.nes.cpu.IRQ_NORMAL); | ||
| } | ||
| return; | ||
| } | ||
| // $5205: Multiplier operand A | ||
| if (address === 0x5205) { | ||
| this.multA = value; | ||
| return; | ||
| } | ||
| // $5206: Multiplier operand B | ||
| if (address === 0x5206) { | ||
| this.multB = value; | ||
| return; | ||
| } | ||
| // $5C00-$5FFF: ExRAM writes | ||
| if (address >= 0x5c00 && address <= 0x5fff) { | ||
| let exAddr = address - 0x5c00; | ||
| if (this.exramMode === 0 || this.exramMode === 1) { | ||
| // Modes 0/1: writable only during rendering (in-frame). | ||
| // If not in-frame, $00 is written instead. | ||
| this.exram[exAddr] = this.inFrame ? value : 0x00; | ||
| // If ExRAM is used as a nametable, sync it to VRAM | ||
| this._syncExramToVram(exAddr); | ||
| } else if (this.exramMode === 2) { | ||
| // Mode 2: general-purpose RAM, always writable | ||
| this.exram[exAddr] = value; | ||
| } | ||
| // Mode 3: read-only, writes have no effect | ||
| return; | ||
| } | ||
| // $6000-$7FFF: PRG RAM writes (write-protected via $5102/$5103) | ||
| if (address >= 0x6000 && address <= 0x7fff) { | ||
| if (this.prgRamProtectA === 0x02 && this.prgRamProtectB === 0x01) { | ||
| let bank = this.prgBankReg[0] & 0x07; | ||
| let offset = bank * 0x2000 + (address - 0x6000); | ||
| this.prgRam[offset & 0xffff] = value; | ||
| // Also write to CPU mem for compatibility with save state / battery RAM | ||
| this.nes.cpu.mem[address] = value; | ||
| this.nes.opts.onBatteryRamWrite(address, value); | ||
| } | ||
| return; | ||
| } | ||
| // $8000-$FFFF: PRG ROM/RAM writes | ||
| if (address >= 0x8000) { | ||
| this._writePrg(address, value); | ||
| return; | ||
| } | ||
| } | ||
| // Handle writes to the PRG address space ($8000-$FFFF). | ||
| // Some bank slots may be mapped to RAM if bit 7 of the bank register is 0. | ||
| _writePrg(address, value) { | ||
| let slot, reg, isRam, bank, base; | ||
| switch (this.prgMode) { | ||
| case 0: | ||
| // Mode 0: Entire $8000-$FFFF is a single 32K ROM bank — not writable | ||
| return; | ||
| case 1: | ||
| // $8000-$BFFF: $5115 (can be RAM) | ||
| // $C000-$FFFF: $5117 (always ROM) | ||
| if (address < 0xc000) { | ||
| reg = this.prgBankReg[2]; | ||
| isRam = (reg & 0x80) === 0; | ||
| if (isRam && this._isPrgRamWritable()) { | ||
| bank = (reg & 0x06) >> 1; | ||
| this.prgRam[bank * 0x4000 + (address - 0x8000)] = value; | ||
| } | ||
| } | ||
| return; | ||
| case 2: | ||
| // $8000-$BFFF: $5115 (can be RAM) | ||
| // $C000-$DFFF: $5116 (can be RAM) | ||
| // $E000-$FFFF: $5117 (always ROM) | ||
| if (address < 0xc000) { | ||
| reg = this.prgBankReg[2]; | ||
| isRam = (reg & 0x80) === 0; | ||
| if (isRam && this._isPrgRamWritable()) { | ||
| bank = (reg & 0x06) >> 1; | ||
| this.prgRam[bank * 0x4000 + (address - 0x8000)] = value; | ||
| } | ||
| } else if (address < 0xe000) { | ||
| reg = this.prgBankReg[3]; | ||
| isRam = (reg & 0x80) === 0; | ||
| if (isRam && this._isPrgRamWritable()) { | ||
| bank = reg & 0x07; | ||
| this.prgRam[bank * 0x2000 + (address - 0xc000)] = value; | ||
| } | ||
| } | ||
| return; | ||
| case 3: | ||
| default: | ||
| // $8000-$9FFF: $5114 (can be RAM) | ||
| // $A000-$BFFF: $5115 (can be RAM) | ||
| // $C000-$DFFF: $5116 (can be RAM) | ||
| // $E000-$FFFF: $5117 (always ROM) | ||
| if (address < 0xa000) { | ||
| slot = 1; | ||
| base = 0x8000; | ||
| } else if (address < 0xc000) { | ||
| slot = 2; | ||
| base = 0xa000; | ||
| } else if (address < 0xe000) { | ||
| slot = 3; | ||
| base = 0xc000; | ||
| } else { | ||
| return; // $5117 is always ROM | ||
| } | ||
| reg = this.prgBankReg[slot]; | ||
| isRam = (reg & 0x80) === 0; | ||
| if (isRam && this._isPrgRamWritable()) { | ||
| bank = reg & 0x07; | ||
| this.prgRam[bank * 0x2000 + (address - base)] = value; | ||
| } | ||
| return; | ||
| } | ||
| } | ||
| // Check if PRG RAM writes are enabled via the two protection registers. | ||
| _isPrgRamWritable() { | ||
| return this.prgRamProtectA === 0x02 && this.prgRamProtectB === 0x01; | ||
| } | ||
| // --- PRG Synchronization --- | ||
| // Copy the selected PRG ROM banks into CPU address space so the CPU can | ||
| // read them directly. This follows the same approach as other mappers. | ||
| // Called when prgMode or bank registers change. | ||
| _syncPrg() { | ||
| switch (this.prgMode) { | ||
| case 0: { | ||
| // 32K bank at $8000-$FFFF from $5117 | ||
| let reg = this.prgBankReg[4]; | ||
| let bank = (reg & 0x7c) >> 2; // 32K page | ||
| this.load32kRomBank(bank, 0x8000); | ||
| break; | ||
| case 0x5101: | ||
| this.chr_size = value & 3; | ||
| } | ||
| case 1: { | ||
| // $8000-$BFFF from $5115, $C000-$FFFF from $5117 | ||
| let regLo = this.prgBankReg[2]; // $5115 | ||
| if (regLo & 0x80) { | ||
| // ROM | ||
| let bank16k = (regLo & 0x7e) >> 1; | ||
| this.loadRomBank(bank16k % this.nes.rom.romCount, 0x8000); | ||
| } | ||
| // else: RAM — reads will be handled by load() override | ||
| let regHi = this.prgBankReg[4]; // $5117 | ||
| let bank16kHi = (regHi & 0x7e) >> 1; | ||
| this.loadRomBank(bank16kHi % this.nes.rom.romCount, 0xc000); | ||
| break; | ||
| case 0x5102: | ||
| this.sram_we_a = value & 3; | ||
| } | ||
| case 2: { | ||
| // $8000-$BFFF from $5115, $C000-$DFFF from $5116, $E000-$FFFF from $5117 | ||
| let regA = this.prgBankReg[2]; // $5115 | ||
| if (regA & 0x80) { | ||
| let bank16k = (regA & 0x7e) >> 1; | ||
| this.loadRomBank(bank16k % this.nes.rom.romCount, 0x8000); | ||
| } | ||
| let regB = this.prgBankReg[3]; // $5116 | ||
| if (regB & 0x80) { | ||
| this.load8kRomBank(regB & 0x7f, 0xc000); | ||
| } | ||
| let regC = this.prgBankReg[4]; // $5117 | ||
| this.load8kRomBank(regC & 0x7f, 0xe000); | ||
| break; | ||
| case 0x5103: | ||
| this.sram_we_b = value & 3; | ||
| } | ||
| case 3: | ||
| default: { | ||
| // Four 8K banks from $5114-$5117 | ||
| for (let i = 1; i <= 4; i++) { | ||
| let reg = this.prgBankReg[i]; | ||
| let addr = 0x6000 + i * 0x2000; // $8000, $A000, $C000, $E000 | ||
| // $5117 (i=4) is always ROM; $5114-$5116 check bit 7 | ||
| if (i === 4 || reg & 0x80) { | ||
| this.load8kRomBank(reg & 0x7f, addr); | ||
| } | ||
| // RAM banks are handled dynamically in load() | ||
| } | ||
| break; | ||
| case 0x5104: | ||
| this.graphic_mode = value & 3; | ||
| } | ||
| } | ||
| } | ||
| // --- CHR Synchronization --- | ||
| // Apply the current CHR bank registers to PPU pattern table memory. | ||
| // See https://www.nesdev.org/wiki/MMC5#CHR_banking | ||
| _syncChr() { | ||
| // Trigger rendering before changing banks, so any accumulated scanlines | ||
| // are drawn with the OLD CHR bank values. This is important for mid-frame | ||
| // bank switches (e.g. via scanline IRQ handlers that change CHR registers | ||
| // before writing to PPU scroll registers). | ||
| this.nes.ppu.triggerRendering(); | ||
| // Invalidate cached CHR bank target so the render hooks re-apply | ||
| // when rendering starts. | ||
| this._chrBankTarget = -1; | ||
| if (this.nes.ppu.f_spriteSize === 0) { | ||
| // 8x8 sprite mode: only bank set A is used for ALL fetches (sprites, | ||
| // backgrounds, and $2007 reads). Bank set B is completely ignored. | ||
| // This was confirmed by hardware tests — see FCEUX bug #787. | ||
| this._applyChrSetA(); | ||
| this._chrBankTarget = 0; | ||
| } | ||
| // In 8x16 sprite mode, the onBgRender/onSpriteRender hooks handle | ||
| // switching between set A (sprites) and set B (backgrounds) during | ||
| // rendering. Outside rendering (VBlank), $2007 reads use whichever | ||
| // set was last loaded by the hooks — this is an acceptable simplification | ||
| // since we can't call load*VromBank here (it triggers triggerRendering). | ||
| } | ||
| // Apply CHR bank set A ($5120-$5127) based on chrMode. | ||
| _applyChrSetA() { | ||
| if (this.nes.rom.vromCount === 0) return; | ||
| switch (this.chrMode) { | ||
| case 0: | ||
| // 8K mode: $5127 selects an 8K page | ||
| this.load8kVromBank((this.chrBankA[7] & 0xff) * 2, 0x0000); | ||
| break; | ||
| case 0x5105: | ||
| this.nametable_mode = value; | ||
| this.nametable_type[0] = value & 3; | ||
| this.load1kVromBank(value & 3, 0x2000); | ||
| value >>= 2; | ||
| this.nametable_type[1] = value & 3; | ||
| this.load1kVromBank(value & 3, 0x2400); | ||
| value >>= 2; | ||
| this.nametable_type[2] = value & 3; | ||
| this.load1kVromBank(value & 3, 0x2800); | ||
| value >>= 2; | ||
| this.nametable_type[3] = value & 3; | ||
| this.load1kVromBank(value & 3, 0x2c00); | ||
| case 1: | ||
| // 4K mode: $5123 selects 4K at $0000, $5127 selects 4K at $1000 | ||
| this.loadVromBank(this.chrBankA[3] & 0xff, 0x0000); | ||
| this.loadVromBank(this.chrBankA[7] & 0xff, 0x1000); | ||
| break; | ||
| case 0x5106: | ||
| this.fill_chr = value; | ||
| case 2: | ||
| // 2K mode: $5121/$5123/$5125/$5127 each select 2K | ||
| this.load2kVromBank(this.chrBankA[1] & 0x1ff, 0x0000); | ||
| this.load2kVromBank(this.chrBankA[3] & 0x1ff, 0x0800); | ||
| this.load2kVromBank(this.chrBankA[5] & 0x1ff, 0x1000); | ||
| this.load2kVromBank(this.chrBankA[7] & 0x1ff, 0x1800); | ||
| break; | ||
| case 0x5107: | ||
| this.fill_pal = value & 3; | ||
| case 3: | ||
| default: | ||
| // 1K mode: $5120-$5127 each select a 1K page | ||
| for (let i = 0; i < 8; i++) { | ||
| this.load1kVromBank(this.chrBankA[i] & 0x3ff, i * 0x0400); | ||
| } | ||
| break; | ||
| case 0x5113: | ||
| this.SetBank_SRAM(3, value & 3); | ||
| } | ||
| } | ||
| // Apply CHR bank set B ($5128-$512B) based on chrMode. | ||
| // Set B uses only 4 registers, so larger modes replicate them. | ||
| _applyChrSetB() { | ||
| if (this.nes.rom.vromCount === 0) return; | ||
| switch (this.chrMode) { | ||
| case 0: | ||
| // 8K mode: $512B selects an 8K page | ||
| this.load8kVromBank((this.chrBankB[3] & 0xff) * 2, 0x0000); | ||
| break; | ||
| case 0x5114: | ||
| case 0x5115: | ||
| case 0x5116: | ||
| case 0x5117: | ||
| this.SetBank_CPU(address, value); | ||
| case 1: | ||
| // 4K mode: $512B selects 4K at both halves | ||
| this.loadVromBank(this.chrBankB[3] & 0xff, 0x0000); | ||
| this.loadVromBank(this.chrBankB[3] & 0xff, 0x1000); | ||
| break; | ||
| case 0x5120: | ||
| case 0x5121: | ||
| case 0x5122: | ||
| case 0x5123: | ||
| case 0x5124: | ||
| case 0x5125: | ||
| case 0x5126: | ||
| case 0x5127: | ||
| this.chr_mode = 0; | ||
| this.chr_page[0][address & 7] = value; | ||
| this.SetBank_PPU(); | ||
| case 2: | ||
| // 2K mode: $5129/$512B each select 2K, replicated across 8K | ||
| this.load2kVromBank(this.chrBankB[1] & 0x1ff, 0x0000); | ||
| this.load2kVromBank(this.chrBankB[3] & 0x1ff, 0x0800); | ||
| this.load2kVromBank(this.chrBankB[1] & 0x1ff, 0x1000); | ||
| this.load2kVromBank(this.chrBankB[3] & 0x1ff, 0x1800); | ||
| break; | ||
| case 0x5128: | ||
| case 0x5129: | ||
| case 0x512a: | ||
| case 0x512b: | ||
| this.chr_mode = 1; | ||
| this.chr_page[1][(address & 3) + 0] = value; | ||
| this.chr_page[1][(address & 3) + 4] = value; | ||
| this.SetBank_PPU(); | ||
| case 3: | ||
| default: | ||
| // 1K mode: $5128-$512B each select 1K, replicated for both halves | ||
| for (let i = 0; i < 4; i++) { | ||
| this.load1kVromBank(this.chrBankB[i] & 0x3ff, i * 0x0400); | ||
| this.load1kVromBank(this.chrBankB[i] & 0x3ff, (i + 4) * 0x0400); | ||
| } | ||
| break; | ||
| case 0x5200: | ||
| this.split_control = value; | ||
| } | ||
| } | ||
| // --- Nametable Synchronization --- | ||
| // Configure the PPU's vramMirrorTable AND internal NameTable objects to | ||
| // reflect the MMC5's nametable mapping. Each of the 4 nametable slots | ||
| // ($2000/$2400/$2800/$2C00) can be mapped to: | ||
| // 0: NES CIRAM page A ($2000) | ||
| // 1: NES CIRAM page B ($2400) | ||
| // 2: ExRAM (internal 1KB, stored at $2800 in VRAM) | ||
| // 3: Fill mode (stored at $2C00 in VRAM) | ||
| // | ||
| // IMPORTANT: The PPU uses TWO parallel data structures for nametables: | ||
| // 1. vramMem[] + vramMirrorTable[] — raw bytes, for $2007 VRAM reads | ||
| // 2. nameTable[0-3] + ntable1[0-3] — parsed tile/attrib, for rendering | ||
| // We must update BOTH so the renderer sees the correct nametable data. | ||
| // See https://www.nesdev.org/wiki/MMC5#Nametable_mapping | ||
| _syncNametables() { | ||
| let ppu = this.nes.ppu; | ||
| // First, populate the fill-mode nametable at VRAM $2C00. | ||
| // 960 bytes of tile index followed by 64 bytes of attribute. | ||
| // The attribute byte packs the fill palette into all four sub-quadrants. | ||
| let fillAttrByte = | ||
| this.fillAttr | | ||
| (this.fillAttr << 2) | | ||
| (this.fillAttr << 4) | | ||
| (this.fillAttr << 6); | ||
| for (let i = 0; i < 960; i++) { | ||
| ppu.vramMem[0x2c00 + i] = this.fillTile; | ||
| } | ||
| for (let i = 960; i < 1024; i++) { | ||
| ppu.vramMem[0x2c00 + i] = fillAttrByte; | ||
| } | ||
| // Copy ExRAM into VRAM at $2800 for nametable use. | ||
| // In modes 2/3 (general-purpose RAM), ExRAM reads as all zeros for nametable. | ||
| if (this.exramMode >= 2) { | ||
| for (let i = 0; i < 0x400; i++) { | ||
| ppu.vramMem[0x2800 + i] = 0; | ||
| } | ||
| } else { | ||
| copyArrayElements(this.exram, 0, ppu.vramMem, 0x2800, 0x400); | ||
| } | ||
| // Physical VRAM locations for each source: | ||
| // 0 → $2000 (CIRAM A) | ||
| // 1 → $2400 (CIRAM B) | ||
| // 2 → $2800 (ExRAM copy) | ||
| // 3 → $2C00 (Fill mode) | ||
| const sourceBase = [0x2000, 0x2400, 0x2800, 0x2c00]; | ||
| for (let nt = 0; nt < 4; nt++) { | ||
| let logicalBase = 0x2000 + nt * 0x400; | ||
| let physBase = sourceBase[this.ntMapping[nt]]; | ||
| ppu.defineMirrorRegion(logicalBase, physBase, 0x400); | ||
| } | ||
| // Also mirror $3000-$3EFF → $2000-$2EFF as per normal NES behavior | ||
| ppu.defineMirrorRegion(0x3000, 0x2000, 0xf00); | ||
| // Update ntable1 so the renderer reads from the correct NameTable objects. | ||
| // ntMapping values 0-3 map directly to NameTable indices 0-3: | ||
| // 0 → NameTable 0 (CIRAM A, VRAM $2000) | ||
| // 1 → NameTable 1 (CIRAM B, VRAM $2400) | ||
| // 2 → NameTable 2 (ExRAM, VRAM $2800) | ||
| // 3 → NameTable 3 (Fill, VRAM $2C00) | ||
| for (let nt = 0; nt < 4; nt++) { | ||
| ppu.ntable1[nt] = this.ntMapping[nt]; | ||
| } | ||
| // Populate NameTable 2 with ExRAM data so the renderer can see it. | ||
| // The PPU renderer reads from nameTable[].tile[] and nameTable[].attrib[], | ||
| // NOT from vramMem directly, so we must sync both. | ||
| this._populateNameTable(2, 0x2800); | ||
| // Populate NameTable 3 with fill-mode data. | ||
| this._populateNameTable(3, 0x2c00); | ||
| } | ||
| // Populate a NameTable object from a 1KB region of vramMem. | ||
| // The first 960 bytes are tile indices, the next 64 are attribute table bytes. | ||
| _populateNameTable(ntIndex, vramBase) { | ||
| let ppu = this.nes.ppu; | ||
| let nt = ppu.nameTable[ntIndex]; | ||
| // Copy tile indices (960 bytes = 30 rows × 32 columns) | ||
| for (let i = 0; i < 960; i++) { | ||
| nt.tile[i] = ppu.vramMem[vramBase + i]; | ||
| } | ||
| // Decode attribute table (64 bytes) into per-tile attributes. | ||
| // Each attribute byte controls a 4×4 tile area (32×32 pixels). | ||
| for (let i = 0; i < 64; i++) { | ||
| nt.writeAttrib(i, ppu.vramMem[vramBase + 960 + i]); | ||
| } | ||
| } | ||
| // Sync a single ExRAM byte to both the VRAM copy at $2800 and NameTable 2. | ||
| // Called when ExRAM is written via $5C00-$5FFF in modes 0/1. | ||
| _syncExramToVram(exAddr) { | ||
| if (this.exramMode < 2) { | ||
| let ppu = this.nes.ppu; | ||
| ppu.vramMem[0x2800 + exAddr] = this.exram[exAddr]; | ||
| // Also update NameTable 2 so the renderer sees the change. | ||
| if (exAddr < 960) { | ||
| // Tile index update | ||
| ppu.nameTable[2].tile[exAddr] = this.exram[exAddr]; | ||
| } else if (exAddr < 1024) { | ||
| // Attribute table update — decode into per-tile attributes | ||
| ppu.nameTable[2].writeAttrib(exAddr - 960, this.exram[exAddr]); | ||
| } | ||
| } | ||
| } | ||
| // --- Expansion Audio --- | ||
| // Write to a pulse channel register. Layout matches the NES APU square channels | ||
| // ($4000-$4003) except that $5001/$5005 (sweep) has no effect on MMC5 pulses. | ||
| _writePulse(pulse, reg, value) { | ||
| switch (reg) { | ||
| case 0: | ||
| // $5000/$5004: Duty, length counter halt, constant volume, volume/envelope | ||
| pulse.dutyCycle = (value >> 6) & 0x03; | ||
| pulse.lengthHalt = (value & 0x20) !== 0; | ||
| pulse.constantVolume = (value & 0x10) !== 0; | ||
| pulse.volume = value & 0x0f; | ||
| break; | ||
| case 0x5201: | ||
| this.split_scroll = value; | ||
| case 1: | ||
| // $5001/$5005: Sweep — no effect on MMC5 pulse channels | ||
| break; | ||
| case 0x5202: | ||
| this.split_page = value & 0x3f; | ||
| case 2: | ||
| // $5002/$5006: Timer low 8 bits | ||
| pulse.timer = (pulse.timer & 0x700) | value; | ||
| break; | ||
| case 0x5203: | ||
| this.irq_line = value; | ||
| this.nes.cpu.ClearIRQ(); | ||
| break; | ||
| case 0x5204: | ||
| this.irq_enable = value; | ||
| this.nes.cpu.ClearIRQ(); | ||
| break; | ||
| case 0x5205: | ||
| this.mult_a = value; | ||
| break; | ||
| case 0x5206: | ||
| this.mult_b = value; | ||
| break; | ||
| default: | ||
| if (address >= 0x5000 && address <= 0x5015) { | ||
| this.nes.papu.exWrite(address, value); | ||
| } else if (address >= 0x5c00 && address <= 0x5fff) { | ||
| if (this.graphic_mode === 2) { | ||
| // ExRAM | ||
| // vram write | ||
| } else if (this.graphic_mode !== 3) { | ||
| // Split,ExGraphic | ||
| if (this.irq_status & 0x40) { | ||
| // vram write | ||
| } else { | ||
| // vram write | ||
| } | ||
| } | ||
| } else if (address >= 0x6000 && address <= 0x7fff) { | ||
| if (this.sram_we_a === 2 && this.sram_we_b === 1) { | ||
| // additional ram write | ||
| } | ||
| case 3: | ||
| // $5003/$5007: Length counter load, timer high 3 bits | ||
| pulse.timer = (pulse.timer & 0x0ff) | ((value & 0x07) << 8); | ||
| if (pulse.enabled) { | ||
| pulse.lengthCounter = this.nes.papu.getLengthMax(value); | ||
| } | ||
| pulse.envelopeStart = true; | ||
| pulse.sequencePos = 0; | ||
| break; | ||
@@ -139,21 +938,269 @@ } | ||
| // --- Scanline IRQ Counter --- | ||
| // Called by the PPU once per scanline when BG or sprites are enabled. | ||
| // The PPU calls this at scanline 20 (pre-render) and scanlines 21-260 (visible). | ||
| // The MMC5 uses an up-counter that resets when entering rendering and increments | ||
| // each scanline, firing an IRQ when it matches the target value in $5203. | ||
| // See https://www.nesdev.org/wiki/MMC5#Scanline_detection_and_scanline_IRQ | ||
| clockIrqCounter() { | ||
| let scanline = this.nes.ppu.scanline; | ||
| if (scanline === 20) { | ||
| // Pre-render scanline: entering active rendering. | ||
| // Set in-frame and reset the scanline counter. | ||
| this.inFrame = true; | ||
| this.irqCounter = 0; | ||
| return; | ||
| } | ||
| // Visible scanlines (21-260): increment counter and compare. | ||
| this.irqCounter++; | ||
| // $5203 value of 0 is a special case that never matches. | ||
| if (this.irqTarget !== 0 && this.irqCounter === this.irqTarget) { | ||
| this.irqPending = true; | ||
| if (this.irqEnabled) { | ||
| this.nes.cpu.requestIrq(this.nes.cpu.IRQ_NORMAL); | ||
| } | ||
| } | ||
| // Clock expansion audio length counters once per scanline. | ||
| // The MMC5 has no frame sequencer; length counters run at a fixed rate | ||
| // tied to scanline timing. We approximate by clocking every 4 scanlines | ||
| // (~240 Hz, matching the APU frame counter quarter-frame rate). | ||
| // See https://www.nesdev.org/wiki/MMC5_audio | ||
| if ((this.irqCounter & 3) === 0) { | ||
| this._clockPulseLengthCounter(this.pulse1); | ||
| this._clockPulseLengthCounter(this.pulse2); | ||
| } | ||
| } | ||
| // Decrement a pulse channel's length counter if it's active and not halted. | ||
| _clockPulseLengthCounter(pulse) { | ||
| if (pulse.enabled && !pulse.lengthHalt && pulse.lengthCounter > 0) { | ||
| pulse.lengthCounter--; | ||
| } | ||
| } | ||
| // --- CHR Bank Switching for Sprite/BG Phases --- | ||
| // The MMC5 uses dual CHR bank sets in 8x16 sprite mode ($2000 bit 5 = 1): | ||
| // - Bank set A ($5120-$5127) is used for sprite pattern fetches | ||
| // - Bank set B ($5128-$512B) is used for background pattern fetches | ||
| // In 8x8 sprite mode, only bank set A is used for all fetches. | ||
| // The PPU calls these hooks before each rendering phase so we can swap | ||
| // the pattern table data in the ptTile cache. | ||
| // See https://www.nesdev.org/wiki/MMC5#CHR_banking | ||
| onBgRender() { | ||
| if (this.nes.ppu.f_spriteSize === 1 && this._chrBankTarget !== 1) { | ||
| this._applyChrSetB(); | ||
| this._chrBankTarget = 1; | ||
| // Invalidate the PPU's tile cache since we swapped CHR data | ||
| this.nes.ppu.validTileData = false; | ||
| } | ||
| } | ||
| onSpriteRender() { | ||
| if (this.nes.ppu.f_spriteSize === 1 && this._chrBankTarget !== 0) { | ||
| this._applyChrSetA(); | ||
| this._chrBankTarget = 0; | ||
| } | ||
| } | ||
| // Look up a sprite pattern tile from Set A's VROM banks directly. | ||
| // In 8x16 mode, ptTile may have BG data (Set B) during BG rendering, | ||
| // but sprite 0 hit detection needs Set A data. This method reads from | ||
| // the pre-decoded VROM tile cache without modifying ptTile or calling | ||
| // load*VromBank (which would trigger triggerRendering). | ||
| // In 8x8 mode, ptTile already has Set A data from _syncChr(), so we | ||
| // just return from ptTile directly. | ||
| // See FCEUX's mmc5_PPURead() which uses separate MMC5SPRVPage/MMC5BGVPage | ||
| // arrays instead of copying banks back and forth. | ||
| getSpritePatternTile(index) { | ||
| // In 8x8 mode, ptTile has the correct Set A data already | ||
| if (this.nes.ppu.f_spriteSize !== 1 || this.nes.rom.vromCount === 0) { | ||
| return this.nes.ppu.ptTile[index]; | ||
| } | ||
| // In 8x16 mode, look up the tile from Set A's VROM banks. | ||
| // The index maps to a slot in the 8KB pattern table space: | ||
| // index 0-255 → $0000-$0FFF, index 256-511 → $1000-$1FFF | ||
| let vromCount = this.nes.rom.vromCount; | ||
| let vromTile = this.nes.rom.vromTile; | ||
| switch (this.chrMode) { | ||
| case 0: { | ||
| // 8K mode: chrBankA[7] selects an 8K page (two 4K banks) | ||
| let bank4kStart = (this.chrBankA[7] & 0xff) * 2; | ||
| let half = index >= 256 ? 1 : 0; | ||
| let bank4k = (bank4kStart + half) % vromCount; | ||
| return vromTile[bank4k][index - half * 256]; | ||
| } | ||
| case 1: { | ||
| // 4K mode: chrBankA[3] → $0000, chrBankA[7] → $1000 | ||
| let bank4k; | ||
| if (index < 256) { | ||
| bank4k = (this.chrBankA[3] & 0xff) % vromCount; | ||
| } else { | ||
| bank4k = (this.chrBankA[7] & 0xff) % vromCount; | ||
| } | ||
| return vromTile[bank4k][index % 256]; | ||
| } | ||
| case 2: { | ||
| // 2K mode: chrBankA[1]/[3]/[5]/[7] select four 2K chunks (128 tiles each) | ||
| let regIndex = [1, 3, 5, 7]; | ||
| let slot = index >> 7; // 0-3 | ||
| let tileInSlot = index & 127; | ||
| let bank2k = this.chrBankA[regIndex[slot]] & 0x1ff; | ||
| let bank4k = Math.floor(bank2k / 2) % vromCount; | ||
| return vromTile[bank4k][((bank2k % 2) << 7) + tileInSlot]; | ||
| } | ||
| case 3: | ||
| default: { | ||
| // 1K mode: chrBankA[0-7] each select a 1K chunk (64 tiles each) | ||
| let slot = index >> 6; // 0-7 | ||
| let tileInSlot = index & 63; | ||
| let bank1k = this.chrBankA[slot] & 0x3ff; | ||
| let bank4k = Math.floor(bank1k / 4) % vromCount; | ||
| return vromTile[bank4k][((bank1k % 4) << 6) + tileInSlot]; | ||
| } | ||
| } | ||
| } | ||
| // ExRAM mode 1 (extended attributes): per-tile CHR bank and palette override. | ||
| // Each byte in ExRAM at $5C00-$5FFF corresponds to a nametable tile position: | ||
| // Bits 5-0: 4KB CHR bank number (combined with $5130 upper bits) | ||
| // Bits 7-6: Palette/attribute number for this tile | ||
| // This replaces both the normal CHR bank set B and the attribute table for | ||
| // background tiles, allowing each tile to independently select from up to | ||
| // 16,384 unique background tiles. Used by Castlevania III for detailed BGs. | ||
| // See https://www.nesdev.org/wiki/MMC5#Extended_RAM | ||
| getBgTileData(baseTile, tileIndex, ht, vt) { | ||
| if (this.exramMode !== 1 || this.nes.rom.vromCount === 0) return null; | ||
| // ExRAM byte for this nametable tile position | ||
| let exAddr = vt * 32 + ht; | ||
| let exByte = this.exram[exAddr]; | ||
| // Bits 5-0 select a 4KB CHR bank, combined with chrUpperBits ($5130) | ||
| // to form the full bank number: (upper << 6) | (exByte & 0x3F) | ||
| let chrBank4k = (exByte & 0x3f) | (this.chrUpperBits << 6); | ||
| let bank4k = chrBank4k % this.nes.rom.vromCount; | ||
| // Look up the pre-decoded tile from VROM. The tile index (0-255) from | ||
| // the nametable directly indexes into the selected 4KB bank. | ||
| let tile = this.nes.rom.vromTile[bank4k][tileIndex]; | ||
| if (!tile) return null; | ||
| // Bits 7-6 provide the attribute (palette number), replacing the | ||
| // normal attribute table. Shift left by 2 to match PPU palette format. | ||
| let attrib = ((exByte >> 6) & 0x03) << 2; | ||
| return { tile, attrib }; | ||
| } | ||
| // --- ROM Loading --- | ||
| loadROM() { | ||
| if (!this.nes.rom.valid) { | ||
| throw new Error("UNROM: Invalid ROM! Unable to load."); | ||
| throw new Error("MMC5: Invalid ROM! Unable to load."); | ||
| } | ||
| // Load PRG-ROM: | ||
| this.load8kRomBank(this.nes.rom.romCount * 2 - 1, 0x8000); | ||
| this.load8kRomBank(this.nes.rom.romCount * 2 - 1, 0xa000); | ||
| this.load8kRomBank(this.nes.rom.romCount * 2 - 1, 0xc000); | ||
| this.load8kRomBank(this.nes.rom.romCount * 2 - 1, 0xe000); | ||
| // Default PRG banking: last bank at $E000-$FFFF (mode 3 default) | ||
| this.prgBankReg[4] = 0xff; | ||
| this._syncPrg(); | ||
| // Load CHR-ROM: | ||
| // Load CHR-ROM if present | ||
| this.loadCHRROM(); | ||
| // Do Reset-Interrupt: | ||
| // Load Battery RAM (if present) | ||
| this.loadBatteryRam(); | ||
| // Initialize nametable mapping (default to vertical mirroring pattern) | ||
| this._syncNametables(); | ||
| // Reset interrupt | ||
| this.nes.cpu.requestIrq(this.nes.cpu.IRQ_RESET); | ||
| } | ||
| // --- Save State Support --- | ||
| toJSON() { | ||
| let s = super.toJSON(); | ||
| s.prgMode = this.prgMode; | ||
| s.prgBankReg = Array.from(this.prgBankReg); | ||
| s.prgRam = Array.from(this.prgRam); | ||
| s.prgRamProtectA = this.prgRamProtectA; | ||
| s.prgRamProtectB = this.prgRamProtectB; | ||
| s.chrMode = this.chrMode; | ||
| s.chrBankA = Array.from(this.chrBankA); | ||
| s.chrBankB = Array.from(this.chrBankB); | ||
| s.chrUpperBits = this.chrUpperBits; | ||
| s.lastChrWrite = this.lastChrWrite; | ||
| s.ntMapping = Array.from(this.ntMapping); | ||
| s.exramMode = this.exramMode; | ||
| s.exram = Array.from(this.exram); | ||
| s.fillTile = this.fillTile; | ||
| s.fillAttr = this.fillAttr; | ||
| s.irqTarget = this.irqTarget; | ||
| s.irqEnabled = this.irqEnabled; | ||
| s.irqPending = this.irqPending; | ||
| s.inFrame = this.inFrame; | ||
| s.irqCounter = this.irqCounter; | ||
| s.multA = this.multA; | ||
| s.multB = this.multB; | ||
| s.splitEnabled = this.splitEnabled; | ||
| s.splitRight = this.splitRight; | ||
| s.splitTile = this.splitTile; | ||
| s.splitScroll = this.splitScroll; | ||
| s.splitPage = this.splitPage; | ||
| s.pcmValue = this.pcmValue; | ||
| s.pcmReadMode = this.pcmReadMode; | ||
| s.pcmIrqEnabled = this.pcmIrqEnabled; | ||
| s.audioEnabled = this.audioEnabled; | ||
| s.pulse1 = Object.assign({}, this.pulse1); | ||
| s.pulse2 = Object.assign({}, this.pulse2); | ||
| return s; | ||
| } | ||
| fromJSON(s) { | ||
| super.fromJSON(s); | ||
| this.prgMode = s.prgMode; | ||
| this.prgBankReg = new Uint8Array(s.prgBankReg); | ||
| this.prgRam = new Uint8Array(s.prgRam); | ||
| this.prgRamProtectA = s.prgRamProtectA; | ||
| this.prgRamProtectB = s.prgRamProtectB; | ||
| this.chrMode = s.chrMode; | ||
| this.chrBankA = new Uint16Array(s.chrBankA); | ||
| this.chrBankB = new Uint16Array(s.chrBankB); | ||
| this.chrUpperBits = s.chrUpperBits; | ||
| this.lastChrWrite = s.lastChrWrite; | ||
| this.ntMapping = new Uint8Array(s.ntMapping); | ||
| this.exramMode = s.exramMode; | ||
| this.exram = new Uint8Array(s.exram); | ||
| this.fillTile = s.fillTile; | ||
| this.fillAttr = s.fillAttr; | ||
| this.irqTarget = s.irqTarget; | ||
| this.irqEnabled = s.irqEnabled; | ||
| this.irqPending = s.irqPending; | ||
| this.inFrame = s.inFrame; | ||
| this.irqCounter = s.irqCounter; | ||
| this.multA = s.multA; | ||
| this.multB = s.multB; | ||
| this.splitEnabled = s.splitEnabled; | ||
| this.splitRight = s.splitRight; | ||
| this.splitTile = s.splitTile; | ||
| this.splitScroll = s.splitScroll; | ||
| this.splitPage = s.splitPage; | ||
| this.pcmValue = s.pcmValue; | ||
| this.pcmReadMode = s.pcmReadMode; | ||
| this.pcmIrqEnabled = s.pcmIrqEnabled; | ||
| this.audioEnabled = s.audioEnabled; | ||
| if (s.pulse1) this.pulse1 = Object.assign(this._initPulse(), s.pulse1); | ||
| if (s.pulse2) this.pulse2 = Object.assign(this._initPulse(), s.pulse2); | ||
| // Re-sync banks after loading state | ||
| this._syncPrg(); | ||
| this._syncChr(); | ||
| this._syncNametables(); | ||
| } | ||
| } | ||
| export default Mapper5; |
+2
-0
| import { ButtonKey } from "./controller"; | ||
| import { GameGenie } from "./gamegenie"; | ||
@@ -23,2 +24,3 @@ export type ControllerId = 1 | 2; | ||
| constructor(opts: NESOptions); | ||
| gameGenie: GameGenie; | ||
| reset: () => void; | ||
@@ -25,0 +27,0 @@ frame: () => void; |
+9
-0
@@ -100,2 +100,3 @@ import CPU from "./cpu.js"; | ||
| cpu.cyclesToHalt -= chunk; | ||
| cpu._cpuCycleBase += chunk; | ||
@@ -185,2 +186,6 @@ if (ppu.frameEnded) { | ||
| papu: this.papu.toJSON(), | ||
| controllers: { | ||
| 1: this.controllers[1].toJSON(), | ||
| 2: this.controllers[2].toJSON(), | ||
| }, | ||
| }; | ||
@@ -196,2 +201,6 @@ } | ||
| this.papu.fromJSON(s.papu); | ||
| if (s.controllers) { | ||
| if (s.controllers[1]) this.controllers[1].fromJSON(s.controllers[1]); | ||
| if (s.controllers[2]) this.controllers[2].fromJSON(s.controllers[2]); | ||
| } | ||
| } | ||
@@ -198,0 +207,0 @@ } |
@@ -84,4 +84,8 @@ import { fromJSON, toJSON } from "../utils.js"; | ||
| } else if (address === 0x400f) { | ||
| // Length counter | ||
| this.lengthCounter = this.papu.getLengthMax(value & 248); | ||
| // Length counter — only loaded when the channel is enabled via $4015. | ||
| // Writing this register while disabled has no effect on the length counter. | ||
| // See https://www.nesdev.org/wiki/APU#Status_($4015) | ||
| if (this.isEnabled) { | ||
| this.lengthCounter = this.papu.getLengthMax(value & 248); | ||
| } | ||
| this.envReset = true; | ||
@@ -88,0 +92,0 @@ } |
@@ -95,3 +95,3 @@ import { fromJSON, toJSON } from "../utils.js"; | ||
| this.progTimerMax += this.progTimerMax >> this.sweepShiftAmount; | ||
| if (this.progTimerMax > 4095) { | ||
| if (this.progTimerMax > 0x7ff) { | ||
| this.progTimerMax = 4095; | ||
@@ -103,3 +103,3 @@ this.sweepCarry = true; | ||
| this.progTimerMax - | ||
| ((this.progTimerMax >> this.sweepShiftAmount) - | ||
| ((this.progTimerMax >> this.sweepShiftAmount) + | ||
| (this.sqr1 ? 1 : 0)); | ||
@@ -120,3 +120,3 @@ } | ||
| this.sweepMode === 0 && | ||
| this.progTimerMax + (this.progTimerMax >> this.sweepShiftAmount) > 4095 | ||
| this.progTimerMax + (this.progTimerMax >> this.sweepShiftAmount) > 0x7ff | ||
| ) { | ||
@@ -123,0 +123,0 @@ //if (this.sweepCarry) { |
@@ -72,3 +72,8 @@ import { fromJSON, toJSON } from "../utils.js"; | ||
| this.progTimerMax |= (value & 0x07) << 8; | ||
| this.lengthCounter = this.papu.getLengthMax(value & 0xf8); | ||
| // Length counter is only loaded when the channel is enabled via $4015. | ||
| // Writing this register while disabled has no effect on the length counter. | ||
| // See https://www.nesdev.org/wiki/APU#Status_($4015) | ||
| if (this.isEnabled) { | ||
| this.lengthCounter = this.papu.getLengthMax(value & 0xf8); | ||
| } | ||
| this.lcHalt = true; | ||
@@ -75,0 +80,0 @@ } |
+129
-42
@@ -13,5 +13,9 @@ import { fromJSON, toJSON } from "../utils.js"; | ||
| // sequence. On real hardware, the APU clock is half the CPU clock, so | ||
| // these correspond to APU cycles 3728.5, 7456.5, 11185.5, 14914.5 etc. | ||
| // these correspond to APU cycles 3728.5, 7456.5, 11185.5, 14914, 14914.5 etc. | ||
| // In 4-step mode, the IRQ flag is set 1 CPU cycle before the clock event | ||
| // (at 29828 vs 29829), so step 3 is split into two sub-steps. | ||
| // See https://www.nesdev.org/wiki/APU_Frame_Counter | ||
| const FRAME_STEPS_4 = [7457, 14913, 22371, 29829]; | ||
| const FRAME_STEPS_4 = [7457, 14913, 22371, 29828, 29829]; | ||
| // 5-step mode step 3 fires at 29829 per the nesdev wiki, not 29828. This is | ||
| // fine because fireFrameStep step 3 in 5-step mode is a no-op (no clock or IRQ). | ||
| const FRAME_STEPS_5 = [7457, 14913, 22371, 29829, 37281]; | ||
@@ -79,2 +83,15 @@ const FRAME_PERIOD_4 = 29830; // Total CPU cycles for 4-step sequence | ||
| this.frameIrqActive = false; | ||
| // Deferred clearing of the frame IRQ flag: on real hardware, reading | ||
| // $4015 doesn't clear bit 6 immediately. The clear takes effect at the | ||
| // next APU "get" cycle (the APU clock runs at half the CPU rate, so | ||
| // get/put phases alternate every CPU cycle). This matters when $4015 is | ||
| // read twice in quick succession (e.g., by the SLO RMW instruction's | ||
| // dummy read + actual read, which are 1 CPU cycle apart). Depending on | ||
| // the APU phase alignment, the second read may or may not still see | ||
| // the flag. See AccuracyCoin test 0x0467 subtests 6 and 7. | ||
| // https://www.nesdev.org/wiki/APU_Frame_Counter | ||
| this.frameIrqClearPending = false; | ||
| // APU cycle parity tracks the CPU cycle count modulo 2, determining | ||
| // which APU half-cycle phase we're on (get or put). | ||
| this.apuCycleParity = 0; | ||
| this.accCount = 0; | ||
@@ -109,7 +126,13 @@ this.smpSquare1 = 0; | ||
| // Reading $4015 clears the frame interrupt flag but NOT the DMC | ||
| // interrupt flag. The DMC flag is only cleared by writing $4015 or | ||
| // writing $4010 with bit 7 clear. | ||
| // Reading $4015 schedules the frame interrupt flag for clearing, but | ||
| // the actual clear is deferred to the next APU "get" cycle. This means | ||
| // if two reads happen 1 CPU cycle apart (e.g., dummy + actual read in | ||
| // an RMW instruction), the second read may still see the flag depending | ||
| // on APU clock phase alignment. The DMC interrupt flag is NOT cleared. | ||
| // Only schedule a clear when the flag is actually set; otherwise a stale | ||
| // pending clear could race with a future fireFrameStep that sets the flag. | ||
| // See https://www.nesdev.org/wiki/APU#Status_($4015) | ||
| this.frameIrqActive = false; | ||
| if (this.frameIrqActive) { | ||
| this.frameIrqClearPending = true; | ||
| } | ||
@@ -159,9 +182,15 @@ return tmp & 0xff; | ||
| // real hardware the reset is delayed after the write cycle. The delay | ||
| // depends on whether the CPU is on an odd or even cycle (3 or 4 cycles | ||
| // respectively). Since the emulator clocks the full STA instruction's | ||
| // cycles (4 for STA absolute) after writeReg, we compensate by starting | ||
| // the counter negative so it reaches 0 at the true reset point. | ||
| // Offset -6: after STA $4017 (4 cycles) → -2, after 2-cycle stall → 0. | ||
| // depends on the APU clock phase at the write: | ||
| // "get" phase (odd parity): reset after 3 CPU cycles | ||
| // "put" phase (even parity): reset after 4 CPU cycles | ||
| // Since the emulator clocks the full STA instruction's cycles (4 for | ||
| // STA absolute) after writeReg, we compensate by starting the counter | ||
| // negative so it reaches 0 at the true reset point. | ||
| // See https://www.nesdev.org/wiki/APU_Frame_Counter | ||
| this.frameCycleCounter = -6; | ||
| let cpu = this.nes.cpu; | ||
| let pendingCycles = cpu.instrBusCycles + 1 - cpu.apuCatchupCycles; | ||
| let writeParity = (this.apuCycleParity + pendingCycles) & 1; | ||
| // "get" phase (odd): -6 → after STA (4 cycles) → -2, after 2 cycles → 0 | ||
| // "put" phase (even): -7 → after STA (4 cycles) → -3, after 3 cycles → 0 | ||
| this.frameCycleCounter = -7 + writeParity; | ||
| this.frameStep = 0; | ||
@@ -174,2 +203,3 @@ | ||
| this.frameIrqActive = false; | ||
| this.frameIrqClearPending = false; | ||
| } else { | ||
@@ -211,2 +241,7 @@ // IRQ inhibit clear: enable frame IRQs (flag is not affected) | ||
| // Process deferred frame IRQ clear and update APU cycle parity for | ||
| // the remaining cycles not yet advanced by advanceFrameCounter. | ||
| this.processFrameIrqClear(frameCounterCycles); | ||
| this.apuCycleParity = (this.apuCycleParity + frameCounterCycles) & 1; | ||
| // Don't process channel ticks beyond next sampling: | ||
@@ -327,14 +362,3 @@ nCycles += this.extraCycles; | ||
| // Uses the uncapped cycle count to maintain accurate timing. | ||
| // See https://www.nesdev.org/wiki/APU_Frame_Counter | ||
| this.frameCycleCounter += frameCounterCycles; | ||
| let steps = this.countSequence === 0 ? FRAME_STEPS_4 : FRAME_STEPS_5; | ||
| let period = this.countSequence === 0 ? FRAME_PERIOD_4 : FRAME_PERIOD_5; | ||
| while (this.frameCycleCounter >= steps[this.frameStep]) { | ||
| this.fireFrameStep(this.frameStep); | ||
| this.frameStep++; | ||
| if (this.frameStep >= steps.length) { | ||
| this.frameStep = 0; | ||
| this.frameCycleCounter -= period; | ||
| } | ||
| } | ||
| this._advanceFrameSteps(frameCounterCycles); | ||
@@ -353,2 +377,19 @@ // Accumulate sample value: | ||
| // Process the deferred frame IRQ flag clear. On real hardware, reading | ||
| // $4015 schedules the clear for the next APU "get" cycle (which happens | ||
| // every 2 CPU cycles). If the current APU phase is "put" (parity 0), | ||
| // the next "get" is 1 cycle away. If "get" (parity 1), it's 2 cycles | ||
| // away. This must be called BEFORE updating apuCycleParity for the | ||
| // current advance, so it sees the parity at the start of the period. | ||
| // See https://www.nesdev.org/wiki/APU_Frame_Counter | ||
| processFrameIrqClear(nCycles) { | ||
| if (!this.frameIrqClearPending || nCycles <= 0) return; | ||
| // Determine how many CPU cycles until the next APU "get" boundary. | ||
| let cyclesToNextGet = (this.apuCycleParity & 1) === 0 ? 1 : 2; | ||
| if (nCycles >= cyclesToNextGet) { | ||
| this.frameIrqActive = false; | ||
| this.frameIrqClearPending = false; | ||
| } | ||
| } | ||
| // Advance only the frame counter steps without clocking channel timers, | ||
@@ -359,11 +400,45 @@ // DMC, or audio sampling. Used by CPU APU catch-up to update frame counter | ||
| advanceFrameCounter(nCycles) { | ||
| this.frameCycleCounter += nCycles; | ||
| this.processFrameIrqClear(nCycles); | ||
| this.apuCycleParity = (this.apuCycleParity + nCycles) & 1; | ||
| this._advanceFrameSteps(nCycles); | ||
| } | ||
| // Advance frame counter steps and handle period wrap. Shared by both | ||
| // clockFrameCounter (full APU tick) and advanceFrameCounter (catch-up only). | ||
| // The step loop and period wrap are separated: steps fire when the counter | ||
| // reaches each step's cycle position, and the period wrap only occurs when | ||
| // the counter reaches the full period length (not immediately after the | ||
| // last step). This matters because in 4-step mode, the last step fires at | ||
| // 29829 but the period wrap (and 3rd IRQ assertion) occurs at 29830. | ||
| // See https://www.nesdev.org/wiki/APU_Frame_Counter | ||
| _advanceFrameSteps(frameCounterCycles) { | ||
| this.frameCycleCounter += frameCounterCycles; | ||
| let steps = this.countSequence === 0 ? FRAME_STEPS_4 : FRAME_STEPS_5; | ||
| let period = this.countSequence === 0 ? FRAME_PERIOD_4 : FRAME_PERIOD_5; | ||
| while (this.frameCycleCounter >= steps[this.frameStep]) { | ||
| this.fireFrameStep(this.frameStep); | ||
| this.frameStep++; | ||
| if (this.frameStep >= steps.length) { | ||
| for (;;) { | ||
| if ( | ||
| this.frameStep < steps.length && | ||
| this.frameCycleCounter >= steps[this.frameStep] | ||
| ) { | ||
| this.fireFrameStep(this.frameStep); | ||
| this.frameStep++; | ||
| } else if ( | ||
| this.frameStep >= steps.length && | ||
| this.frameCycleCounter >= period | ||
| ) { | ||
| // Period wrap: reset the frame counter for the next sequence. | ||
| this.frameStep = 0; | ||
| this.frameCycleCounter -= period; | ||
| // In 4-step mode, the IRQ flag is asserted for 3 consecutive CPU | ||
| // cycles: at 29828 (step 3), 29829 (step 4), and 29830 (period wrap). | ||
| // On the 3rd cycle (period wrap), the flag is set only if the IRQ | ||
| // inhibit flag is clear. If inhibit is set, the flag is actively | ||
| // cleared (it was unconditionally set on cycles 29828-29829). | ||
| // See https://www.nesdev.org/wiki/APU_Frame_Counter | ||
| if (this.countSequence === 0) { | ||
| this.frameIrqActive = this.frameIrqEnabled; | ||
| this.frameIrqClearPending = false; | ||
| } | ||
| } else { | ||
| break; | ||
| } | ||
@@ -418,6 +493,11 @@ } | ||
| // Mode 0 (4-step): | ||
| // Step 0: quarter frame (envelope + linear counter) | ||
| // Step 1: half frame (quarter + length counter + sweep) | ||
| // Step 2: quarter frame | ||
| // Step 3: half frame + set frame IRQ flag | ||
| // Step 0 (7457): quarter frame (envelope + linear counter) | ||
| // Step 1 (14913): half frame (quarter + length counter + sweep) | ||
| // Step 2 (22371): quarter frame | ||
| // Step 3 (29828): set frame IRQ flag only (1 cycle before clock) | ||
| // Step 4 (29829): half frame + set frame IRQ flag | ||
| // On real hardware, the IRQ flag is asserted 1 CPU cycle before the | ||
| // clock event at the end of the 4-step sequence. This is why step 3 | ||
| // is split from step 4. | ||
| // See https://www.nesdev.org/wiki/APU_Frame_Counter | ||
| switch (step) { | ||
@@ -435,13 +515,18 @@ case 0: | ||
| case 3: | ||
| // IRQ flag is UNCONDITIONALLY set 1 CPU cycle before the half-frame | ||
| // clock, regardless of the IRQ inhibit flag ($4017 bit 6). On real | ||
| // hardware, the flag is driven high by the frame counter output for | ||
| // cycles 29828-29829 even when inhibit is set; only the period wrap | ||
| // at 29830 respects the inhibit flag. Cancel any pending deferred | ||
| // clear since the flag is being re-asserted by hardware. | ||
| // See AccuracyCoin tests I-L. | ||
| this.frameIrqActive = true; | ||
| this.frameIrqClearPending = false; | ||
| break; | ||
| case 4: | ||
| this.clockQuarterFrame(); | ||
| this.clockHalfFrame(); | ||
| // Set the frame interrupt flag in step 4 of 4-step mode, but only | ||
| // when IRQ inhibit is clear ($4017 bit 6 = 0). The nesdev wiki says: | ||
| // "If the interrupt inhibit flag is clear, the frame interrupt flag | ||
| // is set." Writing $4017 with bit 6 set prevents the flag from ever | ||
| // being set, not just from firing the IRQ. | ||
| // See https://www.nesdev.org/wiki/APU_Frame_Counter | ||
| if (this.frameIrqEnabled) { | ||
| this.frameIrqActive = true; | ||
| } | ||
| // IRQ flag continues to be unconditionally asserted on this cycle. | ||
| this.frameIrqActive = true; | ||
| this.frameIrqClearPending = false; | ||
| break; | ||
@@ -780,2 +865,4 @@ } | ||
| "frameIrqActive", | ||
| "frameIrqClearPending", | ||
| "apuCycleParity", | ||
| "startedPlaying", | ||
@@ -782,0 +869,0 @@ "recordOutput", |
@@ -32,4 +32,9 @@ class PaletteTable { | ||
| // NTSC emphasis bits from $2001: | ||
| // Bit 5 (emph & 1): Emphasize Red → darken Green + Blue | ||
| // Bit 6 (emph & 2): Emphasize Green → darken Red + Blue | ||
| // Bit 7 (emph & 4): Emphasize Blue → darken Red + Green | ||
| // See https://www.nesdev.org/wiki/PPU_registers#Color_emphasis | ||
| if ((emph & 1) !== 0) { | ||
| rFactor = 0.75; | ||
| gFactor = 0.75; | ||
| bFactor = 0.75; | ||
@@ -39,7 +44,7 @@ } | ||
| rFactor = 0.75; | ||
| gFactor = 0.75; | ||
| bFactor = 0.75; | ||
| } | ||
| if ((emph & 4) !== 0) { | ||
| rFactor = 0.75; | ||
| gFactor = 0.75; | ||
| bFactor = 0.75; | ||
| } | ||
@@ -46,0 +51,0 @@ |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Network access
Supply chain riskThis module accesses the network.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
2026719
25.83%56
1.82%22077
18.6%248
0.4%65
96.97%46
15%