use super::types::{
AUTORUN_VERSION, AutorunCheckpoint, AutorunFile, AutorunFormat, AutorunFrame, StateConverter,
};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub(crate) struct AutorunRleFrame {
pub(crate) player1: u8,
pub(crate) player2: u8,
pub(crate) repeat: u32,
}
#[derive(Debug, Serialize)]
pub(crate) struct AutorunFileV3Ser<'a> {
pub(crate) version: u32,
pub(crate) frames: Vec<AutorunRleFrame>,
pub(crate) checkpoints: &'a [AutorunCheckpoint],
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub(crate) struct AutorunFileV3OnDisk {
pub(crate) version: u32,
pub(crate) frames: Vec<AutorunRleFrame>,
pub(crate) checkpoints: Vec<AutorunCheckpoint>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub(crate) struct AutorunFileV2 {
pub(crate) version: u32,
pub(crate) frames: Vec<AutorunFrame>,
pub(crate) checkpoints: Vec<AutorunCheckpoint>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct AutorunFileBinaryBody {
frames: Vec<AutorunRleFrame>,
checkpoints: Vec<AutorunCheckpointBinary>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct AutorunCheckpointBinary {
frame_index: u32,
screen_crc: u32,
state_bytes: Vec<u8>,
}
fn build_rle_frame(frame: &AutorunFrame, repeat_count: u32) -> AutorunRleFrame {
AutorunRleFrame {
player1: frame.player1,
player2: frame.player2,
repeat: repeat_count,
}
}
fn build_input_frame(rle_frame: &AutorunRleFrame) -> AutorunFrame {
AutorunFrame {
player1: rle_frame.player1,
player2: rle_frame.player2,
}
}
pub(crate) fn encode_rle_frames(frames: &[AutorunFrame]) -> Vec<AutorunRleFrame> {
if frames.is_empty() {
return Vec::new();
}
let mut encoded_frames = Vec::new();
let mut current_frame = &frames[0];
let mut repeat_count: u32 = 1;
for frame in &frames[1..] {
if frame == current_frame && repeat_count < u32::MAX {
repeat_count += 1;
continue;
}
encoded_frames.push(build_rle_frame(current_frame, repeat_count));
current_frame = frame;
repeat_count = 1;
}
encoded_frames.push(build_rle_frame(current_frame, repeat_count));
encoded_frames
}
const MAX_DECODED_FRAMES: usize = 10_000_000;
pub(crate) fn decode_rle_frames(
rle_frames: &[AutorunRleFrame],
) -> Result<Vec<AutorunFrame>, String> {
let mut total_frames: usize = 0;
for rle_frame in rle_frames {
if rle_frame.repeat == 0 {
return Err("Invalid autorun RLE frame with repeat=0".to_string());
}
total_frames = total_frames
.checked_add(rle_frame.repeat as usize)
.filter(|&t| t <= MAX_DECODED_FRAMES)
.ok_or_else(|| {
format!("Autorun file exceeds maximum of {MAX_DECODED_FRAMES} decoded frames")
})?;
}
let mut decoded_frames = Vec::with_capacity(total_frames);
for rle_frame in rle_frames {
decoded_frames.extend(std::iter::repeat_n(
build_input_frame(rle_frame),
rle_frame.repeat as usize,
));
}
Ok(decoded_frames)
}
pub fn autorun_path_for_rom(rom_path: &Path) -> PathBuf {
rom_path.with_extension("autorun")
}
#[allow(dead_code)]
pub fn crc32(data: &[u8]) -> u32 {
crate::platform::crc32::crc32(&[data])
}
pub fn backup_autorun_file(path: &Path) -> Result<(), String> {
if !path.exists() {
return Ok(());
}
let backup = path.with_extension("autorun.bak");
std::fs::copy(path, &backup)
.map(|_| ())
.map_err(|e| format!("Failed to backup autorun file {}: {e}", path.display()))
}
pub fn trim_recording(file: &mut AutorunFile, n: usize) {
if n == 0 {
return;
}
let keep = file.checkpoints.len().saturating_sub(n);
file.checkpoints.truncate(keep);
let frame_limit = file
.checkpoints
.last()
.map(|cp| cp.frame_index as usize + 1)
.unwrap_or(0);
file.frames.truncate(frame_limit);
}
pub const BINARY_MAGIC: &[u8; 6] = b"NESRA3";
pub fn save_autorun_file(
path: &Path,
file: &AutorunFile,
format: AutorunFormat,
state_converter: Option<&dyn StateConverter>,
) -> Result<(), String> {
if file.version != AUTORUN_VERSION {
return Err(format!("Unsupported autorun version: {}", file.version));
}
match format {
AutorunFormat::Json => save_autorun_file_json(path, file),
AutorunFormat::Binary => save_autorun_file_binary(path, file, state_converter),
}
}
fn save_autorun_file_json(path: &Path, file: &AutorunFile) -> Result<(), String> {
let encoded_file = AutorunFileV3Ser {
version: AUTORUN_VERSION,
frames: encode_rle_frames(&file.frames),
checkpoints: &file.checkpoints,
};
let data = serde_json::to_vec_pretty(&encoded_file)
.map_err(|e| format!("Failed to serialize autorun file: {e}"))?;
write_autorun_bytes(path, data)
}
fn encode_checkpoint_to_binary(
cp: &AutorunCheckpoint,
converter: Option<&dyn StateConverter>,
) -> Result<AutorunCheckpointBinary, String> {
let state_bytes = if cp.state_bytes.is_empty() {
Vec::new()
} else if let Some(conv) = converter {
conv.to_binary(&cp.state_bytes)?
} else {
cp.state_bytes.clone()
};
Ok(AutorunCheckpointBinary {
frame_index: cp.frame_index,
screen_crc: cp.screen_crc,
state_bytes,
})
}
fn save_autorun_file_binary(
path: &Path,
file: &AutorunFile,
state_converter: Option<&dyn StateConverter>,
) -> Result<(), String> {
let checkpoints = file
.checkpoints
.iter()
.map(|cp| encode_checkpoint_to_binary(cp, state_converter))
.collect::<Result<Vec<_>, _>>()?;
let body = AutorunFileBinaryBody {
frames: encode_rle_frames(&file.frames),
checkpoints,
};
let payload = postcard::to_allocvec(&body)
.map_err(|e| format!("Failed to serialize binary autorun: {e}"))?;
let mut data = Vec::with_capacity(BINARY_MAGIC.len() + payload.len());
data.extend_from_slice(BINARY_MAGIC);
data.extend_from_slice(&payload);
write_autorun_bytes(path, data)
}
fn write_autorun_bytes(path: &Path, data: Vec<u8>) -> Result<(), String> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| format!("Failed to create autorun directory: {e}"))?;
}
std::fs::write(path, data)
.map_err(|e| format!("Failed to write autorun file {}: {e}", path.display()))
}
pub fn load_autorun_file(
path: &Path,
state_converter: Option<&dyn StateConverter>,
) -> Result<AutorunFile, String> {
let data = std::fs::read(path)
.map_err(|e| format!("Failed to read autorun file {}: {e}", path.display()))?;
if data.starts_with(BINARY_MAGIC) {
return load_autorun_file_binary(&data, state_converter);
}
load_autorun_file_json(&data)
}
fn decode_checkpoint_from_binary(
cp: AutorunCheckpointBinary,
converter: Option<&dyn StateConverter>,
) -> Result<AutorunCheckpoint, String> {
let state_bytes = if cp.state_bytes.is_empty() {
Vec::new()
} else if let Some(conv) = converter {
conv.binary_to_canonical(&cp.state_bytes)?
} else {
cp.state_bytes
};
Ok(AutorunCheckpoint {
frame_index: cp.frame_index,
screen_crc: cp.screen_crc,
state_bytes,
})
}
fn load_autorun_file_binary(
data: &[u8],
state_converter: Option<&dyn StateConverter>,
) -> Result<AutorunFile, String> {
let payload = &data[BINARY_MAGIC.len()..];
let body: AutorunFileBinaryBody = postcard::from_bytes(payload)
.map_err(|e| format!("Failed to deserialize binary autorun file: {e}"))?;
let frames = decode_rle_frames(&body.frames)?;
let checkpoints = body
.checkpoints
.into_iter()
.map(|cp| decode_checkpoint_from_binary(cp, state_converter))
.collect::<Result<Vec<_>, _>>()?;
Ok(AutorunFile {
version: AUTORUN_VERSION,
frames,
checkpoints,
})
}
fn load_autorun_file_json(data: &[u8]) -> Result<AutorunFile, String> {
let json_value: serde_json::Value = serde_json::from_slice(data)
.map_err(|e| format!("Failed to deserialize autorun file: {e}"))?;
let version = json_value["version"]
.as_u64()
.and_then(|v| u32::try_from(v).ok())
.ok_or_else(|| "Missing or invalid version field in autorun file".to_string())?;
match version {
2 => {
let v2: AutorunFileV2 = serde_json::from_value(json_value)
.map_err(|e| format!("Failed to deserialize autorun v2 file: {e}"))?;
Ok(AutorunFile {
version: AUTORUN_VERSION,
frames: v2.frames,
checkpoints: v2.checkpoints,
})
}
3 => {
let v3: AutorunFileV3OnDisk = serde_json::from_value(json_value)
.map_err(|e| format!("Failed to deserialize autorun v3 file: {e}"))?;
Ok(AutorunFile {
version: AUTORUN_VERSION,
frames: decode_rle_frames(&v3.frames)?,
checkpoints: v3.checkpoints,
})
}
_ => Err(format!("Unsupported autorun version: {version}")),
}
}
pub fn convert_autorun_file(
path: &Path,
target_format: AutorunFormat,
state_converter: Option<&dyn StateConverter>,
) -> Result<(), String> {
if !path.exists() {
return Err(format!("Autorun file not found: {}", path.display()));
}
let autorun_file = load_autorun_file(path, state_converter)?;
save_autorun_file(path, &autorun_file, target_format, state_converter)
}
#[cfg(test)]
mod tests {
use super::super::types::{AutorunCheckpoint, AutorunFrame};
use super::*;
use serde_json::json;
use tempfile::NamedTempFile;
fn sample_file_with_checkpoints() -> AutorunFile {
AutorunFile {
version: AUTORUN_VERSION,
frames: (0..600)
.map(|i| AutorunFrame {
player1: (i % 256) as u8,
player2: 0,
})
.collect(),
checkpoints: vec![
AutorunCheckpoint {
frame_index: 299,
screen_crc: 0x1111,
state_bytes: vec![],
},
AutorunCheckpoint {
frame_index: 599,
screen_crc: 0x2222,
state_bytes: vec![],
},
],
}
}
fn sample_large_file() -> AutorunFile {
AutorunFile {
version: AUTORUN_VERSION,
frames: (0..3000)
.map(|i| AutorunFrame {
player1: if i < 2990 { 0 } else { 1 },
player2: 0,
})
.collect(),
checkpoints: vec![AutorunCheckpoint {
frame_index: 299,
screen_crc: 0xABCDEF01,
state_bytes: vec![],
}],
}
}
#[test]
fn test_autorun_path_for_rom_replaces_extension() {
let rom_path = Path::new("roms/games/pac-man.nes");
let expected = Path::new("roms/games/pac-man.autorun");
assert_eq!(autorun_path_for_rom(rom_path), expected);
}
#[test]
fn test_crc32_matches_known_value() {
let value = crc32(b"NESER");
assert_eq!(value, 0xEBBAA24B);
}
#[test]
fn test_save_and_load_roundtrip() {
let temp = NamedTempFile::new().expect("create temp file");
let file = AutorunFile {
version: AUTORUN_VERSION,
frames: vec![
AutorunFrame {
player1: 0b0000_0001,
player2: 0b0001_0000,
},
AutorunFrame {
player1: 0b0000_0010,
player2: 0b0010_0000,
},
],
checkpoints: vec![AutorunCheckpoint {
frame_index: 1,
screen_crc: 0x8BB98613,
state_bytes: vec![10, 20, 30],
}],
};
save_autorun_file(temp.path(), &file, AutorunFormat::Json, None)
.expect("save autorun file");
let loaded = load_autorun_file(temp.path(), None).expect("load autorun file");
assert_eq!(loaded, file);
}
#[test]
fn test_save_binary_and_load_roundtrip() {
let temp = NamedTempFile::new().expect("create temp file");
let file = AutorunFile {
version: AUTORUN_VERSION,
frames: vec![
AutorunFrame {
player1: 0b0000_0001,
player2: 0b0001_0000,
},
AutorunFrame {
player1: 0b0000_0010,
player2: 0b0010_0000,
},
AutorunFrame {
player1: 0b0000_0010,
player2: 0b0010_0000,
},
],
checkpoints: vec![AutorunCheckpoint {
frame_index: 2,
screen_crc: 0xDEADBEEF,
state_bytes: vec![],
}],
};
save_autorun_file(temp.path(), &file, AutorunFormat::Binary, None)
.expect("save binary autorun file");
let loaded = load_autorun_file(temp.path(), None).expect("load binary autorun file");
assert_eq!(loaded, file);
}
#[test]
fn test_binary_file_starts_with_magic_header() {
let temp = NamedTempFile::new().expect("create temp file");
let file = sample_large_file();
save_autorun_file(temp.path(), &file, AutorunFormat::Binary, None).expect("save binary");
let raw = std::fs::read(temp.path()).expect("read binary file");
assert!(
raw.starts_with(BINARY_MAGIC),
"binary file should start with NESRA3 magic, got {:?}",
&raw[..BINARY_MAGIC.len().min(raw.len())]
);
}
#[test]
fn test_load_auto_detects_binary_format() {
let temp = NamedTempFile::new().expect("create temp file");
let file = sample_large_file();
save_autorun_file(temp.path(), &file, AutorunFormat::Binary, None).expect("save binary");
let loaded = load_autorun_file(temp.path(), None).expect("auto-detect binary load");
assert_eq!(loaded.version, AUTORUN_VERSION);
assert_eq!(loaded.frames.len(), file.frames.len());
}
#[test]
fn test_load_auto_detects_json_format() {
let temp = NamedTempFile::new().expect("create temp file");
let file = sample_large_file();
save_autorun_file(temp.path(), &file, AutorunFormat::Json, None).expect("save json");
let loaded = load_autorun_file(temp.path(), None).expect("auto-detect json load");
assert_eq!(loaded.version, AUTORUN_VERSION);
assert_eq!(loaded.frames.len(), file.frames.len());
}
#[test]
fn test_binary_file_is_smaller_than_json() {
let temp_bin = NamedTempFile::new().expect("create temp file");
let temp_json = NamedTempFile::new().expect("create temp file");
let file = sample_large_file();
save_autorun_file(temp_bin.path(), &file, AutorunFormat::Binary, None)
.expect("save binary");
save_autorun_file(temp_json.path(), &file, AutorunFormat::Json, None).expect("save json");
let binary_size = std::fs::metadata(temp_bin.path())
.expect("get binary size")
.len();
let json_size = std::fs::metadata(temp_json.path())
.expect("get json size")
.len();
let ratio = binary_size as f64 / json_size as f64;
assert!(
ratio <= 0.25,
"binary ({binary_size} bytes) should be <=25% of JSON ({json_size} bytes), got {:.1}%",
ratio * 100.0
);
}
#[test]
fn test_backup_creates_bak_file() {
let temp = NamedTempFile::new().expect("create temp file");
let path = temp.path();
std::fs::write(path, b"test data").expect("write test data");
backup_autorun_file(path).expect("backup should succeed");
let bak = path.with_extension("autorun.bak");
assert!(bak.exists(), "backup file should exist");
assert_eq!(
std::fs::read(&bak).unwrap(),
b"test data",
"backup content should match"
);
let _ = std::fs::remove_file(bak);
}
#[test]
fn test_backup_does_nothing_if_file_absent() {
let path = Path::new("/tmp/nonexistent_autorun_test_file_xyz.autorun");
assert!(backup_autorun_file(path).is_ok());
}
#[test]
fn test_trim_recording_removes_last_checkpoint_and_its_frames() {
let mut file = sample_file_with_checkpoints();
assert_eq!(file.checkpoints.len(), 2);
assert_eq!(file.frames.len(), 600);
trim_recording(&mut file, 1);
assert_eq!(file.checkpoints.len(), 1, "one checkpoint should remain");
assert_eq!(
file.checkpoints[0].frame_index, 299,
"first checkpoint should remain"
);
assert_eq!(file.frames.len(), 300);
}
#[test]
fn test_trim_recording_all_checkpoints_clears_frames() {
let mut file = sample_file_with_checkpoints();
trim_recording(&mut file, 2);
assert!(file.checkpoints.is_empty());
assert!(file.frames.is_empty());
}
#[test]
fn test_trim_recording_n_zero_does_nothing() {
let mut file = sample_file_with_checkpoints();
trim_recording(&mut file, 0);
assert_eq!(file.checkpoints.len(), 2);
assert_eq!(file.frames.len(), 600);
}
#[test]
fn test_trim_recording_n_exceeds_checkpoints_clears_all() {
let mut file = sample_file_with_checkpoints();
trim_recording(&mut file, 100);
assert!(file.checkpoints.is_empty());
assert!(file.frames.is_empty());
}
#[test]
fn test_save_writes_version_3_with_rle_frames() {
let temp = NamedTempFile::new().expect("create temp file");
let file = AutorunFile {
version: AUTORUN_VERSION,
frames: vec![
AutorunFrame {
player1: 0,
player2: 0,
},
AutorunFrame {
player1: 0,
player2: 0,
},
AutorunFrame {
player1: 0,
player2: 0,
},
AutorunFrame {
player1: 1,
player2: 0,
},
],
checkpoints: vec![],
};
save_autorun_file(temp.path(), &file, AutorunFormat::Json, None)
.expect("save autorun file");
let raw = std::fs::read_to_string(temp.path()).expect("read saved file as text");
let parsed: serde_json::Value = serde_json::from_str(&raw).expect("parse saved json");
assert_eq!(parsed["version"], json!(3));
assert_eq!(parsed["frames"].as_array().map(Vec::len), Some(2));
assert_eq!(
parsed["frames"][0],
json!({"player1": 0, "player2": 0, "repeat": 3})
);
assert_eq!(
parsed["frames"][1],
json!({"player1": 1, "player2": 0, "repeat": 1})
);
}
#[test]
fn test_load_accepts_version_2_and_expands_to_per_frame_sequence() {
let temp = NamedTempFile::new().expect("create temp file");
let legacy_v2 = json!({
"version": 2,
"frames": [
{"player1": 4, "player2": 0},
{"player1": 4, "player2": 0},
{"player1": 7, "player2": 1}
],
"checkpoints": []
});
std::fs::write(
temp.path(),
serde_json::to_vec_pretty(&legacy_v2).expect("serialize legacy json"),
)
.expect("write legacy v2 file");
let loaded = load_autorun_file(temp.path(), None).expect("load v2 file");
assert_eq!(loaded.version, 3);
assert_eq!(loaded.frames.len(), 3);
assert_eq!(
loaded.frames,
vec![
AutorunFrame {
player1: 4,
player2: 0
},
AutorunFrame {
player1: 4,
player2: 0
},
AutorunFrame {
player1: 7,
player2: 1
},
]
);
}
#[test]
fn test_load_rejects_v3_file_exceeding_max_decoded_frames() {
let temp = NamedTempFile::new().expect("create temp file");
let oversized = json!({
"version": 3,
"frames": [
{"player1": 0, "player2": 0, "repeat": MAX_DECODED_FRAMES + 1}
],
"checkpoints": []
});
std::fs::write(
temp.path(),
serde_json::to_vec_pretty(&oversized).expect("serialize oversized json"),
)
.expect("write oversized v3 file");
let result = load_autorun_file(temp.path(), None);
assert!(
result.is_err(),
"loading a file exceeding MAX_DECODED_FRAMES should fail"
);
assert!(
result.unwrap_err().contains("exceeds maximum"),
"error message should mention exceeds maximum"
);
}
#[test]
fn test_load_rejects_v3_rle_frame_with_zero_repeat() {
let temp = NamedTempFile::new().expect("create temp file");
let zero_repeat = json!({
"version": 3,
"frames": [{"player1": 1, "player2": 0, "repeat": 0}],
"checkpoints": []
});
std::fs::write(
temp.path(),
serde_json::to_vec_pretty(&zero_repeat).expect("serialize"),
)
.expect("write");
let result = load_autorun_file(temp.path(), None);
assert!(result.is_err());
assert!(result.unwrap_err().contains("repeat=0"));
}
#[test]
fn test_convert_autorun_file_fails_when_source_file_missing() {
let temp_dir = tempfile::TempDir::new().expect("create temp dir");
let missing_path = temp_dir.path().join("missing.autorun");
let result = convert_autorun_file(&missing_path, AutorunFormat::Binary, None);
assert!(
result.is_err(),
"conversion should fail when file is missing"
);
}
#[test]
fn test_convert_autorun_file_rewrites_v2_to_v3_rle() {
let temp = NamedTempFile::new().expect("create temp file");
let legacy_v2 = json!({
"version": 2,
"frames": [
{"player1": 0, "player2": 0},
{"player1": 0, "player2": 0},
{"player1": 1, "player2": 0}
],
"checkpoints": []
});
std::fs::write(
temp.path(),
serde_json::to_vec_pretty(&legacy_v2).expect("serialize v2 json"),
)
.expect("write v2 autorun");
convert_autorun_file(temp.path(), AutorunFormat::Json, None).expect("convert file");
let parsed: serde_json::Value =
serde_json::from_slice(&std::fs::read(temp.path()).expect("read converted file"))
.expect("parse converted json");
assert_eq!(parsed["version"], json!(AUTORUN_VERSION));
assert_eq!(parsed["frames"].as_array().map(Vec::len), Some(2));
assert_eq!(parsed["frames"][0]["repeat"], json!(2));
}
#[test]
fn test_convert_json_to_binary_format() {
let temp = NamedTempFile::new().expect("create temp file");
let file = sample_large_file();
save_autorun_file(temp.path(), &file, AutorunFormat::Json, None).expect("save json");
assert!(
!std::fs::read(temp.path())
.unwrap()
.starts_with(BINARY_MAGIC),
"should start as JSON"
);
convert_autorun_file(temp.path(), AutorunFormat::Binary, None).expect("convert to binary");
let raw = std::fs::read(temp.path()).expect("read converted file");
assert!(
raw.starts_with(BINARY_MAGIC),
"converted file should start with binary magic"
);
}
#[test]
fn test_convert_binary_to_json_format() {
let temp = NamedTempFile::new().expect("create temp file");
let file = sample_large_file();
save_autorun_file(temp.path(), &file, AutorunFormat::Binary, None).expect("save binary");
assert!(
std::fs::read(temp.path())
.unwrap()
.starts_with(BINARY_MAGIC),
"should start as binary"
);
convert_autorun_file(temp.path(), AutorunFormat::Json, None).expect("convert to json");
let raw = std::fs::read(temp.path()).expect("read converted file");
assert!(
!raw.starts_with(BINARY_MAGIC),
"converted file should no longer have binary magic"
);
assert_eq!(raw[0], b'{', "converted file should start with JSON object");
}
}