diff --git a/.gitignore b/.gitignore index a0030d1..c8a485b 100644 --- a/.gitignore +++ b/.gitignore @@ -40,11 +40,13 @@ !/assets/aros/aros-amiga-m68k-ext.bin !/assets/services/services_rom.bin -# Local run artifacts (screenshots, recordings, save states, input scripts). +# Local run artifacts (screenshots, recordings, save states, input scripts, +# RTC battery-RAM backing files). copperline-screenshot-*.png copperline-video-*.avi *.clstate *.clscript +*.nvram .DS_Store # MyST documentation build output (see docs/README.md). diff --git a/copperline.example.toml b/copperline.example.toml index 6ef2f33..dddec99 100644 --- a/copperline.example.toml +++ b/copperline.example.toml @@ -125,6 +125,9 @@ model = "68000" # # A seeded clock ticks in emulated time, so the guest-visible # # time is deterministic across runs. # rtc_frozen = true # stop the seeded clock at rtc_time exactly +# battmem = "battmem.nvram" # RP5C01 battery-RAM (battmem.resource) backing file, in the +# # WinUAE/Amiberry .nvram layout. Defaults on when an RP5C01 +# # is fitted; "" keeps the battery registers session-only. # mem_controller = "ramsey-07" # none | ramsey-04 (A3000) | ramsey-07 (A4000) # rom_scsi_device_disable = true # skip the ROM's scsi.device (default: see above) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index aeea48d..ff0718f 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -115,6 +115,7 @@ rtc = true # add a battery RTC (default: only A500+/CDTV/A3000/A4000 ship # rtc_chip = "RP5C01" # MSM6242 (default) or RP5C01 (A3000/A4000 default) # rtc_time = "2005-03-18 01:58:29" # seed the clock; it then ticks in emulated time # rtc_frozen = true # stop the seeded clock at rtc_time exactly +# battmem = "battmem.nvram" # RP5C01 battery-RAM backing file (default when fitted) mem_controller = "ramsey-07" # none, ramsey-04 (A3000), ramsey-07 (A4000) rom_scsi_device_disable = true # skip the ROM's scsi.device (default: when its bus has no drives) ``` @@ -160,6 +161,22 @@ so the choice is mostly invisible to it, but Linux/m68k does not probe: it drives the chip the machine model dictates, so an A3000/A4000 booting Linux needs the RP5C01 answering for its clock to work. +`battmem` persists the RP5C01's battery-backed registers -- the 26 RAM +nibbles behind `battmem.resource` plus the alarm and 12/24 settings -- +across runs, the way the real board's battery does. This is where +`scsi.device` keeps its per-unit SCSI host settings (an A3000's or A4091's +synchronous-transfer, disconnect, and last-drive options, including +remembering attached CD-ROM drives), so without it those revert every run. +The file uses the same `.nvram` layout as WinUAE and Amiberry, so backing +files interchange between emulators; only the battery payload loads back -- +the time-of-save digits in the file never override the (host- or +`rtc_time`-driven) clock. It defaults to `battmem.nvram` in the working +directory whenever an RP5C01 is fitted; point it elsewhere with a path, or +set `battmem = ""` to keep the battery registers session-only. Note that a +persisted file carries guest-visible state from one run into the next by +design, so delete it (or disable it) where byte-for-byte reproducible +headless runs matter. + `rtc_time` seeds the clock instead of letting it mirror the host's: the value is either an integer (Unix seconds, UTC) or a string `"YYYY-MM-DD HH:MM[:SS]"` giving exactly the wall-clock time the guest reads diff --git a/src/config.rs b/src/config.rs index a36ba94..85e194d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -162,6 +162,11 @@ pub struct Config { /// Stop the seeded RTC (`[machine] rtc_frozen`): every read returns /// exactly `rtc_seed_unix`, for pinning a guest to one time window. pub rtc_frozen: bool, + /// RP5C01 battery-RAM (battmem) backing file (`[machine] battmem`), + /// in the WinUAE/Amiberry `.nvram` layout; `None` keeps the battery + /// registers session-only. Defaults to `battmem.nvram` when an + /// RP5C01 is fitted. + pub battmem_path: Option, pub video_standard: VideoStandard, pub audio: AudioConfig, /// Gayle IDE drive images (raw flat HDF, RDB inside), opened @@ -1159,6 +1164,7 @@ impl Default for Config { rtc_chip: crate::rtc::RtcChip::Msm6242, rtc_seed_unix: None, rtc_frozen: false, + battmem_path: None, video_standard: VideoStandard::Pal, audio: AudioConfig::default(), ide: IdeConfig::default(), @@ -1973,6 +1979,13 @@ pub(crate) struct RawMachine { /// Only meaningful together with `rtc_time`. #[serde(skip_serializing_if = "Option::is_none")] pub(crate) rtc_frozen: Option, + /// Backing file for the RP5C01's battery RAM (the storage behind + /// AmigaOS `battmem.resource` on the A3000/A4000), in the + /// WinUAE/Amiberry `.nvram` file layout so files interchange between + /// emulators. Defaults to `battmem.nvram` whenever an RP5C01 is + /// fitted; an empty string keeps the battery registers session-only. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) battmem: Option, /// Memory controller fitted, defaulting per profile: `none`, `ramsey-04` /// (A3000) or `ramsey-07` (A4000). Ramsey answers at $DE0000, which no /// other chip decodes, so it can also be fitted to a wedge machine to @@ -2602,6 +2615,38 @@ impl TryFrom for Config { } } + let rtc_present = match raw.machine.rtc { + // A configured time on an explicitly unfitted clock would + // silently do nothing; make the contradiction loud. + Some(false) if rtc_seed_unix.is_some() => anyhow::bail!( + "[machine] rtc_time is set but rtc = false leaves the \ + clock unfitted; drop one of them" + ), + // Naming a chip for a socket declared empty is the same + // contradiction. + Some(false) if rtc_chip.is_some() => anyhow::bail!( + "[machine] rtc_chip is set but rtc = false leaves the \ + clock unfitted; drop one of them" + ), + Some(fitted) => fitted, + None => defaults.rtc_present || rtc_seed_unix.is_some() || rtc_chip.is_some(), + }; + let rtc_chip = rtc_chip.unwrap_or(defaults.rtc_chip); + let rp5c01_fitted = rtc_present && rtc_chip == crate::rtc::RtcChip::Rp5c01; + let battmem_path = match raw.machine.battmem.as_deref() { + // An empty path keeps the battery registers session-only. + Some("") => None, + // A backing file for battery RAM the machine does not have + // would silently never fill; make the contradiction loud. + Some(path) if !rp5c01_fitted => anyhow::bail!( + "[machine] battmem ({path}) backs the RP5C01's battery RAM, \ + but this machine has no RP5C01 fitted; set \ + rtc_chip = \"RP5C01\" or drop battmem" + ), + Some(path) => Some(PathBuf::from(path)), + None => rp5c01_fitted.then(|| PathBuf::from("battmem.nvram")), + }; + Ok(Config { rom_path: raw.rom.map(PathBuf::from).unwrap_or(defaults.rom_path), cpu, @@ -2668,25 +2713,11 @@ impl TryFrom for Config { .nvram .map(PathBuf::from) .or_else(|| defaults.akiko.then(|| PathBuf::from("cd32-nvram.bin"))), - rtc_present: match raw.machine.rtc { - // A configured time on an explicitly unfitted clock would - // silently do nothing; make the contradiction loud. - Some(false) if rtc_seed_unix.is_some() => anyhow::bail!( - "[machine] rtc_time is set but rtc = false leaves the \ - clock unfitted; drop one of them" - ), - // Naming a chip for a socket declared empty is the same - // contradiction. - Some(false) if rtc_chip.is_some() => anyhow::bail!( - "[machine] rtc_chip is set but rtc = false leaves the \ - clock unfitted; drop one of them" - ), - Some(fitted) => fitted, - None => defaults.rtc_present || rtc_seed_unix.is_some() || rtc_chip.is_some(), - }, - rtc_chip: rtc_chip.unwrap_or(defaults.rtc_chip), + rtc_present, + rtc_chip, rtc_seed_unix, rtc_frozen, + battmem_path, log_unmapped: raw .debug .log_unmapped @@ -3751,6 +3782,65 @@ mod tests { assert!(err.to_string().contains("rtc_chip"), "{err:#}"); } + #[test] + fn battmem_defaults_to_a_backing_file_only_where_an_rp5c01_sits() -> Result<()> { + // The big boxes get the default backing file with their Ricoh part. + for profile in ["A3000", "A4000"] { + let cfg = parse_config(&format!("[machine]\nprofile = \"{profile}\"\n"))?; + assert_eq!( + cfg.battmem_path.as_deref(), + Some(std::path::Path::new("battmem.nvram")), + "{profile}" + ); + } + // The OKI part has no battery RAM: no file, even with a clock. + let cfg = parse_config("[machine]\nprofile = \"A500+\"\n")?; + assert_eq!(cfg.battmem_path, None); + // Fitting an RP5C01 anywhere brings the default with it. + let cfg = parse_config("[machine]\nrtc_chip = \"RP5C01\"\n")?; + assert!(cfg.battmem_path.is_some()); + + // An explicit path wins; an empty one keeps RAM session-only. + let cfg = parse_config( + r#" + [machine] + profile = "A4000" + battmem = "shared/A4000.nvram" + "#, + )?; + assert_eq!( + cfg.battmem_path.as_deref(), + Some(std::path::Path::new("shared/A4000.nvram")) + ); + let cfg = parse_config( + r#" + [machine] + profile = "A4000" + battmem = "" + "#, + )?; + assert_eq!(cfg.battmem_path, None); + Ok(()) + } + + #[test] + fn battmem_without_an_rp5c01_is_rejected() { + // The default A500 has no RP5C01 for the file to back. + let err = parse_config("[machine]\nbattmem = \"battmem.nvram\"\n").unwrap_err(); + assert!(err.to_string().contains("RP5C01"), "{err:#}"); + + // An MSM6242 clock is not enough: it carries no battery RAM. + let err = parse_config( + r#" + [machine] + rtc = true + battmem = "battmem.nvram" + "#, + ) + .unwrap_err(); + assert!(err.to_string().contains("RP5C01"), "{err:#}"); + } + #[test] fn rtc_time_cli_override_matches_the_config_field() -> Result<()> { let cfg = load_overrides(&ConfigOverrides { @@ -3822,6 +3912,7 @@ mod tests { rtc_chip: Some("RP5C01".to_string()), rtc_time: Some(RawRtcTime::Text("2005-03-18 01:58:29".to_string())), rtc_frozen: Some(true), + battmem: Some("battmem.nvram".to_string()), mem_controller: Some("ramsey-07".to_string()), rom_scsi_device_disable: Some(true), }, diff --git a/src/emulator.rs b/src/emulator.rs index f5264dc..019f2a9 100644 --- a/src/emulator.rs +++ b/src/emulator.rs @@ -2060,6 +2060,10 @@ pub fn build_machine( bus.set_rtc_present(cfg.rtc_present); bus.set_rtc_chip(cfg.rtc_chip); bus.rtc.set_seed(cfg.rtc_seed_unix, cfg.rtc_frozen); + if let Some(path) = &cfg.battmem_path { + info!("rtc: battery RAM (battmem) persisted to {}", path.display()); + bus.rtc.set_battmem_path(path.clone()); + } if let Some(id) = cfg.gate_array.gayle_id() { let mut gayle = crate::gayle::Gayle::new(id); if let Some(drive) = &cfg.ide.master { diff --git a/src/rtc.rs b/src/rtc.rs index a734355..0ef04c5 100644 --- a/src/rtc.rs +++ b/src/rtc.rs @@ -18,6 +18,13 @@ //! chips' latch/bank/control state (and the RP5C01's battery RAM), but //! they never change the host clock. //! +//! The RP5C01's battery-backed registers can persist across runs to a +//! backing file (`[machine] battmem`) in the same `.nvram` layout +//! WinUAE and Amiberry use, so files interchange between emulators. +//! This is how AmigaOS `battmem.resource` settings -- the SCSI host +//! options an A3000's or A4091's `scsi.device` stores, among others -- +//! survive a power cycle like they do on a real battery-fitted board. +//! //! The clock is reported in the host's *local* time zone (matching the //! auto-generated filename stamps in `timestamp.rs`), since AmigaOS has no //! real notion of time zones and a UTC clock just confuses users. The @@ -125,6 +132,15 @@ impl Rtc { self.clock_mut().set_seed(seed_unix, frozen); } + /// Persist the RP5C01's battery-backed registers to (and preload them + /// from) `path`, in the WinUAE/Amiberry `.nvram` file layout. No-op + /// for the MSM6242, which carries no battery RAM. + pub fn set_battmem_path(&mut self, path: std::path::PathBuf) { + if let Rtc::Rp5c01(chip) = self { + chip.set_battmem_path(path); + } + } + pub fn seed(&self) -> Option { self.clock().seed() } @@ -361,6 +377,12 @@ pub struct Rp5c01Rtc { /// running, so unlike the real stopped chip no time is lost. latched: Option, clock: ClockSource, + /// Backing file for the battery-backed registers (`[machine] + /// battmem`), in the WinUAE/Amiberry `.nvram` layout. `None` keeps + /// them session-only, like a board with a flat battery. + battmem_path: Option, + /// Battery state changed since the last flush to `battmem_path`. + battmem_dirty: bool, } impl Default for Rp5c01Rtc { @@ -375,6 +397,8 @@ impl Default for Rp5c01Rtc { ram: [0; 13], latched: None, clock: ClockSource::default(), + battmem_path: None, + battmem_dirty: false, } } } @@ -402,6 +426,16 @@ impl Rp5c01Rtc { 0x0, 0x0, 0xF, 0x7, 0xF, 0x3, 0x7, 0xF, 0x3, 0x0, 0x1, 0x0, 0x0, ]; + /// The WinUAE/Amiberry RP5C01 `.nvram` file layout, kept bit-for-bit + /// compatible so backing files interchange between emulators: three + /// 16-byte blocks of one register value per byte -- the time digits + /// as of the last save plus MODE/TEST/RESET at selects D-F, then the + /// block-1 (alarm) registers, then the 13 battery RAM bytes (block 2 + /// low nibbles, block 3 high). + const BATTMEM_FILE_SIZE: usize = 48; + const BATTMEM_ALARM_OFFSET: usize = 16; + const BATTMEM_RAM_OFFSET: usize = 32; + fn bank1_default() -> [u8; 13] { let mut bank1 = [0u8; 13]; bank1[Self::REG_1224] = 1; @@ -441,12 +475,23 @@ impl Rp5c01Rtc { self.latched = None; } self.mode = val; + // Every battery-RAM access sequence brackets its nibble + // writes in MODE writes (bank select in, bank restore + // out -- battmem.resource and Linux's nvram driver both + // do), so a MODE write is the transaction boundary that + // flushes changed battery state to the backing file. + if self.battmem_dirty { + self.flush_battmem(emulated_secs); + } } 0xE => { // TEST: not modelled, deliberately not persistent. } 0xF => { if val & Self::RESET_ALARM != 0 { + if self.bank1[Self::ALARM_REGS].iter().any(|&digit| digit != 0) { + self.battmem_dirty = true; + } self.bank1[Self::ALARM_REGS].fill(0); } // The fraction reset and the 16 Hz / 1 Hz output gates go @@ -458,18 +503,33 @@ impl Rp5c01Rtc { // seeded clock (module policy, same as the MSM6242). Self::BLOCK_TIME => {} Self::BLOCK_ALARM => { - self.bank1[reg as usize] = val & Self::BANK1_WRITE_MASK[reg as usize]; + self.store_battery(reg, val & Self::BANK1_WRITE_MASK[reg as usize], true); } Self::BLOCK_RAM_LO => { - self.ram[reg as usize] = (self.ram[reg as usize] & 0xF0) | val; + self.store_battery(reg, (self.ram[reg as usize] & 0xF0) | val, false); } _ => { - self.ram[reg as usize] = (val << 4) | (self.ram[reg as usize] & 0x0F); + self.store_battery(reg, (val << 4) | (self.ram[reg as usize] & 0x0F), false); } }, } } + /// Store into a battery-backed register (`bank1` when `alarm`, the + /// RAM byte otherwise), marking the backing file stale only when the + /// value actually changed. + fn store_battery(&mut self, reg: u8, val: u8, alarm: bool) { + let slot = if alarm { + &mut self.bank1[reg as usize] + } else { + &mut self.ram[reg as usize] + }; + if *slot != val { + *slot = val; + self.battmem_dirty = true; + } + } + fn time(&self, emulated_secs: f64) -> RtcDateTime { self.latched .unwrap_or_else(|| self.clock.current_time(emulated_secs)) @@ -520,6 +580,68 @@ impl Rp5c01Rtc { self.bank1[reg as usize] } + /// Persist the battery-backed registers to (and preload them from) + /// `path`, in the WinUAE/Amiberry `.nvram` layout. Only the alarm + /// block (behind the hardware write masks) and the RAM bytes load + /// back: the stored time digits are ignored because the clock is + /// read-only (host- or seed-driven), and the stored MODE is ignored + /// because restoring a parked selector would defeat the power-on + /// probe tell every battclock probe matches on (MODE reads `$8`). + pub fn set_battmem_path(&mut self, path: std::path::PathBuf) { + match std::fs::read(&path) { + Ok(data) if data.len() >= Self::BATTMEM_FILE_SIZE => { + for (reg, slot) in self.bank1.iter_mut().enumerate() { + *slot = data[Self::BATTMEM_ALARM_OFFSET + reg] & Self::BANK1_WRITE_MASK[reg]; + } + for (reg, slot) in self.ram.iter_mut().enumerate() { + *slot = data[Self::BATTMEM_RAM_OFFSET + reg]; + } + } + Ok(data) if data.is_empty() => {} + // A 16-byte file is WinUAE's MSM6242 flavour; anything else + // short is a truncated write. Neither holds RP5C01 RAM, so + // start battery-fresh rather than misread it. + Ok(data) => log::warn!( + "rp5c01 battmem: {} is {} bytes, not a {}-byte RP5C01 nvram file; starting fresh", + path.display(), + data.len(), + Self::BATTMEM_FILE_SIZE + ), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => log::warn!("rp5c01 battmem: reading {}: {e}", path.display()), + } + self.battmem_path = Some(path); + self.battmem_dirty = false; + } + + fn flush_battmem(&mut self, emulated_secs: f64) { + if let Some(path) = &self.battmem_path { + if let Err(e) = std::fs::write(path, self.battmem_bytes(emulated_secs)) { + // Stay dirty so the next MODE write retries: a transient + // host error must not lose the battery state until the + // guest happens to change it again. + log::warn!("rp5c01 battmem: writing {}: {e}", path.display()); + return; + } + } + self.battmem_dirty = false; + } + + /// The full `.nvram` image: WinUAE also stores the time digits and + /// control registers as of the save, so write them for file + /// compatibility even though only the alarm and RAM blocks load back. + fn battmem_bytes(&self, emulated_secs: f64) -> [u8; Self::BATTMEM_FILE_SIZE] { + let mut bytes = [0u8; Self::BATTMEM_FILE_SIZE]; + for reg in 0x0..=0xC { + bytes[reg as usize] = self.read_time_register(reg, emulated_secs); + } + bytes[0xD] = self.mode; + // Selects E/F (TEST, RESET) are write-only and hold nothing. + bytes[Self::BATTMEM_ALARM_OFFSET..][..self.bank1.len()].copy_from_slice(&self.bank1); + bytes[Self::BATTMEM_RAM_OFFSET..][..self.ram.len()].copy_from_slice(&self.ram); + bytes + } + /// A CPU reset does not reach the battery-backed chip: the time /// source, alarm registers, 12/24 select, and battery RAM all /// survive. Only the volatile-looking selector state returns to its @@ -1005,6 +1127,151 @@ mod tests { assert_eq!(rtc.clock.seed(), Some(VECTOR_UNIX)); } + /// A per-test battmem backing path in the host temp directory, + /// removed up front so each test starts battery-fresh. + fn battmem_file(name: &str) -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!( + "copperline-battmem-{}-{name}.nvram", + std::process::id() + )); + let _ = std::fs::remove_file(&path); + path + } + + /// A battery-RAM write sequence (bank select in, nibble writes, bank + /// restore out) must land on disk in the WinUAE/Amiberry layout: + /// time digits + MODE in the first 16-byte block, the alarm bank in + /// the second, the 13 combined RAM bytes in the third. The restore + /// of MODE is the flush boundary. + #[test] + fn rp5c01_battmem_flushes_the_winuae_nvram_layout_on_mode_restore() { + let path = battmem_file("flush"); + let mut rtc = seeded_rp5c01(VECTOR_UNIX); + rtc.set_battmem_path(path.clone()); + + rp_write(&mut rtc, 0xD, 0xA); // block 2: low nibbles + rp_write(&mut rtc, 0x0, 0x5); + rp_write(&mut rtc, 0xD, 0xB); // block 3: high nibbles + rp_write(&mut rtc, 0x0, 0xA); + rp_write(&mut rtc, 0xD, 0x8); // restore: transaction over + + let bytes = std::fs::read(&path).unwrap(); + assert_eq!(bytes.len(), 48); + // Time digits of the seeded 2005-03-18T01:58:29 (a Friday) in the + // Ricoh block-0 layout, then MODE as restored. + assert_eq!(&bytes[0x0..=0xC], &[9, 2, 8, 5, 1, 0, 5, 8, 1, 3, 0, 5, 0]); + assert_eq!(bytes[0xD], 0x8); + assert_eq!(bytes[0xE], 0); + assert_eq!(bytes[0xF], 0); + assert_eq!(bytes[0x1A], 1); // 12/24 select in the alarm block: 24h + assert_eq!(bytes[0x20], 0xA5); // RAM byte 0: high and low nibbles + + // A fresh chip preloads the same battery state from the file. + let mut resumed = seeded_rp5c01(VECTOR_UNIX); + resumed.set_battmem_path(path.clone()); + rp_write(&mut resumed, 0xD, 0xA); + assert_eq!(rp_read(&mut resumed, 0x0), 0x5); + rp_write(&mut resumed, 0xD, 0xB); + assert_eq!(rp_read(&mut resumed, 0x0), 0xA); + let _ = std::fs::remove_file(&path); + } + + /// Loading an Amiberry/WinUAE file takes only the battery payload: + /// alarm digits (behind the hardware write masks) and RAM bytes. The + /// stored time never touches the read-only clock, and the stored + /// MODE never overrides the power-on value the chip probes match on. + #[test] + fn rp5c01_battmem_load_takes_ram_and_alarm_but_not_clock_or_mode() { + let path = battmem_file("load"); + let mut file = [0u8; 48]; + // Time block from a foreign save, MODE parked at $9. + file[..16].copy_from_slice(&[7, 4, 4, 5, 4, 1, 4, 3, 2, 7, 0, 6, 2, 9, 0, 0]); + file[0x12] = 0xF; // 1-minute alarm digit (4 bits wide) + file[0x13] = 0xFF; // 10-minute alarm digit: masked to 3 bits + file[0x1A] = 1; // 24-hour select + file[0x20] = 0x07; + file[0x2C] = 0x89; + std::fs::write(&path, file).unwrap(); + + let mut rtc = seeded_rp5c01(VECTOR_UNIX); + rtc.set_battmem_path(path.clone()); + assert_eq!(rp_read(&mut rtc, 0xD), 0x8); // power-on probe tell kept + assert_eq!(rp_read(&mut rtc, 0x0), 9); // seeded clock, not the file's + rp_write(&mut rtc, 0xD, 0x9); // block 1 + assert_eq!(rp_read(&mut rtc, 0x2), 0xF); + assert_eq!(rp_read(&mut rtc, 0x3), 0x7); // hardware mask applied + rp_write(&mut rtc, 0xD, 0xA); // block 2 + assert_eq!(rp_read(&mut rtc, 0x0), 0x7); + assert_eq!(rp_read(&mut rtc, 0xC), 0x9); + rp_write(&mut rtc, 0xD, 0xB); // block 3 + assert_eq!(rp_read(&mut rtc, 0xC), 0x8); + let _ = std::fs::remove_file(&path); + } + + /// Clock reads bracket their digit reads in MODE writes too; those + /// must not rewrite the backing file. Only a write that changes + /// battery state arms the flush -- rewriting an identical value does + /// not count as a change. + #[test] + fn rp5c01_battmem_flushes_only_after_a_real_battery_change() { + let path = battmem_file("clean"); + let mut rtc = seeded_rp5c01(VECTOR_UNIX); + rtc.set_battmem_path(path.clone()); + + rp_write(&mut rtc, 0xD, 0x0); // lock for a clock read + rp_write(&mut rtc, 0xD, 0x8); // unlock + rp_write(&mut rtc, 0xD, 0xA); // select RAM + rp_write(&mut rtc, 0x3, 0x0); // rewrite the value already there + rp_write(&mut rtc, 0xD, 0x8); + assert!(!path.exists(), "no battery change, no file"); + + rp_write(&mut rtc, 0xD, 0xA); + rp_write(&mut rtc, 0x3, 0x6); + rp_write(&mut rtc, 0xD, 0x8); + assert!(path.exists(), "a changed nibble flushes on MODE restore"); + let _ = std::fs::remove_file(&path); + } + + /// A failed backing-file write must leave the dirty bit armed so a + /// later MODE write retries; clearing it up front would silently + /// drop the battery state on a transient host error. + #[test] + fn rp5c01_battmem_retries_the_flush_after_a_failed_write() { + let dir = battmem_file("retry-dir"); // reserved, not yet created + let path = dir.join("battmem.nvram"); + let mut rtc = seeded_rp5c01(VECTOR_UNIX); + rtc.set_battmem_path(path.clone()); + + rp_write(&mut rtc, 0xD, 0xA); + rp_write(&mut rtc, 0x0, 0x5); + rp_write(&mut rtc, 0xD, 0x8); // flush fails: parent dir missing + assert!(!path.exists()); + + std::fs::create_dir(&dir).unwrap(); + rp_write(&mut rtc, 0xD, 0x8); // still dirty: retried and lands + let bytes = std::fs::read(&path).unwrap(); + assert_eq!(bytes[0x20], 0x05); + + rp_write(&mut rtc, 0xD, 0xA); // clean again: no further writes + let _ = std::fs::remove_dir_all(&dir); + } + + /// A file that is not a 48-byte RP5C01 image (WinUAE's 16-byte + /// MSM6242 flavour, or a truncated write) is ignored: the chip + /// starts battery-fresh instead of misreading it. + #[test] + fn rp5c01_battmem_ignores_files_of_the_wrong_shape() { + let path = battmem_file("short"); + std::fs::write(&path, [0xAB; 16]).unwrap(); + let mut rtc = seeded_rp5c01(VECTOR_UNIX); + rtc.set_battmem_path(path.clone()); + rp_write(&mut rtc, 0xD, 0xA); + for reg in 0x0..=0xC { + assert_eq!(rp_read(&mut rtc, reg), 0, "RAM select {reg:#X}"); + } + let _ = std::fs::remove_file(&path); + } + #[test] fn rtc_wrapper_selects_and_reports_the_chip() { let mut rtc = Rtc::new(RtcChip::Rp5c01); diff --git a/src/savestate.rs b/src/savestate.rs index fbb5e79..afd169d 100644 --- a/src/savestate.rs +++ b/src/savestate.rs @@ -136,7 +136,10 @@ const STATE_MAGIC: &[u8; 8] = b"CLSSTATE"; // counter) and the rev-locked motor (hum partial phases, revolution // phase, cascaded rumble poles, per-drive pattern seed); the // read-gated hiss voice was removed outright -pub const STATE_VERSION: u32 = 38; +// 39: Rp5c01Rtc gained the battmem backing-file binding (battmem_path, +// battmem_dirty - [machine] battmem), so a resumed run keeps +// persisting battery RAM to the same file +pub const STATE_VERSION: u32 = 39; /// Default state file name, timestamped like the screenshot/recorder names. pub fn auto_filename() -> std::path::PathBuf {