gb: added RTC

This commit is contained in:
Steven Hugg committed 2026-09-24 15:16:10 +02:00
1 parent 937f6dbf84
commit 602e56db58
4 files changed
+381 -4

No files matched your search

+90
View File
@@ -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 <stdint.h>
#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);
}
}
}
+108 -4
View File
@@ -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;
}
}
+1
View File
@@ -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' },
+182
View File
@@ -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);
});
});