Skip to main content

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}