From 692be36349ff66a1c95c6aafc8e46b7d9f8b8a46 Mon Sep 17 00:00:00 2001 From: 0xrsydn Date: Wed, 7 Oct 2026 05:28:52 +0700 Subject: [PATCH] feat(cartridge): add explicit GPIO and RTC control support --- crates/desktop/src/cartridge.rs | 18 +- crates/desktop/src/cartridge/tests.rs | 3 +- crates/desktop/src/cartridge/window.rs | 4 + crates/desktop/src/desktop/rom.rs | 2 + crates/desktop/src/main.rs | 5 +- crates/desktop/tests/rom_cli.rs | 54 +++- crates/gba-core/src/cartridge.rs | 237 ++++++++++++++++ crates/gba-core/src/lib.rs | 1 + crates/gba-core/src/memory.rs | 51 +++- .../src/memory/cartridge_step_tests.rs | 124 +++++++++ crates/gba-core/tests/cartridge_gpio.rs | 255 ++++++++++++++++++ docs/architecture.md | 3 + docs/hardware/cartridge-gpio.md | 104 +++++++ docs/hardware/cartridge.md | 6 +- docs/research/emerald-reset.md | 28 +- docs/status.md | 10 +- docs/testing.md | 5 + docs/usability-roadmap.md | 5 +- 18 files changed, 889 insertions(+), 26 deletions(-) create mode 100644 crates/gba-core/src/cartridge.rs create mode 100644 crates/gba-core/src/memory/cartridge_step_tests.rs create mode 100644 crates/gba-core/tests/cartridge_gpio.rs create mode 100644 docs/hardware/cartridge-gpio.md diff --git a/crates/desktop/src/cartridge.rs b/crates/desktop/src/cartridge.rs index ed651db..526a959 100644 --- a/crates/desktop/src/cartridge.rs +++ b/crates/desktop/src/cartridge.rs @@ -13,13 +13,14 @@ use std::{ use gba_core::{ bios, + cartridge::CartridgeHardware, machine::{Machine, MachineError, StepKind}, memory::ROM_CAPACITY, }; const MAX_STEPS: u64 = 100_000_000; const MAX_FRAMES: u64 = 100_000; -const USAGE: &str = "usage: gameboy-rust --rom PATH (--steps COUNT | --window [--frames COUNT]); steps: 1..=100000000, frames: 1..=100000; do not combine with demo or help options"; +const USAGE: &str = "usage: gameboy-rust --rom PATH [--rtc] (--steps COUNT | --window [--frames COUNT]); steps: 1..=100000000, frames: 1..=100000; do not combine with demo or help options"; #[derive(Debug, PartialEq, Eq)] enum Mode { @@ -31,6 +32,7 @@ enum Mode { pub struct Options { path: PathBuf, mode: Mode, + hardware: CartridgeHardware, } pub fn parse_args(args: &[OsString]) -> io::Result { @@ -39,6 +41,7 @@ pub fn parse_args(args: &[OsString]) -> io::Result { let mut steps = None; let mut frames = None; let mut window = false; + let mut hardware = CartridgeHardware::None; let mut args = args.iter(); while let Some(arg) = args.next() { if arg == "--rom" && path.is_none() { @@ -47,6 +50,8 @@ pub fn parse_args(args: &[OsString]) -> io::Result { .filter(|value| !value.is_empty()) .ok_or_else(invalid)?; path = Some(PathBuf::from(value)); + } else if arg == "--rtc" && hardware == CartridgeHardware::None { + hardware = CartridgeHardware::Rtc; } else if arg == "--window" && !window { window = true; } else if (arg == "--steps" && steps.is_none()) || (arg == "--frames" && frames.is_none()) { @@ -83,6 +88,7 @@ pub fn parse_args(args: &[OsString]) -> io::Result { Ok(Options { path: path.ok_or_else(invalid)?, mode, + hardware, }) } @@ -237,12 +243,20 @@ pub fn execute(options: Options, writer: &mut impl Write) -> Result<(), Box steps, - Mode::Window { frames } => return crate::desktop::run_rom(bytes, frames, writer), + Mode::Window { frames } => { + return crate::desktop::run_rom(bytes, frames, options.hardware, writer) + } }; writeln!(writer, "Machine-step limit: {steps} (includes boot)")?; let mut machine = bios::boot(bytes)?; + machine + .memory_mut() + .set_cartridge_hardware(options.hardware); let report = run_bounded(&mut machine, steps); write_report(writer, &machine, &report)?; writer.flush()?; diff --git a/crates/desktop/src/cartridge/tests.rs b/crates/desktop/src/cartridge/tests.rs index 5058059..e62d2c5 100644 --- a/crates/desktop/src/cartridge/tests.rs +++ b/crates/desktop/src/cartridge/tests.rs @@ -26,7 +26,8 @@ fn options_require_both_flags_in_either_order() { parse_args(&arguments).unwrap(), Options { path: "original program.gba".into(), - mode: Mode::Terminal { steps: count } + mode: Mode::Terminal { steps: count }, + hardware: CartridgeHardware::None } ); } diff --git a/crates/desktop/src/cartridge/window.rs b/crates/desktop/src/cartridge/window.rs index dacd6db..fe4adc1 100644 --- a/crates/desktop/src/cartridge/window.rs +++ b/crates/desktop/src/cartridge/window.rs @@ -46,6 +46,10 @@ impl Session { }) } + pub fn set_cartridge_hardware(&mut self, hardware: gba_core::cartridge::CartridgeHardware) { + self.machine.memory_mut().set_cartridge_hardware(hardware); + } + pub fn frame(&self) -> &Framebuffer { &self.frame } diff --git a/crates/desktop/src/desktop/rom.rs b/crates/desktop/src/desktop/rom.rs index fbbcc41..d1a430c 100644 --- a/crates/desktop/src/desktop/rom.rs +++ b/crates/desktop/src/desktop/rom.rs @@ -8,9 +8,11 @@ use crate::cartridge::window::{Session, Update}; pub fn run( bytes: Vec, frame_limit: Option, + hardware: gba_core::cartridge::CartridgeHardware, writer: &mut impl Write, ) -> Result<(), Box> { let mut session = Session::new(bytes)?; + session.set_cartridge_hardware(hardware); writeln!( writer, "ROM window: Arrows=D-pad; Z/X=A/B; Q/W=L/R; Enter=Start; Backspace=Select; Escape exits." diff --git a/crates/desktop/src/main.rs b/crates/desktop/src/main.rs index 539bdf8..1d1d6d8 100644 --- a/crates/desktop/src/main.rs +++ b/crates/desktop/src/main.rs @@ -47,7 +47,7 @@ fn parse_args(args: &[OsString]) -> Result { return cartridge::suite::parse_args(args).map(RunMode::TestSuite); } if args.iter().any(|arg| { - ["--rom", "--steps", "--window", "--frames"] + ["--rom", "--steps", "--window", "--frames", "--rtc"] .iter() .any(|flag| arg == flag) }) { @@ -79,7 +79,7 @@ fn parse_args(args: &[OsString]) -> Result { [arg] if arg == "--help" || arg == "-h" => Ok(RunMode::Help), _ => Err(io::Error::new( io::ErrorKind::InvalidInput, - "usage: gameboy-rust [--cpu-demo | --timer-demo | --graphics-demo | --smoke-test | --graphics-smoke-test | --raster-demo | --raster-smoke-test | --mosaic-demo | --mosaic-smoke-test | --effects-demo | --effects-smoke-test | --tile-demo | --tile-smoke-test | --affine-demo | --affine-smoke-test | --affine-raster-demo | --affine-raster-smoke-test | --bitmap4-demo | --bitmap5-demo | --bitmap4-smoke-test | --bitmap5-smoke-test | --help]; or gameboy-rust --rom PATH (--steps COUNT | --window [--frames COUNT]); or gameboy-rust --test-suite PATH.json", + "usage: gameboy-rust [--cpu-demo | --timer-demo | --graphics-demo | --smoke-test | --graphics-smoke-test | --raster-demo | --raster-smoke-test | --mosaic-demo | --mosaic-smoke-test | --effects-demo | --effects-smoke-test | --tile-demo | --tile-smoke-test | --affine-demo | --affine-smoke-test | --affine-raster-demo | --affine-raster-smoke-test | --bitmap4-demo | --bitmap5-demo | --bitmap4-smoke-test | --bitmap5-smoke-test | --help]; or gameboy-rust --rom PATH [--rtc] (--steps COUNT | --window [--frames COUNT]); or gameboy-rust --test-suite PATH.json", )), } } @@ -125,6 +125,7 @@ fn run() -> Result<(), Box> { --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\ --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\ diff --git a/crates/desktop/tests/rom_cli.rs b/crates/desktop/tests/rom_cli.rs index a20605a..83fee74 100644 --- a/crates/desktop/tests/rom_cli.rs +++ b/crates/desktop/tests/rom_cli.rs @@ -77,6 +77,43 @@ fn raw_original_program_runs_with_a_bounded_report_without_changing_the_file() { assert_eq!(fs::read(path).unwrap(), original); } +fn rtc_setup() -> Vec { + // Original LDR/MOV/STRH/LDRH/loop: enable GPIO reads, then inspect control. + vec![ + 0xe59f100c, 0xe3a00001, 0xe1c100b0, 0xe1d120b0, 0xeafffffe, 0x080000c8, + ] +} + +#[test] +fn rtc_selection_is_explicit_and_does_not_modify_the_rom_file() { + let fixture = Fixture::new(); + let path = fixture.rom(&rtc_setup()); + let original = fs::read(&path).unwrap(); + let absent = run(&path, "100"); + assert_eq!(absent.status.code(), Some(1)); + assert!(stderr(&absent).contains("read-only memory at 0x080000c8")); + let selected = command() + .arg("--rtc") + .arg("--rom") + .arg(&path) + .args(["--steps", "100"]) + .output() + .unwrap(); + assert!(selected.status.success(), "{}", stderr(&selected)); + assert!(stdout(&selected).contains("RTC selected: GPIO and command/control only")); + assert!(stdout(&selected).contains("r2=0x00000001")); + assert_eq!(fs::read(&path).unwrap(), original); + let duplicate = command() + .arg("--rom") + .arg(&path) + .args(["--rtc", "--rtc", "--steps", "100"]) + .output() + .unwrap(); + assert_eq!(duplicate.status.code(), Some(1)); + assert!(stderr(&duplicate).contains("usage:")); + assert!(duplicate.stdout.is_empty()); +} + #[test] fn stop_is_a_normal_bounded_outcome() { let fixture = Fixture::new(); @@ -216,8 +253,12 @@ fn native_rom_window_presents_file_bytes_and_reports_stop_cpu_and_video_errors() thread, time::{Duration, Instant}, }; - fn window(path: &Path) -> Output { - let mut child = command() + fn window(path: &Path, rtc: bool) -> Output { + let mut command = command(); + if rtc { + command.arg("--rtc"); + } + let mut child = command .arg("--rom") .arg(path) .args(["--window", "--frames", "3"]) @@ -243,7 +284,7 @@ fn native_rom_window_presents_file_bytes_and_reports_stop_cpu_and_video_errors() let fixture = Fixture::new(); let path = fixture.0.join("original-input.gba"); fs::write(&path, gba_demos::input_rom()).unwrap(); - let output = window(&path); + let output = window(&path, false); assert!( output.status.success(), "{} {}", @@ -252,6 +293,11 @@ fn native_rom_window_presents_file_bytes_and_reports_stop_cpu_and_video_errors() ); assert!(stdout(&output).contains("Captured ROM frames: 3")); assert!(stdout(&output).contains("Result: window frame limit reached")); + let rtc_path = fixture.rom(&rtc_setup()); + let rtc_output = window(&rtc_path, true); + assert!(rtc_output.status.success(), "{}", stderr(&rtc_output)); + assert!(stdout(&rtc_output).contains("Captured ROM frames: 3")); + assert!(stdout(&rtc_output).contains("r2=0x00000001")); for (code, error) in [ (vec![0xef03_0000, 0xeaff_fffe], "entered STOP"), (vec![0xee00_0000], "instruction"), @@ -267,7 +313,7 @@ fn native_rom_window_presents_file_bytes_and_reports_stop_cpu_and_video_errors() .collect::>(), ) .unwrap(); - let output = window(&path); + let output = window(&path, false); assert_eq!(output.status.code(), Some(1), "{}", stdout(&output)); assert!(stdout(&output).contains("Result: ROM window diagnostic")); assert!(stdout(&output).contains("Captured ROM frames: 0")); diff --git a/crates/gba-core/src/cartridge.rs b/crates/gba-core/src/cartridge.rs new file mode 100644 index 0000000..f0ea42d --- /dev/null +++ b/crates/gba-core/src/cartridge.rs @@ -0,0 +1,237 @@ +//! Optional Game Pak hardware. No host clock, filesystem, or ROM-byte mutation. + +use crate::memory::MemoryError; + +pub const GPIO_DATA: u32 = 0x0800_00c4; +pub const GPIO_DIRECTION: u32 = 0x0800_00c6; +pub const GPIO_CONTROL: u32 = 0x0800_00c8; + +/// Explicit peripheral selection; ROM contents do not select a device. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub enum CartridgeHardware { + #[default] + None, + /// GPIO and RTC command/control only. Calendar and interrupt commands remain diagnostic. + Rtc, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub(crate) struct Cartridge { + gpio: Option, +} + +impl Cartridge { + pub(crate) fn new(hardware: CartridgeHardware) -> Self { + Self { + gpio: (hardware == CartridgeHardware::Rtc).then(Gpio::default), + } + } + + pub(crate) fn mapped(&self, address: u32) -> bool { + self.gpio.is_some() && (GPIO_DATA..GPIO_CONTROL + 2).contains(&address) + } + + /// Disabled reads expose the supplied ROM bytes, not a zero-filled replacement. + pub(crate) fn read8(&self, address: u32) -> Option { + let gpio = self.gpio?; + if !self.mapped(address) || !gpio.readable { + return None; + } + let value = match address & !1 { + GPIO_DATA => gpio.pins(), + GPIO_DIRECTION => gpio.direction, + GPIO_CONTROL => 1, + _ => unreachable!(), + }; + Some(if address & 1 == 0 { value } else { 0 }) + } + + /// Apply one access to a copy first. A rejected second halfword changes nothing. + pub(crate) fn write(&mut self, address: u32, bytes: &[u8]) -> Result<(), MemoryError> { + if !self.mapped(address) { + return Ok(()); // Non-cartridge accesses are validated by the bus. + } + let fail = |at, value, operation| MemoryError::UnsupportedIo { + address: at, + value, + operation, + }; + if bytes.len() == 1 { + return Err(fail(address, bytes[0], "Game Pak GPIO byte write")); + } + let mut next = self.gpio.unwrap(); + for (i, pair) in bytes.chunks_exact(2).enumerate() { + let at = address + i as u32 * 2; + let value = pair[0]; // Registers use at most the low four bits. + match at { + GPIO_DATA | GPIO_DIRECTION => { + if at == GPIO_DATA { + next.latch = value & 15; + } else { + next.direction = value & 15; + } + next.rtc + .update(next.latch & next.direction, next.direction) + .map_err(|operation| fail(at, value, operation))?; + } + GPIO_CONTROL => next.readable = value & 1 != 0, + _ => return Err(MemoryError::ReadOnly(at)), + } + } + self.gpio = Some(next); + Ok(()) + } +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +struct Gpio { + latch: u8, + direction: u8, + readable: bool, + rtc: Rtc, +} + +impl Gpio { + fn pins(self) -> u8 { + // RTC input defaults: SIO high outside a read; SCK/CS/unused low. + (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 { + // 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"), + } + } +} diff --git a/crates/gba-core/src/lib.rs b/crates/gba-core/src/lib.rs index 1e86759..9870683 100644 --- a/crates/gba-core/src/lib.rs +++ b/crates/gba-core/src/lib.rs @@ -2,6 +2,7 @@ pub mod audio; pub mod bios; +pub mod cartridge; pub mod cpu; pub mod display; pub mod dma; diff --git a/crates/gba-core/src/memory.rs b/crates/gba-core/src/memory.rs index b64bbc3..66cbdaf 100644 --- a/crates/gba-core/src/memory.rs +++ b/crates/gba-core/src/memory.rs @@ -2,6 +2,8 @@ use std::{cell::Cell, error::Error, fmt}; #[cfg(test)] mod audio_step_tests; +#[cfg(test)] +mod cartridge_step_tests; mod fetch; #[cfg(test)] mod fetch_tests; @@ -25,6 +27,7 @@ use iwram_bus::IwramBus; use crate::{ audio::{Audio, StereoLevel}, + cartridge::{Cartridge, CartridgeHardware}, cpu::InstructionSet, display::{DisplayPosition, CYCLES_PER_LINE, HBLANK_START, VISIBLE_LINES}, dma::{DmaError, DMA_BASE, DMA_END}, @@ -119,6 +122,8 @@ pub struct Memory { video_ram: Vec, oam: Vec, rom: Vec, + cartridge: Cartridge, + cartridge_step: Cell>, bios: Option>, io: Io, cycles: u64, @@ -148,6 +153,8 @@ impl Memory { video_ram: vec![0; VRAM_SIZE], oam: vec![0; 1024], rom, + cartridge: Cartridge::default(), + cartridge_step: Cell::new(None), bios: None, io: Io::default(), cycles: 0, @@ -176,6 +183,20 @@ impl Memory { Ok(memory) } + /// Replace optional cartridge hardware with its initial state. Call between machine steps. + /// This never modifies the supplied ROM bytes or enables a host clock. + pub fn set_cartridge_hardware(&mut self, hardware: CartridgeHardware) { + assert!( + self.cartridge_step.get().is_none(), + "active cartridge transaction" + ); + self.cartridge = Cartridge::new(hardware); + } + + fn cartridge_state(&self) -> Cartridge { + self.cartridge_step.get().unwrap_or(self.cartridge) + } + /// Current committed audio mixer level. This is not a host sample stream. pub fn audio_level(&self) -> StereoLevel { self.io.audio.level() @@ -221,10 +242,12 @@ impl Memory { pub(crate) fn begin_timer_step(&self) { debug_assert!(self.timer_step.get().is_none()); self.timer_step.set(Some(self.io.timer_step())); + self.cartridge_step.set(Some(self.cartridge)); } pub(crate) fn discard_timer_step(&self) { self.timer_step.set(None); + self.cartridge_step.set(None); } fn advance_timer_step(&self, cycles: u32) { @@ -241,6 +264,10 @@ impl Memory { .take() .expect("machine timer transaction is active"); self.io.commit_timer_step(step); + self.cartridge = self + .cartridge_step + .take() + .expect("active cartridge transaction"); // Staged devices already reached this boundary. Advance remaining devices exactly once. self.advance_running_cycles_with_timers(cycles, false); } @@ -732,6 +759,9 @@ impl Memory { // Strict lookup for host access, instruction fetches, and prefetch snapshots. fn read_mapped_byte(&self, address: u32) -> Result { + if let Some(value) = self.cartridge_state().read8(address) { + return Ok(value); + } match address >> 24 { 0x00 => self .bios @@ -784,6 +814,8 @@ impl Memory { ) -> Result<(), MemoryError> { let index = self.write_index::(address, access)?; Self::validate_io_values(&mut self.audio_state(), address, &bytes)?; + let mut cartridge = self.cartridge_state(); + cartridge.write(address, &bytes)?; self.record_access( address, match N { @@ -793,6 +825,14 @@ impl Memory { _ => unreachable!("unsupported bus width"), }, ); + if cartridge.mapped(address) { + if self.cartridge_step.get().is_some() { + self.cartridge_step.set(Some(cartridge)); + } else { + self.cartridge = cartridge; + } + return Ok(()); + } if address < BIOS_SIZE as u32 { // Only validated CPU/DMA writes reach here. Pay the bus cost but // never modify the mapped ROM image or drive BIOS read history. @@ -928,7 +968,14 @@ impl Memory { } Ok(0) // I/O does not use a RAM index. } - 0x08..=0x0d => Err(MemoryError::ReadOnly(address)), + 0x08..=0x0d => { + for offset in 0..N as u32 { + if !self.cartridge_state().mapped(address + offset) { + return Err(MemoryError::ReadOnly(address + offset)); + } + } + Ok(0) + } _ => Err(MemoryError::Unmapped(address)), } } @@ -975,6 +1022,7 @@ impl Memory { pub(crate) fn write_words(&mut self, writes: &[(u32, u32)]) -> Result<(), MemoryError> { let mut audio = self.audio_state(); let mut serial = self.serial_state(); + let mut cartridge = self.cartridge_state(); let timing = self.cpu_timing.get(); let devices = self.timer_step.get(); // Preflight ordered data phases on temporary device/timing copies. @@ -984,6 +1032,7 @@ impl Memory { self.write_index::<4>(address, self.write_access())?; let bytes = value.to_le_bytes(); Self::validate_io_values(&mut audio, address, &bytes)?; + cartridge.write(address, &bytes)?; self.record_access(address, AccessWidth::Word); if Serial::mapped(address) { if let Some(mut step) = self.timer_step.get() { diff --git a/crates/gba-core/src/memory/cartridge_step_tests.rs b/crates/gba-core/src/memory/cartridge_step_tests.rs new file mode 100644 index 0000000..b38e70d --- /dev/null +++ b/crates/gba-core/src/memory/cartridge_step_tests.rs @@ -0,0 +1,124 @@ +//! Original GPIO bus-phase, fetch, and diagnostic-rollback checks. +use super::{timing_event_tests::prepared, *}; +use crate::{ + cartridge::{CartridgeHardware, GPIO_CONTROL, GPIO_DATA, GPIO_DIRECTION}, + machine::Machine, +}; + +#[test] +fn arm_and_thumb_stores_pay_game_pak_costs_and_keep_rom_immutable() { + for thumb in [false, true] { + let (cpu, mut memory) = prepared( + thumb, + &[if thumb { 0x8008 } else { 0xe1c100b0 }], // STRH r0,[r1] + &[(0, 1), (1, GPIO_CONTROL)], + ); + memory.set_cartridge_hardware(CartridgeHardware::Rtc); + let rom = memory.rom.clone(); + let mut machine = Machine::new(cpu, memory); + machine.step().unwrap(); + assert_eq!(machine.memory().read16(GPIO_CONTROL).unwrap(), 1); + assert_eq!(machine.memory().rom, rom); + assert_eq!(machine.last_timing().data_cycles, 5); // Default non-sequential Game Pak halfword. + } +} + +#[test] +fn rejected_rtc_command_edge_rolls_back_arm_and_thumb_steps() { + for thumb in [false, true] { + let (cpu, mut memory) = prepared( + thumb, + &[if thumb { 0x8008 } else { 0xe1c100b0 }], + &[(0, 7), (1, GPIO_DATA)], + ); + 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(); + // All but the final rising edge of an original date/time read command (0x65). + for bit in (1..8).rev() { + let pins = 4 | (((0x65 >> bit) & 1) << 1); + memory.write16(GPIO_DATA, pins).unwrap(); + memory.write16(GPIO_DATA, pins | 1).unwrap(); + } + memory.write16(GPIO_DATA, 6).unwrap(); + let cartridge = memory.cartridge; + 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_eq!(machine.step(), Err(error)); + assert_eq!(machine.cpu(), &cpu); + assert_eq!(machine.memory().cartridge, cartridge); + assert_eq!(machine.memory().read16(GPIO_DATA).unwrap(), 6); + assert_eq!(machine.cycles(), 0); + } +} + +#[test] +fn block_store_failure_retains_gpio_and_cpu_state() { + // First word targets DATA/DIRECTION. Second reaches unmapped padding after CONTROL. + let (cpu, mut memory) = prepared( + false, + &[0xe8a00006], + &[(0, GPIO_DATA), (1, 0x00030002), (2, 1)], + ); + memory.set_cartridge_hardware(CartridgeHardware::Rtc); + memory.write16(GPIO_CONTROL, 1).unwrap(); + let cartridge = memory.cartridge; + let mut machine = Machine::new(cpu, memory); + let cpu = machine.cpu().clone(); + let error = machine.step().unwrap_err(); + assert_eq!(machine.step(), Err(error)); + assert_eq!(machine.cpu(), &cpu); + assert_eq!(machine.memory().cartridge, cartridge); + assert_eq!(machine.cycles(), 0); +} + +#[test] +fn preflight_applies_earlier_gpio_direction_changes_to_later_pin_validation() { + let (_, mut memory) = prepared(false, &[0xe1a00000], &[]); + memory.set_cartridge_hardware(CartridgeHardware::Rtc); + memory.write16(GPIO_CONTROL, 1).unwrap(); + let cartridge = memory.cartridge; + assert!(memory + .write_words(&[ + (0x02000000, 0x12345678), + (GPIO_DATA, 0x00070001), // Drive clock high, select low. + (GPIO_DATA, 0x00070004), // CS rises with clock low: diagnostic. + ]) + .is_err()); + assert_eq!(memory.cartridge, cartridge); + assert_eq!(memory.read32(0x02000000).unwrap(), 0); + memory + .write_words(&[(GPIO_DATA, 0x00070001), (GPIO_DATA, 0x00070005)]) + .unwrap(); + assert_eq!(memory.read32(GPIO_DATA).unwrap(), 0x00070005); +} + +#[test] +fn staged_overlay_is_shared_by_data_and_instruction_reads_then_discarded() { + let mut memory = Memory::new(vec![0xa5; 0x100]).unwrap(); + memory.set_cartridge_hardware(CartridgeHardware::Rtc); + memory.begin_timer_step(); + memory.write16(GPIO_CONTROL, 1).unwrap(); + memory.write16(GPIO_DIRECTION, 3).unwrap(); + memory.write16(GPIO_DATA, 1).unwrap(); + assert_eq!(memory.read32(GPIO_DATA).unwrap(), 0x00030001); + assert_eq!( + memory + .fetch_instruction(GPIO_DATA, InstructionSet::Arm) + .instruction, + Ok(0x00030001) + ); + assert_eq!(memory.cartridge.read8(GPIO_DATA), None); + memory.discard_timer_step(); + assert_eq!(memory.read32(GPIO_DATA).unwrap(), 0xa5a5a5a5); + assert_eq!( + memory + .fetch_instruction(GPIO_DATA, InstructionSet::Arm) + .instruction, + Ok(0xa5a5a5a5) + ); +} diff --git a/crates/gba-core/tests/cartridge_gpio.rs b/crates/gba-core/tests/cartridge_gpio.rs new file mode 100644 index 0000000..22f55a9 --- /dev/null +++ b/crates/gba-core/tests/cartridge_gpio.rs @@ -0,0 +1,255 @@ +//! Original GPIO and RTC control transactions. No game code or host clock. +use gba_core::{ + cartridge::{CartridgeHardware, GPIO_CONTROL, GPIO_DATA, GPIO_DIRECTION}, + cpu::Cpu, + dma::DMA_BASE, + io::IF, + machine::{Machine, StepKind}, + memory::{Memory, MemoryError, ROM_START}, +}; + +fn memory() -> Memory { + let mut rom = vec![0xa5; 0x200]; + rom[..4].copy_from_slice(&0xeafffffeu32.to_le_bytes()); + let mut bus = Memory::new(rom).unwrap(); + bus.set_cartridge_hardware(CartridgeHardware::Rtc); + bus +} + +fn start(bus: &mut Memory) { + bus.write16(GPIO_DATA, 1).unwrap(); + bus.write16(GPIO_DIRECTION, 7).unwrap(); + bus.write16(GPIO_DATA, 5).unwrap(); +} + +fn send_bits(bus: &mut Memory, bits: impl IntoIterator) -> Result<(), MemoryError> { + for bit in bits { + let data = 4 | (u16::from(bit) << 1); + bus.write16(GPIO_DATA, data)?; + bus.write16(GPIO_DATA, data | 1)?; + } + Ok(()) +} + +fn command(bus: &mut Memory, byte: u8, msb_first: bool) -> Result<(), MemoryError> { + // GBATEK's Fwd and Rev representations describe the same physical wire sequence. + let byte = if msb_first { byte } else { byte.reverse_bits() }; + send_bits( + bus, + (0..8).map(|i| byte & (1 << if msb_first { 7 - i } else { i }) != 0), + ) +} + +fn parameter(bus: &mut Memory, byte: u8) -> Result<(), MemoryError> { + send_bits(bus, (0..8).map(|i| byte & (1 << i) != 0)) +} + +fn control(bus: &mut Memory, msb_first: bool) -> u8 { + start(bus); + command(bus, 0x63, msb_first).unwrap(); + bus.write16(GPIO_DIRECTION, 5).unwrap(); + let mut value = 0; + for bit in 0..8 { + bus.write16(GPIO_DATA, 4).unwrap(); + let low = bus.read16(GPIO_DATA).unwrap(); + bus.write16(GPIO_DATA, 5).unwrap(); + let high = bus.read16(GPIO_DATA).unwrap(); + assert_eq!(low & 2, high & 2); // Output changes on falling, not rising, edges. + assert_eq!(bus.read16(GPIO_DATA).unwrap(), high); // Reads do not shift. + value |= ((high as u8 >> 1) & 1) << bit; + } + bus.write16(GPIO_DATA, 1).unwrap(); + value +} + +#[test] +fn explicit_selection_overlay_and_disable_never_patch_rom_bytes() { + let mut bus = memory(); + for address in [GPIO_DATA, GPIO_DIRECTION, GPIO_CONTROL] { + assert_eq!(bus.read16(address).unwrap(), 0xa5a5); + } + bus.write16(GPIO_CONTROL, 0xffff).unwrap(); + assert_eq!(bus.read16(GPIO_CONTROL).unwrap(), 1); + assert_eq!(bus.read16(GPIO_DATA).unwrap(), 2); // Idle SIO high; no host time. + assert_eq!(bus.read16(GPIO_DIRECTION).unwrap(), 0); + assert_eq!(bus.read32(GPIO_CONTROL).unwrap(), 0xa5a50001); + assert_eq!(bus.read8(GPIO_DATA + 1).unwrap(), 0); + for address in [ + GPIO_DATA + 0x02000000, + GPIO_DATA + 0x04000000, + GPIO_DATA + 0x01000000, + ] { + assert!(bus.write16(address, 0).is_err()); // No inferred mirror-write behavior. + } + for base in [0x0a000000, 0x0c000000] { + assert_eq!(bus.read16(base + 0xc4).unwrap(), 0xa5a5); + } + bus.write16(GPIO_CONTROL, 0xfffe).unwrap(); + assert_eq!(bus.read16(GPIO_DATA).unwrap(), 0xa5a5); + bus.set_cartridge_hardware(CartridgeHardware::None); + assert_eq!( + bus.write16(GPIO_CONTROL, 1), + Err(MemoryError::ReadOnly(GPIO_CONTROL)) + ); + assert_eq!(bus.read16(GPIO_CONTROL).unwrap(), 0xa5a5); +} + +#[test] +fn masks_directions_latches_and_access_boundaries() { + let mut bus = memory(); + bus.write16(GPIO_CONTROL, 1).unwrap(); + bus.write16(GPIO_DATA, 0xfffb).unwrap(); // CS low; data writes latch even on inputs. + assert_eq!(bus.read16(GPIO_DATA).unwrap(), 2); + bus.write16(GPIO_DIRECTION, 0xfffb).unwrap(); + assert_eq!(bus.read16(GPIO_DIRECTION).unwrap(), 11); + assert_eq!(bus.read16(GPIO_DATA).unwrap(), 11); + bus.write32(GPIO_DATA, 0x00030002).unwrap(); // Two halfwords: data, then direction. + assert_eq!(bus.read32(GPIO_DATA).unwrap(), 0x00030002); + assert!(bus + .write8(GPIO_DATA, 0) + .unwrap_err() + .to_string() + .contains("GPIO byte write")); + assert_eq!( + bus.write16(GPIO_DATA + 1, 0), + Err(MemoryError::Unaligned(GPIO_DATA + 1)) + ); + assert_eq!( + bus.write32(GPIO_CONTROL, 0), + Err(MemoryError::ReadOnly(GPIO_CONTROL + 2)) + ); + assert_eq!(bus.read16(GPIO_CONTROL).unwrap(), 1); // No partial disable. + assert_eq!( + bus.write16(GPIO_DATA - 2, 0), + Err(MemoryError::ReadOnly(GPIO_DATA - 2)) + ); +} + +#[test] +fn control_read_write_reset_and_both_command_representations() { + for msb in [false, true] { + let mut bus = memory(); + bus.write16(GPIO_CONTROL, 1).unwrap(); + assert_eq!(control(&mut bus, msb), 0x40); + for reset in [0x60, 0x61] { + // Either access direction strobes reset. + start(&mut bus); + command(&mut bus, reset, msb).unwrap(); + bus.write16(GPIO_DATA, 1).unwrap(); + assert_eq!(control(&mut bus, msb), 0); + start(&mut bus); + command(&mut bus, 0x62, msb).unwrap(); + parameter(&mut bus, 0xd5).unwrap(); // Unused and read-only bits do not stick. + bus.write16(GPIO_DATA, 1).unwrap(); + assert_eq!(control(&mut bus, msb), 0x40); + } + bus.advance_cycles(1000000); + assert_eq!(bus.read16(IF).unwrap(), 0); + } +} + +#[test] +fn disabled_read_overlay_does_not_disable_serial_writes() { + let mut bus = memory(); + start(&mut bus); + command(&mut bus, 0x62, true).unwrap(); + parameter(&mut bus, 0).unwrap(); + bus.write16(GPIO_DATA, 1).unwrap(); + assert_eq!(bus.read16(GPIO_CONTROL).unwrap(), 0xa5a5); + bus.write16(GPIO_CONTROL, 1).unwrap(); + assert_eq!(control(&mut bus, true), 0); +} + +#[test] +fn dropping_select_aborts_partial_commands_and_parameters() { + let mut bus = memory(); + bus.write16(GPIO_CONTROL, 1).unwrap(); + for bits in 0..8 { + start(&mut bus); + send_bits(&mut bus, (0..bits).map(|_| false)).unwrap(); + bus.write16(GPIO_DATA, 1).unwrap(); + assert_eq!(control(&mut bus, true), 0x40); + start(&mut bus); + command(&mut bus, 0x62, true).unwrap(); + send_bits(&mut bus, (0..bits).map(|_| false)).unwrap(); + bus.write16(GPIO_DATA, 1).unwrap(); + assert_eq!(control(&mut bus, true), 0x40); + } +} + +#[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"), + (0xc6, "command encoding"), // Do not accept a reversed wire prefix as another format. + ] { + let mut bus = memory(); + bus.write16(GPIO_CONTROL, 1).unwrap(); + start(&mut bus); + let error = command(&mut bus, byte, true).unwrap_err(); + assert!(error.to_string().contains(description), "{error}"); + let pins = bus.read16(GPIO_DATA).unwrap(); + assert_eq!(pins & 1, 0); // Failing rising edge was not committed. + assert_eq!(bus.write16(GPIO_DATA, pins | 1), Err(error)); + bus.write16(GPIO_DATA, 1).unwrap(); + assert_eq!(control(&mut bus, true), 0x40); + assert_eq!(bus.read16(IF).unwrap(), 0); + } + for value in [2, 8, 32, 0xff] { + let mut bus = memory(); + bus.write16(GPIO_CONTROL, 1).unwrap(); + start(&mut bus); + command(&mut bus, 0x62, true).unwrap(); + assert!(parameter(&mut bus, value) + .unwrap_err() + .to_string() + .contains("control bits")); + bus.write16(GPIO_DATA, 1).unwrap(); + assert_eq!(control(&mut bus, true), 0x40); + } +} + +#[test] +fn unsupported_edges_and_word_second_halfword_are_atomic() { + let mut bus = memory(); + bus.write16(GPIO_CONTROL, 1).unwrap(); + // Data writes latch CS high while all pins are inputs. The following direction halfword + // would select with SCK low, so neither halfword of this word may commit. + assert!(bus.write32(GPIO_DATA, 0x00070004).is_err()); + assert_eq!(bus.read32(GPIO_DATA).unwrap(), 2); + bus.write16(GPIO_DIRECTION, 7).unwrap(); + assert_eq!(bus.read16(GPIO_DATA).unwrap(), 0); // Failed word did not retain latch=4. + bus.write16(GPIO_DATA, 1).unwrap(); + bus.write16(GPIO_DATA, 5).unwrap(); + bus.write16(GPIO_DATA, 4).unwrap(); + assert!(bus + .write16(GPIO_DATA, 7) + .unwrap_err() + .to_string() + .contains("sampling edge")); + assert_eq!(bus.read16(GPIO_DATA).unwrap(), 4); + bus.write16(GPIO_DATA, 5).unwrap(); // Same stable data is retryable. +} + +#[test] +fn dma3_word_store_uses_gpio_without_modifying_rom_or_raising_a_cartridge_irq() { + let mut bus = memory(); + bus.write16(GPIO_CONTROL, 1).unwrap(); + bus.write32(0x02000000, 0x00030002).unwrap(); + let dma = DMA_BASE + 36; + bus.write32(dma, 0x02000000).unwrap(); + bus.write32(dma + 4, GPIO_DATA).unwrap(); + bus.write32(dma + 8, 0xc4000001).unwrap(); + let mut machine = Machine::new(Cpu::new(ROM_START), bus); + assert_eq!(machine.step().unwrap(), StepKind::Dma { channel: 3 }); + assert_eq!(machine.memory().read32(GPIO_DATA).unwrap(), 0x00030002); + assert_eq!(machine.memory().read16(IF).unwrap(), 0x800); + machine.memory_mut().write16(GPIO_CONTROL, 0).unwrap(); + assert_eq!(machine.memory().read32(GPIO_DATA).unwrap(), 0xa5a5a5a5); +} diff --git a/docs/architecture.md b/docs/architecture.md index 9775def..4f549d4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -48,6 +48,9 @@ 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/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 | | `crates/gba-core/src/memory/fetch.rs` | Strict instruction samples with captured region-dependent bus observations | | `crates/gba-core/src/memory/fetch_tests.rs` | Fetch widths, bus lanes, captured refill/BIOS values, strict errors, and side-effect isolation | diff --git a/docs/hardware/cartridge-gpio.md b/docs/hardware/cartridge-gpio.md new file mode 100644 index 0000000..bf828cc --- /dev/null +++ b/docs/hardware/cartridge-gpio.md @@ -0,0 +1,104 @@ +# Game Pak GPIO and RTC control subset + +The core supports an explicitly selected cartridge GPIO interface and RTC command/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. + +## Selection + +`Memory::set_cartridge_hardware(CartridgeHardware::Rtc)` attaches the bounded interface with initial state. +`CartridgeHardware::None` is the default. Selecting either option replaces peripheral state, without changing ROM bytes. +Call this API between machine steps. The core does not inspect filenames, game headers, or library signatures to select hardware. + +The desktop enables this interface with `--rtc`, in either ROM execution mode: + +```sh +direnv exec . cargo run --locked --release -- --rom roms/pokemon-emerald.gba --rtc --steps 2000000 +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 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. + +## GPIO registers + +| Address | Register | Retained bits | +| --- | --- | --- | +| `0x080000c4` | Data output latch and pin samples | 0–3 | +| `0x080000c6` | Direction: 0=input, 1=output | 0–3 | +| `0x080000c8` | Read enable: 0=ROM reads, 1=register reads | 0 | + +All other register bits read zero when read enable is set. Initial direction, data latch, and read enable are zero. +With read enable clear, reads return the original supplied ROM bytes, including nonzero bytes. +Disabling reads does not disable writes or reset an RTC transaction. +ROM bytes are never overwritten with register values. + +Data writes retain an output latch independently of direction. +Output pins read their driven latch bits. Changing direction can expose a retained output and create an RTC signal edge. +The RTC mapping uses bit 0 for clock, bit 1 for bidirectional data, and bit 2 for chip select. +The bounded input model holds clock, select, and the unused pin low; RTC data is high outside a read transaction. +This input policy follows the pinned reference implementation, not independent physical measurements. +No solar sensor, rumble, gyro, or cartridge interrupt source is attached. + +Aligned halfword writes are supported. A word at `0x080000c4` applies data, then direction, as two ordered halfwords. +A rejected second halfword leaves both registers unchanged under the core's diagnostic policy. +A word at `0x080000c8` reaches ordinary ROM at `0x080000ca` and fails without changing control. +Byte writes are diagnostic because the consulted bus-width description is uncertain about GBA versus DS behavior. +Byte, halfword, and word reads use ordinary little-endian lanes, including register/ROM boundaries. + +Only the documented first-window addresses are implemented. +Other ROM windows still expose supplied ROM bytes; their writes remain read-only diagnostics. +GPIO mirroring and writes at alias addresses have not been independently validated. +Without explicit hardware selection, all these addresses remain ordinary read-only cartridge memory. + +## RTC command and control transfers + +Chip select rising with clock high begins a command. Chip select falling aborts the current transaction. +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. +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. +- `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. + +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. + +## Bus integration and tests + +CPU and DMA accesses retain Game Pak wait-state costs. +Machine steps stage cartridge state separately from ROM bytes. Failed steps discard pin edges and control changes. +Block-store preflight applies earlier pin/direction changes before validating later accesses. +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. +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. +These tests validate the bounded implementation, not physical RTC timing or game playability. + +## References + +- [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 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. diff --git a/docs/hardware/cartridge.md b/docs/hardware/cartridge.md index a7d79ec..fea9222 100644 --- a/docs/hardware/cartridge.md +++ b/docs/hardware/cartridge.md @@ -33,9 +33,13 @@ It does not decode archives or ELF executables. Supply a raw binary with an ARM 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. -Cartridge writes, save devices, and cartridge peripherals remain unsupported. +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. Both modes call `bios::boot` with the loaded bytes. +With `--rtc`, the host then selects the cartridge peripheral before the first machine step. +Without that option, no cartridge peripheral is attached. Duplicate `--rtc` options are rejected. The [original BIOS replacement](bios.md) executes its minimal boot before entering `0x08000000` in ARM System mode. There is no command-line option for external BIOS files. Unsupported services remain diagnostics. RegisterRamReset now accepts sound/serial reset flags, including `r0=0xff`, within the initialization subset. diff --git a/docs/research/emerald-reset.md b/docs/research/emerald-reset.md index 6e71777..0bae089 100644 --- a/docs/research/emerald-reset.md +++ b/docs/research/emerald-reset.md @@ -25,8 +25,8 @@ Byte, halfword, word, block-store, and DMA validation retain explicit errors wit Use a local ROM that you may lawfully use: ```sh -direnv exec . cargo run --locked --release -- --rom roms/pokemon-emerald.gba --steps 1000000 -direnv exec . cargo run --locked --release -- --rom roms/pokemon-emerald.gba --window --frames 120 +direnv exec . cargo run --locked --release -- --rom roms/pokemon-emerald.gba --rtc --steps 2000000 +direnv exec . cargo run --locked --release -- --rom roms/pokemon-emerald.gba --rtc --window --frames 60 ``` The earlier reset-only implementation stopped at master sound enable (`0x04000084`, PC `0x082e0518`). @@ -64,13 +64,18 @@ A child cannot start itself without a parent clock. No linked transfer, assigned The send register supports 16-bit data; receive latches do not become invented peer data. See [the register model and evidence limits](../hardware/serial.md#disconnected-multiplayer-configuration). -Both terminal and native runs now reach a Game Pak GPIO control write: +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. + +Both terminal and native runs with `--rtc` now reach a calendar-read command: ```text -Steps: 956055; instructions: 953375; IRQ entries: 80; DMA units: 2600; HALT idle: 0 -Nominal cycles: 3514932 -PC=0x082e29f8 -read-only memory at 0x080000c8 +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 ``` The native run captures twelve startup frames. These do not establish a working title screen or gameplay. @@ -85,10 +90,11 @@ 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 cartridge GPIO, starting with the control register at `0x080000c8`. -Research the Game Pak data/direction/control registers and real-time clock protocol before implementing them. -Keep ordinary ROM writes read-only. Do not hide the failure by ignoring every cartridge write. -This serial result does not establish linked gameplay or serial GPIO interrupt support. +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. +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. Noise now has deterministic counter clocks and shares tested length/envelope logic with the independent pulse channels. Sweep applies only to channel 1. Direct Sound has FIFO clocks and nominal DMA requests. diff --git a/docs/status.md b/docs/status.md index bb4c2c3..fc48149 100644 --- a/docs/status.md +++ b/docs/status.md @@ -76,6 +76,8 @@ 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. +- 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. - RegisterRamReset supports disabled sound and disconnected serial initialization, including the common `r0=0xff` request. @@ -139,6 +141,8 @@ 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 [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. @@ -161,7 +165,9 @@ 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. -It next stops at a Game Pak GPIO control write (`0x080000c8`, PC `0x082e29f8`). Cartridge GPIO remains unimplemented. +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. +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. See [cartridge loading](hardware/cartridge.md) for file limits, step budgets, and exit status. @@ -192,7 +198,7 @@ Host inspection and instruction fetches remain strict. Other unmapped reads retu Reads beyond the supplied cartridge bytes also return errors. Direct memory-bus halfword and word accesses require alignment. CPU load/store instructions apply ARM7TDMI alignment and rotation rules before accessing the bus. -Writes to cartridge addresses return errors instead of modeling cartridge hardware. +Ordinary ROM writes remain read-only. Only the explicitly selected cartridge GPIO register subset accepts writes. ## Next steps diff --git a/docs/testing.md b/docs/testing.md index bf70988..511925d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -65,6 +65,11 @@ 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. +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. 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. diff --git a/docs/usability-roadmap.md b/docs/usability-roadmap.md index ef587e5..89f388d 100644 --- a/docs/usability-roadmap.md +++ b/docs/usability-roadmap.md @@ -40,10 +40,11 @@ The native Darwin window also starts and presents frames without a diagnostic. This exposed and corrected missing firmware affine-scale initialization, without a game-specific patch. 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, then stops at Game Pak GPIO control. +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. 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). Cartridge GPIO and its real-time clock protocol are the next demonstrated startup investigation. +See [the runtime result](research/emerald-reset.md). RTC calendar transfers and an explicit clock-source model are the next 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.