gb: double speed on GBC

This commit is contained in:
Steven Hugg committed 2026-09-24 17:07:49 +02:00
1 parent 20e9827210
commit 28e268ea05
3 files changed
+124 -45

No files matched your search

+7 -19
View File
@@ -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++;
}
+28 -26
View File
@@ -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;
+89
View File
@@ -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");
});
});