feat(cartridge): add 512-byte and 8 KiB serial EEPROM saves
Select them with --save-type eeprom512 or eeprom8k. The part follows GBATEK's protocol on Game Pak data bit 0: DMA3 sends one bit per halfword, '11' + address + '0' requests a 64-bit block that reads back after four dummy bits, and '10' + address + data + '0' writes one. A write reports busy for a nominal 108,368 cycles before the block is stored. 8 KiB parts decode the low 10 address bits. ROMs above 16 MiB map EEPROM only at 0x0dffff00..0x0dffffff. Bits shift out only inside machine steps, so failed steps roll back. CPU stores, byte/word accesses, CPU reads during a read-out, and malformed or overlapping requests are diagnostics. Blocks are stored first-bit-as-MSB, matching common .sav layouts, and work with save files and save states. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HhfHfHEuzv2AUTd6erW1gU
This commit is contained in:
parent
4e4dde742d
commit
652ea397f2
15 changed files with 828 additions and 24 deletions
|
|
@ -23,7 +23,7 @@ use gba_core::{
|
|||
|
||||
const MAX_STEPS: u64 = 100_000_000;
|
||||
const MAX_FRAMES: u64 = 100_000;
|
||||
const USAGE: &str = "usage: gameboy-rust --rom PATH [--rtc] [--save-type flash64|flash128|sram] [--save-file PATH] (--steps COUNT | --window [--frames COUNT] [--audio] [--speed MULTIPLIER] [--state-file PATH]); speed: integer 1..=16 (default 1, audio muted above 1); steps: 1..=100000000, frames: 1..=100000; state file (window only): F5 saves and F9 loads a save state, default is the ROM path with a .state extension; do not combine with demo or help options";
|
||||
const USAGE: &str = "usage: gameboy-rust --rom PATH [--rtc] [--save-type flash64|flash128|sram|eeprom512|eeprom8k] [--save-file PATH] (--steps COUNT | --window [--frames COUNT] [--audio] [--speed MULTIPLIER] [--state-file PATH]); speed: integer 1..=16 (default 1, audio muted above 1); steps: 1..=100000000, frames: 1..=100000; state file (window only): F5 saves and F9 loads a save state, default is the ROM path with a .state extension; do not combine with demo or help options";
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum Mode {
|
||||
|
|
@ -70,6 +70,8 @@ pub fn parse_args(args: &[OsString]) -> io::Result<Options> {
|
|||
Some("flash64") => SaveDevice::Flash64,
|
||||
Some("flash128") => SaveDevice::Flash128,
|
||||
Some("sram") => SaveDevice::Sram,
|
||||
Some("eeprom512") => SaveDevice::Eeprom512,
|
||||
Some("eeprom8k") => SaveDevice::Eeprom8k,
|
||||
_ => return Err(invalid()),
|
||||
};
|
||||
} else if arg == "--save-file" && save_file.is_none() {
|
||||
|
|
|
|||
|
|
@ -91,7 +91,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 [--rtc] [--save-type flash64|flash128|sram] [--save-file PATH] (--steps COUNT | --window [--frames COUNT] [--audio] [--speed MULTIPLIER] [--state-file PATH]); 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|sram|eeprom512|eeprom8k] [--save-file PATH] (--steps COUNT | --window [--frames COUNT] [--audio] [--speed MULTIPLIER] [--state-file PATH]); or gameboy-rust --test-suite PATH.json",
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
|
@ -140,7 +140,7 @@ fn run() -> Result<(), Box<dyn Error>> {
|
|||
--speed MULTIPLIER ROM-window speed: integer 1..=16 (default: 1); mutes audio above 1x\n\
|
||||
--audio Enable macOS output at 1x (default: muted)\n\
|
||||
--rtc Select cartridge RTC (UTC startup, host elapsed time)\n\
|
||||
--save-type flash64|flash128|sram Select nominal Macronix Flash or 32 KiB SRAM\n\
|
||||
--save-type flash64|flash128|sram|eeprom512|eeprom8k Select nominal Flash, 32 KiB SRAM, or serial EEPROM\n\
|
||||
--save-file PATH Load raw save image; persist on clean exit or window diagnostic\n\
|
||||
--state-file PATH ROM window save state; F5 saves, F9 loads (default: ROM path + .state)\n\
|
||||
--test-suite PATH.json Run bounded ROM assertions; write a JSON report\n\
|
||||
|
|
|
|||
|
|
@ -383,7 +383,7 @@ fn explicit_flash_selection_runs_ram_byte_reader_and_leaves_files_unchanged() {
|
|||
fn invalid_save_type_options_fail_before_file_access() {
|
||||
for extra in [
|
||||
vec!["--save-type"],
|
||||
vec!["--save-type", "eeprom"],
|
||||
vec!["--save-type", "fram"],
|
||||
vec!["--save-type", ""],
|
||||
vec!["--save-type", "flash128", "--save-type", "flash64"],
|
||||
] {
|
||||
|
|
|
|||
|
|
@ -254,3 +254,82 @@ fn sram_bytes_written_and_read_from_rom_code_persist_across_processes() {
|
|||
assert!(text(&output).contains("r6=0x0000003c"), "{}", text(&output));
|
||||
assert!(text(&output).contains("Save unchanged"));
|
||||
}
|
||||
|
||||
/// One halfword per bit, MSB first, packed into ROM words.
|
||||
fn bit_halfwords(bits: &[u8]) -> Vec<u32> {
|
||||
bits.chunks(2)
|
||||
.map(|pair| u32::from(pair[0]) | pair.get(1).map_or(0, |&b| u32::from(b) << 16))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn bits(value: u64, width: u32) -> Vec<u8> {
|
||||
(0..width)
|
||||
.rev()
|
||||
.map(|shift| ((value >> shift) & 1) as u8)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Run `dma` transfers (source, destination, halfword count) from ROM code, then `tail`.
|
||||
/// Halfword bitstreams live at ROM offset 0x400.
|
||||
fn eeprom_rom(dma: &[(u32, u32, u32)], tail: &[u32], data: &[u8]) -> Vec<u32> {
|
||||
let mut code = Vec::new();
|
||||
for &(source, destination, count) in dma {
|
||||
load(&mut code, 0, 0x0400_00d4); // DMA3 source register
|
||||
load(&mut code, 1, source);
|
||||
load(&mut code, 2, destination);
|
||||
load(&mut code, 3, 0x8000_0000 | count);
|
||||
code.push(0xe880_000e); // STMIA r0,{r1-r3}
|
||||
}
|
||||
code.extend(tail);
|
||||
code.push(0xeafffffe);
|
||||
code.resize(0x100, 0);
|
||||
code.extend(bit_halfwords(data));
|
||||
code
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eeprom_blocks_written_and_read_through_dma3_persist_across_processes() {
|
||||
let f = Fixture::new();
|
||||
let save = f.save();
|
||||
let request = [
|
||||
vec![1, 0],
|
||||
bits(1, 6),
|
||||
bits(0x8011_2233_4455_6677, 64),
|
||||
vec![0],
|
||||
]
|
||||
.concat();
|
||||
let write = f.rom(
|
||||
"eeprom-writer.gba",
|
||||
&eeprom_rom(&[(0x0800_0400, 0x0d00_0000, 73)], &[], &request),
|
||||
);
|
||||
// Enough steps for the nominal ~108k-cycle write to finish before exit.
|
||||
let output = command(&write, &save, "eeprom512")
|
||||
.args(["--steps", "100000"])
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{}", text(&output));
|
||||
assert!(text(&output).contains("Save persisted on clean exit"));
|
||||
let image = fs::read(&save).unwrap();
|
||||
assert_eq!(image.len(), 512);
|
||||
assert_eq!(image[8..16], 0x8011_2233_4455_6677_u64.to_be_bytes());
|
||||
assert!(image[..8].iter().chain(&image[16..]).all(|&v| v == 255));
|
||||
|
||||
// Request block 1, shift out 68 bits into IWRAM, then load data bits 0 and 1.
|
||||
let request = [vec![1, 1], bits(1, 6), vec![0]].concat();
|
||||
let read = f.rom(
|
||||
"eeprom-reader.gba",
|
||||
&eeprom_rom(
|
||||
&[
|
||||
(0x0800_0400, 0x0d00_0000, 9),
|
||||
(0x0d00_0000, 0x0300_0000, 68),
|
||||
],
|
||||
&[0xe1d2_50b8, 0xe1d2_60ba], // LDRH r5,[r2,#8]; LDRH r6,[r2,#10]
|
||||
&request,
|
||||
),
|
||||
);
|
||||
let output = run(&read, &save, "eeprom512");
|
||||
assert!(output.status.success(), "{}", text(&output));
|
||||
assert!(text(&output).contains("r5=0x00000001"), "{}", text(&output));
|
||||
assert!(text(&output).contains("r6=0x00000000"), "{}", text(&output));
|
||||
assert!(text(&output).contains("Save unchanged"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,13 @@ use crate::memory::MemoryError;
|
|||
use crate::snapshot::{Reader, Snapshot, SnapshotError, Writer};
|
||||
|
||||
mod calendar;
|
||||
mod eeprom;
|
||||
mod flash;
|
||||
mod rtc;
|
||||
mod sram;
|
||||
pub use calendar::{RtcDateTime, RtcError};
|
||||
use eeprom::Eeprom;
|
||||
pub use eeprom::EEPROM_WRITE_CYCLES;
|
||||
use flash::Flash;
|
||||
pub(crate) use flash::SaveMutation;
|
||||
pub use flash::{
|
||||
|
|
@ -34,6 +37,7 @@ pub(crate) struct Cartridge {
|
|||
gpio: Option<Gpio>,
|
||||
flash: Option<Flash>,
|
||||
sram: Option<Sram>,
|
||||
eeprom: Option<Eeprom>,
|
||||
}
|
||||
|
||||
impl Cartridge {
|
||||
|
|
@ -41,18 +45,35 @@ impl Cartridge {
|
|||
self.gpio = (hardware == CartridgeHardware::Rtc).then(Gpio::default);
|
||||
}
|
||||
|
||||
pub(crate) fn set_save_device(&mut self, device: SaveDevice) {
|
||||
/// The ROM length selects the EEPROM window: carts above 16 MiB decode only its top 256 bytes.
|
||||
pub(crate) fn set_save_device(&mut self, device: SaveDevice, rom_len: usize) {
|
||||
self.flash = Flash::new(device);
|
||||
self.sram = (device == SaveDevice::Sram).then(Sram::default);
|
||||
self.eeprom = Eeprom::new(device, rom_len);
|
||||
}
|
||||
|
||||
pub(crate) fn save_device(&self) -> SaveDevice {
|
||||
if self.sram.is_some() {
|
||||
return SaveDevice::Sram;
|
||||
}
|
||||
if let Some(eeprom) = self.eeprom {
|
||||
return eeprom.device();
|
||||
}
|
||||
self.flash.map_or(SaveDevice::None, Flash::device)
|
||||
}
|
||||
|
||||
pub(crate) fn eeprom_mapped(&self, address: u32) -> bool {
|
||||
self.eeprom.is_some_and(|eeprom| eeprom.mapped(address))
|
||||
}
|
||||
|
||||
/// One EEPROM halfword read. CPU reads poll status; other reads shift out data.
|
||||
pub(crate) fn read_eeprom16(&mut self, address: u32, cpu: bool) -> Result<u16, MemoryError> {
|
||||
self.eeprom
|
||||
.as_mut()
|
||||
.expect("mapped EEPROM")
|
||||
.read(address, cpu)
|
||||
}
|
||||
|
||||
pub(crate) fn save_mapped(&self, address: u32) -> bool {
|
||||
(self.flash.is_some() || self.sram.is_some()) && (SAVE_START..=SAVE_END).contains(&address)
|
||||
}
|
||||
|
|
@ -66,10 +87,20 @@ impl Cartridge {
|
|||
if let Some(flash) = self.flash.as_mut() {
|
||||
flash.advance(cycles);
|
||||
}
|
||||
if let Some(eeprom) = self.eeprom.as_mut() {
|
||||
eeprom.advance(cycles);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn save_unfinished(&self) -> bool {
|
||||
self.flash.is_some_and(Flash::unfinished) || self.sram.is_some_and(Sram::unfinished)
|
||||
self.flash.is_some_and(Flash::unfinished)
|
||||
|| self.sram.is_some_and(Sram::unfinished)
|
||||
|| self.eeprom.is_some_and(Eeprom::unfinished)
|
||||
}
|
||||
|
||||
/// A completed EEPROM write: its first byte offset and the eight block bytes.
|
||||
pub(crate) fn take_save_block(&mut self) -> Option<(usize, [u8; 8])> {
|
||||
self.eeprom.as_mut().and_then(Eeprom::take_block)
|
||||
}
|
||||
|
||||
pub(crate) fn take_save_mutation(&mut self) -> Option<SaveMutation> {
|
||||
|
|
@ -111,6 +142,7 @@ impl Cartridge {
|
|||
|
||||
pub(crate) fn mapped(&self, address: u32) -> bool {
|
||||
self.save_mapped(address)
|
||||
|| self.eeprom_mapped(address)
|
||||
|| (self.gpio.is_some() && (GPIO_DATA..GPIO_CONTROL + 2).contains(&address))
|
||||
}
|
||||
|
||||
|
|
@ -130,15 +162,31 @@ impl Cartridge {
|
|||
}
|
||||
|
||||
/// Apply one access to a copy first. A rejected second halfword changes nothing.
|
||||
/// `cpu` marks CPU stores, which cannot carry EEPROM bitstreams.
|
||||
pub(crate) fn write(
|
||||
&mut self,
|
||||
address: u32,
|
||||
bytes: &[u8],
|
||||
image: &[u8],
|
||||
cpu: bool,
|
||||
) -> Result<(), MemoryError> {
|
||||
if !self.mapped(address) {
|
||||
return Ok(()); // Non-cartridge accesses are validated by the bus.
|
||||
}
|
||||
if self.eeprom_mapped(address) {
|
||||
if bytes.len() != 2 {
|
||||
return Err(MemoryError::UnsupportedCartridgeAccess {
|
||||
address,
|
||||
operation: "EEPROM non-halfword write",
|
||||
});
|
||||
}
|
||||
let value = u16::from_le_bytes([bytes[0], bytes[1]]);
|
||||
return self
|
||||
.eeprom
|
||||
.as_mut()
|
||||
.unwrap()
|
||||
.write(address, value, cpu, image);
|
||||
}
|
||||
if self.save_mapped(address) {
|
||||
if bytes.len() != 1 {
|
||||
return Err(MemoryError::UnsupportedCartridgeAccess {
|
||||
|
|
@ -213,6 +261,8 @@ impl Cartridge {
|
|||
SaveDevice::Flash64 => 1,
|
||||
SaveDevice::Flash128 => 2,
|
||||
SaveDevice::Sram => 3,
|
||||
SaveDevice::Eeprom512 => 4,
|
||||
SaveDevice::Eeprom8k => 5,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
|
@ -220,7 +270,12 @@ impl Cartridge {
|
|||
|
||||
impl Snapshot for Cartridge {
|
||||
fn save(&self, writer: &mut Writer) {
|
||||
let Self { gpio, flash, sram } = self;
|
||||
let Self {
|
||||
gpio,
|
||||
flash,
|
||||
sram,
|
||||
eeprom,
|
||||
} = self;
|
||||
writer.tag(b"CART");
|
||||
writer.raw(&self.setup_identity());
|
||||
if let Some(gpio) = gpio {
|
||||
|
|
@ -232,11 +287,19 @@ impl Snapshot for Cartridge {
|
|||
if let Some(sram) = sram {
|
||||
sram.save(writer);
|
||||
}
|
||||
if let Some(eeprom) = eeprom {
|
||||
eeprom.save(writer);
|
||||
}
|
||||
}
|
||||
|
||||
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
|
||||
let target_device = self.save_device();
|
||||
let Self { gpio, flash, sram } = self;
|
||||
let Self {
|
||||
gpio,
|
||||
flash,
|
||||
sram,
|
||||
eeprom,
|
||||
} = self;
|
||||
reader.tag(b"CART")?;
|
||||
|
||||
let hardware = reader.u8()?;
|
||||
|
|
@ -252,6 +315,8 @@ impl Snapshot for Cartridge {
|
|||
1 => SaveDevice::Flash64,
|
||||
2 => SaveDevice::Flash128,
|
||||
3 => SaveDevice::Sram,
|
||||
4 => SaveDevice::Eeprom512,
|
||||
5 => SaveDevice::Eeprom8k,
|
||||
_ => return Err(SnapshotError::Corrupt("save device")),
|
||||
};
|
||||
if save_device != target_device {
|
||||
|
|
@ -267,6 +332,9 @@ impl Snapshot for Cartridge {
|
|||
if let Some(sram) = sram {
|
||||
sram.load(reader)?;
|
||||
}
|
||||
if let Some(eeprom) = eeprom {
|
||||
eeprom.load(reader)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
@ -321,7 +389,7 @@ mod snapshot_tests {
|
|||
fn configured_cartridge(hardware: CartridgeHardware, device: SaveDevice) -> Cartridge {
|
||||
let mut cartridge = Cartridge::default();
|
||||
cartridge.set_hardware(hardware);
|
||||
cartridge.set_save_device(device);
|
||||
cartridge.set_save_device(device, 0);
|
||||
cartridge
|
||||
}
|
||||
|
||||
|
|
|
|||
365
crates/gba-core/src/cartridge/eeprom.rs
Normal file
365
crates/gba-core/src/cartridge/eeprom.rs
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
//! Serial EEPROM on Game Pak data bit 0, following GBATEK's bitstream description.
|
||||
//! Requests arrive one bit per halfword: `11` + address + `0` reads a 64-bit block, and
|
||||
//! `10` + address + 64 data bits + `0` writes one. A read returns 4 ignored bits, then the
|
||||
//! block MSB first. A write completes after a nominal delay; until then status reads 0.
|
||||
//! Block bytes are stored in transfer order, the first data bit as byte 0 bit 7.
|
||||
|
||||
use super::SaveDevice;
|
||||
use crate::memory::MemoryError;
|
||||
use crate::snapshot::{Reader, Snapshot, SnapshotError, Writer};
|
||||
|
||||
/// GBATEK: "ca. 108368 clock cycles (ca. 6.5ms)" to erase and program one block.
|
||||
pub const EEPROM_WRITE_CYCLES: u32 = 108_368;
|
||||
const READ_BITS: u8 = 68;
|
||||
/// Carts with ROM above 16 MiB decode EEPROM only in the top 256 bytes of the 0x0d window.
|
||||
const LARGE_ROM: usize = 16 * 1024 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) struct Eeprom {
|
||||
large: bool,
|
||||
start: u32,
|
||||
input: u128,
|
||||
count: u8,
|
||||
output: Option<Readout>,
|
||||
busy: Option<Busy>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct Readout {
|
||||
data: u64,
|
||||
remaining: u8,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct Busy {
|
||||
block: u16,
|
||||
data: u64,
|
||||
remaining: u32,
|
||||
}
|
||||
|
||||
impl Eeprom {
|
||||
pub(super) fn new(device: SaveDevice, rom_len: usize) -> Option<Self> {
|
||||
let large = match device {
|
||||
SaveDevice::Eeprom512 => false,
|
||||
SaveDevice::Eeprom8k => true,
|
||||
_ => return None,
|
||||
};
|
||||
Some(Self {
|
||||
large,
|
||||
start: if rom_len > LARGE_ROM {
|
||||
0x0dff_ff00
|
||||
} else {
|
||||
0x0d00_0000
|
||||
},
|
||||
input: 0,
|
||||
count: 0,
|
||||
output: None,
|
||||
busy: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn device(self) -> SaveDevice {
|
||||
if self.large {
|
||||
SaveDevice::Eeprom8k
|
||||
} else {
|
||||
SaveDevice::Eeprom512
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn mapped(self, address: u32) -> bool {
|
||||
(self.start..=0x0dff_ffff).contains(&address)
|
||||
}
|
||||
|
||||
fn address_bits(self) -> u8 {
|
||||
if self.large {
|
||||
14
|
||||
} else {
|
||||
6
|
||||
}
|
||||
}
|
||||
|
||||
/// Block index from a request's address field. 8 KiB parts decode the low 10 bits.
|
||||
fn block(self, address: u128) -> u16 {
|
||||
(address as u16) & if self.large { 0x3ff } else { 0x3f }
|
||||
}
|
||||
|
||||
/// One halfword read. CPU reads only poll status; DMA reads also shift out data.
|
||||
pub(super) fn read(&mut self, address: u32, cpu: bool) -> Result<u16, MemoryError> {
|
||||
if let Some(output) = self.output.as_mut() {
|
||||
if cpu {
|
||||
return Err(MemoryError::UnsupportedCartridgeAccess {
|
||||
address,
|
||||
operation: "EEPROM CPU read during a data read-out",
|
||||
});
|
||||
}
|
||||
output.remaining -= 1;
|
||||
let bit = if output.remaining < 64 {
|
||||
(output.data >> output.remaining) & 1
|
||||
} else {
|
||||
0 // Four leading bits carry no data.
|
||||
};
|
||||
if output.remaining == 0 {
|
||||
self.output = None;
|
||||
}
|
||||
return Ok(bit as u16);
|
||||
}
|
||||
// Bits 1-15 read as zero here; hardware drives only bit 0.
|
||||
Ok(u16::from(self.busy.is_none()))
|
||||
}
|
||||
|
||||
/// One halfword of a request bitstream. Only bit 0 is used.
|
||||
pub(super) fn write(
|
||||
&mut self,
|
||||
address: u32,
|
||||
value: u16,
|
||||
cpu: bool,
|
||||
image: &[u8],
|
||||
) -> Result<(), MemoryError> {
|
||||
let fail = |operation| MemoryError::UnsupportedIo {
|
||||
address,
|
||||
value: value as u8,
|
||||
operation,
|
||||
};
|
||||
if cpu {
|
||||
// GBATEK: bitstreams need DMA3; manual STRH transfers do not work.
|
||||
return Err(fail("EEPROM CPU bitstream write"));
|
||||
}
|
||||
if self.busy.is_some() {
|
||||
return Err(fail("EEPROM write while busy"));
|
||||
}
|
||||
if self.output.is_some() {
|
||||
return Err(fail("EEPROM request during a data read-out"));
|
||||
}
|
||||
let mut next = *self;
|
||||
next.input = (next.input << 1) | u128::from(value & 1);
|
||||
next.count += 1;
|
||||
if next.count == 1 && value & 1 == 0 {
|
||||
return Err(fail("EEPROM request without a leading 1 bit"));
|
||||
}
|
||||
let address_bits = self.address_bits();
|
||||
let read_bits = 2 + address_bits + 1;
|
||||
let write_bits = 2 + address_bits + 64 + 1;
|
||||
let reading = next.count >= 2 && (next.input >> (next.count - 2)) & 1 == 1;
|
||||
let complete = if reading { read_bits } else { write_bits };
|
||||
if next.count == complete {
|
||||
if next.input & 1 != 0 {
|
||||
return Err(fail("EEPROM request without a trailing 0 bit"));
|
||||
}
|
||||
if reading {
|
||||
let block = next.block(next.input >> 1);
|
||||
let offset = usize::from(block) * 8;
|
||||
let mut bytes = [0; 8];
|
||||
bytes.copy_from_slice(&image[offset..offset + 8]);
|
||||
next.output = Some(Readout {
|
||||
data: u64::from_be_bytes(bytes),
|
||||
remaining: READ_BITS,
|
||||
});
|
||||
} else {
|
||||
next.busy = Some(Busy {
|
||||
block: next.block(next.input >> 65),
|
||||
data: (next.input >> 1) as u64,
|
||||
remaining: EEPROM_WRITE_CYCLES,
|
||||
});
|
||||
}
|
||||
next.input = 0;
|
||||
next.count = 0;
|
||||
}
|
||||
*self = next;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn advance(&mut self, cycles: u32) {
|
||||
if let Some(busy) = self.busy.as_mut() {
|
||||
busy.remaining = busy.remaining.saturating_sub(cycles);
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn unfinished(self) -> bool {
|
||||
self.count != 0 || self.output.is_some() || self.busy.is_some()
|
||||
}
|
||||
|
||||
/// A completed write: its first byte offset and the eight block bytes.
|
||||
pub(super) fn take_block(&mut self) -> Option<(usize, [u8; 8])> {
|
||||
let busy = self.busy.filter(|busy| busy.remaining == 0)?;
|
||||
self.busy = None;
|
||||
Some((usize::from(busy.block) * 8, busy.data.to_be_bytes()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Snapshot for Eeprom {
|
||||
fn save(&self, writer: &mut Writer) {
|
||||
let Self {
|
||||
large,
|
||||
start,
|
||||
input,
|
||||
count,
|
||||
output,
|
||||
busy,
|
||||
} = self;
|
||||
writer.tag(b"EEPR");
|
||||
writer.bool(*large);
|
||||
writer.u32(*start);
|
||||
writer.u64((*input >> 64) as u64);
|
||||
writer.u64(*input as u64);
|
||||
writer.u8(*count);
|
||||
writer.option(output.as_ref(), |output, writer| {
|
||||
writer.u64(output.data);
|
||||
writer.u8(output.remaining);
|
||||
});
|
||||
writer.option(busy.as_ref(), |busy, writer| {
|
||||
writer.u32(u32::from(busy.block));
|
||||
writer.u64(busy.data);
|
||||
writer.u32(busy.remaining);
|
||||
});
|
||||
}
|
||||
|
||||
fn load(&mut self, reader: &mut Reader<'_>) -> Result<(), SnapshotError> {
|
||||
reader.tag(b"EEPR")?;
|
||||
let large = reader.bool()?;
|
||||
let start = reader.u32()?;
|
||||
let input = (u128::from(reader.u64()?) << 64) | u128::from(reader.u64()?);
|
||||
let count = reader.u8()?;
|
||||
let output = reader.option(|reader| {
|
||||
Ok(Readout {
|
||||
data: reader.u64()?,
|
||||
remaining: reader.u8()?,
|
||||
})
|
||||
})?;
|
||||
let busy = reader.option(|reader| {
|
||||
let block =
|
||||
u16::try_from(reader.u32()?).map_err(|_| SnapshotError::Corrupt("EEPROM block"))?;
|
||||
Ok(Busy {
|
||||
block,
|
||||
data: reader.u64()?,
|
||||
remaining: reader.u32()?,
|
||||
})
|
||||
})?;
|
||||
if large != self.large || start != self.start {
|
||||
return Err(SnapshotError::ConfigMismatch("save device"));
|
||||
}
|
||||
let restored = Self {
|
||||
large,
|
||||
start,
|
||||
input,
|
||||
count,
|
||||
output,
|
||||
busy,
|
||||
};
|
||||
let write_bits = 2 + restored.address_bits() + 64 + 1;
|
||||
if count >= write_bits
|
||||
|| input >> count != 0
|
||||
|| output.is_some_and(|o| o.remaining == 0 || o.remaining > READ_BITS)
|
||||
|| busy.is_some_and(|b| b.block != restored.block(u128::from(b.block)))
|
||||
|| (output.is_some() as u8 + busy.is_some() as u8 + (count != 0) as u8) > 1
|
||||
{
|
||||
return Err(SnapshotError::Corrupt("EEPROM state"));
|
||||
}
|
||||
*self = restored;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn bits(eeprom: &mut Eeprom, value: u128, count: u8, image: &[u8]) {
|
||||
for shift in (0..count).rev() {
|
||||
let bit = ((value >> shift) & 1) as u16;
|
||||
eeprom.write(0x0d00_0000, bit, false, image).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_and_large_parts_read_four_dummy_bits_then_the_block_msb_first() {
|
||||
for (device, address_bits, block) in [
|
||||
(SaveDevice::Eeprom512, 6, 0x3f_u128),
|
||||
(SaveDevice::Eeprom8k, 14, 0x3ff),
|
||||
] {
|
||||
let mut image = vec![0xff; device.capacity()];
|
||||
let offset = block as usize * 8;
|
||||
image[offset..offset + 8].copy_from_slice(&0x0123_4567_89ab_cdef_u64.to_be_bytes());
|
||||
let mut eeprom = Eeprom::new(device, 0).unwrap();
|
||||
bits(
|
||||
&mut eeprom,
|
||||
(0b11 << (address_bits + 1)) | (block << 1),
|
||||
address_bits + 3,
|
||||
&image,
|
||||
);
|
||||
let mut value = 0_u64;
|
||||
for index in 0..68 {
|
||||
let bit = eeprom.read(0x0d00_0000, false).unwrap();
|
||||
if index < 4 {
|
||||
assert_eq!(bit, 0);
|
||||
} else {
|
||||
value = (value << 1) | u64::from(bit);
|
||||
}
|
||||
}
|
||||
assert_eq!(value, 0x0123_4567_89ab_cdef);
|
||||
assert!(!eeprom.unfinished());
|
||||
assert_eq!(eeprom.read(0x0d00_0000, true).unwrap(), 1); // Idle and ready.
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_report_busy_until_the_nominal_delay_then_yield_the_block() {
|
||||
let image = vec![0xff; SaveDevice::Eeprom512.capacity()];
|
||||
let mut eeprom = Eeprom::new(SaveDevice::Eeprom512, 0).unwrap();
|
||||
let data = 0xfedc_ba98_7654_3210_u128;
|
||||
bits(
|
||||
&mut eeprom,
|
||||
(0b10 << 71) | (5 << 65) | (data << 1),
|
||||
73,
|
||||
&image,
|
||||
);
|
||||
assert_eq!(eeprom.read(0x0d00_0000, true).unwrap(), 0);
|
||||
eeprom.advance(EEPROM_WRITE_CYCLES - 1);
|
||||
assert_eq!(eeprom.take_block(), None);
|
||||
eeprom.advance(1);
|
||||
assert_eq!(
|
||||
eeprom.take_block(),
|
||||
Some((40, 0xfedc_ba98_7654_3210_u64.to_be_bytes()))
|
||||
);
|
||||
assert_eq!(eeprom.read(0x0d00_0000, true).unwrap(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_requests_cpu_stores_and_busy_writes_are_diagnostics() {
|
||||
let image = vec![0xff; SaveDevice::Eeprom512.capacity()];
|
||||
let mut eeprom = Eeprom::new(SaveDevice::Eeprom512, 0).unwrap();
|
||||
assert!(eeprom.write(0x0d00_0000, 0, false, &image).is_err());
|
||||
assert!(eeprom.write(0x0d00_0000, 1, true, &image).is_err());
|
||||
let mut trailing = Eeprom::new(SaveDevice::Eeprom512, 0).unwrap();
|
||||
bits(&mut trailing, 0b11_000000, 8, &image);
|
||||
assert!(trailing.write(0x0d00_0000, 1, false, &image).is_err());
|
||||
bits(&mut eeprom, 0b10 << 71, 73, &image);
|
||||
assert!(eeprom.write(0x0d00_0000, 1, false, &image).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roms_above_16_mib_map_only_the_top_256_bytes() {
|
||||
let small = Eeprom::new(SaveDevice::Eeprom8k, LARGE_ROM).unwrap();
|
||||
assert!(small.mapped(0x0d00_0000));
|
||||
let large = Eeprom::new(SaveDevice::Eeprom8k, LARGE_ROM + 1).unwrap();
|
||||
assert!(!large.mapped(0x0dff_feff));
|
||||
assert!(large.mapped(0x0dff_ff00));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshots_round_trip_mid_request() {
|
||||
let image = vec![0xff; SaveDevice::Eeprom8k.capacity()];
|
||||
let mut eeprom = Eeprom::new(SaveDevice::Eeprom8k, 0).unwrap();
|
||||
bits(&mut eeprom, 0b10_0000, 6, &image);
|
||||
let mut writer = Writer::new();
|
||||
eeprom.save(&mut writer);
|
||||
let bytes = writer.finish().unwrap();
|
||||
let mut restored = Eeprom::new(SaveDevice::Eeprom8k, 0).unwrap();
|
||||
restored.load(&mut Reader::new(&bytes)).unwrap();
|
||||
assert_eq!(restored, eeprom);
|
||||
let mut other = Eeprom::new(SaveDevice::Eeprom512, 0).unwrap();
|
||||
assert_eq!(
|
||||
other.load(&mut Reader::new(&bytes)),
|
||||
Err(SnapshotError::ConfigMismatch("save device"))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -19,6 +19,10 @@ pub enum SaveDevice {
|
|||
Flash128,
|
||||
/// 32 KiB battery-backed SRAM, byte addressed and mirrored through the save window.
|
||||
Sram,
|
||||
/// 512-byte serial EEPROM with 6-bit block addresses, in the 0x0d window.
|
||||
Eeprom512,
|
||||
/// 8 KiB serial EEPROM with 14-bit block addresses, in the 0x0d window.
|
||||
Eeprom8k,
|
||||
}
|
||||
|
||||
impl SaveDevice {
|
||||
|
|
@ -28,6 +32,8 @@ impl SaveDevice {
|
|||
Self::Flash64 => 64 * 1024,
|
||||
Self::Flash128 => 128 * 1024,
|
||||
Self::Sram => 32 * 1024,
|
||||
Self::Eeprom512 => 512,
|
||||
Self::Eeprom8k => 8 * 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -489,7 +495,9 @@ impl Snapshot for Flash {
|
|||
SaveDevice::None => 0,
|
||||
SaveDevice::Flash64 => 1,
|
||||
SaveDevice::Flash128 => 2,
|
||||
SaveDevice::Sram => unreachable!("SRAM has no Flash state"),
|
||||
SaveDevice::Sram | SaveDevice::Eeprom512 | SaveDevice::Eeprom8k => {
|
||||
unreachable!("only Flash devices have Flash state")
|
||||
}
|
||||
});
|
||||
phase.save(writer);
|
||||
writer.bool(*identify);
|
||||
|
|
|
|||
|
|
@ -226,7 +226,7 @@ impl Memory {
|
|||
);
|
||||
self.save_image = vec![0xff; device.capacity()];
|
||||
self.save_modified = false;
|
||||
self.cartridge.set_save_device(device);
|
||||
self.cartridge.set_save_device(device, self.rom.len());
|
||||
}
|
||||
|
||||
/// Inspect the complete save array, independently of ID mode and selected bank.
|
||||
|
|
@ -253,7 +253,7 @@ impl Memory {
|
|||
}
|
||||
self.save_image.copy_from_slice(bytes);
|
||||
self.save_modified = false;
|
||||
self.cartridge.set_save_device(device);
|
||||
self.cartridge.set_save_device(device, self.rom.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -297,6 +297,11 @@ impl Memory {
|
|||
self.save_modified |= target.iter().any(|&b| b != effect.value);
|
||||
target.fill(effect.value);
|
||||
}
|
||||
if let Some((start, block)) = self.cartridge.take_save_block() {
|
||||
let target = &mut self.save_image[start..start + block.len()];
|
||||
self.save_modified |= *target != block;
|
||||
target.copy_from_slice(&block);
|
||||
}
|
||||
}
|
||||
|
||||
fn cartridge_state(&self) -> Cartridge {
|
||||
|
|
@ -864,6 +869,12 @@ impl Memory {
|
|||
|
||||
// Multi-byte reads use this helper to avoid charging each byte as a bus access.
|
||||
fn read_byte(&self, address: u32) -> Result<u8, MemoryError> {
|
||||
if self.cartridge_state().eeprom_mapped(address) {
|
||||
return Err(MemoryError::UnsupportedCartridgeAccess {
|
||||
address,
|
||||
operation: "EEPROM non-halfword read",
|
||||
});
|
||||
}
|
||||
if self.cartridge_state().flash_mapped(address)
|
||||
&& self
|
||||
.cpu_access
|
||||
|
|
@ -990,7 +1001,7 @@ impl Memory {
|
|||
},
|
||||
);
|
||||
let mut cartridge = self.cartridge_state();
|
||||
cartridge.write(address, &bytes, &self.save_image)?;
|
||||
cartridge.write(address, &bytes, &self.save_image, self.cpu_access.is_some())?;
|
||||
if cartridge.mapped(address) {
|
||||
if self.cartridge_step.get().is_some() {
|
||||
self.cartridge_step.set(Some(cartridge));
|
||||
|
|
@ -1224,7 +1235,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.save_image)?;
|
||||
cartridge.write(address, &bytes, &self.save_image, self.cpu_access.is_some())?;
|
||||
self.record_access(address, AccessWidth::Word);
|
||||
if Serial::mapped(address) {
|
||||
if let Some(mut step) = self.timer_step.get() {
|
||||
|
|
@ -1255,6 +1266,9 @@ impl Memory {
|
|||
if address & 1 != 0 {
|
||||
return Err(MemoryError::Unaligned(address));
|
||||
}
|
||||
if self.cartridge_state().eeprom_mapped(address) {
|
||||
return self.read_eeprom16(address);
|
||||
}
|
||||
self.validate_save_read_width(address)?;
|
||||
self.record_access(address, AccessWidth::Halfword);
|
||||
let value = u16::from_le_bytes([self.read_byte(address)?, self.read_byte(address + 1)?]);
|
||||
|
|
@ -1263,8 +1277,26 @@ impl Memory {
|
|||
Ok(value)
|
||||
}
|
||||
|
||||
/// Shift out one EEPROM bit inside a machine step. Host reads outside a step only
|
||||
/// inspect status or the next bit; nothing outside a step can stage the change.
|
||||
fn read_eeprom16(&self, address: u32) -> Result<u16, MemoryError> {
|
||||
self.record_access(address, AccessWidth::Halfword);
|
||||
let mut cartridge = self.cartridge_state();
|
||||
let value = cartridge.read_eeprom16(address, self.cpu_access.is_some())?;
|
||||
if self.cartridge_step.get().is_some() {
|
||||
self.cartridge_step.set(Some(cartridge));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn validate_save_read_width(&self, address: u32) -> Result<(), MemoryError> {
|
||||
let cartridge = self.cartridge_state();
|
||||
if cartridge.eeprom_mapped(address) {
|
||||
return Err(MemoryError::UnsupportedCartridgeAccess {
|
||||
address,
|
||||
operation: "EEPROM non-halfword read",
|
||||
});
|
||||
}
|
||||
if cartridge.save_mapped(address) {
|
||||
return Err(MemoryError::UnsupportedCartridgeAccess {
|
||||
address,
|
||||
|
|
|
|||
226
crates/gba-core/tests/eeprom.rs
Normal file
226
crates/gba-core/tests/eeprom.rs
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
//! Original EEPROM bitstreams sent through DMA3; no game assets or save files.
|
||||
use gba_core::{
|
||||
cartridge::{SaveDevice, EEPROM_WRITE_CYCLES},
|
||||
cpu::Cpu,
|
||||
dma::DMA_BASE,
|
||||
machine::{Machine, MachineError, StepKind},
|
||||
memory::{Memory, MemoryError, ROM_START},
|
||||
};
|
||||
|
||||
const EEPROM: u32 = 0x0d00_0000;
|
||||
const BUFFER: u32 = 0x0200_0000;
|
||||
const DMA3: u32 = DMA_BASE + 0x24;
|
||||
|
||||
fn words(values: &[u32]) -> Vec<u8> {
|
||||
values
|
||||
.iter()
|
||||
.flat_map(|value| value.to_le_bytes())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A ROM that polls EEPROM status with LDRH r1,[r0] and then loops.
|
||||
fn machine(device: SaveDevice) -> Machine {
|
||||
let mut bus = Memory::new(words(&[0xe59f_0004, 0xe1d0_10b0, 0xeaff_fffe, EEPROM])).unwrap();
|
||||
bus.set_save_device(device);
|
||||
let mut cpu = Cpu::new(ROM_START);
|
||||
cpu.step(&mut bus).unwrap();
|
||||
Machine::new(cpu, bus)
|
||||
}
|
||||
|
||||
fn address_bits(device: SaveDevice) -> u32 {
|
||||
if device == SaveDevice::Eeprom8k {
|
||||
14
|
||||
} else {
|
||||
6
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one immediate halfword DMA3 transfer to completion.
|
||||
fn dma(machine: &mut Machine, source: u32, destination: u32, count: u32) {
|
||||
let bus = machine.memory_mut();
|
||||
bus.write32(DMA3, source).unwrap();
|
||||
bus.write32(DMA3 + 4, destination).unwrap();
|
||||
bus.write32(DMA3 + 8, 0x8000_0000 | count).unwrap();
|
||||
for _ in 0..count {
|
||||
assert_eq!(machine.step().unwrap(), StepKind::Dma { channel: 3 });
|
||||
}
|
||||
assert_eq!(machine.memory().read16(DMA3 + 10).unwrap() & 0x8000, 0);
|
||||
}
|
||||
|
||||
/// Store `bits` MSB first as one halfword per bit and send them to the EEPROM.
|
||||
fn send(machine: &mut Machine, bits: &[u8]) {
|
||||
for (index, &bit) in bits.iter().enumerate() {
|
||||
let value = u16::from(bit) | 0xfffe; // Bits 1-15 are don't care.
|
||||
machine
|
||||
.memory_mut()
|
||||
.write16(BUFFER + index as u32 * 2, value)
|
||||
.unwrap();
|
||||
}
|
||||
dma(machine, BUFFER, EEPROM, bits.len() as u32);
|
||||
}
|
||||
|
||||
fn field(value: u64, width: u32) -> Vec<u8> {
|
||||
(0..width)
|
||||
.rev()
|
||||
.map(|shift| ((value >> shift) & 1) as u8)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn write_request(device: SaveDevice, block: u64, data: u64) -> Vec<u8> {
|
||||
[
|
||||
vec![1, 0],
|
||||
field(block, address_bits(device)),
|
||||
field(data, 64),
|
||||
vec![0],
|
||||
]
|
||||
.concat()
|
||||
}
|
||||
|
||||
fn read_block(machine: &mut Machine, device: SaveDevice, block: u64) -> u64 {
|
||||
send(
|
||||
machine,
|
||||
&[vec![1, 1], field(block, address_bits(device)), vec![0]].concat(),
|
||||
);
|
||||
let output = BUFFER + 0x400;
|
||||
dma(machine, EEPROM, output, 68);
|
||||
let mut value = 0;
|
||||
for index in 0..68 {
|
||||
let bit = machine.memory().read16(output + index * 2).unwrap();
|
||||
assert!(bit <= 1);
|
||||
if index < 4 {
|
||||
assert_eq!(bit, 0, "dummy bit {index}");
|
||||
} else {
|
||||
value = (value << 1) | u64::from(bit);
|
||||
}
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
fn poll(machine: &mut Machine) -> u32 {
|
||||
let mut probe = machine.cpu().clone();
|
||||
probe.step(machine.memory_mut()).unwrap();
|
||||
probe.registers()[1]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dma_writes_program_after_the_nominal_delay_and_reads_return_the_block() {
|
||||
for (device, block) in [(SaveDevice::Eeprom512, 0x3f), (SaveDevice::Eeprom8k, 0x3ff)] {
|
||||
let mut machine = machine(device);
|
||||
assert_eq!(poll(&mut machine), 1); // Idle reads as ready.
|
||||
send(
|
||||
&mut machine,
|
||||
&write_request(device, block, 0x0123_4567_89ab_cdef),
|
||||
);
|
||||
assert_eq!(poll(&mut machine), 0); // Busy.
|
||||
assert!(machine.memory().save_write_pending());
|
||||
machine.memory_mut().advance_cycles(EEPROM_WRITE_CYCLES);
|
||||
assert_eq!(poll(&mut machine), 1);
|
||||
assert!(!machine.memory().save_write_pending());
|
||||
assert!(machine.memory().save_modified());
|
||||
let offset = block as usize * 8;
|
||||
assert_eq!(
|
||||
machine.memory().save_image().unwrap()[offset..offset + 8],
|
||||
0x0123_4567_89ab_cdef_u64.to_be_bytes()
|
||||
);
|
||||
assert_eq!(
|
||||
read_block(&mut machine, device, block),
|
||||
0x0123_4567_89ab_cdef
|
||||
);
|
||||
assert_eq!(read_block(&mut machine, device, 0), u64::MAX); // Erased.
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_parts_decode_only_the_low_ten_address_bits() {
|
||||
let device = SaveDevice::Eeprom8k;
|
||||
let mut machine = machine(device);
|
||||
send(&mut machine, &write_request(device, 0x3c01, 7));
|
||||
machine.memory_mut().advance_cycles(EEPROM_WRITE_CYCLES);
|
||||
assert_eq!(read_block(&mut machine, device, 1), 7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cpu_stores_and_byte_or_word_accesses_are_diagnostics() {
|
||||
let mut machine = machine(SaveDevice::Eeprom512);
|
||||
let bus = machine.memory_mut();
|
||||
assert!(matches!(
|
||||
bus.read8(EEPROM),
|
||||
Err(MemoryError::UnsupportedCartridgeAccess { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
bus.read32(EEPROM),
|
||||
Err(MemoryError::UnsupportedCartridgeAccess { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
bus.write32(EEPROM, 1),
|
||||
Err(MemoryError::UnsupportedCartridgeAccess { .. })
|
||||
));
|
||||
// STRH r0,[r0] from ROM code.
|
||||
let mut bus = Memory::new(words(&[0xe59f_0004, 0xe1c0_00b0, 0xeaff_fffe, EEPROM])).unwrap();
|
||||
bus.set_save_device(SaveDevice::Eeprom512);
|
||||
let mut cpu = Cpu::new(ROM_START);
|
||||
cpu.step(&mut bus).unwrap();
|
||||
let mut machine = Machine::new(cpu, bus);
|
||||
let before = machine.cpu().clone();
|
||||
assert!(matches!(
|
||||
machine.step(),
|
||||
Err(MachineError::Cpu(gba_core::cpu::CpuError::Memory(
|
||||
MemoryError::UnsupportedIo { .. }
|
||||
)))
|
||||
));
|
||||
assert_eq!(machine.cpu(), &before);
|
||||
assert!(!machine.memory().save_write_pending());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_streams_fail_without_partial_state() {
|
||||
let mut machine = machine(SaveDevice::Eeprom512);
|
||||
machine.memory_mut().write16(BUFFER, 0).unwrap(); // Missing leading 1 bit.
|
||||
let bus = machine.memory_mut();
|
||||
bus.write32(DMA3, BUFFER).unwrap();
|
||||
bus.write32(DMA3 + 4, EEPROM).unwrap();
|
||||
bus.write32(DMA3 + 8, 0x8000_0001).unwrap();
|
||||
assert!(matches!(machine.step(), Err(MachineError::Dma(_))));
|
||||
assert!(!machine.memory().save_write_pending());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roms_above_16_mib_map_eeprom_only_in_the_top_256_bytes() {
|
||||
let mut rom = vec![0; 16 * 1024 * 1024 + 4];
|
||||
rom[..4].copy_from_slice(&0xeaff_fffe_u32.to_le_bytes());
|
||||
let mut bus = Memory::new(rom).unwrap();
|
||||
bus.set_save_device(SaveDevice::Eeprom512);
|
||||
assert_eq!(bus.read16(0x0dff_ff00).unwrap(), 1);
|
||||
assert_eq!(bus.read16(0x0d00_0000).unwrap(), 0); // Ordinary ROM bytes.
|
||||
let mut machine = Machine::new(Cpu::new(ROM_START), bus);
|
||||
machine.memory_mut().write16(BUFFER, 1).unwrap();
|
||||
let bus = machine.memory_mut();
|
||||
bus.write32(DMA3, BUFFER).unwrap();
|
||||
bus.write32(DMA3 + 4, 0x0d00_0000).unwrap();
|
||||
bus.write32(DMA3 + 8, 0x8000_0001).unwrap();
|
||||
assert!(matches!(machine.step(), Err(MachineError::Dma(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_states_restore_a_read_out_in_progress() {
|
||||
let device = SaveDevice::Eeprom512;
|
||||
let mut machine = machine(device);
|
||||
send(
|
||||
&mut machine,
|
||||
&write_request(device, 2, 0xa5a5_a5a5_a5a5_a5a5),
|
||||
);
|
||||
machine.memory_mut().advance_cycles(EEPROM_WRITE_CYCLES);
|
||||
send(&mut machine, &[vec![1, 1], field(2, 6), vec![0]].concat());
|
||||
dma(&mut machine, EEPROM, BUFFER + 0x400, 5); // Four dummy bits and the first data bit.
|
||||
let state = machine.save_state().unwrap();
|
||||
dma(&mut machine, EEPROM, BUFFER + 0x400, 63);
|
||||
let finished = machine.memory().read16(BUFFER + 0x400 + 62 * 2).unwrap();
|
||||
machine.load_state(&state).unwrap();
|
||||
assert!(machine.memory().save_write_pending());
|
||||
dma(&mut machine, EEPROM, BUFFER + 0x400, 63);
|
||||
assert_eq!(
|
||||
machine.memory().read16(BUFFER + 0x400 + 62 * 2).unwrap(),
|
||||
finished
|
||||
);
|
||||
assert!(!machine.memory().save_write_pending());
|
||||
}
|
||||
|
|
@ -51,6 +51,8 @@ The repository is a Cargo workspace with three crates:
|
|||
| `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 IDs/banks, delayed program/erase, bounded DQ7 polling, and staged mutations |
|
||||
| `crates/gba-core/src/cartridge/sram.rs` | 32 KiB byte-wide SRAM, mirrored offsets, one staged byte per step, and its save-state section |
|
||||
| `crates/gba-core/src/cartridge/eeprom.rs` | Serial EEPROM request decoding, 68-bit read-out, nominal write busy time, block commits, and its save-state section |
|
||||
| `crates/gba-core/tests/eeprom.rs` | DMA3 EEPROM bitstreams, busy polling, 10-bit addressing, large-ROM window, diagnostics, and mid-read-out save states |
|
||||
| `crates/gba-core/tests/rom_bus.rs` | Game Pak reads beyond the ROM chip in every window, trimmed-image gaps, strict host/fetch reads, and DMA sources |
|
||||
| `crates/gba-core/tests/sram.rs` | SRAM selection, mirroring, byte stores/loads from ROM code, SWPB, width/DMA diagnostics, and save states |
|
||||
| `crates/gba-core/tests/flash.rs` | Erased/supplied images, chip IDs, all bank addresses, invalid commands, reset limits, DMA rejection, and RTC independence |
|
||||
|
|
|
|||
|
|
@ -35,8 +35,8 @@ CPU and DMA reads beyond the ROM chip, the image size rounded up to a power of t
|
|||
Bytes between a trimmed image's end and that chip boundary are unknown and remain explicit memory errors, as do host reads and instruction fetches beyond the image.
|
||||
All three Game Pak ROM windows use the same supplied bytes and existing wait-state rules.
|
||||
Ordinary cartridge bytes remain read-only. CPU and DMA writes to the `0x08`–`0x0c` ROM windows pay their bus cost and are ignored, as on hardware.
|
||||
Writes to the `0x0d` window remain `ReadOnly` diagnostics until EEPROM is modeled there. Host `Memory::write*` calls stay strict everywhere.
|
||||
Optional `--save-type flash64` or `--save-type flash128` enables [nominal Flash programming, erase, and reads](saves.md); `--save-type sram` selects [32 KiB SRAM](saves.md#sram).
|
||||
Writes to the `0x0d` window remain `ReadOnly` diagnostics unless [EEPROM](saves.md#eeprom) is selected there. Host `Memory::write*` calls stay strict everywhere.
|
||||
Optional `--save-type flash64` or `--save-type flash128` enables [nominal Flash programming, erase, and reads](saves.md); `--save-type sram` selects [32 KiB SRAM](saves.md#sram), and `eeprom512` or `eeprom8k` selects [serial EEPROM](saves.md#eeprom).
|
||||
Without `--save-file PATH`, changes remain volatile. With it, the frontend loads an exact-size image and persists changes on clean exit or when a ROM window ends on a diagnostic.
|
||||
Missing images start erased. Existing images receive unique backups before replacement; incomplete Flash operations refuse persistence.
|
||||
Read the [storage safeguards and limits](saves.md#desktop-persistence) before using a valuable save.
|
||||
|
|
|
|||
|
|
@ -68,7 +68,7 @@ Writes into the mapped 16 KiB BIOS [complete without changing its image](bios.md
|
|||
Source reads, channel data, address progression, timing, and completion IRQs still apply to these ignored writes.
|
||||
DMA0–2 mask destination bit 27, so a programmed cartridge address can instead select the BIOS region.
|
||||
Other accesses use the existing memory map, alignment checks, mirrors, and I/O write rules.
|
||||
Other unmapped reads remain diagnostics, not general DMA open-bus behavior. Writes to the `0x08`–`0x0c` ROM windows and to unused I/O addresses are ignored; the `0x0d` window and `0x04000800` remain diagnostics.
|
||||
Other unmapped reads remain diagnostics, not general DMA open-bus behavior. Writes to the `0x08`–`0x0c` ROM windows and to unused I/O addresses are ignored; the `0x0d` window (unless EEPROM is selected) and `0x04000800` remain diagnostics.
|
||||
A failed unit changes no memory, device state, or clock. Earlier units remain committed; the failed unit remains pending.
|
||||
Successful units update local IWRAM lanes for their actual IWRAM reads and writes, in that order.
|
||||
No CPU continuation is required; DMA can establish lanes before the first CPU instruction.
|
||||
|
|
|
|||
|
|
@ -1,15 +1,16 @@
|
|||
# Cartridge Flash, SRAM, and save files
|
||||
# Cartridge Flash, SRAM, EEPROM, and save files
|
||||
|
||||
The core supports explicit Macronix Flash selection, identification, bank selection, byte programming, and sector/chip erase.
|
||||
Operations complete after nominal delays and expose bounded data polling. They do not complete immediately on the command write.
|
||||
The desktop can load and persist raw images through explicit `--save-file PATH` selection.
|
||||
Original ROM tests verify save/restart/load across processes. Saving in an external game remains unverified.
|
||||
The core also supports explicit 32 KiB SRAM; see [SRAM](#sram).
|
||||
EEPROM and other manufacturers' Flash protocols remain separate work.
|
||||
The core also supports explicit 32 KiB SRAM and 512-byte or 8 KiB serial EEPROM; see [SRAM](#sram) and [EEPROM](#eeprom).
|
||||
Other manufacturers' Flash protocols remain separate work.
|
||||
|
||||
## Selection and core API
|
||||
|
||||
Use `--save-type flash64`, `--save-type flash128`, or `--save-type sram` in terminal or window ROM modes.
|
||||
Use `--save-type flash64`, `flash128`, `sram`, `eeprom512`, or `eeprom8k` in terminal or window ROM modes.
|
||||
EEPROM games do not identify their part size, so the type must match the game (GBATEK: no autodetection mechanism exists).
|
||||
Selection is independent of `--rtc`. Omitting the save type leaves save memory unmapped.
|
||||
No filename, ROM signature, or first-write detection changes the selected device.
|
||||
Without `--save-file`, the selected device starts erased (`0xff`) and all changes remain volatile.
|
||||
|
|
@ -17,7 +18,7 @@ The ROM suite schema still has no save-selection or save-file field.
|
|||
|
||||
Core APIs on `Memory`:
|
||||
|
||||
- `set_save_device(SaveDevice)` selects `None`, `Flash64`, `Flash128`, or `Sram` and initializes an erased array.
|
||||
- `set_save_device(SaveDevice)` selects `None`, `Flash64`, `Flash128`, `Sram`, `Eeprom512`, or `Eeprom8k` and initializes an erased array.
|
||||
GPIO/RTC state is preserved.
|
||||
- `load_save_image(&[u8])` accepts exactly the selected capacity. It resets commands, pending operations, ID mode, bank, and modification tracking.
|
||||
Invalid size or missing hardware changes nothing.
|
||||
|
|
@ -40,6 +41,25 @@ Halfword and word reads and writes, and DMA transfers, remain diagnostics until
|
|||
SRAM shares the save window's WAITCNT wait states, save-image API, host persistence, and save states with Flash.
|
||||
A save state records the selected device. Restoring it into a machine with another save device is rejected as a cartridge setup mismatch.
|
||||
|
||||
## EEPROM
|
||||
|
||||
`Eeprom512` and `Eeprom8k` follow GBATEK's serial protocol on Game Pak data bit 0, one bit per halfword.
|
||||
With ROM up to 16 MiB the part answers anywhere in `0x0d000000..0x0dffffff`; above 16 MiB only `0x0dffff00..0x0dffffff`.
|
||||
Elsewhere in that window, reads and writes behave as ordinary ROM.
|
||||
|
||||
- Read request: `11`, the block address (6 bits, or 14 bits with the low 10 decoded), then `0`.
|
||||
The next 68 halfword reads return 4 zero bits, then the 64-bit block MSB first.
|
||||
- Write request: `10`, the block address, 64 data bits MSB first, then `0`.
|
||||
The part then reports busy (bit 0 clear) for a nominal 108,368 cycles before the block is stored.
|
||||
- Idle and ready status reads return 1. Bits 1–15 read as zero; hardware drives only bit 0.
|
||||
|
||||
Block bytes are stored in transfer order: the first data bit is byte 0, bit 7, matching common `.sav` layouts.
|
||||
Bitstreams must come from DMA (or host setup writes). GBATEK states manual `STRH` transfers do not work, so CPU stores are diagnostics.
|
||||
A CPU `LDRH` polls status; a CPU read during a data read-out, byte or word accesses, a missing leading `1`,
|
||||
a missing trailing `0`, a request while busy, and a request during a read-out are all diagnostics.
|
||||
Bits shift out only inside machine steps, so a failed step rolls back; host reads between steps only inspect.
|
||||
An unfinished request, read-out, or write sets `save_write_pending()`, which blocks host persistence.
|
||||
|
||||
## Bus and commands
|
||||
|
||||
Only `0x0e000000` through `0x0e00ffff` are mapped. Accesses are byte-only.
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ Hardware references used for the core:
|
|||
|
||||
- [GBATEK ARM instruction cycle times](https://problemkaputt.de/gbatek-arm-cpu-instruction-cycle-times.htm).
|
||||
- [GBATEK GBA system control and WAITCNT](https://problemkaputt.de/gbatek-gba-system-control.htm), including HALTCNT power-mode selection.
|
||||
- [GBATEK GBA cart backup EEPROM](https://problemkaputt.de/gbatek-gba-cart-backup-eeprom.htm), for the 0x0d mapping by ROM size, request and read-out bitstreams, bit-0 bus, DMA3-only transfers, 10 decoded address bits, and the ~108,368-cycle write.
|
||||
- [GBATEK GBA unpredictable things](https://problemkaputt.de/gbatek-gba-unpredictable-things.htm): with no cartridge, the ROM area reads as `Address/2 AND FFFFh`, because the Game Pak shares its data lines with the low address lines. The emulator applies the same undriven-bus pattern beyond the ROM chip.
|
||||
- [mGBA `src/gba/memory.c`](https://github.com/mgba-emu/mgba/blob/master/src/gba/memory.c): out-of-bounds ROM loads return `(address >> 1) & 0xFFFF` per halfword. mGBA applies it from the image size; this emulator keeps bytes up to the power-of-two chip boundary diagnostic.
|
||||
- [GBATEK BIOS reset functions](https://problemkaputt.de/gbatek-bios-reset-functions.htm), for SoftReset state and restart selection, plus RegisterRamReset flags, RAM bounds, and forced blank.
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ Update this file when a feature lands or a limit is removed.
|
|||
- Explicit Macronix Flash64/Flash128 identification, bank selection, delayed byte programming, sector/chip erase, and nominal DQ7 polling.
|
||||
- Transactional Flash completion and modification tracking, with exact-size core image loading and inspection.
|
||||
- Explicit 32 KiB byte-wide SRAM (`--save-type sram`), mirrored through the save window and readable from ROM code.
|
||||
- Explicit 512-byte and 8 KiB serial EEPROM (`--save-type eeprom512|eeprom8k`) over DMA3 bitstreams, with nominal write busy time.
|
||||
- Optional desktop `--save-file` loading and clean-exit persistence, with exclusive locks, atomic installation, and unique previous-image backups.
|
||||
- Original ROM save/restart/load checks across processes, including native-window persistence on Darwin arm64.
|
||||
- Whole-machine [save states](hardware/save-states.md): `Machine::save_state`/`load_state` with image and cartridge identity checks, a checksum, and verify-before-mutate loading; ROM windows bind F5 (save) and F9 (load) to a `--state-file` slot.
|
||||
|
|
@ -156,7 +157,7 @@ Individual fetch timing, hardware-accurate work cutoffs, video-bus contention, a
|
|||
ROM windows offer opt-in Darwin audio output with `--audio` and explicit save persistence with `--save-file`.
|
||||
The [Flash subset](hardware/saves.md) supports programming and erase with nominal delays and bounded DQ7 polling.
|
||||
Flash timing, STOP clocking, and cancellation are not hardware-verified. Wider accesses and unverified busy addresses remain diagnostic.
|
||||
EEPROM, other Flash manufacturers, SRAM halfword/word access, autosave, crash recovery, and game-level save transactions remain unsupported.
|
||||
Other Flash manufacturers, SRAM halfword/word access, CPU-driven EEPROM bitstreams, autosave, crash recovery, and game-level save transactions remain unsupported.
|
||||
Persistence is tested on Darwin arm64 with original programs, not yet with external-game saves.
|
||||
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.
|
||||
|
|
@ -233,7 +234,7 @@ The unmodeled internal memory control register at `0x04xx0800` and all unused I/
|
|||
CPU and DMA reads beyond the power-of-two ROM chip return the Game Pak halfword-address pattern; reads inside a trimmed image's chip, host reads, and instruction fetches past the image 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.
|
||||
CPU and DMA writes to the `0x08`–`0x0c` ROM windows are ignored; the `0x0d` window stays diagnostic until EEPROM exists. Explicitly selected cartridge GPIO and supported Flash command writes are separate from ROM storage.
|
||||
CPU and DMA writes to the `0x08`–`0x0c` ROM windows are ignored; the `0x0d` window stays diagnostic unless EEPROM is selected. Explicitly selected cartridge GPIO and supported Flash command writes are separate from ROM storage.
|
||||
|
||||
## Next steps
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue