From 28e268ea05878c2a60d35929a9ed8a68b9bf214a Mon Sep 17 00:00:00 2001 From: Steven Hugg Date: Thu, 24 Sep 2026 17:07:49 +0200 Subject: [PATCH] gb: double speed on GBC --- presets/gb/gbc.c | 26 +++--------- src/machine/gb.ts | 54 ++++++++++++------------ test/unit/testgbdouble.ts | 89 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 124 insertions(+), 45 deletions(-) create mode 100644 test/unit/testgbdouble.ts diff --git a/presets/gb/gbc.c b/presets/gb/gbc.c index b8318b23..169d6794 100644 --- a/presets/gb/gbc.c +++ b/presets/gb/gbc.c @@ -5,8 +5,7 @@ A CGB has 8 background and 8 sprite palettes of 4 colors each, chosen from a 15-bit RGB palette, plus a double-speed CPU mode. This demo checks _cpu to see which machine it is running on. On a CGB it: -- pulses one background palette entry smoothly with - set_bkg_palette_entry(), +- animates a background palette entry with set_bkg_palette_entry(), - toggles between vivid color and the DMG-compatible grayscale palettes with cgb_compatibility() (press A), - switches the CPU between normal and double speed with cpu_slow() and @@ -27,7 +26,7 @@ On a monochrome Game Boy it falls back to the four-shade DMG palettes. #include "gb/cgb.h" #include "gbtext.h" -// 2x2 checkerboard tile (colors 1 and 2) +// checkerboard tile (colors 1 and 2) static const uint8_t checker_tile[] = { /*;;{w:8,h:8,bpp:1,count:1,brev:1,np:2,pofs:1,sl:2};;*/ 0xCC,0xCC,0x33,0x33,0xCC,0xCC,0x33,0x33, @@ -35,22 +34,11 @@ static const uint8_t checker_tile[] = { /*;;*/ }; -// background palette. color 1 is replaced each frame by pulse(). +// vivid background palette, used for CGB palette 0 palette_color_t colors[4] = { - RGB_BLACK, RGB_RED, RGB_DARKBLUE, RGB_WHITE + RGB_BLACK, RGB_RED, RGB_BLUE, RGB_CYAN }; -// smooth pastel rainbow: three triangle waves 120 degrees apart -static uint16_t pulse(uint8_t t) { - uint8_t r = (t + 0) & 63; - uint8_t g = (t + 21) & 63; - uint8_t b = (t + 42) & 63; - if (r >= 32) r = 63 - r; - if (g >= 32) g = 63 - g; - if (b >= 32) b = 63 - b; - return RGB(r, g, b); -} - volatile uint8_t vbl_flag; void vbl_handler(void) NONBANKED { vbl_flag++; } @@ -126,9 +114,9 @@ void main(void) { } put_str(7, 7, buf); - // pulse palette entry 1 (CGB only) - if (cgb_mode && color_mode) - set_bkg_palette_entry(0, 1, pulse(frame)); + // pulse one palette entry slowly (CGB only) + if (cgb_mode && color_mode && (frame & 7) == 0) + set_bkg_palette_entry(0, 1, colors[(frame >> 3) & 3]); frame++; } diff --git a/src/machine/gb.ts b/src/machine/gb.ts index 4fbb7c11..84339380 100644 --- a/src/machine/gb.ts +++ b/src/machine/gb.ts @@ -1172,31 +1172,22 @@ export class GameBoyMachine extends BasicScanlineMachine { // CGB speed switch (executed during STOP instruction) // - // TODO: double speed is only partially implemented. performSpeedSwitch() - // toggles the flag, but nothing calls it and advanceCPU() does not account - // for the faster CPU. Plan for fixing: - // 1. Hook STOP: in advanceCPU(), snapshot the PC before advanceInsn() and - // call performSpeedSwitch() when the executed opcode is 0x10. - // 2. Decouple CPU from hardware cycles: at double speed one CPU T-cycle is - // half a hardware T-cycle. Accumulate the fractional remainder (the CPU - // clock is 8 MHz vs the 4 MHz PPU/timer/APU) and pass only the hardware - // T-cycles to updatePPUMode(), the timer and the APU, while returning - // those hardware cycles so advanceFrame() fits ~2x instructions per - // scanline. Keep the full tCycles for probe.logClocks(). - // 3. The timer/APU paths currently take M-cycles; convert them to a - // T-cycle entry point (or pass the halved value) so they stay at 4 MHz. - // 4. Once done, presets/gb/gbc.c's WORK counter should roughly double when - // SPEED is DOUBLE, and DIV/timer rates should be unchanged. + // Double speed runs the CPU at 8 MHz while the PPU/timer/APU stay at + // 4 MHz. SM83 returns whole M-cycles per instruction, so an instruction + // that takes n M-cycles consumes n*4 CPU T-cycles normally, but only + // n*2 hardware T-cycles in double speed. Both are integers, so no + // fractional accumulator is required: advanceCPU() returns the hardware + // (PPU) T-cycles to the scanline loop while clocking the PPU/timer/APU + // with that same hardware count. The frame loop then fits ~2x the + // instructions per scanline. performSpeedSwitch(): void { if (this.cgbMode && this.speedSwitchArmed) { this.doubleSpeed = !this.doubleSpeed; this.speedSwitchArmed = false; - // In double speed, CPU runs at 8 MHz but PPU stays at normal speed - // We handle this by doubling T-cycles returned from advanceCPU } } - // Timer update — called per CPU cycle (M-cycle) + // Timer update — hardware T-cycles updateTimer(cycles: number): void { // DIV increments at 16384 Hz = every 256 T-cycles = 64 M-cycles // But we count in M-cycles (4 T-cycles each) @@ -1246,16 +1237,28 @@ export class GameBoyMachine extends BasicScanlineMachine { var oldFlags = this.cpu.interruptFlags; var c = this.cpu as any; var n = 1; - if (this.cpu.isStable()) { this.probe.logExecute(this.cpu.getPC(), this.cpu.getSP()); } - if (c.advanceInsn) { n = c.advanceInsn(1); } - var tCycles = n * 4; - this.probe.logClocks(tCycles); + var isStop = false; + if (this.cpu.isStable()) { + this.probe.logExecute(this.cpu.getPC(), this.cpu.getSP()); + // STOP (0x10) toggles speed if the switch was armed via KEY1 (FF4D) + if (this.cgbMode && this.speedSwitchArmed && !this.cpu.halted && + this.read(this.cpu.getPC()) === 0x10) { + isStop = true; + } + } + n = c.advanceInsn(1); + var cpuCycles = n * 4; + this.probe.logClocks(cpuCycles); + // Hardware T-cycles: half of the CPU count in double speed. n is a + // whole M-cycle count, so n*2 stays an integer (no accumulator). + var tCycles = this.doubleSpeed ? n * 2 : cpuCycles; + if (isStop) this.performSpeedSwitch(); // Sync back any interrupt handling if (this.cpu.interruptFlags !== oldFlags) { this.syncInterruptsBack(); } // Update timer - this.updateTimerMCycles(n); + this.updateTimerCycles(tCycles); // Update PPU mode based on dot position within scanline this.updatePPUMode(tCycles); // Clock APU and generate audio samples @@ -1269,7 +1272,7 @@ export class GameBoyMachine extends BasicScanlineMachine { this.audio.feedSample(this.apu.getSample(), 1); } } - return tCycles; // return T-cycles to match cpuCyclesPerLine + return tCycles; // hardware T-cycles to match cpuCyclesPerLine } // Update PPU mode based on dot position within the current scanline @@ -1300,8 +1303,7 @@ export class GameBoyMachine extends BasicScanlineMachine { // Timer tracking with proper sub-cycle accuracy private timerSubCycles: number = 0; - updateTimerMCycles(mCycles: number): void { - var tCycles = mCycles * 4; + updateTimerCycles(tCycles: number): void { this.divCounter = (this.divCounter + tCycles) & 0xFFFF; if (!(this.tac & 0x04)) return; diff --git a/test/unit/testgbdouble.ts b/test/unit/testgbdouble.ts new file mode 100644 index 00000000..8e277af2 --- /dev/null +++ b/test/unit/testgbdouble.ts @@ -0,0 +1,89 @@ +import assert from "assert"; +import { describe, it } from "mocha"; +import { GameBoyMachine } from "../../src/machine/gb"; + +// CGB ROM that arms the speed switch (KEY1 = FF4D), executes STOP, then +// spins in a 3-M-cycle JR loop: +// 0100: 3E 01 LD A,$01 +// 0102: E0 4D LDH ($4D),A ; arm speed switch +// 0104: 10 00 STOP 0 +// 0106: 18 FE JR -2 +function makeCGBMachine(): GameBoyMachine { + const rom = new Uint8Array(0x8000); + rom[0x143] = 0x80; // CGB flag + rom[0x147] = 0x00; // ROM only + rom[0x100] = 0x3e; rom[0x101] = 0x01; + rom[0x102] = 0xe0; rom[0x103] = 0x4d; + rom[0x104] = 0x10; rom[0x105] = 0x00; + rom[0x106] = 0x18; rom[0x107] = 0xfe; + const m = new GameBoyMachine(); + m.loadROM(rom); + m.connectVideo(new Uint32Array(160 * 144)); + m.cpu.PC = 0x0100; + return m; +} + +// Run the prologue so the CPU sits in the idle loop with the requested speed. +function makeLoopingMachine(doubleSpeed: boolean): GameBoyMachine { + const m = makeCGBMachine(); + for (let i = 0; i < 3; i++) m.advanceCPU(); // LD, LDH, STOP + assert.strictEqual(m.cpu.PC, 0x0106, "should be in the JR loop"); + m.doubleSpeed = doubleSpeed; + return m; +} + +describe('Game Boy CGB double speed', function () { + + it('arms and toggles speed on STOP', function () { + const m = makeCGBMachine(); + assert.strictEqual(m.doubleSpeed, false); + m.advanceCPU(); // LD A,$01 + m.advanceCPU(); // LDH ($4D),A + assert.strictEqual(m.speedSwitchArmed, true); + m.advanceCPU(); // STOP + assert.strictEqual(m.doubleSpeed, true, "STOP should enter double speed"); + assert.strictEqual(m.speedSwitchArmed, false, "arm bit should clear"); + m.cpu.PC = 0x0106; + m.write(0xFF4D, 0x01); // re-arm + m.cpu.PC = 0x0104; // STOP again + m.advanceCPU(); + assert.strictEqual(m.doubleSpeed, false, "second STOP should return to normal"); + }); + + it('does not switch without a preceding KEY1 write', function () { + const m = makeCGBMachine(); + m.cpu.PC = 0x0104; // jump straight to STOP + m.advanceCPU(); + assert.strictEqual(m.doubleSpeed, false); + }); + + it('halves the hardware T-cycles per instruction', function () { + const normal = makeLoopingMachine(false); + assert.strictEqual(normal.advanceCPU(), 12, "JR takes 3 M-cycles = 12 T-cycles"); + const fast = makeLoopingMachine(true); + assert.strictEqual(fast.advanceCPU(), 6, "double speed halves hardware T-cycles"); + }); + + it('fits roughly twice the instructions into a frame', function () { + const normal = makeLoopingMachine(false); + const fast = makeLoopingMachine(true); + const normalSteps = normal.advanceFrame(null); + const fastSteps = fast.advanceFrame(null); + assert.ok(fastSteps > normalSteps * 1.9 && fastSteps < normalSteps * 2.1, + `expected ~2x steps, got ${normalSteps} vs ${fastSteps}`); + }); + + it('keeps DIV/timer at hardware rate', function () { + const run = (doubleSpeed: boolean) => { + const m = makeLoopingMachine(doubleSpeed); + m.tac = 0x05; // enable timer, 16 T-cycle period + m.advanceFrame(null); + return { div: m.divCounter, tima: m.tima }; + }; + const normal = run(false); + const fast = run(true); + // Same number of scanlines elapsed, so DIV should advance identically. + assert.strictEqual(fast.div, normal.div, "DIV must run at hardware rate"); + assert.strictEqual(fast.tima, normal.tima, "TIMA must run at hardware rate"); + }); +}); \ No newline at end of file