Skip to main content

rustynes_core/
movie.rs

1//! TAS movie (`.rnm`) recording and playback.
2//!
3//! A movie is a *reproducible start point* plus the *per-frame input stream*
4//! applied on top of it. Because the core honours the hard determinism
5//! contract (same seed + ROM + input sequence ⇒ bit-identical framebuffer
6//! and audio — see `CLAUDE.md`), replaying the recorded inputs from the
7//! recorded start point re-derives every pixel and sample bit-for-bit. No
8//! state deltas or frame hashes are stored.
9//!
10//! See `docs/adr/0008-tas-movie-format.md` for the format spec, the
11//! structural references (Mesen2 `.mmo`, FCEUX `.fm2`, `TetaNES` `.replay`),
12//! and the forward-compatibility story (layered on ADR 0003).
13//!
14//! # On-wire layout
15//!
16//! ```text
17//! HEADER:
18//!     magic           : "RNESMOV1"   (8 bytes)
19//!     format version  : u16 LE        (currently 1 = MOVIE_FORMAT_VERSION)
20//!     region          : u8            (0 = NTSC, 1 = PAL, 2 = Dendy)
21//!     flags           : u8            (bit0 = embedded save-state start point)
22//!     rom sha-256     : [u8; 32]      (full hash — authoritative ROM identity)
23//!     frame count     : u32 LE
24//!     bytes per frame : u8            (currently 3: P1, P2, expansion-reserved)
25//! START POINT (only when flags bit0 set):
26//!     length-prefixed `.rns` save-state blob (u32 LE length + bytes)
27//! INPUT STREAM:
28//!     frame_count * bytes_per_frame raw bytes; each frame = [p1, p2, expansion]
29//! ```
30//!
31//! This module is `no_std`-clean: it uses only `core` + `alloc` and the
32//! `BinWriter` / `BinReader` primitives from [`crate::save_state`].
33
34use alloc::vec::Vec;
35
36use crate::Region;
37use crate::controller::Buttons;
38use crate::nes::Nes;
39use crate::save_state::{BinReader, BinWriter, SnapshotError};
40use thiserror::Error;
41
42/// Magic header bytes — first 8 bytes of every `.rnm` movie file.
43pub const MOVIE_MAGIC: &[u8; 8] = b"RNESMOV1";
44
45/// Current movie container-format version.
46///
47/// - v1 (v1.1.0 ..): the format documented above.
48/// - **v2 (v2.0.0 "Timebase" rc.1, ADR 0028)**: on-wire layout unchanged —
49///   this is purely an epoch marker. A `.rnm` with `format_version < 2` was
50///   necessarily recorded on a pre-promote (pre-beta.4) build; per the
51///   determinism contract, its INPUT STREAM still replays fine (nothing
52///   about frame timing or button semantics changed), but the
53///   frame-for-frame bit-identical reproduction guarantee the movie format
54///   depends on is only proven within a single engine timebase — the
55///   one-clock promote changed how master-clock/PPU/CPU phase advances
56///   internally, so a v1-recorded movie's *exact* framebuffer/audio replay
57///   on the v2.0.0-line engine is unverified, not guaranteed. Do NOT
58///   attempt timeline transcoding (re-deriving a v2-native recording from
59///   a v1 one) — that is out of scope; the honest move is surfacing the
60///   epoch, not silently promising equivalence. See
61///   [`recorded_before_v2_timebase`] for the check callers (TAS tooling,
62///   frontend movie-load UI) should use before relying on verify-replay.
63pub const MOVIE_FORMAT_VERSION: u16 = 2;
64
65/// Peek a `.rnm` blob's header to learn its recording epoch.
66///
67/// Checks whether it was recorded on a pre-v2.0.0-timebase build
68/// (`format_version < 2`), WITHOUT fully parsing the movie. Intended for
69/// tooling/UI that wants to warn before relying on the determinism
70/// (verify-replay) guarantee across the v2.0.0 engine-timebase boundary —
71/// see [`MOVIE_FORMAT_VERSION`]'s v2 doc.
72///
73/// Playback itself is unaffected: [`Movie::deserialize`] still accepts and
74/// plays any `format_version <= MOVIE_FORMAT_VERSION` movie as pure input
75/// replay; this function exists only to let a caller decide whether to
76/// additionally warn that the bit-identical guarantee is unverified for a
77/// movie recorded across the boundary.
78///
79/// # Errors
80///
81/// Returns [`MovieError::HeaderTruncated`] or [`MovieError::BadMagic`] if
82/// the blob doesn't even have a valid movie header.
83pub fn recorded_before_v2_timebase(bytes: &[u8]) -> Result<bool, MovieError> {
84    const MIN_LEN: usize = 8 + 2;
85    if bytes.len() < MIN_LEN {
86        return Err(MovieError::HeaderTruncated {
87            expected: MIN_LEN,
88            got: bytes.len(),
89        });
90    }
91    let mut magic = [0u8; 8];
92    magic.copy_from_slice(&bytes[..8]);
93    if &magic != MOVIE_MAGIC {
94        return Err(MovieError::BadMagic { got: magic });
95    }
96    let format_version = u16::from_le_bytes([bytes[8], bytes[9]]);
97    Ok(format_version < 2)
98}
99
100/// Bytes stored per recorded frame: player 1, player 2, and a reserved
101/// expansion-port byte (always `0` in v1).
102///
103/// Stored explicitly in the header so a future device byte can grow the
104/// record without a container-version bump.
105pub const BYTES_PER_FRAME: u8 = 3;
106
107/// Header flag: an embedded `.rns` save-state start point follows the header.
108const FLAG_HAS_SAVE_STATE: u8 = 0x01;
109
110/// Per-frame controller input: the `Buttons` bits for both standard ports
111/// plus a reserved expansion byte. Bit layout matches FCEUX `.fm2`
112/// (`bit0=A .. bit7=Right`), which is exactly [`Buttons::bits`].
113#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
114pub struct FrameInput {
115    /// Player 1 (`$4016`) button state.
116    pub p1: Buttons,
117    /// Player 2 (`$4017`) button state.
118    pub p2: Buttons,
119    /// Reserved expansion-port byte (currently always `0`).
120    pub expansion: u8,
121}
122
123impl FrameInput {
124    /// Build a two-controller frame with no expansion byte.
125    #[must_use]
126    pub const fn new(p1: Buttons, p2: Buttons) -> Self {
127        Self {
128            p1,
129            p2,
130            expansion: 0,
131        }
132    }
133}
134
135/// Where a movie begins. Clean-room analogue of Mesen2's `RecordMovieFrom`.
136#[derive(Clone, Debug, Eq, PartialEq)]
137pub enum StartPoint {
138    /// Power-on the ROM fresh, then apply inputs from frame 0. The most
139    /// durable start point across version transitions (depends only on the
140    /// ROM and the deterministic power-on).
141    PowerOn,
142    /// Restore this embedded `.rns` snapshot, then apply inputs from there.
143    /// Enables save-state branching (a movie that begins mid-game).
144    SaveState(Vec<u8>),
145}
146
147/// Errors produced by movie encode / decode / playback.
148#[derive(Debug, Error)]
149#[non_exhaustive]
150pub enum MovieError {
151    /// The blob is shorter than the fixed header.
152    #[error("movie truncated: header needs {expected} bytes, got {got}")]
153    HeaderTruncated {
154        /// Expected byte count.
155        expected: usize,
156        /// Actual byte count.
157        got: usize,
158    },
159
160    /// The magic prefix is wrong.
161    #[error("movie magic mismatch: expected {:?}, got {got:?}", MOVIE_MAGIC)]
162    BadMagic {
163        /// Bytes observed at the magic offset.
164        got: [u8; 8],
165    },
166
167    /// The container format version is outside the range we understand.
168    #[error("movie container format version {got} not supported (max {max})")]
169    UnsupportedFormat {
170        /// Version we read.
171        got: u16,
172        /// Highest version we accept.
173        max: u16,
174    },
175
176    /// The header declared more bytes-per-frame than this build understands.
177    #[error("movie declares {got} bytes/frame; this build understands {max}")]
178    UnsupportedFrameWidth {
179        /// Declared width.
180        got: u8,
181        /// Width this build can parse.
182        max: u8,
183    },
184
185    /// The region byte is not a value this build understands.
186    #[error("movie region byte {0} is not a known region")]
187    BadRegion(u8),
188
189    /// The body (start point and/or input stream) ran past EOF.
190    #[error("movie truncated mid-body at offset {0}")]
191    Eof(usize),
192
193    /// The embedded start-point save state failed to apply.
194    #[error("movie start-point save state invalid: {0}")]
195    BadSaveState(#[from] SnapshotError),
196
197    /// The running ROM's hash does not match the movie's recorded hash.
198    #[error("movie ROM hash mismatch (this movie was recorded against a different ROM)")]
199    RomMismatch,
200}
201
202/// A complete TAS movie: a versioned header, a start point, and the
203/// per-frame input stream.
204#[derive(Clone, Debug, Eq, PartialEq)]
205pub struct Movie {
206    /// Cartridge region the movie was recorded under.
207    pub region: Region,
208    /// Full SHA-256 of the ROM the movie was recorded against.
209    pub rom_sha256: [u8; 32],
210    /// Where playback begins.
211    pub start: StartPoint,
212    /// Per-frame controller inputs, in playback order.
213    pub frames: Vec<FrameInput>,
214    /// TAS re-record count — how many times the author re-recorded a frame
215    /// (the TAS piano-roll editor's edit tally; 0 for a straight linear
216    /// recording). Round-trips through `.rnm` (appended after the input stream,
217    /// so older readers ignore it) and the `.fm2` / `.bk2` `rerecordCount` header.
218    pub rerecord_count: u32,
219}
220
221impl Movie {
222    /// Number of input frames in the movie.
223    #[must_use]
224    pub const fn len(&self) -> usize {
225        self.frames.len()
226    }
227
228    /// `true` if the movie has no input frames.
229    #[must_use]
230    pub const fn is_empty(&self) -> bool {
231        self.frames.is_empty()
232    }
233
234    /// Serialize the movie to its `.rnm` byte representation.
235    ///
236    /// Deterministic: the same `Movie` always produces identical bytes.
237    #[must_use]
238    pub fn serialize(&self) -> Vec<u8> {
239        let frame_count = u32::try_from(self.frames.len()).expect("frame count exceeds u32");
240        let body_hint = self.frames.len() * usize::from(BYTES_PER_FRAME);
241        let mut w = BinWriter::with_capacity(48 + body_hint);
242        w.bytes(MOVIE_MAGIC);
243        w.u16(MOVIE_FORMAT_VERSION);
244        w.u8(region_to_byte(self.region));
245        let flags = match &self.start {
246            StartPoint::PowerOn => 0,
247            StartPoint::SaveState(_) => FLAG_HAS_SAVE_STATE,
248        };
249        w.u8(flags);
250        w.bytes(&self.rom_sha256);
251        w.u32(frame_count);
252        w.u8(BYTES_PER_FRAME);
253        if let StartPoint::SaveState(blob) = &self.start {
254            w.lp_bytes(blob);
255        }
256        for f in &self.frames {
257            w.u8(f.p1.bits());
258            w.u8(f.p2.bits());
259            w.u8(f.expansion);
260        }
261        // Trailing re-record count (v1.8.9). Appended AFTER the fixed-count input
262        // stream so a reader that stops at `frame_count` records — including older
263        // builds — simply ignores it; deserialize below reads it when present and
264        // defaults to 0 otherwise. No format-version bump needed.
265        w.u32(self.rerecord_count);
266        w.into_vec()
267    }
268
269    /// Parse a `.rnm` movie from its byte representation.
270    ///
271    /// # Errors
272    ///
273    /// Returns [`MovieError`] for a bad magic, an unsupported container
274    /// version, an unknown region byte, a frame width this build can't
275    /// parse, or a truncated body. Never panics on malformed input.
276    pub fn deserialize(bytes: &[u8]) -> Result<Self, MovieError> {
277        // Fixed header: magic(8) + version(2) + region(1) + flags(1) +
278        // sha256(32) + frame_count(4) + bytes_per_frame(1) = 49 bytes.
279        const HEADER_LEN: usize = 8 + 2 + 1 + 1 + 32 + 4 + 1;
280        if bytes.len() < HEADER_LEN {
281            return Err(MovieError::HeaderTruncated {
282                expected: HEADER_LEN,
283                got: bytes.len(),
284            });
285        }
286        let mut r = BinReader::new(bytes);
287        // Magic.
288        let mut magic = [0u8; 8];
289        r.read_into(&mut magic).map_err(map_eof)?;
290        if &magic != MOVIE_MAGIC {
291            return Err(MovieError::BadMagic { got: magic });
292        }
293        // Version.
294        let format_version = r.u16().map_err(map_eof)?;
295        if format_version > MOVIE_FORMAT_VERSION {
296            return Err(MovieError::UnsupportedFormat {
297                got: format_version,
298                max: MOVIE_FORMAT_VERSION,
299            });
300        }
301        // Region + flags.
302        let region = region_from_byte(r.u8().map_err(map_eof)?)?;
303        let flags = r.u8().map_err(map_eof)?;
304        // ROM hash.
305        let mut rom_sha256 = [0u8; 32];
306        r.read_into(&mut rom_sha256).map_err(map_eof)?;
307        // Frame count + width.
308        let frame_count = r.u32().map_err(map_eof)? as usize;
309        let bytes_per_frame = r.u8().map_err(map_eof)?;
310        if bytes_per_frame > BYTES_PER_FRAME {
311            // A newer movie packs more device bytes than we understand; we
312            // fail cleanly rather than mis-parse (the reserved byte exists
313            // precisely so this stays a graceful error, not a corruption).
314            return Err(MovieError::UnsupportedFrameWidth {
315                got: bytes_per_frame,
316                max: BYTES_PER_FRAME,
317            });
318        }
319        // Start point.
320        let start = if flags & FLAG_HAS_SAVE_STATE != 0 {
321            let blob = r.lp_bytes().map_err(map_eof)?;
322            StartPoint::SaveState(blob.to_vec())
323        } else {
324            StartPoint::PowerOn
325        };
326        // Input stream: `frame_count` records of `bytes_per_frame` bytes.
327        let mut frames = Vec::with_capacity(frame_count);
328        let width = usize::from(bytes_per_frame);
329        for _ in 0..frame_count {
330            let rec = r.take(width).map_err(map_eof)?;
331            // rec[0] = p1, rec[1] = p2 (present whenever width >= 2, which it
332            // always is for v1's width of 3); rec[2] = expansion when width
333            // >= 3. Lower widths default the missing fields.
334            let p1 = Buttons::from_bits_truncate(rec.first().copied().unwrap_or(0));
335            let p2 = Buttons::from_bits_truncate(rec.get(1).copied().unwrap_or(0));
336            let expansion = rec.get(2).copied().unwrap_or(0);
337            frames.push(FrameInput { p1, p2, expansion });
338        }
339        // Optional trailing re-record count (v1.8.9). Absent in pre-v1.8.9 `.rnm`
340        // files, which stop exactly at the input stream — default to 0.
341        let rerecord_count = r.u32().unwrap_or(0);
342        Ok(Self {
343            region,
344            rom_sha256,
345            start,
346            frames,
347            rerecord_count,
348        })
349    }
350
351    /// Rewind a running emulator to this movie's start point, ready to replay
352    /// from frame 0.
353    ///
354    /// For [`StartPoint::PowerOn`] this power-cycles `nes`. For
355    /// [`StartPoint::SaveState`] it restores the embedded snapshot. In both
356    /// cases the ROM hash is checked against the movie's recorded hash.
357    ///
358    /// # Errors
359    ///
360    /// Returns [`MovieError::RomMismatch`] if `nes` is running a different
361    /// ROM, or [`MovieError::BadSaveState`] if the embedded snapshot is
362    /// malformed.
363    pub fn seek_to_start(&self, nes: &mut Nes) -> Result<(), MovieError> {
364        if nes.rom_sha256() != &self.rom_sha256 {
365            return Err(MovieError::RomMismatch);
366        }
367        match &self.start {
368            StartPoint::PowerOn => nes.power_cycle(),
369            StartPoint::SaveState(blob) => nes.restore(blob)?,
370        }
371        Ok(())
372    }
373}
374
375/// Records the per-frame input stream applied to an emulator.
376///
377/// Usage (caller-driven, mirrors the frontend's per-frame loop):
378///
379/// ```ignore
380/// let mut rec = MovieRecorder::power_on(&nes);
381/// loop {
382///     nes.set_buttons(0, p1);
383///     nes.set_buttons(1, p2);
384///     rec.capture(&nes); // BEFORE run_frame — captures the inputs it consumes
385///     nes.run_frame();
386/// }
387/// let movie = rec.finish();
388/// ```
389#[derive(Clone, Debug)]
390pub struct MovieRecorder {
391    region: Region,
392    rom_sha256: [u8; 32],
393    start: StartPoint,
394    frames: Vec<FrameInput>,
395}
396
397impl MovieRecorder {
398    /// Begin recording a movie that starts from a fresh power-on of the ROM
399    /// `nes` is running. The caller is responsible for power-cycling `nes`
400    /// before the first captured frame so the recording starts from the same
401    /// state a replay will reconstruct.
402    #[must_use]
403    pub const fn power_on(nes: &Nes) -> Self {
404        Self {
405            region: nes.region(),
406            rom_sha256: *nes.rom_sha256(),
407            start: StartPoint::PowerOn,
408            frames: Vec::new(),
409        }
410    }
411
412    /// Begin recording a movie that starts from `nes`'s *current* state (a
413    /// branch point). Captures a snapshot now and embeds it as the start
414    /// point; the input stream is recorded from here forward.
415    #[must_use]
416    pub fn from_current_state(nes: &Nes) -> Self {
417        Self {
418            region: nes.region(),
419            rom_sha256: *nes.rom_sha256(),
420            start: StartPoint::SaveState(nes.snapshot()),
421            frames: Vec::new(),
422        }
423    }
424
425    /// Record the controller inputs currently held on `nes`. Call this each
426    /// frame *before* [`Nes::run_frame`], after the frontend has applied its
427    /// `set_buttons` calls — this captures exactly the inputs the upcoming
428    /// frame consumes.
429    pub fn capture(&mut self, nes: &Nes) {
430        self.frames.push(FrameInput {
431            p1: nes.buttons(0),
432            p2: nes.buttons(1),
433            expansion: 0,
434        });
435    }
436
437    /// Record an explicit frame of input (for callers that drive input
438    /// programmatically rather than through `set_buttons`).
439    pub fn capture_input(&mut self, input: FrameInput) {
440        self.frames.push(input);
441    }
442
443    /// Number of frames captured so far.
444    #[must_use]
445    pub const fn len(&self) -> usize {
446        self.frames.len()
447    }
448
449    /// `true` if no frames have been captured.
450    #[must_use]
451    pub const fn is_empty(&self) -> bool {
452        self.frames.is_empty()
453    }
454
455    /// Finish recording and produce the [`Movie`].
456    #[must_use]
457    pub fn finish(self) -> Movie {
458        Movie {
459            region: self.region,
460            rom_sha256: self.rom_sha256,
461            start: self.start,
462            frames: self.frames,
463            // A linear recording has no re-records by construction; TAStudio
464            // sets a real count when it exports an edited movie.
465            rerecord_count: 0,
466        }
467    }
468}
469
470/// Plays a movie back, feeding its recorded inputs into an emulator one frame
471/// at a time.
472///
473/// Usage (caller-driven; the player applies `set_buttons`, the caller runs
474/// the frame):
475///
476/// ```ignore
477/// movie.seek_to_start(&mut nes)?;
478/// let mut player = MoviePlayer::new(&movie);
479/// while player.apply_next(&mut nes) {
480///     nes.run_frame();
481/// }
482/// ```
483#[derive(Clone, Debug)]
484pub struct MoviePlayer<'a> {
485    movie: &'a Movie,
486    cursor: usize,
487}
488
489impl<'a> MoviePlayer<'a> {
490    /// Create a player positioned at frame 0 of `movie`.
491    #[must_use]
492    pub const fn new(movie: &'a Movie) -> Self {
493        Self { movie, cursor: 0 }
494    }
495
496    /// Total frames in the movie.
497    #[must_use]
498    pub const fn len(&self) -> usize {
499        self.movie.frames.len()
500    }
501
502    /// `true` if the movie has no frames.
503    #[must_use]
504    pub const fn is_empty(&self) -> bool {
505        self.movie.frames.is_empty()
506    }
507
508    /// Index of the frame that [`Self::apply_next`] will apply next.
509    #[must_use]
510    pub const fn cursor(&self) -> usize {
511        self.cursor
512    }
513
514    /// `true` if every frame has been played.
515    #[must_use]
516    pub const fn is_finished(&self) -> bool {
517        self.cursor >= self.movie.frames.len()
518    }
519
520    /// Peek the next frame's input without advancing.
521    #[must_use]
522    pub fn peek(&self) -> Option<FrameInput> {
523        self.movie.frames.get(self.cursor).copied()
524    }
525
526    /// Apply the next frame's recorded input to `nes` via `set_buttons` and
527    /// advance the cursor. Returns `false` (without applying anything) once
528    /// the movie is exhausted — the caller stops its replay loop on `false`.
529    ///
530    /// Call this *before* [`Nes::run_frame`], mirroring the record-side
531    /// `capture` ordering, so the same inputs are applied to the same frame.
532    pub fn apply_next(&mut self, nes: &mut Nes) -> bool {
533        let Some(input) = self.movie.frames.get(self.cursor).copied() else {
534            return false;
535        };
536        nes.set_buttons(0, input.p1);
537        nes.set_buttons(1, input.p2);
538        self.cursor += 1;
539        true
540    }
541
542    /// Reset the cursor back to frame 0 (the caller is responsible for
543    /// re-seeking `nes` via [`Movie::seek_to_start`]).
544    pub const fn rewind(&mut self) {
545        self.cursor = 0;
546    }
547}
548
549const fn region_to_byte(region: Region) -> u8 {
550    match region {
551        Region::Ntsc => 0,
552        Region::Pal => 1,
553        Region::Dendy => 2,
554    }
555}
556
557const fn region_from_byte(b: u8) -> Result<Region, MovieError> {
558    match b {
559        0 => Ok(Region::Ntsc),
560        1 => Ok(Region::Pal),
561        2 => Ok(Region::Dendy),
562        other => Err(MovieError::BadRegion(other)),
563    }
564}
565
566/// Map a `SnapshotError::Eof`-style truncation reading the movie body into a
567/// movie-level [`MovieError::Eof`]. Other snapshot errors cannot arise from
568/// the `BinReader` calls in this module (they only read fixed primitives).
569fn map_eof(e: SnapshotError) -> MovieError {
570    match e {
571        SnapshotError::Eof(off) => MovieError::Eof(off),
572        other => MovieError::BadSaveState(other),
573    }
574}
575
576#[cfg(test)]
577mod tests {
578    use super::*;
579    use alloc::vec;
580
581    /// Minimal NROM ROM that runs an infinite loop (same shape as the
582    /// `nes.rs` test fixture). Deterministic boot, no input dependence in
583    /// the program itself — the movie machinery is what we exercise.
584    fn synth_nrom() -> Vec<u8> {
585        let mut bytes = Vec::new();
586        bytes.extend_from_slice(b"NES\x1A");
587        bytes.push(1); // 16 KiB PRG
588        bytes.push(1); // 8 KiB CHR
589        bytes.push(0);
590        bytes.push(0);
591        bytes.extend_from_slice(&[0u8; 8]);
592        let mut prg = vec![0u8; 16 * 1024];
593        prg[0] = 0x4C; // JMP $C000
594        prg[1] = 0x00;
595        prg[2] = 0xC0;
596        let len = prg.len();
597        prg[len - 4] = 0x00;
598        prg[len - 3] = 0xC0;
599        prg[len - 6] = 0x00;
600        prg[len - 5] = 0xC0;
601        prg[len - 2] = 0x00;
602        prg[len - 1] = 0xC0;
603        bytes.extend_from_slice(&prg);
604        bytes.extend_from_slice(&vec![0u8; 8 * 1024]);
605        bytes
606    }
607
608    fn fnv(bytes: &[u8]) -> u64 {
609        let mut h: u64 = 0xCBF2_9CE4_8422_2325;
610        for &b in bytes {
611            h ^= u64::from(b);
612            h = h.wrapping_mul(0x0000_0100_0000_01B3);
613        }
614        h
615    }
616
617    fn audio_fnv(samples: &[f32]) -> u64 {
618        let mut h: u64 = 0xCBF2_9CE4_8422_2325;
619        for s in samples {
620            for &b in &s.to_le_bytes() {
621                h ^= u64::from(b);
622                h = h.wrapping_mul(0x0000_0100_0000_01B3);
623            }
624        }
625        h
626    }
627
628    /// A fixed, varied synthetic input sequence (deterministic, no RNG).
629    fn synthetic_inputs(n: usize) -> Vec<FrameInput> {
630        (0..n)
631            .map(|i| {
632                let i = u8::try_from(i % 256).unwrap();
633                let p1 = Buttons::from_bits_truncate(i.wrapping_mul(37));
634                let p2 = Buttons::from_bits_truncate(i.wrapping_mul(101).rotate_left(3));
635                FrameInput::new(p1, p2)
636            })
637            .collect()
638    }
639
640    #[test]
641    fn format_round_trip_power_on() {
642        let inputs = synthetic_inputs(120);
643        let movie = Movie {
644            region: Region::Ntsc,
645            rom_sha256: [0xAB; 32],
646            start: StartPoint::PowerOn,
647            frames: inputs,
648            rerecord_count: 0,
649        };
650        let bytes = movie.serialize();
651        let back = Movie::deserialize(&bytes).expect("round-trip");
652        assert_eq!(movie, back);
653    }
654
655    #[test]
656    fn rerecord_count_round_trips_and_defaults_for_legacy_rnm() {
657        let movie = Movie {
658            region: Region::Ntsc,
659            rom_sha256: [0x5A; 32],
660            start: StartPoint::PowerOn,
661            frames: synthetic_inputs(10),
662            rerecord_count: 4242,
663        };
664        let bytes = movie.serialize();
665        // A full round-trip preserves the count.
666        assert_eq!(Movie::deserialize(&bytes).unwrap().rerecord_count, 4242);
667        // A pre-v1.8.9 `.rnm` ends exactly at the input stream (no trailing
668        // count). Dropping the appended u32 must still parse, defaulting the
669        // count to 0 rather than erroring — the back-compat contract.
670        let legacy = &bytes[..bytes.len() - 4];
671        let back = Movie::deserialize(legacy).expect("legacy .rnm still parses");
672        assert_eq!(back.rerecord_count, 0);
673        assert_eq!(back.frames.len(), 10);
674    }
675
676    #[test]
677    fn format_round_trip_with_save_state_start() {
678        let movie = Movie {
679            region: Region::Pal,
680            rom_sha256: [0x11; 32],
681            start: StartPoint::SaveState(vec![1, 2, 3, 4, 5, 6, 7, 8]),
682            frames: synthetic_inputs(8),
683            rerecord_count: 0,
684        };
685        let bytes = movie.serialize();
686        let back = Movie::deserialize(&bytes).expect("round-trip");
687        assert_eq!(movie, back);
688    }
689
690    #[test]
691    fn deserialize_rejects_bad_magic_cleanly() {
692        let mut bytes = vec![0u8; 49];
693        bytes[..8].copy_from_slice(b"NOTAMOVI");
694        assert!(matches!(
695            Movie::deserialize(&bytes),
696            Err(MovieError::BadMagic { .. })
697        ));
698    }
699
700    #[test]
701    fn deserialize_rejects_too_new_format_cleanly() {
702        let movie = Movie {
703            region: Region::Ntsc,
704            rom_sha256: [0; 32],
705            start: StartPoint::PowerOn,
706            frames: Vec::new(),
707            rerecord_count: 0,
708        };
709        let mut bytes = movie.serialize();
710        // Bump the format-version field (offset 8) past what we support.
711        bytes[8] = 0xFF;
712        bytes[9] = 0xFF;
713        assert!(matches!(
714            Movie::deserialize(&bytes),
715            Err(MovieError::UnsupportedFormat { .. })
716        ));
717    }
718
719    #[test]
720    fn deserialize_rejects_truncated_header() {
721        assert!(matches!(
722            Movie::deserialize(&[0u8; 10]),
723            Err(MovieError::HeaderTruncated { .. })
724        ));
725    }
726
727    #[test]
728    fn deserialize_rejects_truncated_input_stream() {
729        let movie = Movie {
730            region: Region::Ntsc,
731            rom_sha256: [0; 32],
732            start: StartPoint::PowerOn,
733            frames: synthetic_inputs(10),
734            rerecord_count: 0,
735        };
736        let bytes = movie.serialize();
737        // Lop off the last few input bytes — must error, not panic.
738        let truncated = &bytes[..bytes.len() - 5];
739        assert!(matches!(
740            Movie::deserialize(truncated),
741            Err(MovieError::Eof(_))
742        ));
743    }
744
745    /// Drive a ROM with a fixed input sequence, recording as we go; then
746    /// replay from the movie's start point and assert framebuffer + audio +
747    /// cycle count are byte-identical.
748    #[test]
749    fn determinism_round_trip_power_on() {
750        let rom = synth_nrom();
751        let inputs = synthetic_inputs(30);
752
753        // ----- Original run (recording). -----
754        let mut nes = Nes::from_rom(&rom).expect("boot");
755        nes.power_cycle(); // start point a replay will reconstruct
756        let mut rec = MovieRecorder::power_on(&nes);
757        let mut orig_fb = 0u64;
758        let mut orig_audio = Vec::new();
759        for f in &inputs {
760            nes.set_buttons(0, f.p1);
761            nes.set_buttons(1, f.p2);
762            rec.capture(&nes);
763            orig_fb = fnv(nes.run_frame());
764            orig_audio.extend(nes.drain_audio());
765        }
766        let orig_cycle = nes.cycle();
767        let orig_audio_hash = audio_fnv(&orig_audio);
768        let movie = rec.finish();
769        assert_eq!(movie.len(), inputs.len());
770
771        // ----- Replay from the movie's start point. -----
772        let mut replay = Nes::from_rom(&rom).expect("boot");
773        movie.seek_to_start(&mut replay).expect("seek");
774        let mut player = MoviePlayer::new(&movie);
775        let mut replay_fb = 0u64;
776        let mut replay_audio = Vec::new();
777        while player.apply_next(&mut replay) {
778            replay_fb = fnv(replay.run_frame());
779            replay_audio.extend(replay.drain_audio());
780        }
781
782        assert_eq!(orig_fb, replay_fb, "framebuffer must replay bit-identical");
783        assert_eq!(
784            orig_audio_hash,
785            audio_fnv(&replay_audio),
786            "audio must replay bit-identical"
787        );
788        assert_eq!(
789            orig_cycle,
790            replay.cycle(),
791            "cumulative cycle count must replay bit-identical"
792        );
793    }
794
795    /// Replaying the same movie twice must yield identical output (the movie
796    /// itself is internally deterministic).
797    #[test]
798    fn replay_is_internally_deterministic() {
799        let rom = synth_nrom();
800        let movie = Movie {
801            region: Region::Ntsc,
802            rom_sha256: *Nes::from_rom(&rom).unwrap().rom_sha256(),
803            start: StartPoint::PowerOn,
804            frames: synthetic_inputs(20),
805            rerecord_count: 0,
806        };
807
808        let run = |movie: &Movie| -> (u64, u64, u64) {
809            let mut nes = Nes::from_rom(&rom).unwrap();
810            movie.seek_to_start(&mut nes).unwrap();
811            let mut player = MoviePlayer::new(movie);
812            let mut fb = 0u64;
813            let mut audio = Vec::new();
814            while player.apply_next(&mut nes) {
815                fb = fnv(nes.run_frame());
816                audio.extend(nes.drain_audio());
817            }
818            (fb, audio_fnv(&audio), nes.cycle())
819        };
820
821        assert_eq!(run(&movie), run(&movie));
822    }
823
824    /// Save-state branch: run a base movie partway, snapshot, start a new
825    /// branch recorder from that snapshot, and assert the branch replay is
826    /// internally deterministic and reconstructs the branch start point.
827    #[test]
828    fn save_state_branch_round_trip() {
829        let rom = synth_nrom();
830
831        // Base run: advance some frames with a fixed input, then branch.
832        let base_inputs = synthetic_inputs(10);
833        let mut nes = Nes::from_rom(&rom).unwrap();
834        nes.power_cycle();
835        for f in &base_inputs {
836            nes.set_buttons(0, f.p1);
837            nes.set_buttons(1, f.p2);
838            nes.run_frame();
839        }
840        let branch_cycle = nes.cycle();
841        let branch_fb = fnv(nes.framebuffer());
842
843        // Start a branch recorder from the current state, record more frames.
844        let mut branch_rec = MovieRecorder::from_current_state(&nes);
845        let branch_inputs = synthetic_inputs(15);
846        for f in &branch_inputs {
847            nes.set_buttons(0, f.p1);
848            nes.set_buttons(1, f.p2);
849            branch_rec.capture(&nes);
850            nes.run_frame();
851        }
852        let branch_end_cycle = nes.cycle();
853        let branch_end_fb = fnv(nes.framebuffer());
854        let branch_movie = branch_rec.finish();
855        assert!(matches!(branch_movie.start, StartPoint::SaveState(_)));
856
857        // Replay the branch from its embedded snapshot.
858        let run_branch = || -> (u64, u64) {
859            let mut replay = Nes::from_rom(&rom).unwrap();
860            branch_movie.seek_to_start(&mut replay).unwrap();
861            // After seeking, we are back at the branch start point.
862            assert_eq!(replay.cycle(), branch_cycle, "branch start cycle");
863            assert_eq!(fnv(replay.framebuffer()), branch_fb, "branch start fb");
864            let mut player = MoviePlayer::new(&branch_movie);
865            let mut fb = 0u64;
866            while player.apply_next(&mut replay) {
867                fb = fnv(replay.run_frame());
868            }
869            (fb, replay.cycle())
870        };
871
872        let first = run_branch();
873        let second = run_branch();
874        assert_eq!(first, second, "branch replay internally deterministic");
875        // And it reconstructs the live branch end state bit-identically.
876        assert_eq!(first.0, branch_end_fb, "branch end fb matches live run");
877        assert_eq!(
878            first.1, branch_end_cycle,
879            "branch end cycle matches live run"
880        );
881
882        // Format round-trip survives the embedded save state.
883        let bytes = branch_movie.serialize();
884        let back = Movie::deserialize(&bytes).unwrap();
885        assert_eq!(branch_movie, back);
886    }
887
888    #[test]
889    fn seek_rejects_rom_mismatch() {
890        let rom = synth_nrom();
891        let movie = Movie {
892            region: Region::Ntsc,
893            rom_sha256: [0xFF; 32], // deliberately wrong
894            start: StartPoint::PowerOn,
895            frames: Vec::new(),
896            rerecord_count: 0,
897        };
898        let mut nes = Nes::from_rom(&rom).unwrap();
899        assert!(matches!(
900            movie.seek_to_start(&mut nes),
901            Err(MovieError::RomMismatch)
902        ));
903    }
904
905    #[test]
906    fn frame_input_bit_layout_matches_buttons() {
907        // The on-wire byte for a frame is exactly Buttons::bits() (FCEUX
908        // .fm2 layout). Verify the serialize path preserves it.
909        let movie = Movie {
910            region: Region::Ntsc,
911            rom_sha256: [0; 32],
912            start: StartPoint::PowerOn,
913            frames: vec![FrameInput::new(
914                Buttons::A | Buttons::RIGHT,
915                Buttons::B | Buttons::START,
916            )],
917            rerecord_count: 0,
918        };
919        let bytes = movie.serialize();
920        // Input stream begins right after the 49-byte fixed header (no
921        // save state).
922        let p1 = bytes[49];
923        let p2 = bytes[50];
924        assert_eq!(p1, (Buttons::A | Buttons::RIGHT).bits());
925        assert_eq!(p2, (Buttons::B | Buttons::START).bits());
926        assert_eq!(bytes[51], 0, "expansion byte reserved/zero");
927    }
928
929    #[test]
930    fn recorded_before_v2_timebase_flags_pre_promote_movies() {
931        // ADR 0028: a freshly-serialized movie carries the current
932        // MOVIE_FORMAT_VERSION (>= 2) and must NOT be flagged.
933        let movie = Movie {
934            region: Region::Ntsc,
935            rom_sha256: [0; 32],
936            start: StartPoint::PowerOn,
937            frames: vec![],
938            rerecord_count: 0,
939        };
940        let bytes = movie.serialize();
941        assert!(matches!(recorded_before_v2_timebase(&bytes), Ok(false)));
942
943        // A v1-tagged blob (format_version = 1, the only value that existed
944        // pre-v2.0.0) must be flagged, even though it still parses fine.
945        let mut v1_bytes = bytes;
946        v1_bytes[8..10].copy_from_slice(&1u16.to_le_bytes());
947        assert!(matches!(recorded_before_v2_timebase(&v1_bytes), Ok(true)));
948        assert!(
949            Movie::deserialize(&v1_bytes).is_ok(),
950            "a v1-tagged movie must still parse and play as input"
951        );
952
953        // Malformed input still surfaces the normal header errors.
954        assert!(matches!(
955            recorded_before_v2_timebase(&[0u8; 4]),
956            Err(MovieError::HeaderTruncated { .. })
957        ));
958        assert!(matches!(
959            recorded_before_v2_timebase(&[0xFFu8; 10]),
960            Err(MovieError::BadMagic { .. })
961        ));
962    }
963}