fix(desktop): persist the save when a ROM window ends on a diagnostic

A core, DMA, rendering, window, or audio error returned before the save was
written, so every completed in-game save since launch was lost. The window now
writes changed bytes on that path too. The diagnostic stays the session error;
a refused or failed write is reported as output. A failed state restore leaves
save memory undefined and is never written. Terminal runs keep the strict rule.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhfHfHEuzv2AUTd6erW1gU
This commit is contained in:
0xrsydn 2026-10-14 08:26:03 +07:00
commit 66817c919d
5 changed files with 105 additions and 6 deletions

View file

@ -329,7 +329,7 @@ pub fn execute(options: Options, writer: &mut impl Write) -> Result<(), Box<dyn
.map(|path| save_file::SaveFile::open(path, &options.path, options.save_device))
.transpose()?;
if let Some(save) = save_file.as_ref() {
writeln!(writer, "Save file: {}; writes only on clean exit, with unique backups when replacing existing data.", if save.loaded() { "loaded" } else { "new erased image" })?;
writeln!(writer, "Save file: {}; writes on clean exit or a ROM window diagnostic, with unique backups when replacing existing data.", if save.loaded() { "loaded" } else { "new erased image" })?;
} else if options.save_device != SaveDevice::None {
writeln!(
writer,
@ -400,5 +400,36 @@ pub(crate) fn persist_save(
Ok(())
}
/// Keep completed in-game saves when a window session ends on a diagnostic.
/// The diagnostic stays the session's error, so a refused or failed write is only reported.
/// A failed state restore leaves save memory undefined and is never written.
pub(crate) fn persist_save_after_diagnostic(
save: &mut save_file::SaveFile,
memory: &gba_core::memory::Memory,
diagnostic: &(dyn Error + 'static),
writer: &mut impl Write,
) -> io::Result<()> {
if matches!(
diagnostic.downcast_ref(),
Some(gba_core::snapshot::SnapshotError::RestoreFailed(_))
) {
return writeln!(
writer,
"Save not written: machine state is undefined after a failed state restore."
);
}
match save.persist(memory) {
Ok(Some(saved)) => {
writeln!(writer, "Save persisted after diagnostic.")?;
if let Some(backup) = saved.backup {
writeln!(writer, "Previous save backup: {backup:?}")?;
}
Ok(())
}
Ok(None) => writeln!(writer, "Save unchanged; no file written."),
Err(error) => writeln!(writer, "Save not written after diagnostic: {error}"),
}
}
#[cfg(test)]
mod tests;

View file

@ -199,3 +199,56 @@ fn reject_rom_aliases_symlinks_directories_and_readonly_images() {
assert_eq!(fs::read(&f.save).unwrap(), image);
fs::set_permissions(&f.save, fs::Permissions::from_mode(0o600)).unwrap();
}
fn after_diagnostic(
save: &mut SaveFile,
m: &Memory,
error: &(dyn std::error::Error + 'static),
) -> String {
let mut output = Vec::new();
crate::cartridge::persist_save_after_diagnostic(save, m, error, &mut output).unwrap();
String::from_utf8(output).unwrap()
}
#[test]
fn diagnostics_persist_completed_programs_and_report_refusals() {
let diagnostic = io::Error::other("unsupported hardware");
let f = Fixture::new();
let original = f.image(255);
let mut m = memory();
let mut save = f.open().unwrap();
save.initialize(&mut m).unwrap();
assert!(after_diagnostic(&mut save, &m, &diagnostic).contains("Save unchanged"));
program(&mut m, 0x12);
let text = after_diagnostic(&mut save, &m, &diagnostic);
assert!(text.contains("Save persisted after diagnostic."), "{text}");
assert!(text.contains("Previous save backup"), "{text}");
let written = fs::read(&f.save).unwrap();
assert_eq!(written[0], 0x12);
assert_eq!(written[1..], original[1..]);
// An incomplete command is reported, never written, and never replaces the diagnostic.
start_program(&mut m, 0x02);
let text = after_diagnostic(&mut save, &m, &diagnostic);
assert!(text.contains("Save not written after diagnostic"), "{text}");
assert!(text.contains("incomplete"), "{text}");
assert_eq!(fs::read(&f.save).unwrap(), written);
}
#[test]
fn failed_state_restores_never_write_the_save() {
use gba_core::snapshot::SnapshotError;
let f = Fixture::new();
let original = f.image(255);
let mut m = memory();
let mut save = f.open().unwrap();
save.initialize(&mut m).unwrap();
program(&mut m, 0x12);
let error: Box<dyn std::error::Error> = Box::new(SnapshotError::RestoreFailed(Box::new(
SnapshotError::Truncated,
)));
let text = after_diagnostic(&mut save, &m, error.as_ref());
assert!(text.contains("failed state restore"), "{text}");
assert_eq!(fs::read(&f.save).unwrap(), original);
assert_eq!(fs::read_dir(&f.dir).unwrap().count(), 3); // ROM, image, owned lock only.
}

View file

@ -121,7 +121,7 @@ pub fn run(
)?;
writeln!(
writer,
"Input releases and audio mutes on focus loss. Save files require --save-file and clean exit; state file: {state_path:?}. Startup frames are not suppressed."
"Input releases and audio mutes on focus loss. Save files require --save-file and persist on exit; state file: {state_path:?}. Startup frames are not suppressed."
)?;
writeln!(
writer,
@ -256,8 +256,19 @@ pub fn run(
if let Some(output) = audio.as_ref() {
output.report(writer)?;
}
result?;
writer.flush()?;
if let Err(diagnostic) = result {
// A player's completed in-game saves outlive the diagnostic that ended the session.
if let Some(save) = save_file {
crate::cartridge::persist_save_after_diagnostic(
save,
session.memory_mut(),
diagnostic.as_ref(),
writer,
)?;
}
return Err(diagnostic);
}
if let Some(save) = save_file {
crate::cartridge::persist_save(save, session.memory_mut(), writer)?;
}

View file

@ -141,7 +141,7 @@ fn run() -> Result<(), Box<dyn Error>> {
--audio Enable macOS output at 1x (default: muted)\n\
--rtc Select cartridge RTC (UTC startup, host elapsed time)\n\
--save-type flash64|flash128 Select nominal Macronix Flash emulation\n\
--save-file PATH Load raw Flash image; persist only on clean exit\n\
--save-file PATH Load raw Flash 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\
--cpu-demo Run the terminal-only ARM/Thumb instruction demo\n\