feat(cartridge): add explicit Flash identification and banked reads

This commit is contained in:
0xrsydn 2026-10-07 06:13:39 +07:00
commit 8b3b7ae62d
23 changed files with 1042 additions and 52 deletions

View file

@ -14,14 +14,14 @@ use std::{
use gba_core::{ use gba_core::{
bios, bios,
cartridge::{CartridgeHardware, RtcError}, cartridge::{CartridgeHardware, RtcError, SaveDevice},
machine::{Machine, MachineError, StepKind}, machine::{Machine, MachineError, StepKind},
memory::ROM_CAPACITY, memory::ROM_CAPACITY,
}; };
const MAX_STEPS: u64 = 100_000_000; const MAX_STEPS: u64 = 100_000_000;
const MAX_FRAMES: u64 = 100_000; const MAX_FRAMES: u64 = 100_000;
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"; const USAGE: &str = "usage: gameboy-rust --rom PATH [--rtc] [--save-type flash64|flash128] (--steps COUNT | --window [--frames COUNT]); steps: 1..=100000000, frames: 1..=100000; do not combine with demo or help options";
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]
enum Mode { enum Mode {
@ -34,6 +34,7 @@ pub struct Options {
path: PathBuf, path: PathBuf,
mode: Mode, mode: Mode,
hardware: CartridgeHardware, hardware: CartridgeHardware,
save_device: SaveDevice,
} }
pub fn parse_args(args: &[OsString]) -> io::Result<Options> { pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
@ -43,6 +44,7 @@ pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
let mut frames = None; let mut frames = None;
let mut window = false; let mut window = false;
let mut hardware = CartridgeHardware::None; let mut hardware = CartridgeHardware::None;
let mut save_device = SaveDevice::None;
let mut args = args.iter(); let mut args = args.iter();
while let Some(arg) = args.next() { while let Some(arg) = args.next() {
if arg == "--rom" && path.is_none() { if arg == "--rom" && path.is_none() {
@ -53,6 +55,12 @@ pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
path = Some(PathBuf::from(value)); path = Some(PathBuf::from(value));
} else if arg == "--rtc" && hardware == CartridgeHardware::None { } else if arg == "--rtc" && hardware == CartridgeHardware::None {
hardware = CartridgeHardware::Rtc; hardware = CartridgeHardware::Rtc;
} else if arg == "--save-type" && save_device == SaveDevice::None {
save_device = match args.next().and_then(|value| value.to_str()) {
Some("flash64") => SaveDevice::Flash64,
Some("flash128") => SaveDevice::Flash128,
_ => return Err(invalid()),
};
} else if arg == "--window" && !window { } else if arg == "--window" && !window {
window = true; window = true;
} else if (arg == "--steps" && steps.is_none()) || (arg == "--frames" && frames.is_none()) { } else if (arg == "--steps" && steps.is_none()) || (arg == "--frames" && frames.is_none()) {
@ -90,6 +98,7 @@ pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
path: path.ok_or_else(invalid)?, path: path.ok_or_else(invalid)?,
mode, mode,
hardware, hardware,
save_device,
}) })
} }
@ -264,10 +273,19 @@ pub fn execute(options: Options, writer: &mut impl Write) -> Result<(), Box<dyn
if options.hardware == CartridgeHardware::Rtc { if options.hardware == CartridgeHardware::Rtc {
writeln!(writer, "RTC selected: UTC at startup, then host elapsed time; no persistence or RTC interrupts.")?; writeln!(writer, "RTC selected: UTC at startup, then host elapsed time; no persistence or RTC interrupts.")?;
} }
if options.save_device != SaveDevice::None {
writeln!(writer, "Flash selected: {} KiB Macronix, erased array; identification/bank reads only. No programming, erase, or save files.", options.save_device.capacity() / 1024)?;
}
let steps = match options.mode { let steps = match options.mode {
Mode::Terminal { steps } => steps, Mode::Terminal { steps } => steps,
Mode::Window { frames } => { Mode::Window { frames } => {
return crate::desktop::run_rom(bytes, frames, options.hardware, writer) return crate::desktop::run_rom(
bytes,
frames,
options.hardware,
options.save_device,
writer,
)
} }
}; };
writeln!(writer, "Machine-step limit: {steps} (includes boot)")?; writeln!(writer, "Machine-step limit: {steps} (includes boot)")?;
@ -275,6 +293,7 @@ pub fn execute(options: Options, writer: &mut impl Write) -> Result<(), Box<dyn
machine machine
.memory_mut() .memory_mut()
.set_cartridge_hardware(options.hardware); .set_cartridge_hardware(options.hardware);
machine.memory_mut().set_save_device(options.save_device);
let mut clock = if options.hardware == CartridgeHardware::Rtc { let mut clock = if options.hardware == CartridgeHardware::Rtc {
Some(rtc_clock::RtcHostClock::new(machine.memory_mut())?) Some(rtc_clock::RtcHostClock::new(machine.memory_mut())?)
} else { } else {

View file

@ -27,7 +27,8 @@ fn options_require_both_flags_in_either_order() {
Options { Options {
path: "original program.gba".into(), path: "original program.gba".into(),
mode: Mode::Terminal { steps: count }, mode: Mode::Terminal { steps: count },
hardware: CartridgeHardware::None hardware: CartridgeHardware::None,
save_device: SaveDevice::None
} }
); );
} }

View file

