feat(cartridge): implement RTC calendar transfers and clock updates

This commit is contained in:
0xrsydn 2026-10-07 05:55:31 +07:00
commit fce222eea2
22 changed files with 1227 additions and 196 deletions

View file

@ -1,5 +1,6 @@
//! Host-only raw ROM loading, execution modes, and diagnostics.
pub(crate) mod rtc_clock;
pub mod suite;
pub mod window;
@ -13,7 +14,7 @@ use std::{
use gba_core::{
bios,
cartridge::CartridgeHardware,
cartridge::{CartridgeHardware, RtcError},
machine::{Machine, MachineError, StepKind},
memory::ROM_CAPACITY,
};
@ -176,9 +177,23 @@ struct Report {
reason: Reason,
}
#[cfg(test)]
fn run_bounded(machine: &mut Machine, limit: u64) -> Report {
run_bounded_with_clock(machine, limit, None).unwrap()
}
fn run_bounded_with_clock(
machine: &mut Machine,
limit: u64,
mut clock: Option<&mut rtc_clock::RtcHostClock>,
) -> Result<Report, RtcError> {
let mut stats = Stats::default();
let reason = loop {
if stats.steps % 4096 == 0 {
if let Some(clock) = clock.as_deref_mut() {
clock.sync(machine.memory_mut())?;
}
}
// STOP has no host input source here. Do not spin with a frozen clock.
// Check before the budget so STOP on the last step is reported correctly.
if machine.stopped() {
@ -193,7 +208,10 @@ fn run_bounded(machine: &mut Machine, limit: u64) -> Report {
Err(error) => break Reason::Diagnostic(error),
}
};
Report { stats, reason }
if let Some(clock) = clock {
clock.sync(machine.memory_mut())?;
}
Ok(Report { stats, reason })
}
fn write_report(writer: &mut impl Write, machine: &Machine, report: &Report) -> io::Result<()> {
@ -244,7 +262,7 @@ pub fn execute(options: Options, writer: &mut impl Write) -> Result<(), Box<dyn
"Original BIOS replacement; no commercial-game compatibility claim."
)?;
if options.hardware == CartridgeHardware::Rtc {
writeln!(writer, "RTC selected: GPIO and command/control only; calendar and IRQ commands remain diagnostic.")?;
writeln!(writer, "RTC selected: UTC at startup, then host elapsed time; no persistence or RTC interrupts.")?;
}
let steps = match options.mode {
Mode::Terminal { steps } => steps,
@ -257,7 +275,12 @@ pub fn execute(options: Options, writer: &mut impl Write) -> Result<(), Box<dyn
machine
.memory_mut()
.set_cartridge_hardware(options.hardware);
let report = run_bounded(&mut machine, steps);
let mut clock = if options.hardware == CartridgeHardware::Rtc {
Some(rtc_clock::RtcHostClock::new(machine.memory_mut())?)
} else {
None
};
let report = run_bounded_with_clock(&mut machine, steps, clock.as_mut())?;
write_report(writer, &machine, &report)?;
writer.flush()?;
if let Reason::Diagnostic(error) = report.reason {

View file

@ -0,0 +1,122 @@
//! Host-only clock adapter. UTC is sampled once; later changes use monotonic elapsed seconds.
use gba_core::{
cartridge::{RtcDateTime, RtcError},
memory::Memory,
};
use std::{
io,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
const UNIX_2000: u64 = 946_684_800;
pub(crate) struct RtcHostClock {
started: Instant,
supplied: u64,
}
impl RtcHostClock {
pub(crate) fn new(memory: &mut Memory) -> io::Result<Self> {
let unix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(io::Error::other)?
.as_secs();
Self::from_unix_seconds(memory, unix).map_err(io::Error::other)
}
fn from_unix_seconds(memory: &mut Memory, unix: u64) -> Result<Self, RtcError> {
let seconds = unix
.checked_sub(UNIX_2000)
.ok_or(RtcError::InvalidDateTime)?;
memory.set_rtc_datetime(RtcDateTime::from_seconds_since_2000(seconds)?)?;
Ok(Self {
started: Instant::now(),
supplied: 0,
})
}
pub(crate) fn sync(&mut self, memory: &mut Memory) -> Result<(), RtcError> {
self.supply(memory, self.started.elapsed())
}
fn supply(&mut self, memory: &mut Memory, elapsed: Duration) -> Result<(), RtcError> {
let seconds = elapsed.as_secs();
// Ignore a backwards test/source sample without moving the high-water mark.
if seconds > self.supplied {
memory.advance_rtc_seconds(seconds - self.supplied)?;
self.supplied = seconds;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use gba_core::{cartridge::CartridgeHardware, io::HALTCNT};
fn memory() -> Memory {
let mut memory = Memory::new(vec![0; 4]).unwrap();
memory.set_cartridge_hardware(CartridgeHardware::Rtc);
memory
}
#[test]
fn epoch_seed_is_utc_and_range_checked_without_changing_state_on_error() {
let mut memory = memory();
RtcHostClock::from_unix_seconds(&mut memory, UNIX_2000).unwrap();
assert_eq!(
memory.rtc_datetime().unwrap().components(),
[0, 1, 1, 6, 0, 0, 0]
);
for invalid in [UNIX_2000 - 1, UNIX_2000 + 3_155_760_000, u64::MAX] {
assert!(RtcHostClock::from_unix_seconds(&mut memory, invalid).is_err());
assert_eq!(
memory.rtc_datetime().unwrap().components(),
[0, 1, 1, 6, 0, 0, 0]
);
}
}
#[test]
fn elapsed_seconds_are_supplied_once_preserve_guest_time_and_continue_in_stop() {
let mut memory = memory();
let mut clock = RtcHostClock::from_unix_seconds(&mut memory, UNIX_2000).unwrap();
for ms in [999, 1000, 1000, 700, 1999, 2000] {
clock
.supply(&mut memory, Duration::from_millis(ms))
.unwrap();
}
assert_eq!(
memory.rtc_datetime().unwrap().components(),
[0, 1, 1, 6, 0, 0, 2]
);
memory
.set_rtc_datetime(RtcDateTime::new([24, 2, 29, 3, 23, 59, 59]).unwrap())
.unwrap();
memory.write8(HALTCNT, 128).unwrap();
clock.supply(&mut memory, Duration::from_secs(3)).unwrap();
assert_eq!(
memory.rtc_datetime().unwrap().components(),
[24, 3, 1, 4, 0, 0, 0]
);
assert!(memory.stopped());
assert_eq!(memory.cycles(), 0);
}
#[test]
fn failed_tick_does_not_lose_elapsed_seconds() {
let mut memory = memory();
let mut clock = RtcHostClock::from_unix_seconds(&mut memory, UNIX_2000).unwrap();
memory.set_cartridge_hardware(CartridgeHardware::None);
assert_eq!(
clock.supply(&mut memory, Duration::from_secs(5)),
Err(RtcError::NotAttached)
);
memory.set_cartridge_hardware(CartridgeHardware::Rtc);
clock.supply(&mut memory, Duration::from_secs(5)).unwrap();
assert_eq!(
memory.rtc_datetime().unwrap().components(),
[0, 1, 1, 0, 0, 0, 5]
);
}
}

View file

@ -50,6 +50,11 @@ impl Session {
self.machine.memory_mut().set_cartridge_hardware(hardware);
}
/// Host adapters may supply independent cartridge time between execution slices.
pub fn memory_mut(&mut self) -> &mut gba_core::memory::Memory {
self.machine.memory_mut()
}
pub fn frame(&self) -> &Framebuffer {
&self.frame
}

View file

@ -13,6 +13,13 @@ pub fn run(
) -> Result<(), Box<dyn Error>> {
let mut session = Session::new(bytes)?;
session.set_cartridge_hardware(hardware);
let mut rtc_clock = if hardware == gba_core::cartridge::CartridgeHardware::Rtc {
Some(crate::cartridge::rtc_clock::RtcHostClock::new(
session.memory_mut(),
)?)
} else {
None
};
writeln!(
writer,
"ROM window: Arrows=D-pad; Z/X=A/B; Q/W=L/R; Enter=Start; Backspace=Select; Escape exits."
@ -38,6 +45,9 @@ pub fn run(
// Show a black buffer and pump initial events before boot starts.
window.update_with_buffer(session.frame().pixels(), WIDTH, HEIGHT)?;
while window.is_open() && !window.is_key_down(Key::Escape) {
if let Some(clock) = rtc_clock.as_mut() {
clock.sync(session.memory_mut())?;
}
let buttons = read_buttons(window.is_active(), |key| window.is_key_down(key));
let update = session.update(buttons)?;
let stopped = update == Update::Stopped;

View file

@ -125,7 +125,7 @@ fn run() -> Result<(), Box<dyn Error>> {
--rom PATH --steps COUNT Run a raw ROM in the terminal (1..=100000000 steps)\n\
--rom PATH --window Open a raw ROM with keyboard input\n\
--frames COUNT Exit ROM window after 1..=100000 captured frames\n\
--rtc Select RTC GPIO/control subset for --rom (no calendar yet)\n\
--rtc Select cartridge RTC (UTC startup, host elapsed time)\n\
--test-suite PATH.json Run bounded ROM assertions; write a JSON report\n\
--cpu-demo Run the terminal-only ARM/Thumb instruction demo\n\
--timer-demo Run the timer IRQ demo with nominal cycle costs\n\

View file

@ -84,6 +84,64 @@ fn rtc_setup() -> Vec<u32> {
]
}
fn rtc_calendar_setup() -> Vec<u32> {
// Original unrolled ARM GPIO driver. Write 2024-02-29 12:34:56, then read the hour.
// Keep executable instructions past the GPIO overlay at ROM offsets c4..c9.
let mut code = vec![0xe1a00000; 64];
code[0] = 0xea00003e; // B 0x08000100
code.extend([0xe59f1000, 0xea000000, 0x080000c4]); // LDR r1; skip literal.
fn store(code: &mut Vec<u32>, value: u32, offset: u32) {
code.extend([0xe3a00000 | value, 0xe1c100b0 | offset]);
}
fn send(code: &mut Vec<u32>, byte: u8, command: bool) {
for bit in 0..8 {
let shift = if command { 7 - bit } else { bit };
let pins = 4 | (u32::from((byte >> shift) & 1) << 1);
store(code, pins, 0);
store(code, pins | 1, 0);
}
}
store(&mut code, 1, 4); // Read enable.
store(&mut code, 1, 0);
store(&mut code, 7, 2);
store(&mut code, 5, 0);
send(&mut code, 0x64, true);
for byte in [0x24, 2, 0x29, 4, 0x12, 0x34, 0x56] {
send(&mut code, byte, false);
}
store(&mut code, 1, 0); // End write and begin time read.
store(&mut code, 5, 0);
send(&mut code, 0x67, true);
store(&mut code, 5, 2);
code.push(0xe3a02000); // MOV r2,#0
for bit in 0..8 {
store(&mut code, 4, 0);
store(&mut code, 5, 0);
code.extend([0xe1d130b0, 0xe1a030a3, 0xe2033001, 0xe1822003 | (bit << 7)]);
}
store(&mut code, 1, 0); // Aborting the remaining read bytes preserves calendar state.
code.push(0xeafffffe);
code
}
#[test]
fn rtc_calendar_commands_run_through_original_arm_code_with_host_clock_enabled() {
let fixture = Fixture::new();
let path = fixture.rom(&rtc_calendar_setup());
let output = command()
.arg("--rom")
.arg(&path)
.args(["--rtc", "--steps", "5000"])
.output()
.unwrap();
assert!(output.status.success(), "{}", stderr(&output));
assert!(
stdout(&output).contains("r2=0x00000092"),
"{}",
stdout(&output)
);
}
#[test]
fn rtc_selection_is_explicit_and_does_not_modify_the_rom_file() {
let fixture = Fixture::new();
@ -100,7 +158,7 @@ fn rtc_selection_is_explicit_and_does_not_modify_the_rom_file() {
.output()
.unwrap();
assert!(selected.status.success(), "{}", stderr(&selected));
assert!(stdout(&selected).contains("RTC selected: GPIO and command/control only"));
assert!(stdout(&selected).contains("RTC selected: UTC at startup"));
assert!(stdout(&selected).contains("r2=0x00000001"));
assert_eq!(fs::read(&path).unwrap(), original);
let duplicate = command()
@ -298,6 +356,15 @@ fn native_rom_window_presents_file_bytes_and_reports_stop_cpu_and_video_errors()
assert!(rtc_output.status.success(), "{}", stderr(&rtc_output));
assert!(stdout(&rtc_output).contains("Captured ROM frames: 3"));
assert!(stdout(&rtc_output).contains("r2=0x00000001"));
let calendar_path = fixture.rom(&rtc_calendar_setup());
let calendar_output = window(&calendar_path, true);
assert!(
calendar_output.status.success(),
"{}",
stderr(&calendar_output)
);
assert!(stdout(&calendar_output).contains("Captured ROM frames: 3"));
assert!(stdout(&calendar_output).contains("r2=0x00000092"));
for (code, error) in [
(vec![0xef03_0000, 0xeaff_fffe], "entered STOP"),
(vec![0xee00_0000], "instruction"),

View file

@ -2,6 +2,11 @@
use crate::memory::MemoryError;
mod calendar;
mod rtc;
pub use calendar::{RtcDateTime, RtcError};
use rtc::Rtc;
pub const GPIO_DATA: u32 = 0x0800_00c4;
pub const GPIO_DIRECTION: u32 = 0x0800_00c6;
pub const GPIO_CONTROL: u32 = 0x0800_00c8;
@ -11,7 +16,7 @@ pub const GPIO_CONTROL: u32 = 0x0800_00c8;
pub enum CartridgeHardware {
#[default]
None,
/// GPIO and RTC command/control only. Calendar and interrupt commands remain diagnostic.
/// GPIO and RTC calendar/control. Time advances only through explicit caller updates.
Rtc,
}
@ -27,6 +32,29 @@ impl Cartridge {
}
}
pub(crate) fn rtc_datetime(&self) -> Option<RtcDateTime> {
self.gpio.map(|gpio| gpio.rtc.calendar)
}
pub(crate) fn set_rtc_datetime(&mut self, date: RtcDateTime) -> Result<(), RtcError> {
self.gpio
.as_mut()
.ok_or(RtcError::NotAttached)?
.rtc
.calendar = date;
Ok(())
}
pub(crate) fn advance_rtc_seconds(&mut self, seconds: u64) -> Result<(), RtcError> {
self.gpio
.as_mut()
.ok_or(RtcError::NotAttached)?
.rtc
.calendar
.advance(seconds);
Ok(())
}
pub(crate) fn mapped(&self, address: u32) -> bool {
self.gpio.is_some() && (GPIO_DATA..GPIO_CONTROL + 2).contains(&address)
}
@ -97,141 +125,3 @@ impl Gpio {
(self.latch & self.direction) | (u8::from(self.rtc.output) << 1 & !self.direction)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Rtc {
control: u8,
selected: bool,
clock: bool,
sample: bool,
output: bool,
phase: Phase,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Phase {
Command { byte: u8, bits: u8 },
WriteControl { byte: u8, bits: u8 },
ReadControl { byte: u8, bits: u8 },
Done,
}
impl Default for Rtc {
fn default() -> Self {
Self {
control: 0x40, // Deterministic powered 24-hour mode, not a sampled host clock.
selected: false,
clock: true,
sample: true,
output: true,
phase: Phase::Command { byte: 0, bits: 0 },
}
}
}
impl Rtc {
fn update(&mut self, pins: u8, direction: u8) -> Result<(), &'static str> {
let selected = pins & 4 != 0;
let clock = pins & 1 != 0;
let data = pins & 2 != 0;
if !selected {
self.selected = false;
self.clock = true;
self.output = true;
self.phase = Phase::Command { byte: 0, bits: 0 };
return Ok(());
}
if !self.selected {
if !clock {
return Err("RTC select without high clock");
}
self.selected = true;
self.clock = true;
return Ok(());
}
if !clock {
match self.phase {
Phase::Command { .. } | Phase::WriteControl { .. } => {
if direction & 2 == 0 {
return Err("RTC command/data write with SIO input");
}
self.sample = data;
}
Phase::ReadControl { byte, bits } if self.clock => {
if direction & 2 != 0 {
return Err("RTC read with SIO output");
}
self.output = byte & (1 << bits) != 0;
self.phase = if bits == 7 {
Phase::Done
} else {
Phase::ReadControl {
byte,
bits: bits + 1,
}
};
}
Phase::Done if self.clock => return Err("RTC clocks beyond command length"),
_ => {}
}
} else if !self.clock {
match self.phase {
Phase::Command { byte, bits } | Phase::WriteControl { byte, bits } => {
if direction & 2 == 0 || data != self.sample {
return Err("RTC SIO change at sampling edge");
}
let byte = byte | (u8::from(self.sample) << bits);
let command = matches!(self.phase, Phase::Command { .. });
self.phase = if bits != 7 {
if command {
Phase::Command {
byte,
bits: bits + 1,
}
} else {
Phase::WriteControl {
byte,
bits: bits + 1,
}
}
} else if command {
self.command(byte)?
} else {
// Bits 1/3/5 lack an implemented IRQ/control model.
if byte & 0x2a != 0 {
return Err("RTC interrupt/unknown control bits");
}
self.control = byte & 0x40;
Phase::Done
};
}
_ => {}
}
}
self.clock = clock;
Ok(())
}
fn command(&mut self, byte: u8) -> Result<Phase, &'static str> {
// The first four wire bits are 0,1,1,0. Translate GBATEK's LSB-first
// representation to its MSB-first command numbering; parameters stay LSB first.
if byte & 15 != 6 {
return Err("RTC command encoding");
}
let command = byte.reverse_bits();
match (command >> 1) & 7 {
0 => {
self.control = 0;
Ok(Phase::Done)
}
1 if command & 1 != 0 => Ok(Phase::ReadControl {
byte: self.control,
bits: 0,
}),
1 => Ok(Phase::WriteControl { byte: 0, bits: 0 }),
2 | 3 => Err("RTC calendar access"),
6 => Err("RTC force interrupt"),
_ => Err("RTC unused command"),
}
}
}

View file

@ -0,0 +1,157 @@
//! Validated 2000–2099 RTC calendar arithmetic. No clock sampling or timezone conversion.
use std::{error::Error, fmt};
const DAYS_PER_CENTURY: u64 = 36_525;
const SECONDS_PER_DAY: u64 = 86_400;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RtcError {
NotAttached,
InvalidDateTime,
}
impl fmt::Display for RtcError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::NotAttached => "no cartridge RTC is attached",
Self::InvalidDateTime => "invalid RTC date/time (expected a valid date in 2000–2099)",
})
}
}
impl Error for RtcError {}
/// Decimal [year since 2000, month, day, weekday, hour, minute, second].
/// Weekday is an independent 0–6 counter; the host chooses its naming convention.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RtcDateTime([u8; 7]);
impl Default for RtcDateTime {
fn default() -> Self {
Self([0, 1, 1, 0, 0, 0, 0])
}
}
fn month_days(year: u8, month: u8) -> u8 {
match month {
2 => {
if year.is_multiple_of(4) {
29
} else {
28
}
}
4 | 6 | 9 | 11 => 30,
_ => 31,
}
}
fn bcd(value: u8) -> u8 {
((value / 10) << 4) | (value % 10)
}
fn decimal(value: u8) -> Result<u8, RtcError> {
if value & 15 > 9 || value >> 4 > 9 {
return Err(RtcError::InvalidDateTime);
}
Ok((value >> 4) * 10 + (value & 15))
}
impl RtcDateTime {
pub fn new(parts: [u8; 7]) -> Result<Self, RtcError> {
let [year, month, day, week, hour, minute, second] = parts;
if year > 99
|| !(1..=12).contains(&month)
|| day == 0
|| day > month_days(year, month)
|| week > 6
|| hour > 23
|| minute > 59
|| second > 59
{
return Err(RtcError::InvalidDateTime);
}
Ok(Self(parts))
}
pub fn components(self) -> [u8; 7] {
self.0
}
/// Checked epoch conversion. Uses Sunday=0, so 2000-01-01 is weekday 6.
/// Unlike RTC ticking, this constructor rejects dates beyond 2099.
pub fn from_seconds_since_2000(seconds: u64) -> Result<Self, RtcError> {
if seconds >= DAYS_PER_CENTURY * SECONDS_PER_DAY {
return Err(RtcError::InvalidDateTime);
}
let mut result = Self([0, 1, 1, 6, 0, 0, 0]);
result.advance(seconds);
Ok(result)
}
pub(super) fn encode(self, hour24: bool) -> [u8; 7] {
let mut data = self.0.map(bcd);
let hour = self.0[4];
data[4] = bcd(if hour24 { hour } else { hour % 12 }) | if hour >= 12 { 0x80 } else { 0 };
data
}
pub(super) fn decode(data: [u8; 7], hour24: bool) -> Result<Self, RtcError> {
let mut result = [0; 7];
let masks = [255, 31, 63, 7, 63, 127, 127];
for i in 0..7 {
result[i] = decimal(data[i] & masks[i])?;
}
if !hour24 {
if result[4] > 11 {
return Err(RtcError::InvalidDateTime);
}
result[4] += if data[4] & 128 != 0 { 12 } else { 0 };
}
Self::new(result)
}
/// Advance a bounded two-digit year calendar and the independent weekday counter.
/// Arithmetic is bounded even for u64::MAX seconds; year 99 wraps to 00.
pub(super) fn advance(&mut self, seconds: u64) {
let [year, month, day, week, hour, minute, second] = self.0;
let time = u64::from(hour) * 3600
+ u64::from(minute) * 60
+ u64::from(second)
+ seconds % SECONDS_PER_DAY;
let days = seconds / SECONDS_PER_DAY + time / SECONDS_PER_DAY;
let mut day_index = u64::from(day - 1);
for y in 0..year {
day_index += if y.is_multiple_of(4) { 366 } else { 365 };
}
for m in 1..month {
day_index += u64::from(month_days(year, m));
}
day_index = (day_index + days % DAYS_PER_CENTURY) % DAYS_PER_CENTURY;
let mut year: u8 = 0;
loop {
let count = if year.is_multiple_of(4) { 366 } else { 365 };
if day_index < count {
break;
}
day_index -= count;
year += 1;
}
let mut month = 1;
while day_index >= u64::from(month_days(year, month)) {
day_index -= u64::from(month_days(year, month));
month += 1;
}
self.0 = [
year,
month,
day_index as u8 + 1,
((u64::from(week) + days % 7) % 7) as u8,
((time / 3600) % 24) as u8,
((time / 60) % 60) as u8,
(time % 60) as u8,
];
}
}
#[cfg(test)]
mod tests;

View file

@ -0,0 +1,141 @@
use super::*;
#[test]
fn calendar_validates_fields_and_epoch_range() {
for invalid in [
[100, 1, 1, 0, 0, 0, 0],
[0, 0, 1, 0, 0, 0, 0],
[0, 13, 1, 0, 0, 0, 0],
[0, 1, 0, 0, 0, 0, 0],
[1, 2, 29, 0, 0, 0, 0],
[0, 4, 31, 0, 0, 0, 0],
[0, 1, 1, 7, 0, 0, 0],
[0, 1, 1, 0, 24, 0, 0],
[0, 1, 1, 0, 0, 60, 0],
[0, 1, 1, 0, 0, 0, 60],
] {
assert_eq!(RtcDateTime::new(invalid), Err(RtcError::InvalidDateTime));
}
assert_eq!(
RtcDateTime::from_seconds_since_2000(0)
.unwrap()
.components(),
[0, 1, 1, 6, 0, 0, 0]
);
assert_eq!(
RtcDateTime::from_seconds_since_2000(3_155_759_999)
.unwrap()
.components(),
[99, 12, 31, 4, 23, 59, 59]
);
assert_eq!(
RtcDateTime::from_seconds_since_2000(3_155_760_000),
Err(RtcError::InvalidDateTime)
);
assert_eq!(
RtcDateTime::from_seconds_since_2000(u64::MAX),
Err(RtcError::InvalidDateTime)
);
}
#[test]
fn hour_modes_cover_every_second_and_pm_flag() {
for hour in 0..24 {
for minute in 0..60 {
for second in 0..60 {
let date = RtcDateTime::new([24, 2, 29, 3, hour, minute, second]).unwrap();
for mode in [false, true] {
let bytes = date.encode(mode);
assert_eq!(bytes[4] & 128 != 0, hour >= 12);
assert_eq!(RtcDateTime::decode(bytes, mode).unwrap(), date);
}
}
}
}
assert_eq!(
RtcDateTime::new([0, 1, 1, 0, 12, 0, 0])
.unwrap()
.encode(false)[4],
0x80
);
assert_eq!(
RtcDateTime::new([0, 1, 1, 0, 23, 0, 0])
.unwrap()
.encode(true)[4],
0xa3
);
assert!(RtcDateTime::decode([0, 1, 1, 0, 0x12, 0, 0], false).is_err());
assert_eq!(
RtcDateTime::decode([0, 1, 1, 0, 0x80, 0, 0], true)
.unwrap()
.components()[4],
0
);
}
#[test]
fn weekday_advances_independently_and_all_months_roll_over() {
// Independent month table and explicit leap-year set; test every day of the century.
let leaps = [
0, 4, 8, 12, 16, 20, 24, 28, 32, 36, 40, 44, 48, 52, 56, 60, 64, 68, 72, 76, 80, 84, 88,
92, 96,
];
let mut clock = RtcDateTime::new([0, 1, 1, 2, 0, 0, 0]).unwrap();
let mut week = 2;
for year in 0..100 {
let months = [
31,
if leaps.contains(&year) { 29 } else { 28 },
31,
30,
31,
30,
31,
31,
30,
31,
30,
31,
];
for (month, length) in months.into_iter().enumerate() {
for day in 1..=length {
assert_eq!(
clock.components(),
[year, month as u8 + 1, day, week, 0, 0, 0]
);
assert_eq!(
RtcDateTime::decode(clock.encode(true), true).unwrap(),
clock
);
clock.advance(86400);
week = (week + 1) % 7;
}
}
}
assert_eq!(clock.components(), [0, 1, 1, 1, 0, 0, 0]);
}
#[test]
fn large_and_split_advances_do_not_overflow_or_lose_time() {
let mut huge = RtcDateTime::default();
huge.advance(u64::MAX);
// Independent Python datetime calculation using the documented two-digit-year wrap policy.
assert_eq!(huge.components(), [90, 8, 17, 0, 7, 0, 15]);
let mut split = RtcDateTime::default();
split.advance(u64::MAX / 2);
split.advance(u64::MAX - u64::MAX / 2);
assert_eq!(huge, split);
for start in [
[0, 2, 28, 6, 23, 59, 59],
[1, 2, 28, 5, 23, 59, 59],
[99, 12, 31, 2, 23, 59, 59],
] {
let mut batched = RtcDateTime::new(start).unwrap();
let mut small = batched;
batched.advance(100000);
for _ in 0..100000 {
small.advance(1);
}
assert_eq!(batched, small);
}
}

View file

@ -0,0 +1,239 @@
//! Edge-driven RTC command framing with latched reads and complete-payload writes.
use super::RtcDateTime;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct Rtc {
control: u8,
pub(super) calendar: RtcDateTime,
selected: bool,
clock: bool,
sample: bool,
pub(super) output: bool,
phase: Phase,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Register {
Control,
DateTime,
Time,
}
impl Register {
fn len(self) -> u8 {
match self {
Self::Control => 1,
Self::DateTime => 7,
Self::Time => 3,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Phase {
Command {
byte: u8,
bits: u8,
},
Write {
register: Register,
data: [u8; 7],
offset: u8,
byte: u8,
bits: u8,
},
Read {
data: [u8; 7],
len: u8,
bit: u8,
},
Done,
}
impl Default for Rtc {
fn default() -> Self {
Self {
control: 0x40,
calendar: RtcDateTime::default(),
selected: false,
clock: true,
sample: true,
output: true,
phase: Phase::Command { byte: 0, bits: 0 },
}
}
}
impl Rtc {
pub(super) fn update(&mut self, pins: u8, direction: u8) -> Result<(), &'static str> {
let selected = pins & 4 != 0;
let clock = pins & 1 != 0;
let data = pins & 2 != 0;
if !selected {
self.selected = false;
self.clock = true;
self.output = true;
self.phase = Phase::Command { byte: 0, bits: 0 };
return Ok(());
}
if !self.selected {
if !clock {
return Err("RTC select without high clock");
}
self.selected = true;
self.clock = true;
return Ok(());
}
if !clock {
match self.phase {
Phase::Command { .. } | Phase::Write { .. } => {
if direction & 2 == 0 {
return Err("RTC command/data write with SIO input");
}
self.sample = data;
}
Phase::Read { data, len, bit } if self.clock => {
if direction & 2 != 0 {
return Err("RTC read with SIO output");
}
self.output = data[usize::from(bit / 8)] & (1 << (bit % 8)) != 0;
self.phase = if bit + 1 == len * 8 {
Phase::Done
} else {
Phase::Read {
data,
len,
bit: bit + 1,
}
};
}
Phase::Done if self.clock => return Err("RTC clocks beyond command length"),
_ => {}
}
} else if !self.clock && matches!(self.phase, Phase::Command { .. } | Phase::Write { .. }) {
if direction & 2 == 0 || data != self.sample {
return Err("RTC SIO change at sampling edge");
}
self.receive_bit()?;
}
self.clock = clock;
Ok(())
}
fn receive_bit(&mut self) -> Result<(), &'static str> {
self.phase = match self.phase {
Phase::Command { byte, bits } => {
let byte = byte | (u8::from(self.sample) << bits);
if bits == 7 {
self.command(byte)?
} else {
Phase::Command {
byte,
bits: bits + 1,
}
}
}
Phase::Write {
register,
mut data,
offset,
byte,
bits,
} => {
let byte = byte | (u8::from(self.sample) << bits);
if bits != 7 {
Phase::Write {
register,
data,
offset,
byte,
bits: bits + 1,
}
} else {
data[usize::from(offset)] = byte;
if offset + 1 == register.len() {
self.commit(register, data)?;
Phase::Done
} else {
Phase::Write {
register,
data,
offset: offset + 1,
byte: 0,
bits: 0,
}
}
}
}
_ => unreachable!(),
};
Ok(())
}
fn commit(&mut self, register: Register, data: [u8; 7]) -> Result<(), &'static str> {
let hour24 = self.control & 0x40 != 0;
match register {
Register::Control => {
if data[0] & 0x2a != 0 {
return Err("RTC interrupt/unknown control bits");
}
self.control = data[0] & 0x40;
}
Register::DateTime | Register::Time => {
let data = if register == Register::Time {
let mut date = self.calendar.encode(hour24);
date[4..].copy_from_slice(&data[..3]);
date
} else {
data
};
self.calendar =
RtcDateTime::decode(data, hour24).map_err(|_| "RTC invalid calendar data")?;
}
}
Ok(())
}
fn command(&mut self, byte: u8) -> Result<Phase, &'static str> {
// First four wire bits are 0,1,1,0. Convert to MSB-first command numbering.
if byte & 15 != 6 {
return Err("RTC command encoding");
}
let command = byte.reverse_bits();
let register = match (command >> 1) & 7 {
0 => {
self.control = 0;
self.calendar = RtcDateTime::default();
return Ok(Phase::Done);
}
1 => Register::Control,
2 => Register::DateTime,
3 => Register::Time,
6 => return Err("RTC force interrupt"),
_ => return Err("RTC unused command"),
};
if command & 1 == 0 {
Ok(Phase::Write {
register,
data: [0; 7],
offset: 0,
byte: 0,
bits: 0,
})
} else {
let mut data = self.calendar.encode(self.control & 0x40 != 0);
match register {
Register::Control => data[0] = self.control,
Register::Time => {
data.copy_within(4..7, 0);
}
Register::DateTime => {}
}
Ok(Phase::Read {
data,
len: register.len(),
bit: 0,
})
}
}
}

View file

@ -27,7 +27,7 @@ use iwram_bus::IwramBus;
use crate::{
audio::{Audio, StereoLevel},
cartridge::{Cartridge, CartridgeHardware},
cartridge::{Cartridge, CartridgeHardware, RtcDateTime, RtcError},
cpu::InstructionSet,
display::{DisplayPosition, CYCLES_PER_LINE, HBLANK_START, VISIBLE_LINES},
dma::{DmaError, DMA_BASE, DMA_END},
@ -193,6 +193,30 @@ impl Memory {
self.cartridge = Cartridge::new(hardware);
}
/// Inspect the live calendar, not an in-progress serial read snapshot.
pub fn rtc_datetime(&self) -> Option<RtcDateTime> {
self.cartridge_state().rtc_datetime()
}
/// Set the RTC calendar between machine steps. Existing serial read snapshots stay latched.
pub fn set_rtc_datetime(&mut self, date: RtcDateTime) -> Result<(), RtcError> {
assert!(
self.cartridge_step.get().is_none(),
"active cartridge transaction"
);
self.cartridge.set_rtc_datetime(date)
}
/// Supply battery-clock elapsed seconds independently of CPU cycles, HALT, and STOP.
/// Call between machine steps. This requests no IRQ and never wakes the CPU.
pub fn advance_rtc_seconds(&mut self, seconds: u64) -> Result<(), RtcError> {
assert!(
self.cartridge_step.get().is_none(),
"active cartridge transaction"
);
self.cartridge.advance_rtc_seconds(seconds)
}
fn cartridge_state(&self) -> Cartridge {
self.cartridge_step.get().unwrap_or(self.cartridge)
}
@ -215,14 +239,15 @@ impl Memory {
}
/// Total elapsed emulated clock cycles, wrapping at u64::MAX. Reads/writes
/// alone consume no cycles. STOP discards externally supplied clock advances.
/// alone consume no cycles. STOP discards supplied GBA-cycle advances, not independent RTC time.
pub fn cycles(&self) -> u64 {
self.cycles
}
/// Advance devices and optional row capture. Multiple unserviced DMA requests
/// coalesce. No CPU or DMA work is executed by this method. STOP freezes all
/// coalesce. No CPU or DMA work is executed by this method. STOP freezes GBA-clock
/// progress: supplied cycles are ignored until an external wake condition.
/// The independently supplied RTC battery clock is not advanced by this method.
/// This device-only API does not advance opcode prefetch. CPU/DMA/HALT steps
/// account for their own cartridge-bus time before updating device clocks.
pub fn advance_cycles(&mut self, cycles: u32) {

View file

@ -36,9 +36,9 @@ fn rejected_rtc_command_edge_rolls_back_arm_and_thumb_steps() {
memory.write16(GPIO_DATA, 1).unwrap();
memory.write16(GPIO_DIRECTION, 7).unwrap();
memory.write16(GPIO_DATA, 5).unwrap();
// All but the final rising edge of an original date/time read command (0x65).
// All but the final rising edge of an unsupported force-interrupt command (0x6d).
for bit in (1..8).rev() {
let pins = 4 | (((0x65 >> bit) & 1) << 1);
let pins = 4 | (((0x6d >> bit) & 1) << 1);
memory.write16(GPIO_DATA, pins).unwrap();
memory.write16(GPIO_DATA, pins | 1).unwrap();
}
@ -47,7 +47,7 @@ fn rejected_rtc_command_edge_rolls_back_arm_and_thumb_steps() {
let mut machine = Machine::new(cpu, memory);
let cpu = machine.cpu().clone();
let error = machine.step().unwrap_err();
assert!(error.to_string().contains("RTC calendar access"));
assert!(error.to_string().contains("RTC force interrupt"));
assert_eq!(machine.step(), Err(error));
assert_eq!(machine.cpu(), &cpu);
assert_eq!(machine.memory().cartridge, cartridge);
@ -56,6 +56,86 @@ fn rejected_rtc_command_edge_rolls_back_arm_and_thumb_steps() {
}
}
fn prepare_calendar_final_edge(memory: &mut Memory, invalid: bool) {
memory.set_cartridge_hardware(CartridgeHardware::Rtc);
memory.write16(GPIO_CONTROL, 1).unwrap();
memory.write16(GPIO_DATA, 1).unwrap();
memory.write16(GPIO_DIRECTION, 7).unwrap();
memory.write16(GPIO_DATA, 5).unwrap();
let mut bits: Vec<bool> = (0..8).rev().map(|i| 0x64 & (1 << i) != 0).collect();
for byte in [
0x24,
2,
0x29,
4,
0x12,
0x34,
if invalid { 0x6a } else { 0x56 },
] {
bits.extend((0..8).map(|i| byte & (1 << i) != 0));
}
for (i, bit) in bits.into_iter().enumerate() {
let pins = 4 | (u16::from(bit) << 1);
memory.write16(GPIO_DATA, pins).unwrap();
if i != 63 {
memory.write16(GPIO_DATA, pins | 1).unwrap();
}
}
}
#[test]
fn calendar_payload_commits_at_final_arm_or_thumb_edge_and_rejects_invalid_data_atomically() {
for thumb in [false, true] {
for invalid in [false, true] {
let (cpu, mut memory) = prepared(
thumb,
&[if thumb { 0x8008 } else { 0xe1c100b0 }],
&[(0, 5), (1, GPIO_DATA)],
);
prepare_calendar_final_edge(&mut memory, invalid);
let before = memory.cartridge;
let mut machine = Machine::new(cpu, memory);
let cpu = machine.cpu().clone();
if invalid {
let error = machine.step().unwrap_err();
assert!(error.to_string().contains("invalid calendar data"));
assert_eq!(machine.step(), Err(error));
assert_eq!(machine.cpu(), &cpu);
assert_eq!(machine.memory().cartridge, before);
assert_eq!(machine.cycles(), 0);
} else {
machine.step().unwrap();
assert_eq!(
machine.memory().rtc_datetime().unwrap().components(),
[24, 2, 29, 4, 12, 34, 56]
);
assert_eq!(machine.last_timing().data_cycles, 5);
}
}
}
}
#[test]
fn later_block_diagnostic_discards_an_earlier_calendar_commit() {
let (_, mut memory) = prepared(false, &[0xe1a00000], &[]);
prepare_calendar_final_edge(&mut memory, false);
let before = memory.cartridge;
assert!(memory
.write_words(&[
(GPIO_DATA, 0x00070005),
(0x02000000, 0x12345678),
(crate::io::SIOCNT, 0x3000)
])
.is_err());
assert_eq!(memory.cartridge, before);
assert_eq!(memory.read32(0x02000000).unwrap(), 0);
memory.write_words(&[(GPIO_DATA, 0x00070005)]).unwrap();
assert_eq!(
memory.rtc_datetime().unwrap().components(),
[24, 2, 29, 4, 12, 34, 56]
);
}
#[test]
fn block_store_failure_retains_gpio_and_cpu_state() {
// First word targets DATA/DIRECTION. Second reaches unmapped padding after CONTROL.

View file

@ -1,4 +1,6 @@
//! Original GPIO and RTC control transactions. No game code or host clock.
//! Original GPIO and RTC transactions. No game code or host clock.
#[path = "cartridge_gpio/calendar.rs"]
mod calendar;
use gba_core::{
cartridge::{CartridgeHardware, GPIO_CONTROL, GPIO_DATA, GPIO_DIRECTION},
cpu::Cpu,
@ -180,10 +182,6 @@ fn dropping_select_aborts_partial_commands_and_parameters() {
#[test]
fn unsupported_commands_and_control_bits_fail_at_the_last_edge_without_partial_writes() {
for (byte, description) in [
(0x64, "calendar"),
(0x65, "calendar"),
(0x66, "calendar"),
(0x67, "calendar"),
(0x6c, "force interrupt"),
(0x68, "unused command"),
(0xff, "command encoding"),

View file

@ -0,0 +1,196 @@
use super::*;
use gba_core::{
cartridge::{RtcDateTime, RtcError},
io::{HALTCNT, IE, IME},
};
fn setup(parts: [u8; 7]) -> Memory {
let mut bus = memory();
bus.write16(GPIO_CONTROL, 1).unwrap();
bus.set_rtc_datetime(RtcDateTime::new(parts).unwrap())
.unwrap();
bus
}
fn read_byte(bus: &mut Memory) -> u8 {
let mut byte = 0;
for bit in 0..8 {
bus.write16(GPIO_DATA, 4).unwrap();
let data = bus.read16(GPIO_DATA).unwrap() & 2;
bus.write16(GPIO_DATA, 5).unwrap();
assert_eq!(bus.read16(GPIO_DATA).unwrap() & 2, data);
byte |= (data as u8 >> 1) << bit;
}
byte
}
fn read(bus: &mut Memory, cmd: u8, len: usize) -> Vec<u8> {
start(bus);
command(bus, cmd, true).unwrap();
bus.write16(GPIO_DIRECTION, 5).unwrap();
let bytes = (0..len).map(|_| read_byte(bus)).collect();
bus.write16(GPIO_DATA, 1).unwrap();
bytes
}
fn write(bus: &mut Memory, cmd: u8, bytes: &[u8]) -> Result<(), MemoryError> {
start(bus);
command(bus, cmd, true)?;
for &byte in bytes {
parameter(bus, byte)?;
}
bus.write16(GPIO_DATA, 1)
}
#[test]
fn calendar_and_time_reads_use_bcd_and_gba_pm_bit() {
let mut bus = setup([24, 2, 29, 4, 23, 58, 59]);
assert_eq!(
read(&mut bus, 0x65, 7),
[0x24, 2, 0x29, 4, 0xa3, 0x58, 0x59]
);
assert_eq!(read(&mut bus, 0x67, 3), [0xa3, 0x58, 0x59]);
write(&mut bus, 0x62, &[0]).unwrap(); // 12-hour presentation preserves the actual hour.
assert_eq!(read(&mut bus, 0x67, 3), [0x91, 0x58, 0x59]);
bus.advance_rtc_seconds(61).unwrap();
assert_eq!(read(&mut bus, 0x65, 7), [0x24, 3, 1, 5, 0, 0, 0]);
assert_eq!(bus.read16(IF).unwrap(), 0);
}
#[test]
fn complete_writes_commit_calendar_or_time_and_mask_unused_bits() {
let mut bus = setup([0, 1, 1, 0, 0, 0, 0]);
write(&mut bus, 0x64, &[0x24, 0xe2, 0xe9, 0xfc, 0x63, 0xd8, 0xd9]).unwrap();
assert_eq!(
bus.rtc_datetime().unwrap().components(),
[24, 2, 29, 4, 23, 58, 59]
);
write(&mut bus, 0x66, &[0x80, 0x12, 0x34]).unwrap(); // PM ignored in 24-hour writes.
assert_eq!(
bus.rtc_datetime().unwrap().components(),
[24, 2, 29, 4, 0, 12, 34]
);
write(&mut bus, 0x62, &[0]).unwrap();
write(&mut bus, 0x66, &[0x80, 0, 0]).unwrap(); // Noon is 00 with PM, not 12.
assert_eq!(
bus.rtc_datetime().unwrap().components(),
[24, 2, 29, 4, 12, 0, 0]
);
assert_eq!(read(&mut bus, 0x67, 3), [0x80, 0, 0]);
}
#[test]
fn read_snapshot_survives_mid_transfer_tick_and_host_reseed() {
let mut bus = setup([24, 12, 31, 2, 23, 59, 59]);
start(&mut bus);
command(&mut bus, 0x65, true).unwrap();
bus.write16(GPIO_DIRECTION, 5).unwrap();
assert_eq!(read_byte(&mut bus), 0x24);
bus.advance_rtc_seconds(1).unwrap();
assert_eq!(
bus.rtc_datetime().unwrap().components(),
[25, 1, 1, 3, 0, 0, 0]
);
bus.set_rtc_datetime(RtcDateTime::new([50, 6, 7, 1, 8, 9, 10]).unwrap())
.unwrap();
let tail: Vec<_> = (0..6).map(|_| read_byte(&mut bus)).collect();
assert_eq!(tail, [0x12, 0x31, 2, 0xa3, 0x59, 0x59]);
bus.write16(GPIO_DATA, 1).unwrap();
assert_eq!(read(&mut bus, 0x65, 7), [0x50, 6, 7, 1, 8, 9, 0x10]);
}
#[test]
fn partial_payloads_abort_without_calendar_changes_and_time_writes_preserve_live_date() {
let original = [24, 2, 28, 6, 23, 59, 59];
for bits in 0..56 {
let mut bus = setup(original);
start(&mut bus);
command(&mut bus, 0x64, true).unwrap();
send_bits(&mut bus, (0..bits).map(|_| false)).unwrap();
bus.write16(GPIO_DATA, 1).unwrap();
assert_eq!(bus.rtc_datetime().unwrap().components(), original);
}
let mut bus = setup(original);
start(&mut bus);
command(&mut bus, 0x66, true).unwrap();
parameter(&mut bus, 0x12).unwrap();
bus.advance_rtc_seconds(1).unwrap();
parameter(&mut bus, 0x34).unwrap();
parameter(&mut bus, 0x56).unwrap();
bus.write16(GPIO_DATA, 1).unwrap();
assert_eq!(
bus.rtc_datetime().unwrap().components(),
[24, 2, 29, 0, 12, 34, 56]
);
}
#[test]
fn invalid_calendar_payload_is_diagnostic_retryable_and_cannot_commit() {
for payload in [
[0xfa, 1, 1, 0, 0, 0, 0],
[0x23, 2, 0x29, 0, 0, 0, 0],
[0, 0, 1, 0, 0, 0, 0],
[0, 1, 1, 7, 0, 0, 0],
[0, 1, 1, 0, 0x24, 0, 0],
[0, 1, 1, 0, 0, 0x60, 0],
[0, 1, 1, 0, 0, 0, 0x6a],
] {
let mut bus = setup([24, 5, 6, 3, 7, 8, 9]);
let before = bus.rtc_datetime();
let error = write(&mut bus, 0x64, &payload).unwrap_err();
assert!(error.to_string().contains("invalid calendar data"));
let pins = bus.read16(GPIO_DATA).unwrap();
assert_eq!(pins & 1, 0);
assert_eq!(bus.write16(GPIO_DATA, pins | 1), Err(error));
assert_eq!(bus.rtc_datetime(), before);
bus.write16(GPIO_DATA, 1).unwrap();
assert_eq!(read(&mut bus, 0x65, 7), [0x24, 5, 6, 3, 7, 8, 9]);
}
}
#[test]
fn reset_affects_calendar_and_mode_but_not_the_gpio_overlay() {
for cmd in [0x60, 0x61] {
let mut bus = setup([99, 12, 31, 6, 23, 59, 59]);
write(&mut bus, cmd, &[]).unwrap();
assert_eq!(bus.rtc_datetime(), Some(RtcDateTime::default()));
assert_eq!(control(&mut bus, true), 0);
assert_eq!(read(&mut bus, 0x65, 7), [0, 1, 1, 0, 0, 0, 0]);
assert_eq!(bus.read16(GPIO_CONTROL).unwrap(), 1);
bus.advance_rtc_seconds(43200).unwrap();
assert_eq!(read(&mut bus, 0x67, 3), [0x80, 0, 0]);
}
}
#[test]
fn battery_clock_is_independent_of_cpu_cycles_halt_stop_and_device_presence() {
let mut absent = Memory::new(vec![0; 4]).unwrap();
assert_eq!(absent.rtc_datetime(), None);
assert_eq!(
absent.set_rtc_datetime(RtcDateTime::default()),
Err(RtcError::NotAttached)
);
assert_eq!(absent.advance_rtc_seconds(0), Err(RtcError::NotAttached));
for stop in [false, true] {
let mut bus = setup([0, 1, 1, 0, 0, 0, 0]);
bus.write16(IE, 0x2080).unwrap();
bus.write16(IME, 1).unwrap();
bus.write8(HALTCNT, if stop { 128 } else { 0 }).unwrap();
let mut machine = Machine::new(Cpu::new(ROM_START), bus);
for _ in 0..20 {
machine.step().unwrap();
}
machine.memory_mut().advance_cycles(16777216);
assert_eq!(
machine.memory().rtc_datetime(),
Some(RtcDateTime::default())
);
let cycles = machine.cycles();
machine.memory_mut().advance_rtc_seconds(86401).unwrap();
assert_eq!(
machine.memory().rtc_datetime().unwrap().components(),
[0, 1, 2, 1, 0, 0, 1]
);
assert_eq!(machine.cycles(), cycles);
assert_eq!(machine.memory().read16(IF).unwrap(), 0);
assert_eq!(machine.halted(), !stop);
assert_eq!(machine.stopped(), stop);
}
}

View file

@ -48,7 +48,11 @@ The repository is a Cargo workspace with three crates:
| `crates/gba-core/src/cpu/transfer_tests.rs` | Stack, addressing, overlap, empty-list, and swap tests |
| `crates/gba-core/src/cpu/tests.rs` | Condition truth tables and immediate arithmetic tests |
| `crates/gba-core/src/cpu/instruction_tests.rs` | Register operations, transfers, branches, and edge cases |
| `crates/gba-core/src/cartridge.rs` | Explicit GPIO/RTC selection, ROM read overlay, pin directions, serial command/control, and bounded diagnostics |
| `crates/gba-core/src/cartridge.rs` | Explicit GPIO/RTC selection, ROM read overlay, pin directions, caller-clock routing, and bounded diagnostics |
| `crates/gba-core/src/cartridge/rtc.rs` | RTC framing, control/calendar commands, read snapshots, complete writes, and reset |
| `crates/gba-core/src/cartridge/calendar.rs` | Validated decimal calendar, BCD representation, checked epoch conversion, and bounded elapsed-time arithmetic |
| `crates/gba-core/src/cartridge/calendar/tests.rs` | Calendar field validation, century-wide rollovers, hour modes, and large/split advances |
| `crates/gba-core/tests/cartridge_gpio/calendar.rs` | Calendar wire transfers, snapshots, aborts, invalid payloads, reset, and independent HALT/STOP time |
| `crates/gba-core/tests/cartridge_gpio.rs` | GPIO lanes, ROM preservation, RTC framing/control/aborts, unsupported commands, and DMA3 access |
| `crates/gba-core/src/memory/cartridge_step_tests.rs` | ARM/Thumb GPIO timing, rejected command-edge rollback, block preflight, and staged fetch/read overlays |
| `crates/gba-core/src/memory.rs` | Memory mapping, I/O routing, write validation, and device clock |
@ -150,6 +154,7 @@ The repository is a Cargo workspace with three crates:
| `crates/desktop/src/cartridge/suite/gameplay_tests.rs` | VBlank completion, input snapshots, captured pixels, and rendering failures |
| `crates/desktop/tests/rom_suite_cli.rs` | File-backed suite execution, case isolation, output schema, determinism, and exit status |
| `crates/desktop/examples/write_test_suite.rs` | Original ARM/Thumb/BIOS fixture generator with a local JSON suite |
| `crates/desktop/src/cartridge/rtc_clock.rs` | Host-only UTC seed and monotonic elapsed-time adapter, with injected epoch/duration tests |
| `crates/desktop/src/cartridge/window.rs` | ROM-window session, bounded slices, capture, STOP/input handling, and progress limits |
| `crates/desktop/src/cartridge/window/tests.rs` | Original ROM pixels, keyboard mapping, STOP wake, HALT, frame budgets, and diagnostics |
| `crates/desktop/src/desktop/rom.rs` | Main-thread ROM window, input polling, presentation pacing, and frame-limited exit |

View file

@ -1,9 +1,10 @@
# Game Pak GPIO and RTC control subset
# Game Pak GPIO and RTC calendar subset
The core supports an explicitly selected cartridge GPIO interface and RTC command/control transactions.
The core supports an explicitly selected cartridge GPIO interface and RTC calendar/control transactions.
GPIO means general-purpose input/output. RTC means real-time clock.
Calendar access, clock advancement, battery persistence, RTC interrupts, and other cartridge sensors remain unimplemented.
Unsupported operations return diagnostics. No fabricated date or successful calendar transfer is reported.
An explicit caller supplies initial time and elapsed seconds. The core never samples a host clock.
Battery persistence, RTC interrupts, and other cartridge sensors remain unimplemented.
Unsupported operations and invalid calendar payloads return diagnostics.
## Selection
@ -18,9 +19,10 @@ direnv exec . cargo run --locked --release -- --rom roms/pokemon-emerald.gba --r
direnv exec . cargo run --locked --release -- --rom roms/pokemon-emerald.gba --rtc --window --frames 60
```
The report states that only GPIO and command/control are supported.
The report states the desktop clock policy: UTC at startup, then host elapsed time, without persistence or RTC interrupts.
UTC means Coordinated Universal Time. The host does not convert to local time.
The JSON suite schema does not yet select cartridge peripherals. Its default remains no attached peripheral.
There is no host-clock dependency, ROM patch, save file, or automatic device detection.
There is no core host-clock dependency, ROM patch, save file, or automatic device detection.
## GPIO registers
@ -59,27 +61,80 @@ Chip select rising with clock high begins a command. Chip select falling aborts
Command bits enter on rising clock edges after software establishes data while clock is low.
The first four command bits on the wire must be `0,1,1,0`.
GBATEK's forward and reverse command-byte representations describe this same wire sequence, not interchangeable wire prefixes.
Control parameters transfer least-significant bit first.
Control and calendar parameters transfer least-significant bit first.
During reads, the RTC supplies each bit on the falling edge. GPIO reads do not advance the serial state.
Using GBATEK's MSB-first command representation:
- `0x62` writes one control byte; `0x63` reads one control byte.
- `0x60` and `0x61` reset supported control state to zero. Calendar reset side effects are not implemented.
- `0x64` through `0x67` return an explicit calendar-access diagnostic.
- `0x60` and `0x61` reset control to zero and the calendar to year 00, month/day 01, weekday/time zero.
- `0x64` writes seven date/time bytes; `0x65` reads them.
- `0x66` writes three time bytes; `0x67` reads them.
- `0x6c` and `0x6d` return an explicit force-interrupt diagnostic.
- Unused commands and invalid command prefixes remain diagnostic.
Initial control is `0x40`, a deterministic powered 24-hour-mode setting.
Bit 6 is writable. Unused bits and read-only bit 7 do not retain writes.
Writes enabling bits 1, 3, or 5 fail because their interrupt or uncertain control behavior is not implemented.
No power-failure condition is synthesized. There is no calendar value behind this control setting yet.
No power-failure condition is synthesized. Core-only attachment initializes the calendar to the documented reset date.
Changing hour mode changes its wire representation without changing the stored time.
A partial control write does not commit. A complete byte commits at its final rising edge.
Extra clocks beyond the documented parameter length remain diagnostic rather than guessing repeated transfers.
Selecting with clock low, driving data during read clocks, and changing data at a sampling edge also remain diagnostic.
Minimum physical setup/hold durations, signal races, and exact analog behavior are not modeled.
## Calendar registers and transactions
Date/time payload order is year, month, day, weekday, hour, minute, second.
Time-only payloads contain hour, minute, second. Numeric fields use binary-coded decimal (BCD).
Each BCD byte stores a decimal tens digit in its upper nibble and a units digit in its lower nibble.
The weekday remains an independent counter from zero through six; software can choose its naming convention.
The supported date range is 2000–2099. Month lengths and leap years follow that range's Gregorian calendar.
Hours use `00`–`23` in 24-hour mode and `00`–`11` in 12-hour mode.
GBA hour bit 7 indicates PM. In 24-hour reads it reflects hours 12–23; writes ignore it.
In 12-hour mode, noon is `0x80`, not `0x12`. Midnight is zero.
Documented unused bits are masked. Invalid BCD digits, out-of-range fields, and impossible dates remain diagnostic.
The S-3511A datasheet describes invalid-data corrections, including delayed seconds correction. Those behaviors are not implemented.
A read snapshots the selected registers at command completion.
Later caller time updates or reseeding do not change the current serial payload. The next command observes the updated calendar.
A write buffers its entire payload and commits only at its final rising edge.
Dropping chip select before completion discards that payload. A rejected final edge can be retried without partial calendar changes.
Time-only writes preserve the live date and weekday at commit, including a midnight crossed during the transaction.
This is a bounded transaction model, not physical verification of chip latching, partial writes, or oscillator phase.
## Clock ownership
The core exposes these APIs on `Memory`:
- `rtc_datetime()` inspects the live calendar, or returns `None` when no RTC is attached.
- `set_rtc_datetime(RtcDateTime)` replaces the calendar between machine steps without changing pending read snapshots.
- `advance_rtc_seconds(u64)` applies elapsed battery-clock seconds between machine steps.
It changes neither CPU cycles nor interrupt state, and it cannot wake HALT or STOP.
`RtcDateTime::new` validates decimal calendar fields. `from_seconds_since_2000` provides checked epoch conversion with Sunday=0.
Calendar advancement is bounded for any `u64` duration, without iterating once per second.
The two-digit year wraps from 99 to 00 while weekday continues independently. This is the bounded century-wrap policy.
Epoch construction rejects values outside 2000–2099 rather than wrapping a supplied host date.
CPU execution, `advance_cycles`, HALT, and STOP do not advance RTC time automatically.
A caller can advance the battery clock while GBA clocks are stopped. Core tests inject deterministic dates and durations.
Reset changes calendar registers but does not reset the caller's elapsed-time source or its fractional phase.
Exact oscillator startup, subsecond reset/write phase, century-wrap behavior, and mode-switch timing lack physical validation.
The desktop adapter samples `SystemTime` once for UTC startup, at whole-second resolution.
It then supplies elapsed whole seconds from `Instant`, preserving the fractional remainder in its elapsed-time measurement.
Guest time writes and reset therefore remain effective; each update does not replace them with a fresh host date.
Later system-clock adjustments do not change the running RTC. Host dates outside the supported range produce an error.
Terminal runs synchronize between bounded groups of machine steps and at exit.
Native windows synchronize between execution slices, including while waiting for input in STOP.
Host sleeping can advance the RTC on the next update but never advances CPU/device cycles.
This is a coarse host-clock adapter, not cycle-exact pin scheduling or a guarantee about system-suspend behavior.
There is no battery save file or offline catch-up. Each new `--rtc` process starts from host UTC.
## Bus integration and tests
CPU and DMA accesses retain Game Pak wait-state costs.
@ -89,16 +144,20 @@ A later diagnostic leaves earlier RAM, GPIO, and CPU state unchanged.
Data reads and instruction fetches share the selected register overlay. Fetches have no serial side effects.
Original tests cover selection, masks, directions, retained latches, read enable, widths, ROM preservation, and command framing.
They check control reads/writes/reset, aborts, both command-byte representations, edge stability, and unsupported commands.
They check control/calendar reads, complete writes, reset, aborts, both command-byte representations, and unsupported commands.
Calendar tests cover every day in the supported century, hour modes, PM flags, leap days, midnight, large advances, and invalid payloads.
Read snapshots, live-date time writes, independent HALT/STOP advancement, and failed final-edge rollback have separate checks.
ARM/Thumb, DMA3, block preflight, staged fetches, retryable errors, and nominal bus costs have separate regressions.
File-backed desktop tests check explicit `--rtc` selection, unchanged files, option rejection, and native-window propagation.
File-backed desktop tests execute original ARM calendar writes/reads with `--rtc` in terminal and native-window modes.
The probe places executable code outside the GPIO overlay. Host-adapter tests inject epoch values and elapsed durations without sleeping.
These tests validate the bounded implementation, not physical RTC timing or game playability.
## References
- [Seiko S-3511A datasheet](https://www.rockby.com.au/DSheets/10110.pdf), sections 2-1, 3-3, and 4: BCD calendar fields, weekday counter, reset values, PM behavior, and invalid-data corrections. Chip variants and physical transfer timing remain unverified; unsupported corrections are not silently approximated.
- [GBATEK cartridge GPIO](https://problemkaputt.de/gbatek-gba-cart-i-o-port-gpio.htm): register addresses, masks, directions, read control, and uncertain byte-store behavior.
- [GBATEK GBA RTC](https://problemkaputt.de/gbatek-gba-cart-real-time-clock-rtc.htm): control fields, reset, calendar formats, and command mapping relative to the DS chip.
- [GBATEK DS RTC](https://problemkaputt.de/gbatek-ds-real-time-clock-rtc.htm): the linked serial flow, chip-select sequencing, bit edges, and forward/reverse command representations. GBA register differences remain separate.
- [mGBA GPIO at 3a5e34be](https://github.com/mgba-emu/mgba/blob/3a5e34be33dc7f8f707e5bc9db69e8a430046f21/src/gba/cart/gpio.c): comparison for retained outputs, direction changes, idle inputs, serial edges, and initial control. Its host-clock lookup, ROM-buffer mutation, and repeated transfers are not copied.
- [mGBA GPIO at 3a5e34be](https://github.com/mgba-emu/mgba/blob/3a5e34be33dc7f8f707e5bc9db69e8a430046f21/src/gba/cart/gpio.c): comparison for retained outputs, direction changes, idle inputs, serial edges, and initial control. Its host-clock lookup, ROM-buffer mutation, and repeated transfers are not copied. The calendar implementation uses the documented GBA PM bit, including 24-hour readback; the pinned implementation does not supply that flag.
- [mGBA memory at 3a5e34be](https://github.com/mgba-emu/mgba/blob/3a5e34be33dc7f8f707e5bc9db69e8a430046f21/src/gba/memory.c): first-window halfword GPIO write routing. This is not physical proof of all address aliases.
- [Explaining GBA RTC](https://bmchtech.github.io/post/rtc/): introductory background surfaced during research; the author explicitly warns that details may be inaccurate. Register and protocol decisions use the references above.

View file

@ -34,8 +34,9 @@ Headerless original programs are accepted.
Reads past supplied cartridge bytes remain explicit memory errors, not open-bus values.
All three Game Pak ROM windows use the same supplied bytes and existing wait-state rules.
Ordinary cartridge bytes remain read-only. Save devices remain unsupported.
Optional `--rtc` selection enables the bounded [Game Pak GPIO and RTC control interface](cartridge-gpio.md).
It supports configuration and control transactions, not calendar access, clock advancement, or RTC interrupts.
Optional `--rtc` selection enables the bounded [Game Pak GPIO and RTC calendar interface](cartridge-gpio.md).
It supports date/time reads, complete writes, reset, and explicit elapsed-time updates. RTC interrupts and persistence remain unsupported.
The desktop seeds UTC once, then supplies monotonic host elapsed seconds independently of GBA cycles and STOP.
Both modes call `bios::boot` with the loaded bytes.
With `--rtc`, the host then selects the cartridge peripheral before the first machine step.

View file

@ -235,7 +235,9 @@ The current desktop demos do not enter STOP; the frontend does not yet provide a
Only keypad wake-up is implemented. General-purpose serial and Game Pak wake sources require their missing external devices.
Oscillator restart delays, exact entry/wake edges, and hardware-specific IF behavior remain unverified.
There is no host-thread sleep or wall-clock accounting. STOP freezes the emulated timer-driven audio clock.
Core STOP does not sleep a host thread or account for wall time. It freezes the emulated timer-driven audio clock.
The optional [cartridge RTC](cartridge-gpio.md#clock-ownership) uses a separate caller-supplied battery clock that can advance during STOP.
Desktop `--rtc` continues supplying elapsed time while its native window waits for input. RTC updates do not wake STOP.
The graphics demo now uses the optional original BIOS replacement's VBlankIntrWait service.
Its IRQ callback acknowledges VBlank and updates the BIOS RAM flag before the service returns.

View file

@ -65,17 +65,19 @@ The send register supports 16-bit data; receive latches do not become invented p
See [the register model and evidence limits](../hardware/serial.md#disconnected-multiplayer-configuration).
Multiplayer configuration then reached a Game Pak GPIO control write (`0x080000c8`, PC `0x082e29f8`).
The optional [cartridge GPIO/RTC interface](../hardware/cartridge-gpio.md) now supports read enable, directions, pin latches, and RTC control transactions.
Select it explicitly with `--rtc`; ordinary ROM bytes and other ROMs' default hardware remain unchanged.
No host date, RTC calendar, or successful calendar read is invented.
The optional [cartridge GPIO/RTC interface](../hardware/cartridge-gpio.md) first added read enable, directions, pin latches, and RTC control transactions.
That reached a calendar-read command at PC `0x082e28a6`.
The calendar model now supports date/time reads, complete writes, reset, and caller-supplied elapsed seconds.
Core tests inject deterministic time. Desktop `--rtc` uses UTC startup and monotonic elapsed time, including during GBA STOP.
Select it explicitly; ordinary ROM bytes and other ROMs' default hardware remain unchanged.
Both terminal and native runs with `--rtc` now reach a calendar-read command:
Both terminal and native runs with `--rtc` now reach a Flash unlock write:
```text
Steps: 957729; instructions: 955049; IRQ entries: 80; DMA units: 2600; HALT idle: 0
Nominal cycles: 3520685
PC=0x082e28a6
unsupported RTC calendar access: write 0x07 at 0x080000c4
Steps: 964375; instructions: 961679; IRQ entries: 80; DMA units: 2616; HALT idle: 0
Nominal cycles: 3546961
PC=0x082e1892
unmapped memory at 0x0e005555
```
The native run captures twelve startup frames. These do not establish a working title screen or gameplay.
@ -90,9 +92,10 @@ Workspace debug/release tests, strict Clippy, public ARM/Thumb/memory/BIOS check
Native ROM-window tests and graphics smoke modes pass on Darwin arm64.
The pinned Pong debug/release reports match.
The next requirement is RTC calendar transfers with an explicit clock-source model.
Implement validated date/time formats, read snapshots, writes/reset, and clock ownership without putting host time in the core.
Keep battery persistence and cartridge IRQ behavior explicit; do not equate GBA STOP with stopping a battery-powered RTC.
The next requirement is a cartridge Flash save device, beginning with command unlock and identification.
The failing state contains value `0xaa` and address `0x0e005555`, matching the documented unlock sequence.
Research device selection, IDs, banking, program/erase, busy behavior, and safe persistence before claiming save compatibility.
RTC persistence and cartridge IRQ behavior remain separate missing features.
Ordinary ROM writes remain read-only. Without `--rtc`, the earlier GPIO diagnostic is still expected.
This result does not establish gameplay, linked transfers, or cartridge/serial GPIO interrupt support.
@ -104,3 +107,5 @@ Do not treat digital-level tests as audible-game validation or suppress remainin
Cartridge saves remain a separate required feature. SRAM alone will not support Emerald's Flash save protocol.
Hardware and emulator references are recorded in the linked subsystem documents.
[GBATEK backup Flash](https://problemkaputt.de/gbatek-gba-cart-backup-flash-rom.htm) identifies the `0xaa`/`0x5555` unlock and chip-identification sequence.
The bounded failure establishes an access requirement, not the device's capacity or full save compatibility.

View file

@ -43,7 +43,7 @@ Update this file when a feature lands or a limit is removed.
- One-shot non-sequential nominal CPU code timing after successful DMA, including RAM-only transfers.
- HALT with enabled-request wake-up, continued device clocks, and bounded idle steps.
- BIOS-only CPU writes to POSTFLG/HALTCNT, with HALT and keypad-wake STOP.
- STOP freezes CPU/DMA/device clocks, retains device phases, and reports a stopped frame runner without spinning.
- STOP freezes CPU/DMA/GBA-device clocks, retains device phases, and reports a stopped frame runner without spinning.
- Machine execution to the next VBlank event, with a configurable step limit.
- CPU/device stepping with nominal ARM7 instruction costs and memory wait states.
- Ordered CPU timing transactions record source, data, internal, and refill costs, with whole-step diagnostic rollback.
@ -76,7 +76,9 @@ Update this file when a feature lands or a limit is removed.
- `SWP`/`SWPB`, including unaligned word rotation and register aliases.
- 256 KiB external work RAM and 32 KiB internal work RAM, including mirrors.
- Read-only cartridge bytes in the three GBA cartridge windows.
- Explicit cartridge RTC selection with first-window GPIO read enable, direction/latch behavior, and serial command/control transactions.
- Explicit cartridge RTC selection with first-window GPIO read enable, direction/latch behavior, and serial calendar/control transactions.
- Validated RTC date/time, BCD transfers, read snapshots, complete writes/reset, and caller-supplied battery-clock advancement.
- Desktop `--rtc` seeds UTC once and supplies host elapsed seconds independently of CPU cycles and STOP.
- Cartridge GPIO changes participate in CPU/DMA rollback and block-store preflight without modifying ROM bytes.
- Raw ROM-file loading with original BIOS boot, terminal diagnostics, and final CPU/step reports.
- Firmware boot initializes BG2/BG3 identity scales for bitmap and affine programs that do not program their own matrices.
@ -141,8 +143,9 @@ Horizontal window comparators retain four-cycle event history.
Sprite rows prepare one line ahead with a whole-row cycle40 sample and nominal per-row work limits.
Individual fetch timing, hardware-accurate work cutoffs, video-bus contention, and per-pixel color composition remain unimplemented.
There is no host audio output or cartridge save support.
The optional [cartridge GPIO/RTC subset](hardware/cartridge-gpio.md) supports command/control only, selected with `--rtc` in ROM modes.
RTC calendar access, clock advancement, persistence, interrupts, GPIO aliases, and byte writes remain unsupported or unverified.
The optional [cartridge GPIO/RTC subset](hardware/cartridge-gpio.md) supports calendar/control transfers and explicit clock updates, selected with `--rtc` in ROM modes.
RTC persistence, interrupts, invalid-date correction, physical subsecond timing, GPIO aliases, and byte writes remain unsupported or unverified.
The core uses deterministic caller time. Desktop RTC runs use host UTC/elapsed time and are not fully deterministic.
The [audio subset](hardware/audio.md) supports Direct Sound, both PSG pulse channels, noise channel 4, idle wave channel 3 state, and instantaneous digital mixing.
It does not yet provide a continuous sample stream. Wave channel 3 playback and unverified sound-DMA pairings remain diagnostic.
PSG timing and signed mixing are nominal; hardware startup edges and analog behavior remain unverified.
@ -165,8 +168,8 @@ It also passes the pulse/noise triggers and normal-mode RCNT configuration.
It also completes a CpuSet call to address zero through the corrected ignored BIOS-write behavior.
It also accepts an external-clock serial request without inventing clock edges or completion.
It now also completes internally clocked normal transfers and selects idle multiplayer mode.
With explicit `--rtc` selection, it also passes Game Pak GPIO configuration and reaches an RTC calendar-read command.
That command remains diagnostic (`0x080000c4`, PC `0x082e28a6`); no date is fabricated.
With explicit `--rtc` selection, it also passes Game Pak GPIO configuration and RTC calendar reads.
It next stops at a Flash unlock write (`0x0e005555`, PC `0x082e1892`). Cartridge save devices remain unimplemented.
Without `--rtc`, cartridge GPIO writes still return read-only diagnostics.
No title-screen, gameplay, or audible-game compatibility is established.
See [the startup result](research/emerald-reset.md) for the bounded run and next investigation.

View file

@ -65,11 +65,14 @@ They verify suite selection, pinned URLs, hashes, sizes, checkpoint validation,
The [Pong adapter](public-pong.md) has additional offline tests for its pinned downloads, retained license, and bounded gameplay scenarios.
Run the external Pong suite separately; ordinary Cargo and Python tests require no public ROM or network access.
Device initialization regressions use original instructions to check disabled sound, wave RAM, disconnected serial pins, and reset flags.
Cartridge GPIO tests use an explicitly attached RTC control subset and original pin sequences, never game code or host time.
They cover read overlays, unchanged ROM bytes, masks, directions, widths, serial framing, control/reset, aborts, and explicit calendar/IRQ diagnostics.
Core cartridge GPIO tests use an explicitly attached RTC and original pin sequences, never game code or host time.
They cover read overlays, unchanged ROM bytes, masks, directions, widths, framing, control/calendar/reset, aborts, and unsupported commands.
Calendar tests cover all dates in 2000–2099, 12/24-hour representation, PM flags, midnight, leap days, and large/split elapsed durations.
Payload tests verify read snapshots, complete writes, invalid-date diagnostics, reset, and clock advancement during HALT/STOP.
ARM/Thumb and DMA3 checks retain Game Pak costs; staged fetches, rejected edges, and block-store preflight retain diagnostic atomicity.
Desktop tests check explicit `--rtc` selection, duplicate rejection, unchanged files, and native-window configuration.
Physical pin timing, GPIO aliases, RTC calendar values, and battery behavior remain unverified or unimplemented.
Desktop tests execute original ARM calendar commands in terminal and native-window modes with explicit `--rtc` selection.
Host-adapter tests inject UTC epoch values and elapsed durations without sleeping. Guest time changes survive subsequent elapsed updates.
Physical pin timing, invalid-date hardware correction, GPIO aliases, and battery persistence remain unverified or unimplemented.
They verify explicit activation errors and atomic byte/word/block-store/DMA failure behavior.
The optional [local Emerald startup check](research/emerald-reset.md) now reaches sound activation but still fails deliberately there.
[Jev evidence review](jev-debugging.md) is optional and never changes deterministic test verdicts.

View file

@ -41,10 +41,10 @@ This exposed and corrected missing firmware affine-scale initialization, without
Frame-based scenarios and the preparation adapter now make the result repeatable.
A local Emerald run now passes its initial all-device reset, master sound enable, and pulse/noise triggers.
It also passes normal serial transfers and idle multiplayer configuration.
With `--rtc`, it passes Game Pak GPIO setup and stops at the first unsupported RTC calendar-read command.
With `--rtc`, it passes Game Pak GPIO setup and RTC calendar reads, then stops at a Flash unlock write.
The core has tested Direct Sound, both pulse channels, and noise channel 4.
Wave channel 3, a continuous sample stream, and desktop audio output remain missing.
See [the runtime result](research/emerald-reset.md). RTC calendar transfers and an explicit clock-source model are the next startup requirement.
See [the runtime result](research/emerald-reset.md). Flash identification and its save protocol are the next demonstrated startup requirement.
The separate save requirement remains cartridge SRAM, followed by safe host save persistence.
SRAM is byte-addressable battery-backed save memory. Flash and EEPROM remain separate protocols.