use crate::autorun::{
AUTORUN_VERSION, AutorunCheckpoint, AutorunFile, AutorunFrame, CHECKPOINT_INTERVAL_FRAMES,
};
use crate::console::AutorunMode;
pub struct WasmAutorunState {
autorun: AutorunFile,
frame_index: usize,
extending_playback: bool,
mode: AutorunMode,
playback_checkpoint_idx: usize,
crc_mismatches: usize,
total_checkpoints_verified: usize,
current_frame_prerecorded: bool,
}
impl WasmAutorunState {
pub fn new_recording() -> Self {
WasmAutorunState {
autorun: AutorunFile {
version: AUTORUN_VERSION,
frames: Vec::new(),
checkpoints: Vec::new(),
},
frame_index: 0,
extending_playback: false,
mode: AutorunMode::Record,
playback_checkpoint_idx: 0,
crc_mismatches: 0,
total_checkpoints_verified: 0,
current_frame_prerecorded: false,
}
}
pub fn new_playback(
bytes: &[u8],
checkpoint_idx: Option<u32>,
extend: bool,
) -> Result<(Self, Option<Vec<u8>>), String> {
let autorun = parse_autorun_bytes(bytes)?;
if extend {
return Ok(Self::new_extend(autorun));
}
let (frame_index, playback_checkpoint_idx, pending) = if let Some(cp_idx) = checkpoint_idx {
let cp_idx = cp_idx as usize;
let cp = autorun.checkpoints.get(cp_idx).ok_or_else(|| {
format!(
"Checkpoint index {cp_idx} out of range (recording has {} checkpoints)",
autorun.checkpoints.len()
)
})?;
let pending = cp.state_bytes.clone();
(cp.frame_index as usize + 1, cp_idx + 1, Some(pending))
} else {
(0, 0, None)
};
Ok((
WasmAutorunState {
autorun,
frame_index,
extending_playback: false,
mode: AutorunMode::Playback,
playback_checkpoint_idx,
crc_mismatches: 0,
total_checkpoints_verified: 0,
current_frame_prerecorded: false,
},
pending,
))
}
fn new_extend(autorun: AutorunFile) -> (Self, Option<Vec<u8>>) {
let n = autorun.checkpoints.len();
let (frame_index, playback_checkpoint_idx, pending, extending_playback) = if n >= 2 {
let second_to_last = &autorun.checkpoints[n - 2];
let pending = second_to_last.state_bytes.clone();
(
second_to_last.frame_index as usize + 1,
n - 1,
Some(pending),
true,
)
} else if n == 1 {
let cp = &autorun.checkpoints[0];
let pending = cp.state_bytes.clone();
(cp.frame_index as usize + 1, 1, Some(pending), false)
} else {
(0, 0, None, false)
};
(
WasmAutorunState {
autorun,
frame_index,
extending_playback,
mode: AutorunMode::Record,
playback_checkpoint_idx,
crc_mismatches: 0,
total_checkpoints_verified: 0,
current_frame_prerecorded: false,
},
pending,
)
}
pub fn begin_frame(&mut self) {
self.current_frame_prerecorded = false;
}
pub fn current_frame_was_prerecorded(&self) -> bool {
self.current_frame_prerecorded
}
pub fn next_playback_frame(&mut self) -> Option<AutorunFrame> {
if self.frame_index < self.autorun.frames.len() {
let frame = self.autorun.frames[self.frame_index].clone();
self.frame_index += 1;
self.current_frame_prerecorded = true;
Some(frame)
} else {
None
}
}
pub fn record_frame(&mut self, player1: u8, player2: u8) -> bool {
self.autorun.frames.push(AutorunFrame { player1, player2 });
self.frame_index += 1;
(self.frame_index as u32).is_multiple_of(CHECKPOINT_INTERVAL_FRAMES)
}
pub fn record_checkpoint(&mut self, screen_crc: u32, state_bytes: Vec<u8>) {
self.autorun.checkpoints.push(AutorunCheckpoint {
frame_index: (self.frame_index - 1) as u32,
screen_crc,
state_bytes,
});
}
pub fn finalize_recording(&mut self, screen_crc: u32, state_bytes: Vec<u8>) -> Vec<u8> {
self.autorun.checkpoints.push(AutorunCheckpoint {
frame_index: self.frame_index.saturating_sub(1) as u32,
screen_crc,
state_bytes,
});
serde_json::to_vec_pretty(&self.autorun).unwrap_or_default()
}
pub fn check_playback_checkpoint(&mut self, screen_crc: u32) -> Option<bool> {
let current = (self.frame_index - 1) as u32;
let mut matched = None;
while self.playback_checkpoint_idx < self.autorun.checkpoints.len()
&& self.autorun.checkpoints[self.playback_checkpoint_idx].frame_index == current
{
let expected = self.autorun.checkpoints[self.playback_checkpoint_idx].screen_crc;
let ok = screen_crc == expected;
if !ok {
self.crc_mismatches += 1;
}
self.total_checkpoints_verified += 1;
self.playback_checkpoint_idx += 1;
matched = Some(ok);
}
matched
}
pub fn is_extending_playback(&self) -> bool {
self.extending_playback && self.frame_index < self.autorun.frames.len()
}
pub fn is_recording(&self) -> bool {
self.mode == AutorunMode::Record
}
pub fn is_playback(&self) -> bool {
self.mode == AutorunMode::Playback
}
pub fn is_playback_finished(&self) -> bool {
self.mode == AutorunMode::Playback && self.frame_index >= self.autorun.frames.len()
}
pub fn crc_mismatches(&self) -> usize {
self.crc_mismatches
}
pub fn total_checkpoints_verified(&self) -> usize {
self.total_checkpoints_verified
}
pub fn current_frame_index(&self) -> usize {
self.frame_index
}
pub fn total_frames(&self) -> usize {
self.autorun.frames.len()
}
}
fn parse_autorun_bytes(bytes: &[u8]) -> Result<AutorunFile, String> {
let file: AutorunFile =
serde_json::from_slice(bytes).map_err(|e| format!("Failed to parse autorun file: {e}"))?;
if file.version != AUTORUN_VERSION {
return Err(format!("Unsupported autorun version: {}", file.version));
}
Ok(file)
}
#[cfg(test)]
mod tests {
use super::*;
fn make_autorun_bytes(num_frames: usize, num_checkpoints: usize) -> Vec<u8> {
let checkpoints: Vec<AutorunCheckpoint> = (0..num_checkpoints)
.map(|i| {
let frame = if num_checkpoints > 0 {
((i + 1) * num_frames / num_checkpoints).saturating_sub(1) as u32
} else {
0
};
AutorunCheckpoint {
frame_index: frame,
screen_crc: (i as u32 + 1) * 0x1111,
state_bytes: format!("state{i}").into_bytes(),
}
})
.collect();
let file = AutorunFile {
version: AUTORUN_VERSION,
frames: (0..num_frames)
.map(|i| AutorunFrame {
player1: (i % 256) as u8,
player2: 0,
})
.collect(),
checkpoints,
};
serde_json::to_vec(&file).expect("serialize")
}
#[test]
fn new_recording_starts_with_no_frames() {
let state = WasmAutorunState::new_recording();
assert_eq!(state.total_frames(), 0);
}
#[test]
fn new_recording_is_in_record_mode() {
let state = WasmAutorunState::new_recording();
assert!(state.is_recording());
assert!(!state.is_playback());
}
#[test]
fn new_recording_frame_index_is_zero() {
let state = WasmAutorunState::new_recording();
assert_eq!(state.current_frame_index(), 0);
}
#[test]
fn record_frame_returns_true_at_checkpoint_interval() {
let mut state = WasmAutorunState::new_recording();
for _ in 0..299 {
assert!(!state.record_frame(0, 0));
}
assert!(
state.record_frame(0, 0),
"frame 300 should trigger checkpoint"
);
}
#[test]
fn record_frame_increments_frame_index() {
let mut state = WasmAutorunState::new_recording();
state.record_frame(1, 2);
assert_eq!(state.current_frame_index(), 1);
}
#[test]
fn finalize_recording_returns_valid_bytes() {
let mut state = WasmAutorunState::new_recording();
state.record_frame(1, 0);
let bytes = state.finalize_recording(0xDEAD, b"end".to_vec());
let file: AutorunFile = serde_json::from_slice(&bytes).expect("bytes must be valid JSON");
assert_eq!(file.version, AUTORUN_VERSION);
assert_eq!(file.frames.len(), 1);
assert_eq!(file.checkpoints.last().unwrap().screen_crc, 0xDEAD);
}
#[test]
fn record_checkpoint_stores_crc_and_state() {
let mut state = WasmAutorunState::new_recording();
for _ in 0..300 {
state.record_frame(0, 0);
}
state.record_checkpoint(0xCAFE, b"s".to_vec());
assert_eq!(state.autorun.checkpoints.len(), 1);
assert_eq!(state.autorun.checkpoints[0].screen_crc, 0xCAFE);
}
#[test]
fn new_playback_from_start_has_no_pending_restore() {
let bytes = make_autorun_bytes(300, 1);
let (state, pending) =
WasmAutorunState::new_playback(&bytes, None, false).expect("create playback");
assert!(pending.is_none());
assert_eq!(state.current_frame_index(), 0);
}
#[test]
fn new_playback_is_in_playback_mode() {
let bytes = make_autorun_bytes(300, 1);
let (state, _) =
WasmAutorunState::new_playback(&bytes, None, false).expect("create playback");
assert!(state.is_playback());
assert!(!state.is_recording());
}
#[test]
fn new_playback_from_checkpoint_returns_pending_restore() {
let bytes = make_autorun_bytes(600, 2);
let (state, pending) = WasmAutorunState::new_playback(&bytes, Some(0), false).expect("ok");
assert!(pending.is_some());
let file: AutorunFile = serde_json::from_slice(&bytes).unwrap();
let expected_idx = file.checkpoints[0].frame_index as usize + 1;
assert_eq!(state.current_frame_index(), expected_idx);
}
#[test]
fn new_playback_invalid_bytes_returns_error() {
let result = WasmAutorunState::new_playback(b"not json", None, false);
assert!(result.is_err());
}
#[test]
fn new_playback_checkpoint_out_of_range_returns_error() {
let bytes = make_autorun_bytes(300, 1);
let result = WasmAutorunState::new_playback(&bytes, Some(99), false);
assert!(result.is_err());
}
#[test]
fn next_playback_frame_returns_recorded_input() {
let file = AutorunFile {
version: AUTORUN_VERSION,
frames: vec![AutorunFrame {
player1: 42,
player2: 7,
}],
checkpoints: vec![AutorunCheckpoint {
frame_index: 0,
screen_crc: 0,
state_bytes: vec![],
}],
};
let bytes = serde_json::to_vec(&file).unwrap();
let (mut state, _) = WasmAutorunState::new_playback(&bytes, None, false).unwrap();
state.begin_frame();
let frame = state.next_playback_frame();
assert!(frame.is_some());
let f = frame.unwrap();
assert_eq!(f.player1, 42);
assert_eq!(f.player2, 7);
}
#[test]
fn next_playback_frame_returns_none_when_exhausted() {
let bytes = make_autorun_bytes(1, 1);
let (mut state, _) = WasmAutorunState::new_playback(&bytes, None, false).unwrap();
state.begin_frame();
state.next_playback_frame(); state.begin_frame();
assert!(state.next_playback_frame().is_none());
}
#[test]
fn extend_mode_is_extending_playback_initially() {
let bytes = make_autorun_bytes(600, 2);
let (state, _) = WasmAutorunState::new_playback(&bytes, None, true).unwrap();
assert!(state.is_extending_playback());
assert!(state.is_recording()); }
#[test]
fn extend_mode_with_no_checkpoints_starts_fresh() {
let bytes = make_autorun_bytes(0, 0);
let (state, pending) = WasmAutorunState::new_playback(&bytes, None, true).unwrap();
assert!(pending.is_none());
assert!(!state.is_extending_playback());
assert_eq!(state.current_frame_index(), 0);
}
#[test]
fn check_playback_checkpoint_detects_mismatch() {
let file = AutorunFile {
version: AUTORUN_VERSION,
frames: vec![AutorunFrame {
player1: 0,
player2: 0,
}],
checkpoints: vec![AutorunCheckpoint {
frame_index: 0,
screen_crc: 0x1234,
state_bytes: vec![],
}],
};
let bytes = serde_json::to_vec(&file).unwrap();
let (mut state, _) = WasmAutorunState::new_playback(&bytes, None, false).unwrap();
state.begin_frame();
state.next_playback_frame();
let result = state.check_playback_checkpoint(0xBAD0);
assert_eq!(result, Some(false));
assert_eq!(state.crc_mismatches(), 1);
}
#[test]
fn check_playback_checkpoint_matching_crc_no_mismatch() {
let file = AutorunFile {
version: AUTORUN_VERSION,
frames: vec![AutorunFrame {
player1: 0,
player2: 0,
}],
checkpoints: vec![AutorunCheckpoint {
frame_index: 0,
screen_crc: 0x5678,
state_bytes: vec![],
}],
};
let bytes = serde_json::to_vec(&file).unwrap();
let (mut state, _) = WasmAutorunState::new_playback(&bytes, None, false).unwrap();
state.begin_frame();
state.next_playback_frame();
let result = state.check_playback_checkpoint(0x5678);
assert_eq!(result, Some(true));
assert_eq!(state.crc_mismatches(), 0);
assert_eq!(state.total_checkpoints_verified(), 1);
}
#[test]
fn playback_not_finished_before_frames_exhausted() {
let bytes = make_autorun_bytes(3, 0);
let (mut state, _) = WasmAutorunState::new_playback(&bytes, None, false).unwrap();
assert!(!state.is_playback_finished());
state.begin_frame();
state.next_playback_frame();
assert!(!state.is_playback_finished());
}
#[test]
fn playback_finished_when_all_frames_consumed() {
let bytes = make_autorun_bytes(2, 0);
let (mut state, _) = WasmAutorunState::new_playback(&bytes, None, false).unwrap();
state.begin_frame();
state.next_playback_frame();
state.begin_frame();
state.next_playback_frame();
assert!(state.is_playback_finished());
}
#[test]
fn playback_finished_is_false_for_recording_mode() {
let mut state = WasmAutorunState::new_recording();
state.begin_frame();
state.record_frame(0, 0);
state.begin_frame();
state.record_frame(0, 0);
assert!(!state.is_playback_finished());
}
#[test]
fn playback_finished_is_false_for_extend_mode() {
let bytes = make_autorun_bytes(2, 1);
let (mut state, _) = WasmAutorunState::new_playback(&bytes, None, true).unwrap();
state.begin_frame();
state.next_playback_frame();
state.begin_frame();
state.next_playback_frame();
assert!(!state.is_playback_finished());
}
}