@ -9,10 +9,12 @@ pub fn run(
bytes: Vec<u8>, bytes: Vec<u8>,
frame_limit: Option<u64>, frame_limit: Option<u64>,
hardware: gba_core::cartridge::CartridgeHardware, hardware: gba_core::cartridge::CartridgeHardware,
save_device: gba_core::cartridge::SaveDevice,
writer: &mut impl Write, writer: &mut impl Write,
) -> Result<(), Box<dyn Error>> { ) -> Result<(), Box<dyn Error>> {
let mut session = Session::new(bytes)?; let mut session = Session::new(bytes)?;
session.set_cartridge_hardware(hardware); session.set_cartridge_hardware(hardware);
session.memory_mut().set_save_device(save_device);
let mut rtc_clock = if hardware == gba_core::cartridge::CartridgeHardware::Rtc { let mut rtc_clock = if hardware == gba_core::cartridge::CartridgeHardware::Rtc {
Some(crate::cartridge::rtc_clock::RtcHostClock::new( Some(crate::cartridge::rtc_clock::RtcHostClock::new(
session.memory_mut(), session.memory_mut(),

View file

@ -47,7 +47,14 @@ fn parse_args(args: &[OsString]) -> Result<RunMode, io::Error> {
return cartridge::suite::parse_args(args).map(RunMode::TestSuite); return cartridge::suite::parse_args(args).map(RunMode::TestSuite);
} }
if args.iter().any(|arg| { if args.iter().any(|arg| {
["--rom", "--steps", "--window", "--frames", "--rtc"] [
"--rom",
"--steps",
"--window",
"--frames",
"--rtc",
"--save-type",
]
.iter() .iter()
.any(|flag| arg == flag) .any(|flag| arg == flag)
}) { }) {
@ -79,7 +86,7 @@ fn parse_args(args: &[OsString]) -> Result<RunMode, io::Error> {
[arg] if arg == "--help" || arg == "-h" => Ok(RunMode::Help), [arg] if arg == "--help" || arg == "-h" => Ok(RunMode::Help),
_ => Err(io::Error::new( _ => Err(io::Error::new(
io::ErrorKind::InvalidInput, 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 [--rtc] (--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] [--save-type flash64|flash128] (--steps COUNT | --window [--frames COUNT]); or gameboy-rust --test-suite PATH.json",
)), )),
} }
} }
@ -126,6 +133,7 @@ fn run() -> Result<(), Box<dyn Error>> {
--rom PATH --window Open a raw ROM with keyboard input\n\ --rom PATH --window Open a raw ROM with keyboard input\n\
--frames COUNT Exit ROM window after 1..=100000 captured frames\n\ --frames COUNT Exit ROM window after 1..=100000 captured frames\n\
--rtc Select cartridge RTC (UTC startup, host elapsed time)\n\ --rtc Select cartridge RTC (UTC startup, host elapsed time)\n\
--save-type flash64|flash128 Select Flash identification/read subset (no saving yet)\n\
--test-suite PATH.json Run bounded ROM assertions; write a JSON report\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\ --cpu-demo Run the terminal-only ARM/Thumb instruction demo\n\
--timer-demo Run the timer IRQ demo with nominal cycle costs\n\ --timer-demo Run the timer IRQ demo with nominal cycle costs\n\

View file

@ -142,6 +142,75 @@ fn rtc_calendar_commands_run_through_original_arm_code_with_host_clock_enabled()
); );
} }
fn flash_setup() -> Vec<u32> {
// Original ARM unlock/identify, then install a byte reader in IWRAM and execute it.
let mut code = Vec::new();
let mut literals = Vec::new();
fn load(code: &mut Vec<u32>, literals: &mut Vec<(usize, u32, u32)>, register: u32, value: u32) {
literals.push((code.len(), register, value));
code.push(0);
}
load(&mut code, &mut literals, 1, 0x0e005555);
code.extend([0xe3a000aa, 0xe5c10000]);
load(&mut code, &mut literals, 2, 0x0e002aaa);
code.extend([0xe3a00055, 0xe5c20000, 0xe3a00090, 0xe5c10000]);
load(&mut code, &mut literals, 3, 0x0e000000);
load(&mut code, &mut literals, 4, 0x03000000);
for (offset, instruction) in [(0, 0xe5d35000), (4, 0xe5d36001), (8, 0xeafffffe)] {
load(&mut code, &mut literals, 0, instruction);
code.push(0xe5840000 | offset);
}
code.push(0xe12fff14); // BX r4
for (at, register, value) in literals {
let offset = ((code.len() - at) * 4 - 8) as u32;
code[at] = 0xe59f0000 | (register << 12) | offset;
code.push(value);
}
code
}
#[test]
fn explicit_flash_selection_runs_ram_byte_reader_and_leaves_files_unchanged() {
let fixture = Fixture::new();
let path = fixture.rom(&flash_setup());
let bytes = fs::read(&path).unwrap();
assert!(stderr(&run(&path, "1000")).contains("unmapped memory at 0x0e005555"));
for (device, id) in [("flash64", "r6=0x0000001c"), ("flash128", "r6=0x00000009")] {
let output = command()
.args(["--save-type", device, "--rtc", "--rom"])
.arg(&path)
.args(["--steps", "1000"])
.output()
.unwrap();
assert!(output.status.success(), "{}", stderr(&output));
let text = stdout(&output);
assert!(text.contains("identification/bank reads only"));
assert!(text.contains("r5=0x000000c2"), "{text}");
assert!(text.contains(id), "{text}");
assert_eq!(fs::read(&path).unwrap(), bytes);
}
assert_eq!(fs::read_dir(&fixture.0).unwrap().count(), 1); // No save file is invented.
}
#[test]
fn invalid_save_type_options_fail_before_file_access() {
for extra in [
vec!["--save-type"],
vec!["--save-type", "sram"],
vec!["--save-type", ""],
vec!["--save-type", "flash128", "--save-type", "flash64"],
] {
let output = command()
.args(["--rom", "missing.gba", "--steps", "1"])
.args(extra)
.output()
.unwrap();
assert_eq!(output.status.code(), Some(1));
assert!(stderr(&output).contains("usage:"));
assert!(!stderr(&output).contains("cannot load"));
}
}
#[test] #[test]
fn rtc_selection_is_explicit_and_does_not_modify_the_rom_file() { fn rtc_selection_is_explicit_and_does_not_modify_the_rom_file() {
let fixture = Fixture::new(); let fixture = Fixture::new();
@ -311,11 +380,14 @@ fn native_rom_window_presents_file_bytes_and_reports_stop_cpu_and_video_errors()
thread, thread,
time::{Duration, Instant}, time::{Duration, Instant},
}; };
fn window(path: &Path, rtc: bool) -> Output { fn window(path: &Path, rtc: bool, save: Option<&str>) -> Output {
let mut command = command(); let mut command = command();
if rtc { if rtc {
command.arg("--rtc"); command.arg("--rtc");
} }
if let Some(device) = save {
command.args(["--save-type", device]);
}
let mut child = command let mut child = command
.arg("--rom") .arg("--rom")
.arg(path) .arg(path)
@ -342,7 +414,7 @@ fn native_rom_window_presents_file_bytes_and_reports_stop_cpu_and_video_errors()
let fixture = Fixture::new(); let fixture = Fixture::new();
let path = fixture.0.join("original-input.gba"); let path = fixture.0.join("original-input.gba");
fs::write(&path, gba_demos::input_rom()).unwrap(); fs::write(&path, gba_demos::input_rom()).unwrap();
let output = window(&path, false); let output = window(&path, false, None);
assert!( assert!(
output.status.success(), output.status.success(),
"{} {}", "{} {}",
@ -352,12 +424,12 @@ 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("Captured ROM frames: 3"));
assert!(stdout(&output).contains("Result: window frame limit reached")); assert!(stdout(&output).contains("Result: window frame limit reached"));
let rtc_path = fixture.rom(&rtc_setup()); let rtc_path = fixture.rom(&rtc_setup());
let rtc_output = window(&rtc_path, true); let rtc_output = window(&rtc_path, true, None);
assert!(rtc_output.status.success(), "{}", stderr(&rtc_output)); assert!(rtc_output.status.success(), "{}", stderr(&rtc_output));
assert!(stdout(&rtc_output).contains("Captured ROM frames: 3")); assert!(stdout(&rtc_output).contains("Captured ROM frames: 3"));
assert!(stdout(&rtc_output).contains("r2=0x00000001")); assert!(stdout(&rtc_output).contains("r2=0x00000001"));
let calendar_path = fixture.rom(&rtc_calendar_setup()); let calendar_path = fixture.rom(&rtc_calendar_setup());
let calendar_output = window(&calendar_path, true); let calendar_output = window(&calendar_path, true, None);
assert!( assert!(
calendar_output.status.success(), calendar_output.status.success(),
"{}", "{}",
@ -365,6 +437,12 @@ fn native_rom_window_presents_file_bytes_and_reports_stop_cpu_and_video_errors()
); );
assert!(stdout(&calendar_output).contains("Captured ROM frames: 3")); assert!(stdout(&calendar_output).contains("Captured ROM frames: 3"));
assert!(stdout(&calendar_output).contains("r2=0x00000092")); assert!(stdout(&calendar_output).contains("r2=0x00000092"));
let flash_path = fixture.rom(&flash_setup());
let flash_output = window(&flash_path, true, Some("flash128"));
assert!(flash_output.status.success(), "{}", stderr(&flash_output));
assert!(stdout(&flash_output).contains("Captured ROM frames: 3"));
assert!(stdout(&flash_output).contains("r5=0x000000c2"));
assert!(stdout(&flash_output).contains("r6=0x00000009"));
for (code, error) in [ for (code, error) in [
(vec![0xef03_0000, 0xeaff_fffe], "entered STOP"), (vec![0xef03_0000, 0xeaff_fffe], "entered STOP"),
(vec![0xee00_0000], "instruction"), (vec![0xee00_0000], "instruction"),
@ -380,7 +458,7 @@ fn native_rom_window_presents_file_bytes_and_reports_stop_cpu_and_video_errors()
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
) )
.unwrap(); .unwrap();
let output = window(&path, false); let output = window(&path, false, None);
assert_eq!(output.status.code(), Some(1), "{}", stdout(&output)); assert_eq!(output.status.code(), Some(1), "{}", stdout(&output));
assert!(stdout(&output).contains("Result: ROM window diagnostic")); assert!(stdout(&output).contains("Result: ROM window diagnostic"));
assert!(stdout(&output).contains("Captured ROM frames: 0")); assert!(stdout(&output).contains("Captured ROM frames: 0"));

View file

@ -3,8 +3,11 @@
use crate::memory::MemoryError; use crate::memory::MemoryError;
mod calendar; mod calendar;
mod flash;
mod rtc; mod rtc;
pub use calendar::{RtcDateTime, RtcError}; pub use calendar::{RtcDateTime, RtcError};
use flash::Flash;
pub use flash::{SaveDevice, SaveError, SAVE_END, SAVE_START};
use rtc::Rtc; use rtc::Rtc;
pub const GPIO_DATA: u32 = 0x0800_00c4; pub const GPIO_DATA: u32 = 0x0800_00c4;
@ -23,13 +26,28 @@ pub enum CartridgeHardware {
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub(crate) struct Cartridge { pub(crate) struct Cartridge {
gpio: Option<Gpio>, gpio: Option<Gpio>,
flash: Option<Flash>,
} }
impl Cartridge { impl Cartridge {
pub(crate) fn new(hardware: CartridgeHardware) -> Self { pub(crate) fn set_hardware(&mut self, hardware: CartridgeHardware) {
Self { self.gpio = (hardware == CartridgeHardware::Rtc).then(Gpio::default);
gpio: (hardware == CartridgeHardware::Rtc).then(Gpio::default),
} }
pub(crate) fn set_save_device(&mut self, device: SaveDevice) {
self.flash = Flash::new(device);
}
pub(crate) fn save_device(&self) -> SaveDevice {
self.flash.map_or(SaveDevice::None, Flash::device)
}
pub(crate) fn save_mapped(&self, address: u32) -> bool {
self.flash.is_some() && (SAVE_START..=SAVE_END).contains(&address)
}
pub(crate) fn read_save8(&self, address: u32, image: &[u8]) -> Result<u8, MemoryError> {
self.flash.expect("mapped save device").read(address, image)
} }
pub(crate) fn rtc_datetime(&self) -> Option<RtcDateTime> { pub(crate) fn rtc_datetime(&self) -> Option<RtcDateTime> {
@ -56,13 +74,14 @@ impl Cartridge {
} }
pub(crate) fn mapped(&self, address: u32) -> bool { pub(crate) fn mapped(&self, address: u32) -> bool {
self.gpio.is_some() && (GPIO_DATA..GPIO_CONTROL + 2).contains(&address) self.save_mapped(address)
|| (self.gpio.is_some() && (GPIO_DATA..GPIO_CONTROL + 2).contains(&address))
} }
/// Disabled reads expose the supplied ROM bytes, not a zero-filled replacement. /// Disabled reads expose the supplied ROM bytes, not a zero-filled replacement.
pub(crate) fn read8(&self, address: u32) -> Option<u8> { pub(crate) fn read8(&self, address: u32) -> Option<u8> {
let gpio = self.gpio?; let gpio = self.gpio?;
if !self.mapped(address) || !gpio.readable { if !(GPIO_DATA..GPIO_CONTROL + 2).contains(&address) || !gpio.readable {
return None; return None;
} }
let value = match address & !1 { let value = match address & !1 {
@ -79,6 +98,15 @@ impl Cartridge {
if !self.mapped(address) { if !self.mapped(address) {
return Ok(()); // Non-cartridge accesses are validated by the bus. return Ok(()); // Non-cartridge accesses are validated by the bus.
} }
if self.save_mapped(address) {
if bytes.len() != 1 {
return Err(MemoryError::UnsupportedCartridgeAccess {
address,
operation: "Flash non-byte write",
});
}
return self.flash.as_mut().unwrap().write(address, bytes[0]);
}
let fail = |at, value, operation| MemoryError::UnsupportedIo { let fail = |at, value, operation| MemoryError::UnsupportedIo {
address: at, address: at,
value, value,

View file

@ -0,0 +1,148 @@
//! Bounded Macronix Flash identification, array reads, and 128 KiB banking.
//! Program/erase commands remain diagnostic: this module cannot claim a completed save.
use crate::memory::MemoryError;
use std::{error::Error, fmt};
pub const SAVE_START: u32 = 0x0e00_0000;
pub const SAVE_END: u32 = 0x0e00_ffff;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum SaveDevice {
#[default]
None,
/// Macronix ID 1CC2h. Array reads and identification only.
Flash64,
/// Macronix ID 09C2h. Adds two 64 KiB banks; no programming or erase yet.
Flash128,
}
impl SaveDevice {
pub fn capacity(self) -> usize {
match self {
Self::None => 0,
Self::Flash64 => 64 * 1024,
Self::Flash128 => 128 * 1024,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SaveError {
NoDevice,
InvalidSize { expected: usize, actual: usize },
}
impl fmt::Display for SaveError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NoDevice => f.write_str("no cartridge save device selected"),
Self::InvalidSize { expected, actual } => write!(
f,
"save image size {actual}; expected exactly {expected} bytes"
),
}
}
}
impl Error for SaveError {}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct Flash {
device: SaveDevice,
phase: Phase,
identify: bool,
bank: u8,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Phase {
Ready,
Unlock,
Command,
Bank,
}
impl Flash {
pub(super) fn new(device: SaveDevice) -> Option<Self> {
(device != SaveDevice::None).then_some(Self {
device,
phase: Phase::Ready,
identify: false,
bank: 0,
})
}
pub(super) fn device(self) -> SaveDevice {
self.device
}
pub(super) fn read(self, address: u32, image: &[u8]) -> Result<u8, MemoryError> {
let offset = (address - SAVE_START) as usize;
if self.identify {
return match offset {
0 => Ok(0xc2),
1 => Ok(if self.device == SaveDevice::Flash64 {
0x1c
} else {
9
}),
_ => Err(MemoryError::UnsupportedCartridgeAccess {
address,
operation: "Flash ID read outside manufacturer/device bytes",
}),
};
}
Ok(image[usize::from(self.bank) * 0x10000 + offset])
}
pub(super) fn write(&mut self, address: u32, value: u8) -> Result<(), MemoryError> {
let offset = address - SAVE_START;
let error = |operation| MemoryError::UnsupportedIo {
address,
value,
operation,
};
let mut next = *self;
// GBATEK documents direct reset for the 64 KiB chip. For 128 KiB, accept
// only a redundant reset while already idle in array mode. No busy/ID exit is inferred.
if offset == 0x5555
&& value == 0xf0
&& (self.device == SaveDevice::Flash64
|| (self.phase == Phase::Ready && !self.identify))
{
next.identify = false;
next.phase = Phase::Ready;
} else {
next.phase = match self.phase {
Phase::Ready if offset == 0x5555 && value == 0xaa => Phase::Unlock,
Phase::Unlock if offset == 0x2aaa && value == 0x55 => Phase::Command,
Phase::Command if offset == 0x5555 => {
if self.identify && value != 0xf0 {
return Err(error("Flash command while in ID mode"));
}
match value {
0x90 => {
next.identify = true;
Phase::Ready
}
0xf0 => {
next.identify = false;
Phase::Ready
}
0xb0 if self.device == SaveDevice::Flash128 => Phase::Bank,
0xb0 => return Err(error("Flash bank selection on 64 KiB device")),
0xa0 => return Err(error("Flash byte programming")),
0x80 => return Err(error("Flash erase setup")),
_ => return Err(error("Flash command")),
}
}
Phase::Bank if offset == 0 && value < 2 => {
next.bank = value;
Phase::Ready
}
Phase::Bank => return Err(error("Flash bank address/value")),
_ => return Err(error("Flash unlock/reset sequence")),
};
}
*self = next;
Ok(())
}
}

View file

@ -8,6 +8,8 @@ mod fetch;
#[cfg(test)] #[cfg(test)]
mod fetch_tests; mod fetch_tests;
#[cfg(test)] #[cfg(test)]
mod flash_step_tests;
#[cfg(test)]
mod irq_fetch_tests; mod irq_fetch_tests;
mod iwram_bus; mod iwram_bus;
#[cfg(test)] #[cfg(test)]
@ -27,7 +29,7 @@ use iwram_bus::IwramBus;
use crate::{ use crate::{
audio::{Audio, StereoLevel}, audio::{Audio, StereoLevel},
cartridge::{Cartridge, CartridgeHardware, RtcDateTime, RtcError}, cartridge::{Cartridge, CartridgeHardware, RtcDateTime, RtcError, SaveDevice, SaveError},
cpu::InstructionSet, cpu::InstructionSet,
display::{DisplayPosition, CYCLES_PER_LINE, HBLANK_START, VISIBLE_LINES}, display::{DisplayPosition, CYCLES_PER_LINE, HBLANK_START, VISIBLE_LINES},
dma::{DmaError, DMA_BASE, DMA_END}, dma::{DmaError, DMA_BASE, DMA_END},
@ -53,6 +55,10 @@ pub enum MemoryError {
Unaligned(u32), Unaligned(u32),
RomTooLarge(usize), RomTooLarge(usize),
InvalidBiosSize(usize), InvalidBiosSize(usize),
UnsupportedCartridgeAccess {
address: u32,
operation: &'static str,
},
UnsupportedIo { UnsupportedIo {
address: u32, address: u32,
value: u8, value: u8,
@ -63,6 +69,9 @@ pub enum MemoryError {
impl fmt::Display for MemoryError { impl fmt::Display for MemoryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self { match self {
Self::UnsupportedCartridgeAccess { address, operation } => {
write!(f, "unsupported {operation} at {address:#010x}")
}
Self::UnsupportedIo { Self::UnsupportedIo {
address, address,
value, value,
@ -122,6 +131,7 @@ pub struct Memory {
video_ram: Vec<u8>, video_ram: Vec<u8>,
oam: Vec<u8>, oam: Vec<u8>,
rom: Vec<u8>, rom: Vec<u8>,
save_image: Vec<u8>,
cartridge: Cartridge, cartridge: Cartridge,
cartridge_step: Cell<Option<Cartridge>>, cartridge_step: Cell<Option<Cartridge>>,
bios: Option<Vec<u8>>, bios: Option<Vec<u8>>,
@ -153,6 +163,7 @@ impl Memory {
video_ram: vec![0; VRAM_SIZE], video_ram: vec![0; VRAM_SIZE],
oam: vec![0; 1024], oam: vec![0; 1024],
rom, rom,
save_image: Vec::new(),
cartridge: Cartridge::default(), cartridge: Cartridge::default(),
cartridge_step: Cell::new(None), cartridge_step: Cell::new(None),
bios: None, bios: None,
@ -183,14 +194,52 @@ impl Memory {
Ok(memory) Ok(memory)
} }
/// Replace optional cartridge hardware with its initial state. Call between machine steps. /// Replace GPIO/RTC hardware with its initial state. Call between machine steps.
/// This never modifies the supplied ROM bytes or enables a host clock. /// Save hardware/data are preserved. This never modifies ROM bytes or enables a host clock.
pub fn set_cartridge_hardware(&mut self, hardware: CartridgeHardware) { pub fn set_cartridge_hardware(&mut self, hardware: CartridgeHardware) {
assert!( assert!(
self.cartridge_step.get().is_none(), self.cartridge_step.get().is_none(),
"active cartridge transaction" "active cartridge transaction"
); );
self.cartridge = Cartridge::new(hardware); self.cartridge.set_hardware(hardware);
}
/// Replace save hardware and its array with erased bytes. GPIO/RTC state is unchanged.
/// Call between machine steps; no file is loaded or written by the core.
pub fn set_save_device(&mut self, device: SaveDevice) {
assert!(
self.cartridge_step.get().is_none(),
"active cartridge transaction"
);
self.save_image = vec![0xff; device.capacity()];
self.cartridge.set_save_device(device);
}
/// Inspect the complete save array, independently of ID mode and selected bank.
pub fn save_image(&self) -> Option<&[u8]> {
(self.cartridge.save_device() != SaveDevice::None).then_some(self.save_image.as_slice())
}
/// Replace the entire array and reset Flash command/bank state. RTC state is unchanged.
/// A wrong length changes nothing. This setup API does not emulate programming.
pub fn load_save_image(&mut self, bytes: &[u8]) -> Result<(), SaveError> {
assert!(
self.cartridge_step.get().is_none(),
"active cartridge transaction"
);
let device = self.cartridge.save_device();
if device == SaveDevice::None {
return Err(SaveError::NoDevice);
}
if bytes.len() != device.capacity() {
return Err(SaveError::InvalidSize {
expected: device.capacity(),
actual: bytes.len(),
});
}
self.save_image.copy_from_slice(bytes);
self.cartridge.set_save_device(device);
Ok(())
} }
/// Inspect the live calendar, not an in-progress serial read snapshot. /// Inspect the live calendar, not an in-progress serial read snapshot.
@ -749,6 +798,16 @@ impl Memory {
// Multi-byte reads use this helper to avoid charging each byte as a bus access. // Multi-byte reads use this helper to avoid charging each byte as a bus access.
fn read_byte(&self, address: u32) -> Result<u8, MemoryError> { fn read_byte(&self, address: u32) -> Result<u8, MemoryError> {
if self.cartridge_state().save_mapped(address)
&& self
.cpu_access
.is_some_and(|access| !matches!(access.pc >> 24, 0x02 | 0x03))
{
return Err(MemoryError::UnsupportedCartridgeAccess {
address,
operation: "Flash CPU read outside work RAM",
});
}
if address >> 24 == 0x04 { if address >> 24 == 0x04 {
if let Some(step) = self.timer_step.get() { if let Some(step) = self.timer_step.get() {
if Serial::mapped(address) { if Serial::mapped(address) {
@ -784,7 +843,11 @@ impl Memory {
// Strict lookup for host access, instruction fetches, and prefetch snapshots. // Strict lookup for host access, instruction fetches, and prefetch snapshots.
fn read_mapped_byte(&self, address: u32) -> Result<u8, MemoryError> { fn read_mapped_byte(&self, address: u32) -> Result<u8, MemoryError> {
if let Some(value) = self.cartridge_state().read8(address) { let cartridge = self.cartridge_state();
if cartridge.save_mapped(address) {
return cartridge.read_save8(address, &self.save_image);
}
if let Some(value) = cartridge.read8(address) {
return Ok(value); return Ok(value);
} }
match address >> 24 { match address >> 24 {
@ -1001,6 +1064,15 @@ impl Memory {
} }
Ok(0) Ok(0)
} }
0x0e if self.cartridge_state().save_mapped(address) => {
if N != 1 {
return Err(MemoryError::UnsupportedCartridgeAccess {
address,
operation: "Flash non-byte write",
});
}
Ok(0)
}
_ => Err(MemoryError::Unmapped(address)), _ => Err(MemoryError::Unmapped(address)),
} }
} }
@ -1086,6 +1158,7 @@ impl Memory {
if address & 1 != 0 { if address & 1 != 0 {
return Err(MemoryError::Unaligned(address)); return Err(MemoryError::Unaligned(address));
} }
self.validate_save_read_width(address)?;
self.record_access(address, AccessWidth::Halfword); self.record_access(address, AccessWidth::Halfword);
let value = u16::from_le_bytes([self.read_byte(address)?, self.read_byte(address + 1)?]); let value = u16::from_le_bytes([self.read_byte(address)?, self.read_byte(address + 1)?]);
self.iwram_bus self.iwram_bus
@ -1093,11 +1166,22 @@ impl Memory {
Ok(value) Ok(value)
} }
fn validate_save_read_width(&self, address: u32) -> Result<(), MemoryError> {
if self.cartridge_state().save_mapped(address) {
return Err(MemoryError::UnsupportedCartridgeAccess {
address,
operation: "Flash non-byte read",
});
}
Ok(())
}
/// The bus requires alignment; the CPU applies ARM7TDMI load rotation. /// The bus requires alignment; the CPU applies ARM7TDMI load rotation.
pub fn read32(&self, address: u32) -> Result<u32, MemoryError> { pub fn read32(&self, address: u32) -> Result<u32, MemoryError> {
if address & 3 != 0 { if address & 3 != 0 {
return Err(MemoryError::Unaligned(address)); return Err(MemoryError::Unaligned(address));
} }
self.validate_save_read_width(address)?;
self.record_access(address, AccessWidth::Word); self.record_access(address, AccessWidth::Word);
let value = u32::from_le_bytes([ let value = u32::from_le_bytes([
self.read_byte(address)?, self.read_byte(address)?,

View file

@ -62,6 +62,12 @@ impl Memory {
if address & (width as u32 - 1) != 0 { if address & (width as u32 - 1) != 0 {
return Err(MemoryError::Unaligned(address)); return Err(MemoryError::Unaligned(address));
} }
if self.cartridge_state().save_mapped(address) {
return Err(MemoryError::UnsupportedCartridgeAccess {
address,
operation: "Flash instruction fetch",
});
}
let mut bytes = [0; 4]; let mut bytes = [0; 4];
for (offset, byte) in bytes[..width].iter_mut().enumerate() { for (offset, byte) in bytes[..width].iter_mut().enumerate() {
*byte = self.read_mapped_byte(address.wrapping_add(offset as u32))?; *byte = self.read_mapped_byte(address.wrapping_add(offset as u32))?;

View file

@ -0,0 +1,148 @@
//! Original Flash CPU bus phases, failed writes, fetch restrictions, and preflight checks.
use super::{timing_event_tests::prepared, *};
use crate::{
cartridge::{SaveDevice, SAVE_START},
cpu::Cpu,
io::{IF, WAITCNT},
machine::Machine,
};
const HI: u32 = SAVE_START + 0x5555;
const LO: u32 = SAVE_START + 0x2aaa;
fn unlocked(memory: &mut Memory) {
memory.set_save_device(SaveDevice::Flash128);
memory.write8(HI, 0xaa).unwrap();
memory.write8(LO, 0x55).unwrap();
}
fn ram_reader(thumb: bool, base: u32) -> (Cpu, Memory) {
let (mut cpu, mut memory) = prepared(
false,
&[0xe12fff12],
&[(1, SAVE_START), (2, base | u32::from(thumb))],
);
for at in (0..32).step_by(4) {
memory
.write32(base + at, if thumb { 0x46c046c0 } else { 0xe1a00000 })
.unwrap();
}
if thumb {
memory.write16(base, 0x7808).unwrap();
} else {
memory.write32(base, 0xe5d10000).unwrap();
}
cpu.step(&mut memory).unwrap(); // BX from setup ROM into original RAM reader.
(cpu, memory)
}
#[test]
fn arm_and_thumb_flash_commands_commit_once_at_all_save_wait_settings() {
for thumb in [false, true] {
for (wait, cycles) in [5, 4, 3, 9].into_iter().enumerate() {
let (cpu, mut memory) = prepared(
thumb,
&[if thumb { 0x7008 } else { 0xe5c10000 }],
&[(0, 0x90), (1, HI)],
);
unlocked(&mut memory);
memory.write16(WAITCNT, wait as u16).unwrap();
let mut machine = Machine::new(cpu, memory);
machine.step().unwrap();
assert_eq!(machine.memory().read8(SAVE_START).unwrap(), 0xc2);
assert_eq!(machine.last_timing().data_cycles, cycles);
assert_eq!(machine.memory().read16(IF).unwrap(), 0);
}
}
}
#[test]
fn work_ram_byte_loads_observe_selected_id_with_save_bus_costs() {
for thumb in [false, true] {
for base in [0x02001000, 0x03001000] {
for (wait, cycles) in [5, 4, 3, 9].into_iter().enumerate() {
let (cpu, mut memory) = ram_reader(thumb, base);
unlocked(&mut memory);
memory.write8(HI, 0x90).unwrap();
memory.write16(WAITCNT, wait as u16).unwrap();
let mut machine = Machine::new(cpu, memory);
machine.step().unwrap();
assert_eq!(machine.cpu().registers()[0], 0xc2);
assert_eq!(machine.last_timing().data_cycles, cycles);
}
}
}
}
#[test]
fn rejected_programming_and_rom_execution_reads_roll_back_cpu_and_command_state() {
for thumb in [false, true] {
for read in [false, true] {
let (cpu, mut memory) = prepared(
thumb,
&[match (thumb, read) {
(false, false) => 0xe5c10000,
(true, false) => 0x7008,
(false, true) => 0xe5d10000,
(true, true) => 0x7808,
}],
&[(0, 0xa0), (1, if read { SAVE_START } else { HI })],
);
unlocked(&mut memory);
let before = 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(if read {
"outside work RAM"
} else {
"programming"
}));
assert_eq!(machine.step(), Err(error));
assert_eq!(machine.cpu(), &cpu);
assert_eq!(machine.memory().cartridge, before);
assert_eq!(machine.cycles(), 0);
machine.memory_mut().write8(HI, 0x90).unwrap(); // Failure retained the accepted unlock prefix.
assert_eq!(machine.memory().read8(SAVE_START).unwrap(), 0xc2);
}
}
}
#[test]
fn staged_id_reads_and_discard_do_not_mutate_array_or_committed_controller() {
let (_, mut memory) = prepared(false, &[0xe1a00000], &[]);
unlocked(&mut memory);
let before = memory.cartridge;
memory.begin_timer_step();
memory.write8(HI, 0x90).unwrap();
assert_eq!(memory.read8(SAVE_START).unwrap(), 0xc2);
assert_eq!(memory.cartridge, before);
memory.discard_timer_step();
assert_eq!(memory.read8(SAVE_START).unwrap(), 255);
memory.write8(HI, 0x90).unwrap();
memory.begin_timer_step();
memory.write8(HI, 0xaa).unwrap();
memory.write8(LO, 0x55).unwrap();
memory.write8(HI, 0xf0).unwrap();
assert_eq!(memory.read8(SAVE_START).unwrap(), 255);
memory.discard_timer_step();
assert_eq!(memory.read8(SAVE_START).unwrap(), 0xc2);
assert!(memory.save_image().unwrap().iter().all(|&b| b == 255));
}
#[test]
fn instruction_fetch_and_block_word_access_cannot_bypass_byte_only_device() {
let (_, mut memory) = prepared(false, &[0xe1a00000], &[]);
unlocked(&mut memory);
for state in [InstructionSet::Arm, InstructionSet::Thumb] {
let error = memory
.fetch_instruction(SAVE_START, state)
.instruction
.unwrap_err();
assert!(error.to_string().contains("Flash instruction fetch"));
}
let before = memory.cartridge;
assert!(memory
.write_words(&[(0x02000000, 0x12345678), (SAVE_START, 0)])
.is_err());
assert_eq!(memory.read32(0x02000000).unwrap(), 0);
assert_eq!(memory.cartridge, before);
}

View file

@ -87,6 +87,8 @@ pub fn bus_cycles(waitcnt: u16, address: u32, width: AccessWidth, kind: AccessKi
0 0
} }
} }
// The supported save bus permits byte transfers only; N/S use the same SRAM waits.
0x0e | 0x0f => 1 + [4, 3, 2, 8][usize::from(waitcnt & 3)],
// BIOS, IWRAM, I/O, and OAM use a 32-bit bus. Other areas remain unmapped. // BIOS, IWRAM, I/O, and OAM use a 32-bit bus. Other areas remain unmapped.
_ => 1, _ => 1,
} }

View file

@ -10,6 +10,30 @@ fn queue(waitcnt: u16) -> Prefetch {
queue queue
} }
#[test]
fn save_wait_changes_preserve_the_queue_until_a_flash_data_access_cancels_it() {
for (wait, raw) in [5, 4, 3, 9].into_iter().enumerate() {
let mut q = queue(ENABLE);
assert_eq!(
q.code(PC, AccessWidth::Halfword, AccessKind::NonSequential),
5
);
q.advance(2); // One cycle remains in the nominal three-cycle ROM halfword.
let stream = q.stream;
q.configure(ENABLE | wait as u16);
assert_eq!(q.stream, stream);
assert_eq!(
q.data(0x0e005555, AccessWidth::Byte, AccessKind::NonSequential),
raw + 1
);
assert_eq!(q.stream, None);
assert_eq!(
q.code(PC + 2, AccessWidth::Halfword, AccessKind::NonSequential),
5
);
}
}
#[test] #[test]
fn ready_and_in_progress_fetches_follow_all_rom_wait_settings_and_widths() { fn ready_and_in_progress_fetches_follow_all_rom_wait_settings_and_widths() {
for window in 0..3 { for window in 0..3 {

View file

@ -0,0 +1,276 @@
//! Original Flash command sequences and supplied save bytes; no game assets or save files.
use gba_core::{
cartridge::{CartridgeHardware, SaveDevice, SaveError, SAVE_END, SAVE_START},
cpu::Cpu,
dma::DMA_BASE,
io::{HALTCNT, IE, IF, IME},
machine::Machine,
memory::{Memory, MemoryError, ROM_START},
};
const HI: u32 = SAVE_START + 0x5555;
const LO: u32 = SAVE_START + 0x2aaa;
fn memory(device: SaveDevice) -> Memory {
let mut bus = Memory::new(0xeafffffeu32.to_le_bytes().to_vec()).unwrap();
bus.set_save_device(device);
bus
}
fn command(bus: &mut Memory, byte: u8) -> Result<(), MemoryError> {
bus.write8(HI, 0xaa)?;
bus.write8(LO, 0x55)?;
bus.write8(HI, byte)
}
fn bank(bus: &mut Memory, number: u8) {
command(bus, 0xb0).unwrap();
bus.write8(SAVE_START, number).unwrap();
}
fn image(device: SaveDevice) -> Vec<u8> {
(0..device.capacity())
.map(|i| ((i >> 16) * 73 + i * 19 + (i >> 8)) as u8)
.collect()
}
#[test]
fn selection_erased_state_and_exact_image_loading_are_explicit() {
let mut bus = memory(SaveDevice::None);
assert_eq!(bus.save_image(), None);
assert_eq!(
bus.read8(SAVE_START),
Err(MemoryError::Unmapped(SAVE_START))
);
assert_eq!(bus.write8(HI, 0xaa), Err(MemoryError::Unmapped(HI)));
assert_eq!(bus.load_save_image(&[]), Err(SaveError::NoDevice));
for device in [SaveDevice::Flash64, SaveDevice::Flash128] {
bus.set_save_device(device);
assert_eq!(bus.save_image().unwrap(), vec![255; device.capacity()]);
assert_eq!(bus.read8(SAVE_START).unwrap(), 255);
assert_eq!(bus.read8(SAVE_END).unwrap(), 255);
let bytes = image(device);
bus.load_save_image(&bytes).unwrap();
command(&mut bus, 0x90).unwrap();
for len in [0, device.capacity() - 1, device.capacity() + 1] {
assert_eq!(
bus.load_save_image(&vec![0; len]),
Err(SaveError::InvalidSize {
expected: device.capacity(),
actual: len
})
);
assert_eq!(bus.save_image().unwrap(), bytes);
assert_eq!(bus.read8(SAVE_START).unwrap(), 0xc2); // Invalid load did not reset ID state.
}
bus.load_save_image(&bytes).unwrap(); // Valid host load resets command/bank state.
assert_eq!(bus.read8(SAVE_START).unwrap(), bytes[0]);
assert_eq!(bus.read32(ROM_START).unwrap(), 0xeafffffe);
}
}
#[test]
fn manufacturer_and_device_id_are_separate_bytes_and_exit_preserves_data() {
for (device, id) in [(SaveDevice::Flash64, 0x1c), (SaveDevice::Flash128, 9)] {
let mut bus = memory(device);
let data = image(device);
bus.load_save_image(&data).unwrap();
command(&mut bus, 0x90).unwrap();
assert_eq!(bus.read8(SAVE_START).unwrap(), 0xc2);
assert_eq!(bus.read8(SAVE_START + 1).unwrap(), id);
assert!(bus
.read8(SAVE_START + 2)
.unwrap_err()
.to_string()
.contains("ID read outside"));
command(&mut bus, 0xf0).unwrap();
assert_eq!(bus.read8(SAVE_START).unwrap(), data[0]);
assert_eq!(bus.read8(SAVE_START + 1).unwrap(), data[1]);
assert_eq!(bus.save_image().unwrap(), data);
assert_eq!(bus.read16(IF).unwrap(), 0);
}
}
#[test]
fn bank_selection_covers_every_byte_and_survives_id_mode() {
let mut bus = memory(SaveDevice::Flash128);
let data = image(SaveDevice::Flash128);
bus.load_save_image(&data).unwrap();
for selected in [1, 0, 1] {
bank(&mut bus, selected);
for offset in 0..=0xffff {
assert_eq!(
bus.read8(SAVE_START + offset).unwrap(),
data[selected as usize * 0x10000 + offset as usize]
);
}
command(&mut bus, 0x90).unwrap();
assert_eq!(bus.read8(SAVE_START).unwrap(), 0xc2);
assert_eq!(bus.read8(SAVE_START + 1).unwrap(), 9);
command(&mut bus, 0xf0).unwrap();
assert_eq!(
bus.read8(SAVE_START).unwrap(),
data[selected as usize * 0x10000]
);
}
let mut small = memory(SaveDevice::Flash64);
assert!(command(&mut small, 0xb0)
.unwrap_err()
.to_string()
.contains("64 KiB"));
small.write8(HI, 0xf0).unwrap();
assert_eq!(small.save_image().unwrap().len(), 0x10000);
}
#[test]
fn unsupported_commands_and_bad_bank_values_are_retryable_without_data_changes() {
for device in [SaveDevice::Flash64, SaveDevice::Flash128] {
for (byte, message) in [
(0xa0, "programming"),
(0x80, "erase setup"),
(0x10, "Flash command"),
] {
let mut bus = memory(device);
let data = image(device);
bus.load_save_image(&data).unwrap();
let error = command(&mut bus, byte).unwrap_err();
assert!(error.to_string().contains(message));
assert_eq!(bus.write8(HI, byte), Err(error));
assert_eq!(bus.save_image().unwrap(), data);
bus.write8(HI, 0xf0).unwrap(); // Finish the already accepted unlock sequence.
assert_eq!(bus.read8(SAVE_START).unwrap(), data[0]);
assert_eq!(bus.read16(IF).unwrap(), 0);
}
}
let mut bus = memory(SaveDevice::Flash128);
let data = image(SaveDevice::Flash128);
bus.load_save_image(&data).unwrap();
command(&mut bus, 0xb0).unwrap();
for (address, value) in [(SAVE_START, 2), (SAVE_START, 255), (SAVE_START + 1, 1)] {
let error = bus.write8(address, value).unwrap_err();
assert_eq!(bus.write8(address, value), Err(error));
assert_eq!(bus.read8(SAVE_START).unwrap(), data[0]);
}
bus.write8(SAVE_START, 1).unwrap();
assert_eq!(bus.read8(SAVE_START).unwrap(), data[0x10000]);
}
#[test]
fn widths_aliases_and_invalid_unlocks_remain_diagnostic() {
let mut bus = memory(SaveDevice::Flash128);
for address in [SAVE_START - 1, SAVE_END + 1, 0x0f000000, 0x0eff5555] {
assert_eq!(bus.read8(address), Err(MemoryError::Unmapped(address)));
}
assert!(bus
.read16(SAVE_START)
.unwrap_err()
.to_string()
.contains("non-byte read"));
assert!(bus
.read32(SAVE_START)
.unwrap_err()
.to_string()
.contains("non-byte read"));
assert!(bus
.write16(HI & !1, 0xaa)
.unwrap_err()
.to_string()
.contains("non-byte write"));
assert!(bus
.write32(SAVE_START, 0)
.unwrap_err()
.to_string()
.contains("non-byte write"));
for (address, value) in [(HI, 0x55), (LO, 0xaa)] {
let error = bus.write8(address, value).unwrap_err();
assert_eq!(bus.write8(address, value), Err(error));
}
bus.write8(HI, 0xaa).unwrap();
assert!(bus.write8(LO + 1, 0x55).is_err());
bus.write8(LO, 0x55).unwrap();
assert!(bus.write8(HI + 1, 0x90).is_err());
bus.write8(HI, 0x90).unwrap();
assert_eq!(bus.read8(SAVE_START).unwrap(), 0xc2);
}
#[test]
fn redundant_128_reset_preserves_array_bank_but_does_not_exit_id_or_partial_unlock() {
let mut bus = memory(SaveDevice::Flash128);
let bytes = image(SaveDevice::Flash128);
bus.load_save_image(&bytes).unwrap();
bank(&mut bus, 1);
bus.write8(HI, 0xf0).unwrap(); // Already idle in array mode.
assert_eq!(bus.read8(SAVE_START).unwrap(), bytes[0x10000]);
command(&mut bus, 0x90).unwrap();
assert!(bus.write8(HI, 0xf0).is_err()); // Direct ID exit for this device is not established.
assert_eq!(bus.read8(SAVE_START).unwrap(), 0xc2);
command(&mut bus, 0xf0).unwrap();
bus.write8(HI, 0xf0).unwrap(); // Redundant reset after documented unlocked ID exit.
assert_eq!(bus.read8(SAVE_START).unwrap(), bytes[0x10000]);
bus.write8(HI, 0xaa).unwrap();
assert!(bus.write8(HI, 0xf0).is_err());
bus.write8(LO, 0x55).unwrap();
bus.write8(HI, 0x90).unwrap();
assert_eq!(bus.save_image().unwrap(), bytes);
}
#[test]
fn macronix_64_direct_reset_and_gpio_selection_are_independent() {
let mut bus = memory(SaveDevice::Flash64);
bus.set_cartridge_hardware(CartridgeHardware::Rtc);
bus.advance_rtc_seconds(42).unwrap();
let rtc = bus.rtc_datetime();
command(&mut bus, 0x90).unwrap();
bus.write8(HI, 0xf0).unwrap();
assert_eq!(bus.read8(SAVE_START).unwrap(), 255);
bus.write8(HI, 0xaa).unwrap();
bus.write8(HI, 0xf0).unwrap(); // Abort partial unlock for this documented device.
command(&mut bus, 0x90).unwrap();
bus.set_cartridge_hardware(CartridgeHardware::None);
assert_eq!(bus.read8(SAVE_START).unwrap(), 0xc2);
bus.set_cartridge_hardware(CartridgeHardware::Rtc);
bus.set_rtc_datetime(rtc.unwrap()).unwrap();
bus.set_save_device(SaveDevice::Flash128);
assert_eq!(bus.rtc_datetime(), rtc);
bus.set_save_device(SaveDevice::None);
assert_eq!(bus.save_image(), None);
assert_eq!(bus.rtc_datetime(), rtc);
}
#[test]
fn halt_stop_and_dma_do_not_create_a_flash_completion_or_change_data() {
for stop in [false, true] {
let mut bus = memory(SaveDevice::Flash128);
command(&mut bus, 0x90).unwrap();
bus.write16(IE, 0x2000).unwrap();
bus.write16(IME, 1).unwrap();
bus.write8(HALTCNT, if stop { 128 } else { 0 }).unwrap();
let mut machine = Machine::new(Cpu::new(ROM_START), bus);
for _ in 0..20 {
machine.step().unwrap();
}
assert_eq!(machine.memory().read8(SAVE_START).unwrap(), 0xc2);
assert_eq!(machine.memory().read16(IF).unwrap(), 0);
assert_eq!(machine.halted(), !stop);
assert_eq!(machine.stopped(), stop);
}
for source in [false, true] {
let mut bus = memory(SaveDevice::Flash128);
let dma = DMA_BASE + 36;
bus.write32(0x02000000, 0xaa).unwrap();
bus.write32(dma, if source { SAVE_START } else { 0x02000000 })
.unwrap();
bus.write32(dma + 4, if source { 0x02000010 } else { SAVE_START })
.unwrap();
bus.write32(dma + 8, 0xc0000001).unwrap();
let mut machine = Machine::new(Cpu::new(ROM_START), bus);
let error = machine.step().unwrap_err();
assert!(error.to_string().contains("Flash non-byte"));
assert_eq!(machine.step(), Err(error));
assert_eq!(machine.cycles(), 0);
assert_eq!(machine.memory().read32(0x02000010).unwrap(), 0);
assert_eq!(machine.memory().read16(IF).unwrap(), 0);
assert!(machine
.memory()
.save_image()
.unwrap()
.iter()
.all(|&x| x == 255));
}
}

View file

@ -27,6 +27,20 @@ fn waitcnt_masks_unused_bits_and_supports_all_bus_widths() {
assert_eq!(memory.waitcnt(), 0x4317); assert_eq!(memory.waitcnt(), 0x4317);
} }
#[test]
fn save_byte_access_costs_follow_waitcnt_and_ignore_sequential_kind() {
for (wait, cycles) in [5, 4, 3, 9].into_iter().enumerate() {
for kind in [AccessKind::NonSequential, AccessKind::Sequential] {
for address in [0x0e000000, 0x0e005555, 0x0e00ffff] {
assert_eq!(
bus_cycles(wait as u16, address, AccessWidth::Byte, kind),
cycles
);
}
}
}
}
#[test] #[test]
fn every_rom_wait_setting_has_the_documented_halfword_and_word_costs() { fn every_rom_wait_setting_has_the_documented_halfword_and_word_costs() {
for window in 0..3 { for window in 0..3 {
@ -112,8 +126,8 @@ fn internal_regions_and_ewram_use_fixed_width_specific_costs() {
} }
#[test] #[test]
fn stored_prefetch_phi_and_sram_settings_do_not_change_supported_bus_costs() { fn stored_prefetch_phi_and_save_wait_settings_do_not_change_rom_bus_costs() {
// This stateless helper excludes opcode prefetch. PHI output and SRAM remain unmapped. // This stateless helper excludes opcode prefetch. Save waits do not affect ROM cycles.
for width in [AccessWidth::Halfword, AccessWidth::Word] { for width in [AccessWidth::Halfword, AccessWidth::Word] {
for address in [ROM_START, ROM_START + 4, 0x0a00_0004, 0x0c00_0004] { for address in [ROM_START, ROM_START + 4, 0x0a00_0004, 0x0c00_0004] {
for kind in [AccessKind::NonSequential, AccessKind::Sequential] { for kind in [AccessKind::NonSequential, AccessKind::Sequential] {

View file

@ -49,6 +49,9 @@ The repository is a Cargo workspace with three crates:
| `crates/gba-core/src/cpu/tests.rs` | Condition truth tables and immediate arithmetic 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/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, caller-clock routing, and bounded diagnostics | | `crates/gba-core/src/cartridge.rs` | Explicit GPIO/RTC selection, ROM read overlay, pin directions, caller-clock routing, and bounded diagnostics |
| `crates/gba-core/src/cartridge/flash.rs` | Explicit save devices, Macronix identification/read controller, bank selection, and unsupported-write diagnostics |
| `crates/gba-core/tests/flash.rs` | Erased/supplied images, chip IDs, all bank addresses, invalid commands, reset limits, DMA rejection, and RTC independence |
| `crates/gba-core/src/memory/flash_step_tests.rs` | ARM/Thumb Flash writes and RAM reads, wait costs, staged IDs, fetch restrictions, and failed-step/preflight isolation |
| `crates/gba-core/src/cartridge/rtc.rs` | RTC framing, control/calendar commands, read snapshots, complete writes, and reset | | `crates/gba-core/src/cartridge/rtc.rs` | RTC framing, control/calendar commands, read snapshots, complete writes, and reset |
| `crates/gba-core/src/cartridge/calendar.rs` | Validated decimal calendar, BCD representation, checked epoch conversion, and bounded elapsed-time arithmetic | | `crates/gba-core/src/cartridge/calendar.rs` | Validated decimal calendar, BCD representation, checked epoch conversion, and bounded elapsed-time arithmetic |
| `crates/gba-core/src/cartridge/calendar/tests.rs` | Calendar field validation, century-wide rollovers, hour modes, and large/split advances | | `crates/gba-core/src/cartridge/calendar/tests.rs` | Calendar field validation, century-wide rollovers, hour modes, and large/split advances |

View file

@ -9,7 +9,8 @@ Unsupported operations and invalid calendar payloads return diagnostics.
## Selection ## Selection
`Memory::set_cartridge_hardware(CartridgeHardware::Rtc)` attaches the bounded interface with initial state. `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. `CartridgeHardware::None` is the default. Selecting either option replaces GPIO/RTC state, without changing ROM bytes.
The separately selected [Flash device](saves.md) and its array remain unchanged.
Call this API between machine steps. The core does not inspect filenames, game headers, or library signatures to select hardware. 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: The desktop enables this interface with `--rtc`, in either ROM execution mode:

View file

@ -33,14 +33,18 @@ It does not decode archives or ELF executables. Supply a raw binary with an ARM
Headerless original programs are accepted. Headerless original programs are accepted.
Reads past supplied cartridge bytes remain explicit memory errors, not open-bus values. 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. All three Game Pak ROM windows use the same supplied bytes and existing wait-state rules.
Ordinary cartridge bytes remain read-only. Save devices remain unsupported. Ordinary cartridge bytes remain read-only.
Optional `--save-type flash64` or `--save-type flash128` enables [Flash identification and array reads](saves.md).
The desktop starts an erased device and does not load or write save files. Programming and erase remain diagnostic.
Optional `--rtc` selection enables the bounded [Game Pak GPIO and RTC calendar interface](cartridge-gpio.md). Optional `--rtc` selection enables the bounded [Game Pak GPIO and RTC calendar interface](cartridge-gpio.md).
It supports date/time reads, complete writes, reset, and explicit elapsed-time updates. RTC interrupts and persistence remain unsupported. It supports date/time reads, complete writes, reset, and explicit elapsed-time updates. RTC interrupts and persistence remain unsupported.
The desktop seeds UTC once, then supplies monotonic host elapsed seconds independently of GBA cycles and STOP. The desktop seeds UTC once, then supplies monotonic host elapsed seconds independently of GBA cycles and STOP.
Both modes call `bios::boot` with the loaded bytes. Both modes call `bios::boot` with the loaded bytes.
With `--rtc`, the host then selects the cartridge peripheral before the first machine step. 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. Without that option, no RTC is attached. Duplicate `--rtc` options are rejected.
Save-device selection is independent of RTC selection. Omitting `--save-type` leaves save memory unmapped.
Unknown save types and duplicate options fail before file access. No ROM-based hardware detection is used.
The [original BIOS replacement](bios.md) executes its minimal boot before entering `0x08000000` in ARM System mode. 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. 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. Unsupported services remain diagnostics. RegisterRamReset now accepts sound/serial reset flags, including `r0=0xff`, within the initialization subset.

View file

@ -351,6 +351,8 @@ Palette and video RAM cost 1 cycle for byte/halfword accesses and 2 for words, w
ROM costs include one cycle plus the configured wait states for each 16-bit transfer. ROM costs include one cycle plus the configured wait states for each 16-bit transfer.
A ROM word uses two halfword accesses; its second halfword always uses sequential timing. A ROM word uses two halfword accesses; its second halfword always uses sequential timing.
Accesses at 128 KiB ROM boundaries force non-sequential timing for the first halfword. Accesses at 128 KiB ROM boundaries force non-sequential timing for the first halfword.
Supported save-byte accesses use WAITCNT bits 0–1: one cycle plus 4, 3, 2, or 8 wait states.
Sequential and non-sequential save requests use the same nominal cost. Flash data accesses cancel the opcode prefetch queue.
`WAITCNT` resets to zero and supports byte, halfword, and word access. `WAITCNT` resets to zero and supports byte, halfword, and word access.
The writable mask is `0x5fff`; the Game Pak type flag reads as GBA, and the upper halfword reads as zero. The writable mask is `0x5fff`; the Game Pak type flag reads as GBA, and the upper halfword reads as zero.
@ -421,6 +423,7 @@ Important timing limits:
- Refill cost calculation does not read target bytes. ARM/Thumb instruction-buffer and local IWRAM bus-history samples add no extra cycles. - Refill cost calculation does not read target bytes. ARM/Thumb instruction-buffer and local IWRAM bus-history samples add no extra cycles.
- Target-pair sampling cannot fail the branch early. An invalid branch target still fails on the following instruction fetch. - Target-pair sampling cannot fail the branch early. An invalid branch target still fails on the following instruction fetch.
- Game Pak prefetch is nominal. Full-buffer restart, page boundaries, cancellation, and live WAITCNT changes need independent hardware validation. - Game Pak prefetch is nominal. Full-buffer restart, page boundaries, cancellation, and live WAITCNT changes need independent hardware validation.
- PHI and SRAM wait fields are stored; PHI output and SRAM mapping are not implemented. - Save wait fields drive the supported Flash byte-access costs. PHI output and SRAM mapping remain unimplemented.
See [Flash access restrictions](saves.md#bus-behavior); wider save transfers and physical bus timing remain unsupported or unverified.
- External work RAM timing is fixed. The undocumented memory-control register is not implemented. - External work RAM timing is fixed. The undocumented memory-control register is not implemented.
- Exact DMA startup/resumption delays, display-bus contention, and timer startup/register-write delays remain unmodeled. - Exact DMA startup/resumption delays, display-bus contention, and timer startup/register-write delays remain unmodeled.

122
docs/hardware/saves.md Normal file
View file

@ -0,0 +1,122 @@
# Cartridge Flash identification and read subset
The core supports explicit Macronix Flash selection, chip identification, array reads, and 128 KiB bank selection.
Programming, erase, busy polling, and host save-file persistence remain unimplemented.
This is not working game-save support. Unsupported writes fail instead of reporting a completed save.
SRAM, EEPROM, and other manufacturers' protocols remain separate work.
## Selection and initial data
The desktop accepts either `--save-type flash64` or `--save-type flash128` in ROM terminal/window modes.
The option is independent of `--rtc`. Omitting it leaves save memory unmapped.
Unknown values and duplicate options fail before opening the ROM.
```sh
direnv exec . cargo run --locked --release -- --rom roms/pokemon-emerald.gba --rtc --save-type flash128 --steps 3000000
direnv exec . cargo run --locked --release -- --rom roms/pokemon-emerald.gba --rtc --save-type flash128 --window --frames 60
```
Selection is explicit, not inferred from a filename, ROM signature, or the first write.
A 64 KiB device cannot silently become a 128 KiB device.
The desktop starts each selected device with erased `0xff` bytes and reports the read-only subset's limits.
It does not look for, read, create, or overwrite a save file. Existing game saves remain untouched.
The ROM suite schema still uses no save device by default and has no save-selection field yet.
Core APIs on `Memory`:
- `set_save_device(SaveDevice)` replaces save hardware and initializes its array to erased bytes.
`None`, `Flash64`, and `Flash128` are available. The call preserves GPIO/RTC state.
- `load_save_image(&[u8])` supplies an exact-capacity image and resets command state, ID mode, and bank selection.
A wrong length or missing device changes nothing. This is host setup, not an emulated program operation.
- `save_image()` inspects the complete array, independently of ID mode or selected bank.
It returns `None` when no save device is selected.
Configuration and image replacement occur between machine steps. No filesystem operations belong to the core.
Selecting GPIO/RTC hardware does not replace an attached save device or its bytes.
This feature can inspect caller-supplied images but cannot modify their data through Flash commands.
## Bus behavior
Only `0x0e000000` through `0x0e00ffff` are mapped when a device is selected.
The Flash interface uses byte accesses. Halfword/word transfers, DMA, and instruction fetches remain diagnostic.
Unverified aliases outside this window remain unmapped rather than returning mirrored data.
Ordinary ROM writes remain read-only.
CPU Flash reads must execute from external or internal work RAM, as required by GBATEK's access description.
A read from ROM/BIOS code reports a diagnostic rather than inventing a value for an unsupported bus sequence.
Host byte inspection has no CPU execution restriction. CPU command writes may execute from ROM.
WAITCNT bits 0–1 select save wait states 4, 3, 2, or 8.
The nominal byte-access costs are therefore 5, 4, 3, or 9 cycles, for both sequential and non-sequential requests.
Save waits do not alter ROM wait fields. Flash data accesses cancel Game Pak opcode prefetch through the existing nominal model.
Changing only save wait bits preserves the queue until a cartridge access cancels it.
These costs are source-backed estimates, not physical timing measurements.
## Commands
Use these byte addresses and values for the unlock prefix:
1. Write `0xaa` to `0x0e005555`.
2. Write `0x55` to `0x0e002aaa`.
3. Write a command byte to `0x0e005555`.
Supported commands after the prefix:
| Command | Behavior |
| --- | --- |
| `0x90` | Enter chip-identification mode |
| `0xf0` | Exit identification mode and return to array reads |
| `0xb0` | On Flash128 only, accept a bank byte next at `0x0e000000` |
In ID mode, byte zero is the manufacturer and byte one is the device:
| Selection | Manufacturer | Device | Combined ID |
| --- | --- | --- | --- |
| Flash64 | `0xc2` | `0x1c` | `0x1cc2` |
| Flash128 | `0xc2` | `0x09` | `0x09c2` |
Other ID-mode addresses remain diagnostic because their values are not established by this subset.
Array mode reads bytes from the selected 64 KiB bank. Flash64 always uses bank zero.
Flash128 accepts bank values zero and one only. Bank changes do not alter stored data.
Entering and leaving ID mode preserves the selected bank.
Reads during an incomplete unlock retain the previous read mode and bank.
For the documented 64 KiB Macronix device, a direct `0xf0` write at `0x0e005555` resets command/ID state.
For Flash128, the same write is accepted only as a redundant reset when already idle in array mode.
This bounded no-op matches the inspected emulators' idle behavior. It does not establish direct ID exit or busy-command cancellation.
Flash128 still requires the full unlock prefix to exit ID mode. Direct reset during an incomplete unlock remains diagnostic.
No matching manufacturer datasheet or physical measurement was used to claim broader 128 KiB reset behavior.
`0xa0` byte-program setup and `0x80` erase setup remain diagnostic at the command write.
No operation starts, data changes, busy state, completion flag, or interrupt is fabricated.
Unknown commands, invalid prefixes, and invalid bank writes are also diagnostic.
Rejected accesses retain the previous command state and bytes, so retrying the same access gives the same result.
This atomic diagnostic policy does not reproduce hardware handling of every malformed sequence.
## Transactions and validation
The small Flash controller state joins the existing cartridge machine-step transaction.
The full save image stays outside the copied controller state; this read-only subset never mutates it during emulation.
Failed CPU or DMA steps discard speculative controller changes and timing.
Block-store preflight rejects a wide Flash destination before committing earlier RAM or device writes.
HALT/STOP and elapsed cycles cannot create Flash completion events. There is no pending program or erase operation to finish.
Original tests cover both device IDs, every byte in both banks, exact image lengths, erased state, invalid sequences, and unchanged ROM/save data.
They distinguish redundant idle reset from unsupported Flash128 ID/partial-command reset.
ARM/Thumb probes verify ROM command writes, RAM byte reads, wait settings, restrictions, and retryable failed steps.
Additional tests cover staged ID reads, block rollback, DMA rejection, GPIO/RTC independence, and prefetch cancellation.
Desktop tests execute an original IWRAM byte reader with explicit device selection in terminal and native-window modes.
No game code, save file, or copyrighted assets are included in these tests.
## Remaining work
Implement and validate program/erase commands, busy polling, cancellation, and byte-program bit transitions before claiming save functionality.
Then add explicit host save loading and safe persistence, with exact-size validation and restart tests.
Do not overwrite existing files or add immediate-success responses merely to pass a game check.
## References
- [GBATEK backup Flash](https://problemkaputt.de/gbatek-gba-cart-backup-flash-rom.htm): capacities, IDs, unlock sequences, bank selection, 64 KiB Macronix reset, byte-only access, and RAM-execution requirements.
- [GBATEK](https://mgba-emu.github.io/gbatek/), WAITCNT section: save wait fields and one-cycle-plus-wait timing.
- [mGBA savedata at 3a5e34be](https://github.com/mgba-emu/mgba/blob/3a5e34be33dc7f8f707e5bc9db69e8a430046f21/src/gba/savedata.c): comparison for controller phases, bank addressing, ID overlays, and idle reset handling. Its default chip IDs differ; its write/erase timing and busy approximation are not copied.
- [ares Flash at 9408cb43](https://github.com/ares-emulator/ares/blob/9408cb43d4948fc3ea6e152a307a34348df3fe04/ares/gba/cartridge/flash.cpp): comparison for IDs, banking, unlocked ID exit, and unchanged idle state after an unrecognized direct reset. Its immediate program/erase behavior and permissive unlock handling are not adopted.

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: Use a local ROM that you may lawfully use:
```sh ```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 --save-type flash128 --steps 3000000
direnv exec . cargo run --locked --release -- --rom roms/pokemon-emerald.gba --rtc --window --frames 60 direnv exec . cargo run --locked --release -- --rom roms/pokemon-emerald.gba --rtc --save-type flash128 --window --frames 60
``` ```
The earlier reset-only implementation stopped at master sound enable (`0x04000084`, PC `0x082e0518`). The earlier reset-only implementation stopped at master sound enable (`0x04000084`, PC `0x082e0518`).
@ -71,16 +71,22 @@ The calendar model now supports date/time reads, complete writes, reset, and cal
Core tests inject deterministic time. Desktop `--rtc` uses UTC startup and monotonic elapsed time, including during GBA STOP. Core tests inject deterministic time. Desktop `--rtc` uses UTC startup and monotonic elapsed time, including during GBA STOP.
Select it explicitly; ordinary ROM bytes and other ROMs' default hardware remain unchanged. Select it explicitly; ordinary ROM bytes and other ROMs' default hardware remain unchanged.
Both terminal and native runs with `--rtc` now reach a Flash unlock write: RTC support then reached a Flash unlock write (`0xaa` at `0x0e005555`, PC `0x082e1892`).
The explicit [Flash read subset](../hardware/saves.md) now handles identification, array reads, and bank selection.
Select `--save-type flash128` for this bounded run; the device begins erased and no save file is opened.
A later redundant `0xf0` write occurred after returning to idle array mode.
The subset accepts that no-op without claiming direct Flash128 ID exit or busy-command cancellation.
Both terminal and native runs with these options now reach Joybus mode selection:
```text ```text
Steps: 964375; instructions: 961679; IRQ entries: 80; DMA units: 2616; HALT idle: 0 Steps: 1389249; instructions: 1386088; IRQ entries: 89; DMA units: 3072; HALT idle: 0
Nominal cycles: 3546961 Nominal cycles: 4688039
PC=0x082e1892 PC=0x082deeca
unmapped memory at 0x0e005555 unsupported Joybus serial mode: write 0xc0 at 0x04000135
``` ```
The native run captures twelve startup frames. These do not establish a working title screen or gameplay. The native run captures sixteen startup frames. These do not establish a working title screen or gameplay.
Audio-device behavior is validated by original tests, not by audible game output. Audio-device behavior is validated by original tests, not by audible game output.
The window run does not reach its requested frame limit and correctly exits with failure. The window run does not reach its requested frame limit and correctly exits with failure.
Terminal execution is also diagnostic, not a compatibility pass. Terminal execution is also diagnostic, not a compatibility pass.
@ -92,9 +98,9 @@ Workspace debug/release tests, strict Clippy, public ARM/Thumb/memory/BIOS check
Native ROM-window tests and graphics smoke modes pass on Darwin arm64. Native ROM-window tests and graphics smoke modes pass on Darwin arm64.
The pinned Pong debug/release reports match. The pinned Pong debug/release reports match.
The next requirement is a cartridge Flash save device, beginning with command unlock and identification. The next startup requirement is disconnected Joybus configuration, beginning with `RCNT=0xc000`.
The failing state contains value `0xaa` and address `0x0e005555`, matching the documented unlock sequence. Research idle registers, absent-device status, and mode transitions before implementing them. Do not fabricate a connected peer.
Research device selection, IDs, banking, program/erase, busy behavior, and safe persistence before claiming save compatibility. Flash programming, erase, busy behavior, and safe persistence remain required before claiming save compatibility.
RTC persistence and cartridge IRQ behavior remain separate missing features. RTC persistence and cartridge IRQ behavior remain separate missing features.
Ordinary ROM writes remain read-only. Without `--rtc`, the earlier GPIO diagnostic is still expected. 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. This result does not establish gameplay, linked transfers, or cartridge/serial GPIO interrupt support.
@ -108,4 +114,5 @@ Cartridge saves remain a separate required feature. SRAM alone will not support
Hardware and emulator references are recorded in the linked subsystem documents. Hardware and emulator references are recorded in the linked subsystem documents.
[GBATEK backup Flash](https://problemkaputt.de/gbatek-gba-cart-backup-flash-rom.htm) identifies the `0xaa`/`0x5555` unlock and chip-identification sequence. [GBATEK backup Flash](https://problemkaputt.de/gbatek-gba-cart-backup-flash-rom.htm) identifies the `0xaa`/`0x5555` unlock and chip-identification sequence.
The bounded failure establishes an access requirement, not the device's capacity or full save compatibility. The selected device is an explicit test configuration, not a cartridge-capacity determination from the filename or unlock write.
The bounded run does not establish save compatibility.

View file

@ -50,7 +50,7 @@ Update this file when a feature lands or a limit is removed.
- Code costs use actual ARM PC+8/Thumb PC+4 source fetches and branch/exception target pairs, including region and ROM boundaries. - Code costs use actual ARM PC+8/Thumb PC+4 source fetches and branch/exception target pairs, including region and ROM boundaries.
- IRQ entry samples its discarded incoming-state fetch before the ARM vector pair, with local IWRAM history updates. - IRQ entry samples its discarded incoming-state fetch before the ARM vector pair, with local IWRAM history updates.
- Persistent fetch access kinds follow CPU data/internal work, compose with DMA resume, and reset after target refills. - Persistent fetch access kinds follow CPU data/internal work, compose with DMA resume, and reset after target refills.
- Game Pak wait-state control (`WAITCNT`) for all three ROM windows. - Game Pak wait-state control (`WAITCNT`) for all three ROM windows and supported save-byte accesses.
- Bit 14 enables a nominal eight-halfword opcode prefetch queue, with partial fetches, cancellation, and DMA/idle integration. - Bit 14 enables a nominal eight-halfword opcode prefetch queue, with partial fetches, cancellation, and DMA/idle integration.
- Staged timer/audio/serial clocks follow CPU and DMA bus-completion phases, including timer/serial IF reads, acknowledgements, and failed-step rollback. - Staged timer/audio/serial clocks follow CPU and DMA bus-completion phases, including timer/serial IF reads, acknowledgements, and failed-step rollback.
- The unchanged original ARM cancellation probe matches both published read interval totals and its unadjusted timer samples. - The unchanged original ARM cancellation probe matches both published read interval totals and its unadjusted timer samples.
@ -79,7 +79,9 @@ Update this file when a feature lands or a limit is removed.
- Explicit cartridge RTC selection with first-window GPIO read enable, direction/latch behavior, and serial calendar/control transactions. - Explicit cartridge RTC selection with first-window GPIO read enable, direction/latch behavior, and serial calendar/control transactions.
- Validated RTC date/time, BCD transfers, read snapshots, complete writes/reset, and caller-supplied battery-clock advancement. - Validated RTC date/time, BCD transfers, read snapshots, complete writes/reset, and caller-supplied battery-clock advancement.
- Desktop `--rtc` seeds UTC once and supplies host elapsed seconds independently of CPU cycles and STOP. - Desktop `--rtc` seeds UTC once and supplies host elapsed seconds independently of CPU cycles and STOP.
- Cartridge GPIO changes participate in CPU/DMA rollback and block-store preflight without modifying ROM bytes. - Cartridge GPIO and Flash controller changes participate in CPU/DMA rollback and block-store preflight without modifying ROM bytes.
- Explicit Macronix Flash64/Flash128 identification, read-only arrays, and 128 KiB bank selection.
- Exact-size core save-image initialization and inspection; desktop save selection starts erased and performs no save-file access.
- Raw ROM-file loading with original BIOS boot, terminal diagnostics, and final CPU/step reports. - 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. - 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. - RegisterRamReset supports disabled sound and disconnected serial initialization, including the common `r0=0xff` request.
@ -142,7 +144,9 @@ Internal affine origins, vertical window flags, and vertical mosaic counters tra
Horizontal window comparators retain four-cycle event history. 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. 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. 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. There is no host audio output or save writing/persistence.
The [Flash subset](hardware/saves.md) supports explicit device selection, identification, array reads, and banking.
Flash programming, erase, busy polling, wider accesses, unverified aliases, and other save-device protocols remain diagnostic or unimplemented.
The optional [cartridge GPIO/RTC subset](hardware/cartridge-gpio.md) supports calendar/control transfers and explicit clock updates, selected with `--rtc` in ROM modes. The optional [cartridge GPIO/RTC subset](hardware/cartridge-gpio.md) supports calendar/control transfers and explicit clock updates, selected with `--rtc` in ROM modes.
RTC persistence, interrupts, invalid-date correction, physical subsecond timing, GPIO aliases, and byte writes remain unsupported or unverified. RTC persistence, interrupts, invalid-date correction, physical subsecond timing, GPIO aliases, and byte writes remain unsupported or unverified.
The core uses deterministic caller time. Desktop RTC runs use host UTC/elapsed time and are not fully deterministic. The core uses deterministic caller time. Desktop RTC runs use host UTC/elapsed time and are not fully deterministic.
@ -169,7 +173,8 @@ It also completes a CpuSet call to address zero through the corrected ignored BI
It also accepts an external-clock serial request without inventing clock edges or completion. 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 now also completes internally clocked normal transfers and selects idle multiplayer mode.
With explicit `--rtc` selection, it also passes Game Pak GPIO configuration and RTC calendar reads. With explicit `--rtc` selection, it also passes Game Pak GPIO configuration and RTC calendar reads.
It next stops at a Flash unlock write (`0x0e005555`, PC `0x082e1892`). Cartridge save devices remain unimplemented. With `--save-type flash128`, it also passes Flash identification and reaches a later startup check.
It next stops at Joybus mode selection (`0x04000135`, PC `0x082deeca`). Flash writing and persistence remain unimplemented.
Without `--rtc`, cartridge GPIO writes still return read-only diagnostics. Without `--rtc`, cartridge GPIO writes still return read-only diagnostics.
No title-screen, gameplay, or audible-game compatibility is established. 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 [the startup result](research/emerald-reset.md) for the bounded run and next investigation.
@ -201,7 +206,7 @@ Host inspection and instruction fetches remain strict. Other unmapped reads retu
Reads beyond the supplied cartridge bytes also return errors. Reads beyond the supplied cartridge bytes also return errors.
Direct memory-bus halfword and word accesses require alignment. Direct memory-bus halfword and word accesses require alignment.
CPU load/store instructions apply ARM7TDMI alignment and rotation rules before accessing the bus. CPU load/store instructions apply ARM7TDMI alignment and rotation rules before accessing the bus.
Ordinary ROM writes remain read-only. Only the explicitly selected cartridge GPIO register subset accepts writes. Ordinary ROM writes remain read-only. Explicitly selected cartridge GPIO and supported Flash command writes are separate from ROM storage.
## Next steps ## Next steps

View file

@ -73,6 +73,11 @@ ARM/Thumb and DMA3 checks retain Game Pak costs; staged fetches, rejected edges,
Desktop tests execute original ARM calendar commands in terminal and native-window modes with explicit `--rtc` selection. Desktop tests execute original ARM calendar commands in terminal and native-window modes with explicit `--rtc` selection.
Host-adapter tests inject UTC epoch values and elapsed durations without sleeping. Guest time changes survive subsequent elapsed updates. Host-adapter tests inject UTC epoch values and elapsed durations without sleeping. Guest time changes survive subsequent elapsed updates.
Physical pin timing, invalid-date hardware correction, GPIO aliases, and battery persistence remain unverified or unimplemented. Physical pin timing, invalid-date hardware correction, GPIO aliases, and battery persistence remain unverified or unimplemented.
Flash regressions use original command sequences and exact-size generated arrays, not game saves.
They check device IDs, every byte in both 128 KiB banks, erased state, image validation, reset limits, and unsupported program/erase commands.
ARM/Thumb probes verify RAM byte reads, ROM command writes, all save wait settings, staged ID state, and failed-step rollback.
Wide accesses, DMA, instruction fetches, and CPU reads outside work RAM retain explicit diagnostics.
An original desktop ROM installs its Flash byte reader in IWRAM; terminal/native tests check explicit selection and unchanged files.
They verify explicit activation errors and atomic byte/word/block-store/DMA failure behavior. 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. 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. [Jev evidence review](jev-debugging.md) is optional and never changes deterministic test verdicts.

View file

@ -41,11 +41,12 @@ This exposed and corrected missing firmware affine-scale initialization, without
Frame-based scenarios and the preparation adapter now make the result repeatable. 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. 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. It also passes normal serial transfers and idle multiplayer configuration.
With `--rtc`, it passes Game Pak GPIO setup and RTC calendar reads, then stops at a Flash unlock write. With `--rtc --save-type flash128`, it passes Game Pak GPIO setup, RTC reads, and Flash identification.
It next stops at Joybus serial-mode selection.
The core has tested Direct Sound, both pulse channels, and noise channel 4. 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. Wave channel 3, a continuous sample stream, and desktop audio output remain missing.
See [the runtime result](research/emerald-reset.md). Flash identification and its save protocol are the next demonstrated startup requirement. See [the runtime result](research/emerald-reset.md). Disconnected Joybus configuration is the next demonstrated startup requirement.
The separate save requirement remains cartridge SRAM, followed by safe host save persistence. The separate save requirement includes Flash programming/erase and safe host persistence. Identification/read support is not successful saving.
SRAM is byte-addressable battery-backed save memory. Flash and EEPROM remain separate protocols. SRAM is byte-addressable battery-backed save memory. Flash and EEPROM remain separate protocols.
[Paperdomo101/2048-GBA at ae36800d](https://github.com/Paperdomo101/2048-GBA/tree/ae36800dfa1314f96c4c8785b5c2d077fb731aff) is the next independent candidate. [Paperdomo101/2048-GBA at ae36800d](https://github.com/Paperdomo101/2048-GBA/tree/ae36800dfa1314f96c4c8785b5c2d077fb731aff) is the next independent candidate.
@ -56,8 +57,9 @@ A reproducible source build or an accessible upstream ROM is needed before recor
Next implementation steps: Next implementation steps:
1. Verify SRAM bus widths, mirroring, erased state, and wait-state behavior against independent references. 1. Research the demonstrated Joybus configuration failure without inventing a connected device.
2. Add explicit cartridge save-device selection and original core regressions. Keep absent-device accesses diagnostic. 2. Extend explicit Flash selection with verified programming, erase, and busy behavior. Keep absent devices and unsupported commands diagnostic.
Add SRAM separately when the independent candidate requires it; verify its widths, mirroring, and wait behavior first.
3. Add bounded save loading and safe persistence in the desktop crate. Do not put file operations in the core. 3. Add bounded save loading and safe persistence in the desktop crate. Do not put file operations in the core.
4. Test a save/restart/load cycle across processes with original ROM instructions before external-game validation. 4. Test a save/restart/load cycle across processes with original ROM instructions before external-game validation.
5. Resume the second-game run and implement demonstrated audio requirements rather than accepting writes without behavior. 5. Resume the second-game run and implement demonstrated audio requirements rather than accepting writes without behavior.