diff --git a/presets/gb/clock.c b/presets/gb/clock.c new file mode 100644 index 00000000..30591bc0 --- /dev/null +++ b/presets/gb/clock.c @@ -0,0 +1,90 @@ +/* +Digital clock for Game Boy, reading the MBC3 real-time clock. + +The cartridge header declares an MBC3 + timer + battery cart, so +the emulator runs a wall-clock RTC that starts at the current time. +Reading the clock is the same on real hardware: + +- write 0x08-0x0C to 0x4000 to select a register + (0x08 seconds, 0x09 minutes, 0x0A hours, 0x0B/0x0C days+flags) +- write 0x00 then 0x01 to 0x6000 to latch the live time +- read the latched value from 0xA000 + +The screen redraws only when the seconds change. +*/ + +//#link "gb/sfr.sgb" +//#link "gb/crt0.sgb" +//#resource "gb/global.sgb" +//#symbol ld GB_MAPPER=0x10 + +#include +#include "gb/types.h" +#include "gb/hardware.h" +#include "gb/gb.h" +#include "gbtext.h" + +// MBC3 registers +#define MBC_SELECT (*(volatile uint8_t*)0x4000) +#define MBC_LATCH (*(volatile uint8_t*)0x6000) +#define RTC_DATA (*(volatile uint8_t*)0xA000) + +// latch the live clock into the readable registers +static void rtc_latch(void) { + MBC_LATCH = 0x00; + MBC_LATCH = 0x01; +} + +// read a latched register (0x08-0x0C) +static uint8_t rtc_read(uint8_t reg) { + MBC_SELECT = reg; + return RTC_DATA; +} + +// write two decimal digits, zero-padded +static void put2(char* p, uint8_t v) { + p[0] = '0' + (v / 10) % 10; + p[1] = '0' + (v % 10); +} + +void main(void) { + char buf[9]; + uint8_t last_sec = 0xFF; + uint8_t sec, min, hour; + + buf[8] = '\0'; + + // enable cartridge RAM/RTC access + MBC_SELECT = 0x00; + *(volatile uint8_t*)0x0000 = 0x0A; + + DISPLAY_OFF; + BGP_REG = 0xE4; + OBP0_REG = OBP1_REG = 0xE4; + + font_init(); + put_str(6, 7, "MBC3 RTC"); + put_str(6, 9, "--:--:--"); + + SHOW_BKG; + DISPLAY_ON; + + while (1) { + wait_vbl_done(); + + rtc_latch(); + sec = rtc_read(0x08); + min = rtc_read(0x09); + hour = rtc_read(0x0A); + + if (sec != last_sec) { + last_sec = sec; + put2(buf + 0, hour); + buf[2] = ':'; + put2(buf + 3, min); + buf[5] = ':'; + put2(buf + 6, sec); + put_str(6, 9, buf); + } + } +} diff --git a/src/machine/gb.ts b/src/machine/gb.ts index 12c101da..29c22eae 100644 --- a/src/machine/gb.ts +++ b/src/machine/gb.ts @@ -754,8 +754,66 @@ export class GameBoyMachine extends BasicScanlineMachine { mbcMode: number = 0; // 0=ROM banking, 1=RAM banking romBankMask: number = 0x1F; + // MBC3 real-time clock (cart types 0x0F, 0x10) + hasRTC: boolean = false; + rtcRegisterSelect: number = -1; // -1 = none, else 0x08..0x0C + rtcLatchLast: number = 0; // last value written to 0x6000-0x7FFF + rtcSec: number = 0; + rtcMin: number = 0; + rtcHour: number = 0; + rtcDay: number = 0; // 9-bit day counter + rtcHalt: boolean = false; + rtcCarry: boolean = false; + rtcLatched = new Uint8Array(5); // snapshot returned to the CPU + rtcLastSync: number = 0; // host time (ms) at last sync + getKeyboardMap() { return GB_KEYCODE_MAP; } + // Host wall clock in ms; override in tests for deterministic time. + now(): number { return Date.now(); } + + // Advance the live RTC by elapsed host time (unless halted). + syncRTC(): void { + if (!this.hasRTC || this.rtcHalt) { this.rtcLastSync = this.now(); return; } + var elapsed = Math.floor((this.now() - this.rtcLastSync) / 1000); + if (elapsed <= 0) return; + this.rtcLastSync += elapsed * 1000; + var total = this.rtcSec + elapsed; + this.rtcSec = total % 60; + total = this.rtcMin + Math.floor(total / 60); + this.rtcMin = total % 60; + total = this.rtcHour + Math.floor(total / 60); + this.rtcHour = total % 24; + var days = this.rtcDay + Math.floor(total / 24); + if (days > 511) { this.rtcCarry = true; days &= 0x1FF; } + this.rtcDay = days; + } + + // Copy the live clock into the latched registers (0x6000-0x7FFF: 0 then 1). + latchRTC(): void { + this.syncRTC(); + this.rtcLatched[0] = this.rtcSec; + this.rtcLatched[1] = this.rtcMin; + this.rtcLatched[2] = this.rtcHour; + this.rtcLatched[3] = this.rtcDay & 0xFF; + this.rtcLatched[4] = ((this.rtcDay >> 8) & 0x01) | (this.rtcHalt ? 0x40 : 0) | (this.rtcCarry ? 0x80 : 0); + } + + // Power on the RTC at the current wall-clock time. + resetRTC(): void { + var d = new Date(this.now()); + this.rtcSec = d.getSeconds(); + this.rtcMin = d.getMinutes(); + this.rtcHour = d.getHours(); + this.rtcDay = 0; + this.rtcHalt = false; + this.rtcCarry = false; + this.rtcRegisterSelect = -1; + this.rtcLatchLast = 0; + this.rtcLastSync = this.now(); + this.latchRTC(); + } + constructor() { super(); this.handler = newKeyboardHandler(this.inputs, this.getKeyboardMap()); @@ -852,8 +910,13 @@ export class GameBoyMachine extends BasicScanlineMachine { } } else if (fullAddr < 0x6000) { if (this.mbcType === 3) { - // MBC3: RAM bank 0-3 (or RTC register select) - this.ramBank = v & 0x0F; + // MBC3: RTC register select (0x08-0x0C) or RAM bank (0x00-0x07) + if (this.hasRTC && v >= 0x08 && v <= 0x0C) { + this.rtcRegisterSelect = v; + } else { + this.rtcRegisterSelect = -1; + this.ramBank = v & 0x03; + } } else { // MBC1: RAM bank / upper ROM bank bits if (this.mbcMode === 0) { @@ -864,8 +927,13 @@ export class GameBoyMachine extends BasicScanlineMachine { } } } else { - // MBC1: banking mode select; MBC3: latch clock data (ignored) - if (this.mbcType === 1) this.mbcMode = v & 0x01; + // MBC1: banking mode select; MBC3: latch clock data on 0x00 then 0x01 + if (this.mbcType === 1) { + this.mbcMode = v & 0x01; + } else if (this.mbcType === 3 && this.rtcLatchLast === 0x00 && v === 0x01) { + this.latchRTC(); + } + this.rtcLatchLast = v; } } @@ -886,12 +954,17 @@ export class GameBoyMachine extends BasicScanlineMachine { } readExtRAM(a: number): number { + if (this.hasRTC && this.rtcRegisterSelect >= 0) { + this.syncRTC(); + return this.rtcLatched[this.rtcRegisterSelect - 0x08]; + } if (!this.ramEnabled && this.mbcType > 0) return 0xFF; var offset = a + (this.ramBank * 0x2000); return offset < this.extram.length ? this.extram[offset] : 0xFF; } writeExtRAM(a: number, v: number): void { + if (this.hasRTC && this.rtcRegisterSelect >= 0) return; // RTC registers are read-only if (!this.ramEnabled && this.mbcType > 0) return; var offset = a + (this.ramBank * 0x2000); if (offset < this.extram.length) this.extram[offset] = v; @@ -1518,6 +1591,7 @@ export class GameBoyMachine extends BasicScanlineMachine { case 0x19: case 0x1A: case 0x1B: case 0x1C: case 0x1D: case 0x1E: this.mbcType = 5; break; // MBC5 default: console.log(`Unhandled cartridge type 0x${cartType.toString(16)} @ 0x147, treating as MBC5`); this.mbcType = 5; break; } + this.hasRTC = cartType === 0x0F || cartType === 0x10; } else throw new EmuHalt("ROM not long enough for header"); // Detect CGB mode from header byte 0x143 @@ -1604,6 +1678,13 @@ export class GameBoyMachine extends BasicScanlineMachine { if (this.cgbMode) { this.cpu.A = 0x11; } + // Power on the RTC at the current wall-clock time (RTC carts only) + if (this.hasRTC) { + this.resetRTC(); + } else { + this.rtcRegisterSelect = -1; + this.rtcLatchLast = 0; + } } preFrame(): void { @@ -1638,6 +1719,13 @@ export class GameBoyMachine extends BasicScanlineMachine { }; state['apu'] = this.apu.saveState(); state['apuCycleAccum'] = this.apuCycleAccum; + state['rtc'] = { + hasRTC: this.hasRTC, + sec: this.rtcSec, min: this.rtcMin, hour: this.rtcHour, day: this.rtcDay, + halt: this.rtcHalt, carry: this.rtcCarry, + registerSelect: this.rtcRegisterSelect, latchLast: this.rtcLatchLast, + latched: this.rtcLatched.slice(0), lastSync: this.rtcLastSync, + }; // GBC state if (this.cgbMode) { state['cgb'] = { @@ -1677,6 +1765,16 @@ export class GameBoyMachine extends BasicScanlineMachine { this.timerSubCycles = io.timerSubCycles; if (state.apu) this.apu.loadState(state.apu); this.apuCycleAccum = state.apuCycleAccum || 0; + if (state.rtc) { + var rtc = state.rtc; + this.hasRTC = rtc.hasRTC; + this.rtcSec = rtc.sec; this.rtcMin = rtc.min; this.rtcHour = rtc.hour; this.rtcDay = rtc.day; + this.rtcHalt = rtc.halt; this.rtcCarry = rtc.carry; + this.rtcRegisterSelect = rtc.registerSelect; this.rtcLatchLast = rtc.latchLast; + if (rtc.latched) this.rtcLatched.set(rtc.latched); + // keep wall-clock semantics: elapsed time since the save still counts + this.rtcLastSync = rtc.lastSync; + } // GBC state if (state.cgb) { var cgb = state.cgb; @@ -1716,6 +1814,12 @@ export class GameBoyMachine extends BasicScanlineMachine { + "VBK " + this.vramBankSelect + " SVBK " + this.wramBankSelect + "\n" + "SPD " + (this.doubleSpeed ? "2x" : "1x") + "\n"; } + if (this.hasRTC) { + this.syncRTC(); + s += "--- RTC ---\n" + + hex(this.rtcHour, 2) + ":" + hex(this.rtcMin, 2) + ":" + hex(this.rtcSec, 2) + + " day " + this.rtcDay + (this.rtcHalt ? " HALT" : "") + (this.rtcCarry ? " CARRY" : "") + "\n"; + } return s; } } diff --git a/src/platform/gb.ts b/src/platform/gb.ts index 92ff6e50..5a46a9bb 100644 --- a/src/platform/gb.ts +++ b/src/platform/gb.ts @@ -16,6 +16,7 @@ const GB_PRESETS: Preset[] = [ { id: 'galaxy.c', name: 'Window + Sprites' }, { id: 'gbdecompress.c', name: 'Decompression' }, { id: 'music.c', name: 'Music Player' }, + { id: 'clock.c', name: 'MBC3 Real-Time Clock' }, { id: 'banking.c', name: 'ROM Bank Switching' }, { id: 'siegegame.c', name: 'Siege Game' }, { id: 'climber.c', name: 'Climber Game' }, diff --git a/test/unit/testgbrtc.ts b/test/unit/testgbrtc.ts new file mode 100644 index 00000000..c246159d --- /dev/null +++ b/test/unit/testgbrtc.ts @@ -0,0 +1,182 @@ +import assert from "assert"; +import { describe, it } from "mocha"; +import { GameBoyMachine } from "../../src/machine/gb"; + +// Build a minimal ROM with the given cartridge type and RAM size. +function makeROM(cartType: number, ramSize = 0x02): Uint8Array { + const rom = new Uint8Array(0x8000); + rom[0x143] = 0x00; // DMG mode + rom[0x147] = cartType; + rom[0x149] = ramSize; + return rom; +} + +function newMBC3RTC(): GameBoyMachine { + const m = new GameBoyMachine(); + let fakeNow = 1700000000000; // fixed epoch + m.now = () => fakeNow; + m.loadROM(makeROM(0x10)); // MBC3 + TIMER + RAM + BATTERY + return m; +} + +// Select an RTC register (0x08-0x0C) then latch (write 0x00, then 0x01). +function latch(m: GameBoyMachine, reg: number): number { + m.write(0x4000, reg); + m.write(0x6000, 0x00); + m.write(0x6000, 0x01); + return m.read(0xA000); +} + +describe('Game Boy MBC3 RTC', function () { + + it('detects RTC cartridges', function () { + const rtc = new GameBoyMachine(); + rtc.loadROM(makeROM(0x10)); + assert.strictEqual(rtc.hasRTC, true); + + const noTimer = new GameBoyMachine(); + noTimer.loadROM(makeROM(0x11)); // MBC3 without RTC + assert.strictEqual(noTimer.hasRTC, false); + + const mbc1 = new GameBoyMachine(); + mbc1.loadROM(makeROM(0x03)); + assert.strictEqual(mbc1.hasRTC, false); + }); + + it('powers on at the wall-clock time', function () { + const m = new GameBoyMachine(); + const fake = new Date(2024, 0, 15, 9, 30, 45).getTime(); + m.now = () => fake; + m.loadROM(makeROM(0x10)); + assert.strictEqual(m.rtcHour, 9); + assert.strictEqual(m.rtcMin, 30); + assert.strictEqual(m.rtcSec, 45); + // latched snapshot matches the live clock at boot + assert.strictEqual(m.rtcLatched[0], 45); + assert.strictEqual(m.rtcLatched[1], 30); + assert.strictEqual(m.rtcLatched[2], 9); + }); + + it('advances by elapsed wall-clock seconds', function () { + const m = newMBC3RTC(); + let fake = m.now(); + // pin a known time (set through the machine, then reload the base) + m.rtcSec = 57; m.rtcMin = 58; m.rtcHour = 0; m.rtcDay = 0; + m.rtcHalt = false; m.rtcCarry = false; + m.rtcLastSync = fake; + + fake += 5 * 1000; + m.now = () => fake; + m.syncRTC(); + assert.strictEqual(m.rtcSec, 2); + assert.strictEqual(m.rtcMin, 59); + assert.strictEqual(m.rtcHour, 0); + assert.strictEqual(m.rtcDay, 0); + }); + + it('rolls over hours into days', function () { + const m = newMBC3RTC(); + const fake = m.now(); + m.rtcSec = 0; m.rtcMin = 0; m.rtcHour = 23; m.rtcDay = 0; + m.rtcLastSync = fake; + m.now = () => fake + 3600 * 1000; + m.syncRTC(); + assert.strictEqual(m.rtcHour, 0); + assert.strictEqual(m.rtcDay, 1); + }); + + it('wraps the 9-bit day counter and sets carry', function () { + const m = newMBC3RTC(); + const fake = m.now(); + m.rtcSec = 0; m.rtcMin = 0; m.rtcHour = 0; m.rtcDay = 511; + m.rtcCarry = false; + m.rtcLastSync = fake; + m.now = () => fake + 24 * 3600 * 1000; + m.syncRTC(); + assert.strictEqual(m.rtcDay, 0); + assert.strictEqual(m.rtcCarry, true); + }); + + it('freezes while the halt bit is set', function () { + const m = newMBC3RTC(); + const fake = m.now(); + m.rtcSec = 10; m.rtcMin = 0; m.rtcHour = 0; m.rtcDay = 0; + m.rtcHalt = true; + m.rtcLastSync = fake; + m.now = () => fake + 100 * 1000; + m.syncRTC(); + assert.strictEqual(m.rtcSec, 10); + assert.strictEqual(m.rtcMin, 0); + }); + + it('returns latched registers at A000-BFFF', function () { + const m = newMBC3RTC(); + const fixed = new Date(2024, 0, 15, 1, 2, 3).getTime(); + m.now = () => fixed; + m.loadROM(makeROM(0x10)); // re-init to pick up the new clock + assert.strictEqual(latch(m, 0x08), 3); // seconds + assert.strictEqual(latch(m, 0x09), 2); // minutes + assert.strictEqual(latch(m, 0x0A), 1); // hours + assert.strictEqual(latch(m, 0x0B), 0); // day low + assert.strictEqual(latch(m, 0x0C), 0); // day high / flags + }); + + it('reflects elapsed time across a re-latch', function () { + const m = newMBC3RTC(); + const fake = m.now(); + m.rtcSec = 50; m.rtcMin = 0; m.rtcHour = 0; m.rtcDay = 0; + m.rtcLastSync = fake; + assert.strictEqual(latch(m, 0x08), 50); + assert.strictEqual(latch(m, 0x09), 0); + + m.now = () => fake + 15 * 1000; + assert.strictEqual(latch(m, 0x08), 5); + assert.strictEqual(latch(m, 0x09), 1); + }); + + it('ignores writes to RTC registers', function () { + const m = newMBC3RTC(); + const fixed = new Date(2024, 0, 15, 1, 2, 3).getTime(); + m.now = () => fixed; + m.loadROM(makeROM(0x10)); + m.write(0x4000, 0x08); // select seconds + m.write(0x6000, 0x00); // latch + m.write(0x6000, 0x01); + assert.strictEqual(m.read(0xA000), 3); + m.write(0xA000, 0xAA); // read-only + assert.strictEqual(m.read(0xA000), 3); + }); + + it('does not treat RTC select on non-RTC carts', function () { + const m = new GameBoyMachine(); + m.loadROM(makeROM(0x11)); + m.write(0x4000, 0x08); + assert.strictEqual(m.rtcRegisterSelect, -1); + // normal external RAM path still works + m.write(0x0000, 0x0A); // enable RAM + m.write(0xA000, 0x42); + assert.strictEqual(m.read(0xA000), 0x42); + }); + + it('round-trips RTC state through save/load', function () { + const m = newMBC3RTC(); + const fake = m.now(); + m.rtcSec = 11; m.rtcMin = 22; m.rtcHour = 3; m.rtcDay = 4; + m.rtcHalt = true; m.rtcCarry = true; + m.rtcLastSync = fake; + m.latchRTC(); + const state = m.saveState(); + + const m2 = new GameBoyMachine(); + m2.loadState(state); + assert.strictEqual(m2.hasRTC, true); + assert.strictEqual(m2.rtcSec, 11); + assert.strictEqual(m2.rtcMin, 22); + assert.strictEqual(m2.rtcHour, 3); + assert.strictEqual(m2.rtcDay, 4); + assert.strictEqual(m2.rtcHalt, true); + assert.strictEqual(m2.rtcCarry, true); + assert.deepStrictEqual(Array.from(m2.rtcLatched), Array.from(m.rtcLatched)); + assert.strictEqual(m2.rtcLastSync, fake); + }); +});