rustysnes_core/movie.rs
1//! TAS movie record/playback — a deterministic input log plus a `System::save_state`-compatible
2//! start point.
3//!
4//! `docs/adr/0004`'s determinism contract: same seed + ROM + input ⇒ bit-identical
5//! framebuffer/audio. Ported from RustyNES's proven `rustynes-core::movie` shape (confirmed by
6//! reading its source directly), SNES-adapted: a single [`FrameInput`] per frame (the SNES
7//! auto-joypad latches one `u16` per controller, unlike the NES's separate button set), and the
8//! start point's seed is recorded explicitly since `System::new` takes a caller-chosen seed
9//! rather than always defaulting to one value.
10//!
11//! [`MovieRecorder`]/[`MoviePlayer`] are pure data + a capture/apply loop — no Lua/frontend
12//! coupling, and no `System`/`Bus` reach-around either (see [`MoviePlayer::next_frame`]'s doc for
13//! why). The frontend's per-frame drive calls [`MovieRecorder::capture`] (recording) or
14//! [`MoviePlayer::next_frame`] (playback) and feeds the result through whatever input
15//! abstraction it already uses, immediately before [`crate::System::run_frame`] — the same place
16//! it already applies live controller input today.
17//!
18//! # Format
19//!
20//! ```text
21//! HEADER:
22//! magic : "RSNESMOV" (8 bytes)
23//! format version : u16 LE (1 = MOVIE_FORMAT_VERSION)
24//! region : u8 (0 = NTSC, 1 = PAL)
25//! seed : u64 LE (the System::new seed this recording used)
26//! rom sha-256 : [u8; 32] (full hash — authoritative ROM identity, checked on replay)
27//! frame count : u32 LE
28//! START POINT:
29//! kind : u8 (0 = power-on, 1 = embedded save-state)
30//! [save-state] : u32 LE length-prefixed bytes (only when kind == 1)
31//! INPUT STREAM:
32//! frame_count * 4 bytes; each frame = p1 (u16 LE), p2 (u16 LE)
33//! ```
34
35use alloc::vec::Vec;
36
37use rustysnes_cart::Region;
38use rustysnes_savestate::{SaveReader, SaveStateError, SaveWriter};
39use sha2::{Digest as _, Sha256};
40
41use crate::scheduler::System;
42
43/// The movie envelope's leading magic bytes.
44const MAGIC: &[u8; 8] = b"RSNESMOV";
45/// The movie format's version. Bumped any time the header/frame layout changes in a way an
46/// older reader can't skip past.
47const MOVIE_FORMAT_VERSION: u16 = 1;
48
49/// One frame's worth of recorded controller input — the SNES auto-joypad's latched `u16` per
50/// port (the same value [`crate::bus::Bus::set_joypad`] takes).
51#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
52pub struct FrameInput {
53 /// Player 1's latched controller state.
54 pub p1: u16,
55 /// Player 2's latched controller state.
56 pub p2: u16,
57}
58
59/// Where a movie's replay begins.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub enum StartPoint {
62 /// Boot fresh from the cart's reset vector (`System::reset`). The caller is expected to have
63 /// already constructed `System::new(movie.seed)` with the movie's ROM installed and never
64 /// stepped it — [`Movie::seek_to_start`] calls `reset()` to boot it from there.
65 PowerOn,
66 /// Restore this embedded save-state blob (a branch point mid-recording), via
67 /// `System::load_state`.
68 SaveState(Vec<u8>),
69}
70
71/// A recorded TAS movie: a deterministic input log plus everything needed to reproduce the exact
72/// power-on/branch-point state it was recorded against.
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub struct Movie {
75 /// The `System::new` seed this recording used (power-on phase alignment,
76 /// `docs/adr/0004`) — irrelevant for a [`StartPoint::SaveState`] start (the blob already
77 /// carries its own seed), but always recorded for a uniform format.
78 pub seed: u64,
79 /// The cart's region at recording time.
80 pub region: Region,
81 /// SHA-256 of the exact ROM byte image recorded against — the authoritative "is this the
82 /// right ROM" check on replay, independent of the cart's internal parsed representation.
83 pub rom_sha256: [u8; 32],
84 /// Where replay begins.
85 pub start: StartPoint,
86 /// The recorded per-frame input log, oldest first.
87 pub frames: Vec<FrameInput>,
88}
89
90/// Errors from decoding or replaying a movie.
91#[derive(Debug, PartialEq, Eq, thiserror::Error)]
92pub enum MovieError {
93 /// The blob's leading magic bytes didn't match — not a RustySNES movie at all.
94 #[error("not a RustySNES movie (bad magic)")]
95 BadMagic,
96 /// The format version is newer than this build understands.
97 #[error("unsupported movie format version {found} (this build supports up to {max})")]
98 UnsupportedVersion {
99 /// The version found in the blob.
100 found: u16,
101 /// The newest version this build supports.
102 max: u16,
103 },
104 /// The start-point kind byte was neither 0 (power-on) nor 1 (save-state).
105 #[error("unrecognized movie start-point kind {0}")]
106 BadStartPointKind(u8),
107 /// `Movie::verify_rom` was called with bytes that don't hash to this movie's recorded
108 /// `rom_sha256` — replaying against the wrong ROM would not reproduce the recording.
109 #[error("ROM does not match this movie's recorded ROM (wrong ROM loaded)")]
110 RomMismatch,
111 /// [`Movie::seek_to_start`] was called for a [`StartPoint::PowerOn`] movie against a
112 /// `System` whose seed doesn't match the movie's recorded seed — replay would diverge from
113 /// the very first frame (different power-on phase alignment), not just eventually.
114 #[error("System seed {found} does not match this movie's recorded seed {expected}")]
115 SeedMismatch {
116 /// The seed the movie was recorded with.
117 expected: u64,
118 /// The seed the `System` passed to `seek_to_start` was actually constructed with.
119 found: u64,
120 },
121 /// The embedded save-state failed to decode/restore.
122 #[error("embedded save-state: {0}")]
123 SaveState(#[from] SaveStateError),
124 /// The buffer ended before the expected number of bytes were available.
125 #[error("truncated movie data")]
126 Truncated,
127}
128
129/// SHA-256 of `rom`, in the form [`Movie::rom_sha256`] / [`Movie::verify_rom`] compare against.
130#[must_use]
131pub fn hash_rom(rom: &[u8]) -> [u8; 32] {
132 let mut hasher = Sha256::new();
133 hasher.update(rom);
134 hasher.finalize().into()
135}
136
137impl Movie {
138 /// Verify `rom` is the exact byte image this movie was recorded against.
139 ///
140 /// # Errors
141 /// [`MovieError::RomMismatch`] if the hash doesn't match.
142 pub fn verify_rom(&self, rom: &[u8]) -> Result<(), MovieError> {
143 if hash_rom(rom) == self.rom_sha256 {
144 Ok(())
145 } else {
146 Err(MovieError::RomMismatch)
147 }
148 }
149
150 /// Put `sys` into this movie's recorded starting position, ready for
151 /// [`MoviePlayer::next_frame`] + [`System::run_frame`] to replay the input log.
152 ///
153 /// For [`StartPoint::PowerOn`], `sys` MUST already be a freshly-constructed
154 /// `System::new(self.seed)` with the movie's ROM installed and never yet stepped — this
155 /// verifies the seed matches (a mismatch cannot possibly replay identically) and calls
156 /// `System::reset()` to boot it. For [`StartPoint::SaveState`], this restores the embedded
157 /// blob via `System::load_state` (which carries its own seed).
158 ///
159 /// Callers should call [`Self::verify_rom`] separately before this — `sys`/`System` retain no
160 /// raw ROM bytes to hash against, so the ROM-identity check happens at the byte-image level
161 /// the caller already has (e.g. the frontend's retained ROM buffer).
162 ///
163 /// # Errors
164 /// [`MovieError::SeedMismatch`] if `sys`'s seed doesn't match a `PowerOn` movie's recorded
165 /// seed; [`MovieError::SaveState`] if an embedded save-state fails to decode/restore.
166 pub fn seek_to_start(&self, sys: &mut System) -> Result<(), MovieError> {
167 match &self.start {
168 StartPoint::PowerOn => {
169 if sys.seed() != self.seed {
170 return Err(MovieError::SeedMismatch {
171 expected: self.seed,
172 found: sys.seed(),
173 });
174 }
175 sys.reset();
176 Ok(())
177 }
178 StartPoint::SaveState(blob) => {
179 sys.load_state(blob)?;
180 Ok(())
181 }
182 }
183 }
184
185 /// Serialize this movie to its on-disk byte format (see the module doc for the layout).
186 ///
187 /// # Panics
188 /// Panics if `self.frames.len()` exceeds `u32::MAX` (over 2 years of continuous 60fps
189 /// recording) — a header count that couldn't round-trip would silently corrupt the file.
190 #[must_use]
191 pub fn serialize(&self) -> Vec<u8> {
192 let mut w = SaveWriter::new();
193 w.write_bytes(MAGIC);
194 w.write_u16(MOVIE_FORMAT_VERSION);
195 w.write_u8(match self.region {
196 Region::Ntsc => 0,
197 Region::Pal => 1,
198 });
199 w.write_u64(self.seed);
200 w.write_bytes(&self.rom_sha256);
201 w.write_u32(
202 u32::try_from(self.frames.len())
203 .expect("a recorded movie never exceeds u32::MAX frames (~2.27 years at 60fps)"),
204 );
205 match &self.start {
206 StartPoint::PowerOn => w.write_u8(0),
207 StartPoint::SaveState(blob) => {
208 w.write_u8(1);
209 w.write_len_prefixed(blob);
210 }
211 }
212 for f in &self.frames {
213 w.write_u16(f.p1);
214 w.write_u16(f.p2);
215 }
216 w.into_bytes()
217 }
218
219 /// The inverse of [`Self::serialize`].
220 ///
221 /// # Errors
222 /// [`MovieError::BadMagic`] if `bytes` doesn't lead with the expected magic;
223 /// [`MovieError::UnsupportedVersion`] if the format version is newer than this build
224 /// understands; [`MovieError::BadStartPointKind`] on a corrupt start-point tag;
225 /// [`MovieError::Truncated`] on truncated/corrupt input.
226 pub fn deserialize(bytes: &[u8]) -> Result<Self, MovieError> {
227 let mut r = SaveReader::new(bytes);
228 if r.read_bytes(8).map_err(|_| MovieError::Truncated)? != MAGIC {
229 return Err(MovieError::BadMagic);
230 }
231 let version = r.read_u16().map_err(|_| MovieError::Truncated)?;
232 if version > MOVIE_FORMAT_VERSION {
233 return Err(MovieError::UnsupportedVersion {
234 found: version,
235 max: MOVIE_FORMAT_VERSION,
236 });
237 }
238 let region = match r.read_u8().map_err(|_| MovieError::Truncated)? {
239 1 => Region::Pal,
240 _ => Region::Ntsc,
241 };
242 let seed = r.read_u64().map_err(|_| MovieError::Truncated)?;
243 let rom_sha256: [u8; 32] = r
244 .read_bytes(32)
245 .map_err(|_| MovieError::Truncated)?
246 .try_into()
247 .unwrap_or([0; 32]);
248 let frame_count = r.read_u32().map_err(|_| MovieError::Truncated)? as usize;
249 let start = match r.read_u8().map_err(|_| MovieError::Truncated)? {
250 0 => StartPoint::PowerOn,
251 1 => {
252 let blob = r.read_len_prefixed().map_err(|_| MovieError::Truncated)?;
253 StartPoint::SaveState(blob.to_vec())
254 }
255 other => return Err(MovieError::BadStartPointKind(other)),
256 };
257 // Deliberately NOT `Vec::with_capacity(frame_count)`: `frame_count` is an untrusted u32
258 // straight from the file header, and a crafted/truncated file could claim up to ~4
259 // billion frames, demanding a huge up-front allocation before a single frame is actually
260 // read. Growing organically means allocation tracks real, successfully-read data — a
261 // truncated file bails via `MovieError::Truncated` on its first missing frame instead.
262 let mut frames = Vec::new();
263 for _ in 0..frame_count {
264 let p1 = r.read_u16().map_err(|_| MovieError::Truncated)?;
265 let p2 = r.read_u16().map_err(|_| MovieError::Truncated)?;
266 frames.push(FrameInput { p1, p2 });
267 }
268 Ok(Self {
269 seed,
270 region,
271 rom_sha256,
272 start,
273 frames,
274 })
275 }
276}
277
278/// Records a movie frame-by-frame as the emulator runs.
279#[derive(Debug)]
280pub struct MovieRecorder {
281 movie: Movie,
282}
283
284impl MovieRecorder {
285 /// Start recording from a power-on. `rom` is the exact byte image that was (or is about to
286 /// be) loaded — hashed for the recorded movie's ROM-identity check on replay.
287 #[must_use]
288 pub fn power_on(seed: u64, region: Region, rom: &[u8]) -> Self {
289 Self {
290 movie: Movie {
291 seed,
292 region,
293 rom_sha256: hash_rom(rom),
294 start: StartPoint::PowerOn,
295 frames: Vec::new(),
296 },
297 }
298 }
299
300 /// Start recording from `sys`'s current live state (a branch point mid-session) — embeds a
301 /// full save-state as the start point. `rom` is the exact byte image `sys`'s cart was loaded
302 /// from.
303 #[must_use]
304 pub fn from_current_state(region: Region, rom: &[u8], sys: &System) -> Self {
305 Self {
306 movie: Movie {
307 seed: sys.seed(),
308 region,
309 rom_sha256: hash_rom(rom),
310 start: StartPoint::SaveState(sys.save_state()),
311 frames: Vec::new(),
312 },
313 }
314 }
315
316 /// Capture this frame's about-to-be-consumed input. Call BEFORE [`System::run_frame`] —
317 /// this records exactly what that call will consume, matching [`MoviePlayer::next_frame`]'s
318 /// own "apply, then run" order on replay.
319 pub fn capture(&mut self, p1: u16, p2: u16) {
320 self.movie.frames.push(FrameInput { p1, p2 });
321 }
322
323 /// The number of frames captured so far.
324 #[must_use]
325 pub const fn frame_count(&self) -> usize {
326 self.movie.frames.len()
327 }
328
329 /// Consume the recorder, returning the finished [`Movie`].
330 #[must_use]
331 pub fn finish(self) -> Movie {
332 self.movie
333 }
334}
335
336/// Replays a recorded [`Movie`]'s input log against a `System` already positioned at its start
337/// point (via [`Movie::seek_to_start`]).
338///
339/// Owns the [`Movie`] (rather than borrowing it) specifically so a long-lived host — the
340/// frontend's per-frame drive, holding a player across many real frames — can store one without
341/// a self-referential lifetime; [`Self::movie`] hands it back if the caller needs it afterward
342/// (e.g. to re-verify the ROM hash, or to inspect how many frames were recorded).
343#[derive(Debug)]
344pub struct MoviePlayer {
345 movie: Movie,
346 index: usize,
347}
348
349impl MoviePlayer {
350 /// A player starting at the first recorded frame.
351 #[must_use]
352 pub const fn new(movie: Movie) -> Self {
353 Self { movie, index: 0 }
354 }
355
356 /// The movie being played back.
357 #[must_use]
358 pub const fn movie(&self) -> &Movie {
359 &self.movie
360 }
361
362 /// Advance the playback cursor and return the next recorded frame's input, or `None` if the
363 /// movie is exhausted (the caller should stop).
364 ///
365 /// Deliberately does NOT touch a `System`/`Bus` itself (unlike an earlier design) — a host
366 /// that drives input through its own abstraction (e.g. `rustysnes-frontend`'s `EmuCore::
367 /// set_pad`, which `EmuCore::run_frame` re-applies from its OWN retained pad state every
368 /// call) needs to feed the returned [`FrameInput`] through THAT abstraction, not have this
369 /// reach around it and write `Bus::set_joypad` directly — the two would race for who "wins"
370 /// depending on call order. A caller working with a bare `System` directly (as the
371 /// determinism-replay test does) can just call `sys.bus.set_joypad(0, f.p1)` /
372 /// `set_joypad(1, f.p2)` itself with the returned value.
373 pub fn next_frame(&mut self) -> Option<FrameInput> {
374 let f = *self.movie.frames.get(self.index)?;
375 self.index += 1;
376 Some(f)
377 }
378
379 /// Frames not yet applied.
380 #[must_use]
381 pub const fn frames_remaining(&self) -> usize {
382 self.movie.frames.len() - self.index
383 }
384
385 /// Whether every recorded frame has been applied.
386 #[must_use]
387 pub const fn is_finished(&self) -> bool {
388 self.index >= self.movie.frames.len()
389 }
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395
396 fn tiny_movie() -> Movie {
397 Movie {
398 seed: 42,
399 region: Region::Ntsc,
400 rom_sha256: hash_rom(b"fake rom bytes"),
401 start: StartPoint::PowerOn,
402 frames: alloc::vec![
403 FrameInput { p1: 0x8000, p2: 0 },
404 FrameInput {
405 p1: 0x0000,
406 p2: 0x4000
407 },
408 FrameInput {
409 p1: 0xFFFF,
410 p2: 0xFFFF
411 },
412 ],
413 }
414 }
415
416 #[test]
417 fn format_round_trip_power_on() {
418 let movie = tiny_movie();
419 let bytes = movie.serialize();
420 let decoded = Movie::deserialize(&bytes).expect("round-trips");
421 assert_eq!(decoded, movie);
422 }
423
424 #[test]
425 fn format_round_trip_with_save_state_start() {
426 let mut movie = tiny_movie();
427 movie.start = StartPoint::SaveState(alloc::vec![1, 2, 3, 4, 5]);
428 let bytes = movie.serialize();
429 let decoded = Movie::deserialize(&bytes).expect("round-trips");
430 assert_eq!(decoded, movie);
431 }
432
433 #[test]
434 fn deserialize_rejects_bad_magic_cleanly() {
435 let bytes = alloc::vec![0u8; 64];
436 assert_eq!(Movie::deserialize(&bytes), Err(MovieError::BadMagic));
437 }
438
439 #[test]
440 fn deserialize_rejects_truncated_data_cleanly() {
441 let movie = tiny_movie();
442 let bytes = movie.serialize();
443 // Chop off the last frame's worth of bytes.
444 let truncated = &bytes[..bytes.len() - 2];
445 assert_eq!(Movie::deserialize(truncated), Err(MovieError::Truncated));
446 }
447
448 #[test]
449 fn deserialize_rejects_a_forged_huge_frame_count_without_oom() {
450 // A crafted header claims ~4 billion frames but the file actually has none — this must
451 // fail via `Truncated` on the first missing frame, not attempt a
452 // `Vec::with_capacity(u32::MAX)` allocation up front (a real DoS vector for untrusted
453 // movie files).
454 let movie = tiny_movie();
455 let mut bytes = movie.serialize();
456 // The frame-count u32 sits right after magic(8) + version(2) + region(1) + seed(8) +
457 // rom_sha256(32).
458 let frame_count_offset = 8 + 2 + 1 + 8 + 32;
459 bytes[frame_count_offset..frame_count_offset + 4].copy_from_slice(&u32::MAX.to_le_bytes());
460 // Truncate right after the start-point byte, before any frame data.
461 let start_point_offset = frame_count_offset + 4;
462 let truncated = &bytes[..=start_point_offset];
463 assert_eq!(Movie::deserialize(truncated), Err(MovieError::Truncated));
464 }
465
466 #[test]
467 fn verify_rom_accepts_matching_and_rejects_different_bytes() {
468 let movie = tiny_movie();
469 assert!(movie.verify_rom(b"fake rom bytes").is_ok());
470 assert_eq!(
471 movie.verify_rom(b"a different rom"),
472 Err(MovieError::RomMismatch)
473 );
474 }
475
476 #[test]
477 fn recorder_and_player_round_trip_the_same_inputs() {
478 let mut rec = MovieRecorder::power_on(7, Region::Ntsc, b"rom");
479 let inputs = [(0x8000u16, 0u16), (0, 0x4000), (0xFFFF, 0xFFFF)];
480 for &(p1, p2) in &inputs {
481 rec.capture(p1, p2);
482 }
483 assert_eq!(rec.frame_count(), 3);
484 let movie = rec.finish();
485
486 let mut player = MoviePlayer::new(movie);
487 let mut sys = System::new(7);
488 let mut seen = alloc::vec::Vec::new();
489 while let Some(f) = player.next_frame() {
490 sys.bus.set_joypad(0, f.p1);
491 sys.bus.set_joypad(1, f.p2);
492 seen.push((sys.bus.joypad(0), sys.bus.joypad(1)));
493 }
494 assert!(player.is_finished());
495 assert_eq!(player.frames_remaining(), 0);
496 assert_eq!(seen.as_slice(), inputs.as_slice());
497 }
498
499 #[test]
500 fn seek_to_start_power_on_rejects_seed_mismatch() {
501 let movie = tiny_movie(); // seed 42
502 let mut sys = System::new(43); // wrong seed
503 assert_eq!(
504 movie.seek_to_start(&mut sys),
505 Err(MovieError::SeedMismatch {
506 expected: 42,
507 found: 43,
508 })
509 );
510 }
511
512 #[test]
513 fn seek_to_start_power_on_boots_with_matching_seed() {
514 let movie = tiny_movie(); // seed 42
515 let mut sys = System::new(42);
516 assert!(movie.seek_to_start(&mut sys).is_ok());
517 }
518}