feat(cartridge): add explicit GPIO and RTC control support

This commit is contained in:
0xrsydn 2026-10-07 05:28:52 +07:00
commit 692be36349
18 changed files with 889 additions and 26 deletions

View file

@ -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<Options> {
@ -39,6 +41,7 @@ pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
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<Options> {
.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<Options> {
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<dyn
writer,
"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.")?;
}
let steps = match options.mode {
Mode::Terminal { steps } => 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()?;

View file

@ -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
}
);
}

View file

@ -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
}

View file

@ -8,9 +8,11 @@ use crate::cartridge::window::{Session, Update};
pub fn run(
bytes: Vec<u8>,
frame_limit: Option<u64>,
hardware: gba_core::cartridge::CartridgeHardware,
writer: &mut impl Write,
) -> Result<(), Box<dyn Error>> {
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."

View file

@ -47,7 +47,7 @@ fn parse_args(args: &[OsString]) -> Result<RunMode, io::Error> {
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<RunMode, io::Error> {
[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<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\
--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

@ -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<u32> {
// 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::<Vec<_>>(),
)
.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"));

View file

@ -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<Gpio>,
}
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<u8> {
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<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

@ -2,6 +2,7 @@
pub mod audio;
pub mod bios;
pub mod cartridge;
pub mod cpu;
pub mod display;
pub mod dma;

View file

@ -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<u8>,
oam: Vec<u8>,
rom: Vec<u8>,
cartridge: Cartridge,
cartridge_step: Cell<Option<Cartridge>>,
bios: Option<Vec<u8>>,
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<u8, MemoryError> {
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::<N>(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() {

View file

@ -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)
);
}

View file

@ -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<Item = bool>) -> 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);
}

View file

@ -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 |

View file

@ -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.

View file

@ -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.

View file

@ -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.

View file

@ -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

View file

@ -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.

View file

@ -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.