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/// Marker for the optional attestation tail: `"RNAT"` little-endian.
136///
137/// Read as a `u32` after the re-record count. A movie with no attestation simply
138/// ends there, so the marker is what distinguishes "no attestation recorded" from
139/// "attestation present" — absence is a fact, not a parse failure.
140pub const ATTESTATION_MAGIC: u32 = u32::from_le_bytes(*b"RNAT");
141
142/// Attestation tail schema version.
143pub const ATTESTATION_VERSION: u16 = 1;
144
145/// Frames between recorded checkpoint hashes.
146///
147/// The final hash alone would answer "did this run reproduce?"; the checkpoints
148/// answer "and if not, roughly where did it stop reproducing?", which is the
149/// difference between a verdict and a diagnosis. At 8 bytes per checkpoint a
150/// ten-minute run costs about 4.5 KiB.
151pub const ATTESTATION_CHECKPOINT_INTERVAL: u32 = 64;
152
153/// A rolling hash of a run's video output, and the checkpoints along the way.
154///
155/// # What is attested
156///
157/// Per frame, **the input applied and the framebuffer it produced**, folded into
158/// one rolling hash. Both halves are load-bearing:
159///
160/// - The framebuffer is the user-visible output the determinism contract
161///   promises is bit-identical for the same ROM, seed, and input sequence.
162/// - The input is folded in because output alone does not pin the input stream.
163///   A ROM that ignores the controller — a test ROM, an attract-mode demo, a
164///   cutscene — produces identical video no matter what buttons the movie
165///   claims were pressed, so an output-only hash would confirm a tampered input
166///   log as genuine. Found by exactly that: an end-to-end tamper test flipped a
167///   button bit in a movie for an input-ignoring ROM and the run still verified.
168///
169/// Together they attest the real claim: *these inputs, applied to this ROM,
170/// produced this output.*
171///
172/// Hashing the core snapshot instead would be strictly stronger at detecting
173/// divergence, and was rejected for one reason: the snapshot schema is versioned
174/// and bumps between releases (`PPU_SNAPSHOT_VERSION` has reached 8), so every
175/// schema bump would silently invalidate every previously-recorded attestation.
176/// A 256x240 RGBA framebuffer is stable for as long as the NES is the NES. An
177/// attestation is only worth recording if it can still be checked years later.
178///
179/// Audio is **not** covered: samples are drained by the host as they are
180/// produced, so the core cannot see a whole run's audio without the frontend
181/// cooperating. Saying so is better than implying coverage that is not there.
182#[derive(Clone, Debug, Eq, PartialEq)]
183pub struct Attestation {
184    /// Number of frames the attestation covers. Cross-checked against the input
185    /// stream on load, so a tail that describes a different run is rejected
186    /// rather than compared against the wrong frame count.
187    pub frame_count: u32,
188    /// Rolling hash after the final frame.
189    pub final_hash: u64,
190    /// Rolling hash after frames `INTERVAL-1`, `2*INTERVAL-1`, ... in order.
191    pub checkpoints: Vec<u64>,
192}
193
194/// FNV-1a-style rolling hash over 64-bit words.
195///
196/// # This is a tamper-EVIDENT digest, not a cryptographic one
197///
198/// 64-bit FNV-1a is not collision resistant, and its round function is
199/// invertible (`PRIME` is odd, so multiplication is a bijection mod 2^64). It
200/// reliably detects accidental divergence — a different build, a real
201/// nondeterminism bug, a truncated file — and casual edits, which is what
202/// [`Movie::verify`] is for. It does **not** resist a motivated forger: anyone
203/// who edits the movie can recompute the digest, and nothing here binds the
204/// record to an author.
205///
206/// Say "reproduces the recorded run", not "proves the run is genuine". Making a
207/// forgery-resistant claim would need a signature over the whole record with a
208/// key the verifier trusts, which is a different feature. Flagged in review on
209/// PR #356, where the surrounding prose had drifted into the stronger claim.
210///
211/// Written here rather than reused: the two existing `fnv1a64` helpers in this
212/// workspace are behind `rustynes-ppu`'s `ppu-state-trace` feature and in the
213/// test harness respectively, and neither is reachable from `no_std` core code
214/// on the default build. It is six lines; taking a feature dependency to avoid
215/// them would cost more than it saves.
216///
217/// Words rather than bytes because a framebuffer is 245,760 bytes and this runs
218/// once per frame during both recording and verification.
219#[derive(Clone, Copy, Debug)]
220struct RollingHash(u64);
221
222impl RollingHash {
223    const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
224    const PRIME: u64 = 0x0000_0100_0000_01b3;
225
226    const fn new() -> Self {
227        Self(Self::OFFSET_BASIS)
228    }
229
230    /// Fold a byte slice in, 8 bytes at a time. A trailing partial word is
231    /// zero-padded, which is unambiguous here because every input is a
232    /// fixed-size framebuffer.
233    fn write(&mut self, bytes: &[u8]) {
234        let mut chunks = bytes.chunks_exact(8);
235        for c in &mut chunks {
236            let w = u64::from_le_bytes([c[0], c[1], c[2], c[3], c[4], c[5], c[6], c[7]]);
237            self.0 = (self.0 ^ w).wrapping_mul(Self::PRIME);
238        }
239        let rem = chunks.remainder();
240        if !rem.is_empty() {
241            let mut buf = [0u8; 8];
242            buf[..rem.len()].copy_from_slice(rem);
243            self.0 = (self.0 ^ u64::from_le_bytes(buf)).wrapping_mul(Self::PRIME);
244        }
245    }
246}
247
248/// Accumulates an [`Attestation`] one frame at a time.
249///
250/// Feed it the framebuffer after each `run_frame`; it maintains the rolling hash
251/// and emits a checkpoint every [`ATTESTATION_CHECKPOINT_INTERVAL`] frames.
252#[derive(Clone, Debug)]
253pub struct AttestationBuilder {
254    hash: RollingHash,
255    frame_count: u32,
256    checkpoints: Vec<u64>,
257}
258
259impl AttestationBuilder {
260    /// Start a fresh attestation.
261    #[must_use]
262    pub const fn new() -> Self {
263        Self {
264            hash: RollingHash::new(),
265            frame_count: 0,
266            checkpoints: Vec::new(),
267        }
268    }
269
270    /// Fold in one frame: the input applied, then the video it produced.
271    pub fn push_frame(&mut self, input: FrameInput, framebuffer: &[u8]) {
272        self.hash
273            .write(&[input.p1.bits(), input.p2.bits(), input.expansion]);
274        self.hash.write(framebuffer);
275        self.frame_count = self.frame_count.saturating_add(1);
276        if self
277            .frame_count
278            .is_multiple_of(ATTESTATION_CHECKPOINT_INTERVAL)
279        {
280            self.checkpoints.push(self.hash.0);
281        }
282    }
283
284    /// Frames folded in so far.
285    #[must_use]
286    pub const fn frame_count(&self) -> u32 {
287        self.frame_count
288    }
289
290    /// The rolling hash as it stands.
291    #[must_use]
292    pub const fn current_hash(&self) -> u64 {
293        self.hash.0
294    }
295
296    /// Finish and produce the attestation.
297    #[must_use]
298    pub fn finish(self) -> Attestation {
299        Attestation {
300            frame_count: self.frame_count,
301            final_hash: self.hash.0,
302            checkpoints: self.checkpoints,
303        }
304    }
305}
306
307impl Default for AttestationBuilder {
308    fn default() -> Self {
309        Self::new()
310    }
311}
312
313/// The result of replaying an attested movie and comparing it to its record.
314#[derive(Clone, Debug, Eq, PartialEq)]
315pub enum VerifyOutcome {
316    /// The replay reproduced the recorded run exactly.
317    Match {
318        /// Frames replayed.
319        frames: u32,
320        /// The hash both the record and the replay produced.
321        hash: u64,
322    },
323    /// The replay diverged.
324    Mismatch {
325        /// Frames replayed.
326        frames: u32,
327        /// The hash the movie claims.
328        expected: u64,
329        /// The hash this replay produced.
330        got: u64,
331        /// Index of the first checkpoint that disagreed, if any did. The
332        /// divergence began somewhere in the
333        /// [`ATTESTATION_CHECKPOINT_INTERVAL`] frames ending at
334        /// `(index + 1) * INTERVAL - 1`. `None` means every recorded checkpoint
335        /// matched and only the final hash differs — i.e. the divergence is in
336        /// the tail after the last checkpoint.
337        first_bad_checkpoint: Option<u32>,
338    },
339    /// The movie carries no attestation, so there is nothing to verify against.
340    /// Not an error: most movies are recorded without one.
341    NotAttested,
342}
343
344/// Read the optional attestation tail, if one is present and coherent.
345///
346/// Returns `None` — never an error — for every way the tail can be absent or
347/// unusable: no bytes left, a different marker, a schema version this build does
348/// not know, a truncated body, or a frame count that disagrees with the input
349/// stream. A movie without a usable attestation is a perfectly good movie; the
350/// only wrong answer would be to report an attestation that does not describe
351/// this run, so a `frame_count` mismatch drops it rather than comparing against
352/// the wrong length.
353///
354/// The checkpoint count is bounded by what the remaining input could actually
355/// hold before reserving, for the same reason `frame_count` is: a hostile
356/// four-byte field must not be able to request a multi-gigabyte allocation.
357fn read_attestation(r: &mut BinReader<'_>, frames: usize) -> Option<Attestation> {
358    if r.u32().ok()? != ATTESTATION_MAGIC {
359        return None;
360    }
361    if r.u16().ok()? != ATTESTATION_VERSION {
362        return None;
363    }
364    let frame_count = r.u32().ok()?;
365    if frame_count as usize != frames {
366        return None;
367    }
368    let final_hash = r.u64().ok()?;
369    let declared = r.u32().ok()? as usize;
370    let max_plausible = r.remaining() / core::mem::size_of::<u64>();
371    let mut checkpoints = Vec::with_capacity(declared.min(max_plausible));
372    for _ in 0..declared {
373        checkpoints.push(r.u64().ok()?);
374    }
375    Some(Attestation {
376        frame_count,
377        final_hash,
378        checkpoints,
379    })
380}
381
382/// Where a movie begins. Clean-room analogue of Mesen2's `RecordMovieFrom`.
383#[derive(Clone, Debug, Eq, PartialEq)]
384pub enum StartPoint {
385    /// Power-on the ROM fresh, then apply inputs from frame 0. The most
386    /// durable start point across version transitions (depends only on the
387    /// ROM and the deterministic power-on).
388    PowerOn,
389    /// Restore this embedded `.rns` snapshot, then apply inputs from there.
390    /// Enables save-state branching (a movie that begins mid-game).
391    SaveState(Vec<u8>),
392}
393
394/// Errors produced by movie encode / decode / playback.
395#[derive(Debug, Error)]
396#[non_exhaustive]
397pub enum MovieError {
398    /// The blob is shorter than the fixed header.
399    #[error("movie truncated: header needs {expected} bytes, got {got}")]
400    HeaderTruncated {
401        /// Expected byte count.
402        expected: usize,
403        /// Actual byte count.
404        got: usize,
405    },
406
407    /// The magic prefix is wrong.
408    #[error("movie magic mismatch: expected {:?}, got {got:?}", MOVIE_MAGIC)]
409    BadMagic {
410        /// Bytes observed at the magic offset.
411        got: [u8; 8],
412    },
413
414    /// The container format version is outside the range we understand.
415    #[error("movie container format version {got} not supported (max {max})")]
416    UnsupportedFormat {
417        /// Version we read.
418        got: u16,
419        /// Highest version we accept.
420        max: u16,
421    },
422
423    /// The header declared more bytes-per-frame than this build understands.
424    #[error("movie declares {got} bytes/frame; this build understands {max}")]
425    UnsupportedFrameWidth {
426        /// Declared width.
427        got: u8,
428        /// Width this build can parse.
429        max: u8,
430    },
431
432    /// The region byte is not a value this build understands.
433    #[error("movie region byte {0} is not a known region")]
434    BadRegion(u8),
435
436    /// The body (start point and/or input stream) ran past EOF.
437    #[error("movie truncated mid-body at offset {0}")]
438    Eof(usize),
439
440    /// The embedded start-point save state failed to apply.
441    #[error("movie start-point save state invalid: {0}")]
442    BadSaveState(#[from] SnapshotError),
443
444    /// The running ROM's hash does not match the movie's recorded hash.
445    #[error("movie ROM hash mismatch (this movie was recorded against a different ROM)")]
446    RomMismatch,
447}
448
449/// A complete TAS movie: a versioned header, a start point, and the
450/// per-frame input stream.
451#[derive(Clone, Debug, Eq, PartialEq)]
452pub struct Movie {
453    /// Cartridge region the movie was recorded under.
454    pub region: Region,
455    /// Full SHA-256 of the ROM the movie was recorded against.
456    pub rom_sha256: [u8; 32],
457    /// Where playback begins.
458    pub start: StartPoint,
459    /// Per-frame controller inputs, in playback order.
460    pub frames: Vec<FrameInput>,
461    /// TAS re-record count — how many times the author re-recorded a frame
462    /// (the TAS piano-roll editor's edit tally; 0 for a straight linear
463    /// recording). Round-trips through `.rnm` (appended after the input stream,
464    /// so older readers ignore it) and the `.fm2` / `.bk2` `rerecordCount` header.
465    pub rerecord_count: u32,
466    /// Optional replay attestation (v2.3.2 "Lucid"): a rolling hash of the run's
467    /// video output plus periodic checkpoints, letting a third party replay the
468    /// movie and prove it reproduces the recorded run.
469    ///
470    /// `None` for every movie recorded without one, which is most of them.
471    /// Appended after [`Self::rerecord_count`] behind [`ATTESTATION_MAGIC`], so
472    /// an older reader stops at the re-record count and never sees it — the same
473    /// additive-tail trick that field itself used, and the reason no container
474    /// version bump was needed.
475    pub attestation: Option<Attestation>,
476}
477
478impl Movie {
479    /// Number of input frames in the movie.
480    #[must_use]
481    pub const fn len(&self) -> usize {
482        self.frames.len()
483    }
484
485    /// `true` if the movie has no input frames.
486    #[must_use]
487    pub const fn is_empty(&self) -> bool {
488        self.frames.is_empty()
489    }
490
491    /// Serialize the movie to its `.rnm` byte representation.
492    ///
493    /// Deterministic: the same `Movie` always produces identical bytes.
494    #[must_use]
495    pub fn serialize(&self) -> Vec<u8> {
496        let frame_count = u32::try_from(self.frames.len()).expect("frame count exceeds u32");
497        let body_hint = self.frames.len() * usize::from(BYTES_PER_FRAME);
498        let mut w = BinWriter::with_capacity(48 + body_hint);
499        w.bytes(MOVIE_MAGIC);
500        w.u16(MOVIE_FORMAT_VERSION);
501        w.u8(region_to_byte(self.region));
502        let flags = match &self.start {
503            StartPoint::PowerOn => 0,
504            StartPoint::SaveState(_) => FLAG_HAS_SAVE_STATE,
505        };
506        w.u8(flags);
507        w.bytes(&self.rom_sha256);
508        w.u32(frame_count);
509        w.u8(BYTES_PER_FRAME);
510        if let StartPoint::SaveState(blob) = &self.start {
511            w.lp_bytes(blob);
512        }
513        for f in &self.frames {
514            w.u8(f.p1.bits());
515            w.u8(f.p2.bits());
516            w.u8(f.expansion);
517        }
518        // Trailing re-record count (v1.8.9). Appended AFTER the fixed-count input
519        // stream so a reader that stops at `frame_count` records — including older
520        // builds — simply ignores it; deserialize below reads it when present and
521        // defaults to 0 otherwise. No format-version bump needed.
522        w.u32(self.rerecord_count);
523        // Optional attestation tail (v2.3.2 "Lucid"). Written only when present,
524        // so a movie without one is byte-for-byte what previous versions wrote.
525        if let Some(att) = &self.attestation {
526            w.u32(ATTESTATION_MAGIC);
527            w.u16(ATTESTATION_VERSION);
528            w.u32(att.frame_count);
529            w.u64(att.final_hash);
530            w.u32(u32::try_from(att.checkpoints.len()).unwrap_or(u32::MAX));
531            for &c in &att.checkpoints {
532                w.u64(c);
533            }
534        }
535        w.into_vec()
536    }
537
538    /// Parse a `.rnm` movie from its byte representation.
539    ///
540    /// # Errors
541    ///
542    /// Returns [`MovieError`] for a bad magic, an unsupported container
543    /// version, an unknown region byte, a frame width this build can't
544    /// parse, or a truncated body. Never panics on malformed input.
545    pub fn deserialize(bytes: &[u8]) -> Result<Self, MovieError> {
546        // Fixed header: magic(8) + version(2) + region(1) + flags(1) +
547        // sha256(32) + frame_count(4) + bytes_per_frame(1) = 49 bytes.
548        const HEADER_LEN: usize = 8 + 2 + 1 + 1 + 32 + 4 + 1;
549        if bytes.len() < HEADER_LEN {
550            return Err(MovieError::HeaderTruncated {
551                expected: HEADER_LEN,
552                got: bytes.len(),
553            });
554        }
555        let mut r = BinReader::new(bytes);
556        // Magic.
557        let mut magic = [0u8; 8];
558        r.read_into(&mut magic).map_err(map_eof)?;
559        if &magic != MOVIE_MAGIC {
560            return Err(MovieError::BadMagic { got: magic });
561        }
562        // Version.
563        let format_version = r.u16().map_err(map_eof)?;
564        if format_version > MOVIE_FORMAT_VERSION {
565            return Err(MovieError::UnsupportedFormat {
566                got: format_version,
567                max: MOVIE_FORMAT_VERSION,
568            });
569        }
570        // Region + flags.
571        let region = region_from_byte(r.u8().map_err(map_eof)?)?;
572        let flags = r.u8().map_err(map_eof)?;
573        // ROM hash.
574        let mut rom_sha256 = [0u8; 32];
575        r.read_into(&mut rom_sha256).map_err(map_eof)?;
576        // Frame count + width.
577        let frame_count = r.u32().map_err(map_eof)? as usize;
578        let bytes_per_frame = r.u8().map_err(map_eof)?;
579        if bytes_per_frame == 0 || bytes_per_frame > BYTES_PER_FRAME {
580            // A newer movie packs more device bytes than we understand; we
581            // fail cleanly rather than mis-parse (the reserved byte exists
582            // precisely so this stays a graceful error, not a corruption).
583            //
584            // SECURITY: a `bytes_per_frame` of 0 is likewise rejected. With a
585            // zero-width record each frame read (`r.take(0)`) consumes no input,
586            // so the `for _ in 0..frame_count` loop below would push
587            // `frame_count` (an untrusted u32, up to ~4.3 billion) empty frames
588            // out of a finite file — an OOM DoS (found by the `movie` fuzz
589            // target). A real movie always writes the fixed `BYTES_PER_FRAME`
590            // (>= 1), so rejecting 0 costs no legitimate file.
591            return Err(MovieError::UnsupportedFrameWidth {
592                got: bytes_per_frame,
593                max: BYTES_PER_FRAME,
594            });
595        }
596        // Start point.
597        let start = if flags & FLAG_HAS_SAVE_STATE != 0 {
598            let blob = r.lp_bytes().map_err(map_eof)?;
599            StartPoint::SaveState(blob.to_vec())
600        } else {
601            StartPoint::PowerOn
602        };
603        // Input stream: `frame_count` records of `bytes_per_frame` bytes
604        // (`width >= 1`, enforced above).
605        let width = usize::from(bytes_per_frame);
606        // SECURITY: `frame_count` is an untrusted 4-byte field (up to ~4.3
607        // billion). Pre-sizing `Vec::with_capacity(frame_count)` from it lets a
608        // 49-byte header claim a multi-gigabyte allocation — an OOM DoS (found
609        // by the `movie` fuzz target). A real movie carries exactly
610        // `frame_count * width` more bytes, so cap the reservation at what the
611        // remaining input could actually hold: for a valid file this equals
612        // `frame_count` (identical allocation, byte-for-byte the same result),
613        // and for a truncated / hostile one the `r.take(width)` below still
614        // fails cleanly with an EOF error once the real bytes run out.
615        let max_plausible_frames = r.remaining() / width;
616        let mut frames = Vec::with_capacity(frame_count.min(max_plausible_frames));
617        for _ in 0..frame_count {
618            let rec = r.take(width).map_err(map_eof)?;
619            // rec[0] = p1, rec[1] = p2 (present whenever width >= 2, which it
620            // always is for v1's width of 3); rec[2] = expansion when width
621            // >= 3. Lower widths default the missing fields.
622            let p1 = Buttons::from_bits_truncate(rec.first().copied().unwrap_or(0));
623            let p2 = Buttons::from_bits_truncate(rec.get(1).copied().unwrap_or(0));
624            let expansion = rec.get(2).copied().unwrap_or(0);
625            frames.push(FrameInput { p1, p2, expansion });
626        }
627        // Optional trailing re-record count (v1.8.9). Absent in pre-v1.8.9 `.rnm`
628        // files, which stop exactly at the input stream — default to 0.
629        let rerecord_count = r.u32().unwrap_or(0);
630        // Optional attestation tail (v2.3.2 "Lucid"). Absent in every movie
631        // recorded before it existed, and in any recorded without it — so a
632        // missing or unrecognized marker yields `None` rather than an error.
633        let attestation = read_attestation(&mut r, frames.len());
634        Ok(Self {
635            region,
636            rom_sha256,
637            start,
638            frames,
639            rerecord_count,
640            attestation,
641        })
642    }
643
644    /// Rewind a running emulator to this movie's start point, ready to replay
645    /// from frame 0.
646    ///
647    /// For [`StartPoint::PowerOn`] this power-cycles `nes`. For
648    /// [`StartPoint::SaveState`] it restores the embedded snapshot. In both
649    /// cases the ROM hash is checked against the movie's recorded hash.
650    ///
651    /// # Errors
652    ///
653    /// Returns [`MovieError::RomMismatch`] if `nes` is running a different
654    /// ROM, or [`MovieError::BadSaveState`] if the embedded snapshot is
655    /// malformed.
656    pub fn seek_to_start(&self, nes: &mut Nes) -> Result<(), MovieError> {
657        if nes.rom_sha256() != &self.rom_sha256 {
658            return Err(MovieError::RomMismatch);
659        }
660        match &self.start {
661            StartPoint::PowerOn => nes.power_cycle(),
662            StartPoint::SaveState(blob) => nes.restore(blob)?,
663        }
664        Ok(())
665    }
666
667    /// v2.3.2 "Lucid" — replay this movie and check it reproduces its
668    /// attestation.
669    ///
670    /// Seeks `nes` to the movie's start point, replays the whole input stream,
671    /// and compares the resulting rolling hash (and every checkpoint along the
672    /// way) against what the movie recorded. Anyone with the ROM and the `.rnm`
673    /// can run it and get the same answer, so an accidental divergence — a
674    /// different build, a nondeterminism bug, a corrupted file — or a casual
675    /// edit to the input stream, the start point, or the claimed hash shows up
676    /// as a [`VerifyOutcome::Mismatch`].
677    ///
678    /// **Reproducibility, not provenance.** The digest is a 64-bit FNV-1a
679    /// variant: tamper-evident, not forgery-resistant. A `Match` means "these inputs,
680    /// applied to this ROM, on a verifier configured like the recorder, produce
681    /// this video". It does not establish who produced the movie, and a
682    /// motivated forger can edit the movie and recompute the digest.
683    ///
684    /// Consumes real emulation time — it runs every frame of the movie.
685    ///
686    /// # Errors
687    ///
688    /// [`MovieError::RomMismatch`] if `nes` is running a different ROM, or
689    /// [`MovieError::BadSaveState`] if an embedded start point is malformed.
690    /// A movie with no attestation is **not** an error; it returns
691    /// [`VerifyOutcome::NotAttested`], because "this movie makes no claim" and
692    /// "this movie makes a false claim" are different answers.
693    pub fn verify(&self, nes: &mut Nes) -> Result<VerifyOutcome, MovieError> {
694        let Some(att) = self.attestation.as_ref() else {
695            return Ok(VerifyOutcome::NotAttested);
696        };
697        self.seek_to_start(nes)?;
698        let mut builder = AttestationBuilder::new();
699        let mut first_bad_checkpoint = None;
700        let mut player = MoviePlayer::new(self);
701        let mut idx = 0usize;
702        while player.apply_next(nes) {
703            let input = self.frames.get(idx).copied().unwrap_or_default();
704            idx += 1;
705            let fb = nes.run_frame();
706            builder.push_frame(input, fb);
707            // Compare each checkpoint as it is produced rather than collecting
708            // and diffing afterwards: the first disagreement is the useful one,
709            // and it localizes the divergence to a 64-frame window.
710            if builder
711                .frame_count()
712                .is_multiple_of(ATTESTATION_CHECKPOINT_INTERVAL)
713                && first_bad_checkpoint.is_none()
714            {
715                let idx = builder.frame_count() / ATTESTATION_CHECKPOINT_INTERVAL - 1;
716                // Compare ONLY against a checkpoint the movie actually recorded.
717                // `get()` returning `None` means "no recorded value here", not
718                // "mismatch": treating absence as disagreement made a short
719                // checkpoint list report a divergence even when every hash the
720                // movie does carry — including the final one — matched. The
721                // final hash is the gate; the checkpoints only localize.
722                if let Some(&want) = att.checkpoints.get(idx as usize)
723                    && want != builder.current_hash()
724                {
725                    first_bad_checkpoint = Some(idx);
726                }
727            }
728        }
729        let got = builder.current_hash();
730        let frames = builder.frame_count();
731        if got == att.final_hash && first_bad_checkpoint.is_none() {
732            Ok(VerifyOutcome::Match { frames, hash: got })
733        } else {
734            Ok(VerifyOutcome::Mismatch {
735                frames,
736                expected: att.final_hash,
737                got,
738                first_bad_checkpoint,
739            })
740        }
741    }
742}
743
744/// Records the per-frame input stream applied to an emulator.
745///
746/// Usage (caller-driven, mirrors the frontend's per-frame loop):
747///
748/// ```ignore
749/// let mut rec = MovieRecorder::power_on(&nes);
750/// loop {
751///     nes.set_buttons(0, p1);
752///     nes.set_buttons(1, p2);
753///     rec.capture(&nes); // BEFORE run_frame — captures the inputs it consumes
754///     nes.run_frame();
755/// }
756/// let movie = rec.finish();
757/// ```
758#[derive(Clone, Debug)]
759pub struct MovieRecorder {
760    region: Region,
761    rom_sha256: [u8; 32],
762    start: StartPoint,
763    frames: Vec<FrameInput>,
764    /// v2.3.2 "Lucid" — optional attestation accumulator. `None` (the default)
765    /// records a plain movie, byte-for-byte what previous versions produced.
766    attestation: Option<AttestationBuilder>,
767}
768
769impl MovieRecorder {
770    /// Begin recording a movie that starts from a fresh power-on of the ROM
771    /// `nes` is running. The caller is responsible for power-cycling `nes`
772    /// before the first captured frame so the recording starts from the same
773    /// state a replay will reconstruct.
774    #[must_use]
775    pub const fn power_on(nes: &Nes) -> Self {
776        Self {
777            region: nes.region(),
778            rom_sha256: *nes.rom_sha256(),
779            start: StartPoint::PowerOn,
780            frames: Vec::new(),
781            attestation: None,
782        }
783    }
784
785    /// Begin recording a movie that starts from `nes`'s *current* state (a
786    /// branch point). Captures a snapshot now and embeds it as the start
787    /// point; the input stream is recorded from here forward.
788    #[must_use]
789    pub fn from_current_state(nes: &Nes) -> Self {
790        Self {
791            region: nes.region(),
792            rom_sha256: *nes.rom_sha256(),
793            start: StartPoint::SaveState(nes.snapshot()),
794            frames: Vec::new(),
795            attestation: None,
796        }
797    }
798
799    /// Record the controller inputs currently held on `nes`. Call this each
800    /// frame *before* [`Nes::run_frame`], after the frontend has applied its
801    /// `set_buttons` calls — this captures exactly the inputs the upcoming
802    /// frame consumes.
803    ///
804    /// # Two ports only, including under a Four Score
805    ///
806    /// [`FrameInput`] models ports 0 and 1, so this reads `nes.buttons(0)` and
807    /// `nes.buttons(1)` and **nothing else**. The core itself carries four —
808    /// the frontend calls `set_buttons(2)` / `set_buttons(3)` whenever the Four
809    /// Score adapter is active — so recording a four-player session captures
810    /// half of what drove it, and replaying that movie diverges from the run it
811    /// came from.
812    ///
813    /// Stated here rather than left to be discovered, because the failure is
814    /// silent at record time: nothing about a `.rnm` says which ports it could
815    /// not hold, and the divergence only appears on playback. The frontend's
816    /// Replay panel says so where the topology is displayed, and
817    /// `Movie::verify`'s attestation catches it after the fact.
818    ///
819    /// Widening [`FrameInput`] is a `.rnm` format epoch change (ADR 0028), not
820    /// an additive one, which is why this is a documented limit rather than a
821    /// fix. The `.fm2` importer already takes the same position for the same
822    /// reason — it keeps pads 1 and 2, drops 3 and 4, and preserves the
823    /// `fourscore` flag so the caller is not silently misled.
824    pub fn capture(&mut self, nes: &Nes) {
825        self.frames.push(FrameInput {
826            p1: nes.buttons(0),
827            p2: nes.buttons(1),
828            expansion: 0,
829        });
830    }
831
832    /// Record an explicit frame of input (for callers that drive input
833    /// programmatically rather than through `set_buttons`).
834    pub fn capture_input(&mut self, input: FrameInput) {
835        self.frames.push(input);
836    }
837
838    /// Number of frames captured so far.
839    #[must_use]
840    pub const fn len(&self) -> usize {
841        self.frames.len()
842    }
843
844    /// `true` if no frames have been captured.
845    #[must_use]
846    pub const fn is_empty(&self) -> bool {
847        self.frames.is_empty()
848    }
849
850    /// v2.3.2 "Lucid" — start accumulating a replay attestation.
851    ///
852    /// Call before the first frame. The caller must then call
853    /// [`Self::attest_frame`] after every `run_frame`, in lockstep with
854    /// [`Self::capture`], or the recorded hash will describe a different run
855    /// than the input stream does — which [`Movie::verify`] would then report as
856    /// a mismatch, correctly but unhelpfully.
857    pub fn enable_attestation(&mut self) {
858        self.attestation = Some(AttestationBuilder::new());
859    }
860
861    /// Abandon an in-progress attestation, keeping the recording itself.
862    ///
863    /// For a host that rewinds or otherwise moves the emulator off the timeline
864    /// the accumulated hash describes. Once dropped it is not resumed: the
865    /// prefix already folded in cannot be un-folded, and a partial hash that
866    /// silently covers only part of the run would be worse than none.
867    pub fn disable_attestation(&mut self) {
868        self.attestation = None;
869    }
870
871    /// Fold this frame's video output into the attestation.
872    ///
873    /// A no-op unless [`Self::enable_attestation`] was called. Pass the slice
874    /// `Nes::run_frame` returned (or `Nes::framebuffer()`), AFTER the frame ran.
875    ///
876    /// Uses the input recorded by the matching [`Self::capture`], so the two
877    /// must stay in lockstep — one `capture` then one `attest_frame` per frame.
878    /// If they drift the recorded frame counts disagree and `Movie::deserialize`
879    /// drops the tail, which is the safe direction.
880    pub fn attest_frame(&mut self, framebuffer: &[u8]) {
881        // The input for THIS frame is the one `capture` just pushed.
882        let input = self.frames.last().copied().unwrap_or_default();
883        if let Some(a) = self.attestation.as_mut() {
884            a.push_frame(input, framebuffer);
885        }
886    }
887
888    /// Finish recording and produce the [`Movie`].
889    #[must_use]
890    pub fn finish(self) -> Movie {
891        Movie {
892            region: self.region,
893            rom_sha256: self.rom_sha256,
894            start: self.start,
895            frames: self.frames,
896            // A linear recording has no re-records by construction; TAStudio
897            // sets a real count when it exports an edited movie.
898            rerecord_count: 0,
899            attestation: self.attestation.map(AttestationBuilder::finish),
900        }
901    }
902}
903
904/// Plays a movie back, feeding its recorded inputs into an emulator one frame
905/// at a time.
906///
907/// Usage (caller-driven; the player applies `set_buttons`, the caller runs
908/// the frame):
909///
910/// ```ignore
911/// movie.seek_to_start(&mut nes)?;
912/// let mut player = MoviePlayer::new(&movie);
913/// while player.apply_next(&mut nes) {
914///     nes.run_frame();
915/// }
916/// ```
917#[derive(Clone, Debug)]
918pub struct MoviePlayer<'a> {
919    movie: &'a Movie,
920    cursor: usize,
921}
922
923impl<'a> MoviePlayer<'a> {
924    /// Create a player positioned at frame 0 of `movie`.
925    #[must_use]
926    pub const fn new(movie: &'a Movie) -> Self {
927        Self { movie, cursor: 0 }
928    }
929
930    /// Total frames in the movie.
931    #[must_use]
932    pub const fn len(&self) -> usize {
933        self.movie.frames.len()
934    }
935
936    /// `true` if the movie has no frames.
937    #[must_use]
938    pub const fn is_empty(&self) -> bool {
939        self.movie.frames.is_empty()
940    }
941
942    /// Index of the frame that [`Self::apply_next`] will apply next.
943    #[must_use]
944    pub const fn cursor(&self) -> usize {
945        self.cursor
946    }
947
948    /// `true` if every frame has been played.
949    #[must_use]
950    pub const fn is_finished(&self) -> bool {
951        self.cursor >= self.movie.frames.len()
952    }
953
954    /// Peek the next frame's input without advancing.
955    #[must_use]
956    pub fn peek(&self) -> Option<FrameInput> {
957        self.movie.frames.get(self.cursor).copied()
958    }
959
960    /// Apply the next frame's recorded input to `nes` via `set_buttons` and
961    /// advance the cursor. Returns `false` (without applying anything) once
962    /// the movie is exhausted — the caller stops its replay loop on `false`.
963    ///
964    /// Call this *before* [`Nes::run_frame`], mirroring the record-side
965    /// `capture` ordering, so the same inputs are applied to the same frame.
966    pub fn apply_next(&mut self, nes: &mut Nes) -> bool {
967        let Some(input) = self.movie.frames.get(self.cursor).copied() else {
968            return false;
969        };
970        nes.set_buttons(0, input.p1);
971        nes.set_buttons(1, input.p2);
972        self.cursor += 1;
973        true
974    }
975
976    /// Reset the cursor back to frame 0 (the caller is responsible for
977    /// re-seeking `nes` via [`Movie::seek_to_start`]).
978    pub const fn rewind(&mut self) {
979        self.cursor = 0;
980    }
981}
982
983const fn region_to_byte(region: Region) -> u8 {
984    match region {
985        Region::Ntsc => 0,
986        Region::Pal => 1,
987        Region::Dendy => 2,
988    }
989}
990
991const fn region_from_byte(b: u8) -> Result<Region, MovieError> {
992    match b {
993        0 => Ok(Region::Ntsc),
994        1 => Ok(Region::Pal),
995        2 => Ok(Region::Dendy),
996        other => Err(MovieError::BadRegion(other)),
997    }
998}
999
1000/// Map a `SnapshotError::Eof`-style truncation reading the movie body into a
1001/// movie-level [`MovieError::Eof`]. Other snapshot errors cannot arise from
1002/// the `BinReader` calls in this module (they only read fixed primitives).
1003fn map_eof(e: SnapshotError) -> MovieError {
1004    match e {
1005        SnapshotError::Eof(off) => MovieError::Eof(off),
1006        other => MovieError::BadSaveState(other),
1007    }
1008}
1009
1010#[cfg(test)]
1011mod tests {
1012    use super::*;
1013    use alloc::vec;
1014
1015    // ----------------------------------------------------------------- v2.3.2
1016    // Replay attestation ("Lucid" phase 4)
1017    // -------------------------------------------------------------------------
1018
1019    /// Record a short attested run and prove it replays to the same hash.
1020    ///
1021    /// This is the whole feature in one test: the claim is not "the hash is
1022    /// stable" but "an independent replay re-derives it", which is what makes
1023    /// the record evidence rather than decoration.
1024    #[test]
1025    fn attested_movie_verifies_against_an_independent_replay() {
1026        let rom = synth_nrom();
1027        let mut nes = Nes::from_rom(&rom).expect("parse");
1028        let mut rec = MovieRecorder::power_on(&nes);
1029        rec.enable_attestation();
1030        for _ in 0..8 {
1031            rec.capture(&nes);
1032            let fb = nes.run_frame().to_vec();
1033            rec.attest_frame(&fb);
1034        }
1035        let movie = rec.finish();
1036        let att = movie.attestation.as_ref().expect("attestation recorded");
1037        assert_eq!(att.frame_count, 8);
1038
1039        // A SEPARATE emulator instance, as a third party would use.
1040        let mut fresh = Nes::from_rom(&rom).expect("parse");
1041        match movie.verify(&mut fresh).expect("verify runs") {
1042            VerifyOutcome::Match { frames, hash } => {
1043                assert_eq!(frames, 8);
1044                assert_eq!(hash, att.final_hash);
1045            }
1046            other => panic!("expected a match, got {other:?}"),
1047        }
1048    }
1049
1050    /// The attestation survives the `.rnm` round-trip, and a movie WITHOUT one
1051    /// still serializes to exactly the bytes it did before this feature existed.
1052    #[test]
1053    fn attestation_round_trips_and_is_absent_when_not_recorded() {
1054        let rom = synth_nrom();
1055        let mut nes = Nes::from_rom(&rom).expect("parse");
1056
1057        // Plain recording: no attestation, and the tail is not written.
1058        let mut plain = MovieRecorder::power_on(&nes);
1059        plain.capture(&nes);
1060        let _ = nes.run_frame();
1061        let plain = plain.finish();
1062        assert!(plain.attestation.is_none());
1063        let plain_bytes = plain.serialize();
1064        let reparsed = Movie::deserialize(&plain_bytes).expect("round-trip");
1065        assert_eq!(reparsed, plain);
1066        assert!(reparsed.attestation.is_none());
1067
1068        // Attested recording: the tail round-trips intact.
1069        let mut nes2 = Nes::from_rom(&rom).expect("parse");
1070        let mut rec = MovieRecorder::power_on(&nes2);
1071        rec.enable_attestation();
1072        // Enough frames to cross a checkpoint boundary.
1073        for _ in 0..(ATTESTATION_CHECKPOINT_INTERVAL + 3) {
1074            rec.capture(&nes2);
1075            let fb = nes2.run_frame().to_vec();
1076            rec.attest_frame(&fb);
1077        }
1078        let attested = rec.finish();
1079        assert_eq!(attested.attestation.as_ref().unwrap().checkpoints.len(), 1);
1080        let bytes = attested.serialize();
1081        assert_eq!(Movie::deserialize(&bytes).expect("round-trip"), attested);
1082
1083        // The attested file is strictly longer, and the plain one is unchanged
1084        // by this feature existing.
1085        assert!(bytes.len() > plain_bytes.len());
1086    }
1087
1088    /// A reader that stops at the re-record count — i.e. every build before this
1089    /// feature — must still parse an attested movie as a plain one. Simulated by
1090    /// truncating the tail, which is exactly what such a reader sees.
1091    #[test]
1092    fn attested_movie_stays_readable_as_a_plain_movie() {
1093        let rom = synth_nrom();
1094        let mut nes = Nes::from_rom(&rom).expect("parse");
1095        let mut rec = MovieRecorder::power_on(&nes);
1096        rec.enable_attestation();
1097        for _ in 0..4 {
1098            rec.capture(&nes);
1099            let fb = nes.run_frame().to_vec();
1100            rec.attest_frame(&fb);
1101        }
1102        let attested = rec.finish();
1103        let full = attested.serialize();
1104
1105        // Everything an older reader consumes: header + inputs + rerecord count.
1106        // The attestation tail is 4 + 2 + 4 + 8 + 4 = 22 bytes plus checkpoints
1107        // (none here, 4 frames < the interval).
1108        let tail_len = 4 + 2 + 4 + 8 + 4;
1109        let older_view = &full[..full.len() - tail_len];
1110        let parsed = Movie::deserialize(older_view).expect("older readers still parse it");
1111        assert_eq!(parsed.frames, attested.frames);
1112        assert_eq!(parsed.rerecord_count, attested.rerecord_count);
1113        assert!(parsed.attestation.is_none());
1114    }
1115
1116    /// Tampering with the input stream must be detected. This is the property
1117    /// that makes an attestation worth anything.
1118    #[test]
1119    fn tampering_with_the_input_stream_fails_verification() {
1120        let rom = synth_nrom();
1121        let mut nes = Nes::from_rom(&rom).expect("parse");
1122        let mut rec = MovieRecorder::power_on(&nes);
1123        rec.enable_attestation();
1124        for _ in 0..6 {
1125            rec.capture(&nes);
1126            let fb = nes.run_frame().to_vec();
1127            rec.attest_frame(&fb);
1128        }
1129        let mut movie = rec.finish();
1130
1131        // Forge the claimed hash. A replay must refuse to confirm it.
1132        let real = movie.attestation.as_ref().unwrap().final_hash;
1133        movie.attestation.as_mut().unwrap().final_hash = real ^ 1;
1134        let mut fresh = Nes::from_rom(&rom).expect("parse");
1135        match movie.verify(&mut fresh).expect("verify runs") {
1136            VerifyOutcome::Mismatch { expected, got, .. } => {
1137                assert_eq!(expected, real ^ 1);
1138                assert_eq!(got, real, "the replay re-derives the TRUE hash");
1139            }
1140            other => panic!("a forged hash must not verify, got {other:?}"),
1141        }
1142    }
1143
1144    /// A flipped INPUT bit must fail verification even when the ROM ignores
1145    /// input entirely and the video output is therefore unchanged.
1146    ///
1147    /// This is the case an output-only hash gets wrong, and it is not
1148    /// hypothetical: an end-to-end `rustynes verify` run against a test ROM that
1149    /// never reads the controller happily confirmed a movie whose input log had
1150    /// been edited. The fix was to fold the per-frame input into the hash; this
1151    /// test is what keeps it folded in.
1152    #[test]
1153    fn flipped_input_fails_even_when_the_rom_ignores_input() {
1154        // `synth_nrom` is an infinite `JMP` — it never reads $4016, so its video
1155        // output is identical for every possible input stream.
1156        let rom = synth_nrom();
1157        let mut nes = Nes::from_rom(&rom).expect("parse");
1158        let mut rec = MovieRecorder::power_on(&nes);
1159        rec.enable_attestation();
1160        for _ in 0..6 {
1161            rec.capture(&nes);
1162            let fb = nes.run_frame().to_vec();
1163            rec.attest_frame(&fb);
1164        }
1165        let mut movie = rec.finish();
1166
1167        // Sanity: the honest movie verifies.
1168        let mut fresh = Nes::from_rom(&rom).expect("parse");
1169        assert!(matches!(
1170            movie.verify(&mut fresh).expect("verify runs"),
1171            VerifyOutcome::Match { .. }
1172        ));
1173
1174        // Now edit the input log. The video output will be bit-identical,
1175        // because this ROM never looks at the controller.
1176        movie.frames[3].p1 = Buttons::A;
1177        let mut fresh = Nes::from_rom(&rom).expect("parse");
1178        match movie.verify(&mut fresh).expect("verify runs") {
1179            VerifyOutcome::Mismatch { .. } => {}
1180            other => panic!("an edited input log must not verify, got {other:?}"),
1181        }
1182    }
1183
1184    /// An attestation whose frame count disagrees with the input stream
1185    /// describes a different run, so it is dropped rather than compared against
1186    /// the wrong length.
1187    #[test]
1188    fn attestation_with_a_mismatched_frame_count_is_rejected_on_load() {
1189        let rom = synth_nrom();
1190        let mut nes = Nes::from_rom(&rom).expect("parse");
1191        let mut rec = MovieRecorder::power_on(&nes);
1192        rec.enable_attestation();
1193        for _ in 0..3 {
1194            rec.capture(&nes);
1195            let fb = nes.run_frame().to_vec();
1196            rec.attest_frame(&fb);
1197        }
1198        let mut movie = rec.finish();
1199        movie.attestation.as_mut().unwrap().frame_count = 999;
1200        let bytes = movie.serialize();
1201        let parsed = Movie::deserialize(&bytes).expect("the movie itself is fine");
1202        assert_eq!(parsed.frames.len(), 3);
1203        assert!(
1204            parsed.attestation.is_none(),
1205            "a tail describing a different run must be dropped, not trusted"
1206        );
1207    }
1208
1209    /// Drive `first_bad_checkpoint` to `Some(_)` — the path bug #12 lived on,
1210    /// which no test previously exercised.
1211    #[test]
1212    fn a_corrupted_checkpoint_is_localized() {
1213        let rom = synth_nrom();
1214        let mut nes = Nes::from_rom(&rom).expect("parse");
1215        let mut rec = MovieRecorder::power_on(&nes);
1216        rec.enable_attestation();
1217        // Three checkpoint windows.
1218        for _ in 0..(ATTESTATION_CHECKPOINT_INTERVAL * 3) {
1219            rec.capture(&nes);
1220            let fb = nes.run_frame().to_vec();
1221            rec.attest_frame(&fb);
1222        }
1223        let mut movie = rec.finish();
1224        assert_eq!(movie.attestation.as_ref().unwrap().checkpoints.len(), 3);
1225
1226        // Corrupt the SECOND checkpoint only. The final hash still matches, so
1227        // the checkpoint comparison is the only thing that can catch this.
1228        movie.attestation.as_mut().unwrap().checkpoints[1] ^= 0xFF;
1229        let mut fresh = Nes::from_rom(&rom).expect("parse");
1230        match movie.verify(&mut fresh).expect("verify runs") {
1231            VerifyOutcome::Mismatch {
1232                first_bad_checkpoint,
1233                expected,
1234                got,
1235                ..
1236            } => {
1237                assert_eq!(
1238                    first_bad_checkpoint,
1239                    Some(1),
1240                    "the SECOND checkpoint is the first bad one"
1241                );
1242                assert_eq!(expected, got, "the final hash still agrees");
1243            }
1244            other => panic!("a corrupted checkpoint must not verify, got {other:?}"),
1245        }
1246    }
1247
1248    /// A short checkpoint list must NOT manufacture a mismatch.
1249    ///
1250    /// Regression for bug #12: `checkpoints.get(idx)` returning `None` was
1251    /// compared against `Some(&hash)` and read as disagreement, so an
1252    /// attestation carrying fewer checkpoints than the replay produces failed
1253    /// even when every hash it did record — and the final hash — matched.
1254    #[test]
1255    fn a_short_checkpoint_list_still_verifies() {
1256        let rom = synth_nrom();
1257        let mut nes = Nes::from_rom(&rom).expect("parse");
1258        let mut rec = MovieRecorder::power_on(&nes);
1259        rec.enable_attestation();
1260        for _ in 0..(ATTESTATION_CHECKPOINT_INTERVAL * 2) {
1261            rec.capture(&nes);
1262            let fb = nes.run_frame().to_vec();
1263            rec.attest_frame(&fb);
1264        }
1265        let mut movie = rec.finish();
1266        assert_eq!(movie.attestation.as_ref().unwrap().checkpoints.len(), 2);
1267
1268        // Drop the trailing checkpoint, leaving the final hash intact.
1269        movie.attestation.as_mut().unwrap().checkpoints.pop();
1270        let mut fresh = Nes::from_rom(&rom).expect("parse");
1271        match movie.verify(&mut fresh).expect("verify runs") {
1272            VerifyOutcome::Match { frames, .. } => {
1273                assert_eq!(frames, ATTESTATION_CHECKPOINT_INTERVAL * 2);
1274            }
1275            other => panic!(
1276                "a missing checkpoint is 'nothing recorded here', not a \
1277                 disagreement; got {other:?}"
1278            ),
1279        }
1280    }
1281
1282    /// A rewind mid-recording must drop the attestation rather than ship one
1283    /// that describes a timeline the input log no longer encodes.
1284    #[test]
1285    fn disabling_attestation_mid_recording_yields_an_unattested_movie() {
1286        let rom = synth_nrom();
1287        let mut nes = Nes::from_rom(&rom).expect("parse");
1288        let mut rec = MovieRecorder::power_on(&nes);
1289        rec.enable_attestation();
1290        for _ in 0..4 {
1291            rec.capture(&nes);
1292            let fb = nes.run_frame().to_vec();
1293            rec.attest_frame(&fb);
1294        }
1295        // What the frontend does on a successful `rewind_step_back`.
1296        rec.disable_attestation();
1297        for _ in 0..4 {
1298            rec.capture(&nes);
1299            let fb = nes.run_frame().to_vec();
1300            rec.attest_frame(&fb);
1301        }
1302        let movie = rec.finish();
1303        assert!(
1304            movie.attestation.is_none(),
1305            "a partial hash covering only part of the run is worse than none"
1306        );
1307        assert_eq!(movie.frames.len(), 8, "the recording itself is unaffected");
1308    }
1309
1310    /// A movie with no attestation reports that, rather than passing or failing.
1311    #[test]
1312    fn unattested_movie_reports_not_attested() {
1313        let rom = synth_nrom();
1314        let mut nes = Nes::from_rom(&rom).expect("parse");
1315        let mut rec = MovieRecorder::power_on(&nes);
1316        rec.capture(&nes);
1317        let _ = nes.run_frame();
1318        let movie = rec.finish();
1319        let mut fresh = Nes::from_rom(&rom).expect("parse");
1320        assert_eq!(
1321            movie.verify(&mut fresh).expect("verify runs"),
1322            VerifyOutcome::NotAttested
1323        );
1324    }
1325
1326    /// Minimal NROM ROM that runs an infinite loop (same shape as the
1327    /// `nes.rs` test fixture). Deterministic boot, no input dependence in
1328    /// the program itself — the movie machinery is what we exercise.
1329    fn synth_nrom() -> Vec<u8> {
1330        let mut bytes = Vec::new();
1331        bytes.extend_from_slice(b"NES\x1A");
1332        bytes.push(1); // 16 KiB PRG
1333        bytes.push(1); // 8 KiB CHR
1334        bytes.push(0);
1335        bytes.push(0);
1336        bytes.extend_from_slice(&[0u8; 8]);
1337        let mut prg = vec![0u8; 16 * 1024];
1338        prg[0] = 0x4C; // JMP $C000
1339        prg[1] = 0x00;
1340        prg[2] = 0xC0;
1341        let len = prg.len();
1342        prg[len - 4] = 0x00;
1343        prg[len - 3] = 0xC0;
1344        prg[len - 6] = 0x00;
1345        prg[len - 5] = 0xC0;
1346        prg[len - 2] = 0x00;
1347        prg[len - 1] = 0xC0;
1348        bytes.extend_from_slice(&prg);
1349        bytes.extend_from_slice(&vec![0u8; 8 * 1024]);
1350        bytes
1351    }
1352
1353    fn fnv(bytes: &[u8]) -> u64 {
1354        let mut h: u64 = 0xCBF2_9CE4_8422_2325;
1355        for &b in bytes {
1356            h ^= u64::from(b);
1357            h = h.wrapping_mul(0x0000_0100_0000_01B3);
1358        }
1359        h
1360    }
1361
1362    fn audio_fnv(samples: &[f32]) -> u64 {
1363        let mut h: u64 = 0xCBF2_9CE4_8422_2325;
1364        for s in samples {
1365            for &b in &s.to_le_bytes() {
1366                h ^= u64::from(b);
1367                h = h.wrapping_mul(0x0000_0100_0000_01B3);
1368            }
1369        }
1370        h
1371    }
1372
1373    /// A fixed, varied synthetic input sequence (deterministic, no RNG).
1374    fn synthetic_inputs(n: usize) -> Vec<FrameInput> {
1375        (0..n)
1376            .map(|i| {
1377                let i = u8::try_from(i % 256).unwrap();
1378                let p1 = Buttons::from_bits_truncate(i.wrapping_mul(37));
1379                let p2 = Buttons::from_bits_truncate(i.wrapping_mul(101).rotate_left(3));
1380                FrameInput::new(p1, p2)
1381            })
1382            .collect()
1383    }
1384
1385    #[test]
1386    fn format_round_trip_power_on() {
1387        let inputs = synthetic_inputs(120);
1388        let movie = Movie {
1389            region: Region::Ntsc,
1390            rom_sha256: [0xAB; 32],
1391            start: StartPoint::PowerOn,
1392            frames: inputs,
1393            rerecord_count: 0,
1394            attestation: None,
1395        };
1396        let bytes = movie.serialize();
1397        let back = Movie::deserialize(&bytes).expect("round-trip");
1398        assert_eq!(movie, back);
1399    }
1400
1401    #[test]
1402    fn rerecord_count_round_trips_and_defaults_for_legacy_rnm() {
1403        let movie = Movie {
1404            region: Region::Ntsc,
1405            rom_sha256: [0x5A; 32],
1406            start: StartPoint::PowerOn,
1407            frames: synthetic_inputs(10),
1408            rerecord_count: 4242,
1409            attestation: None,
1410        };
1411        let bytes = movie.serialize();
1412        // A full round-trip preserves the count.
1413        assert_eq!(Movie::deserialize(&bytes).unwrap().rerecord_count, 4242);
1414        // A pre-v1.8.9 `.rnm` ends exactly at the input stream (no trailing
1415        // count). Dropping the appended u32 must still parse, defaulting the
1416        // count to 0 rather than erroring — the back-compat contract.
1417        let legacy = &bytes[..bytes.len() - 4];
1418        let back = Movie::deserialize(legacy).expect("legacy .rnm still parses");
1419        assert_eq!(back.rerecord_count, 0);
1420        assert_eq!(back.frames.len(), 10);
1421    }
1422
1423    #[test]
1424    fn format_round_trip_with_save_state_start() {
1425        let movie = Movie {
1426            region: Region::Pal,
1427            rom_sha256: [0x11; 32],
1428            start: StartPoint::SaveState(vec![1, 2, 3, 4, 5, 6, 7, 8]),
1429            frames: synthetic_inputs(8),
1430            rerecord_count: 0,
1431            attestation: None,
1432        };
1433        let bytes = movie.serialize();
1434        let back = Movie::deserialize(&bytes).expect("round-trip");
1435        assert_eq!(movie, back);
1436    }
1437
1438    #[test]
1439    fn deserialize_rejects_bad_magic_cleanly() {
1440        let mut bytes = vec![0u8; 49];
1441        bytes[..8].copy_from_slice(b"NOTAMOVI");
1442        assert!(matches!(
1443            Movie::deserialize(&bytes),
1444            Err(MovieError::BadMagic { .. })
1445        ));
1446    }
1447
1448    #[test]
1449    fn deserialize_rejects_too_new_format_cleanly() {
1450        let movie = Movie {
1451            region: Region::Ntsc,
1452            rom_sha256: [0; 32],
1453            start: StartPoint::PowerOn,
1454            frames: Vec::new(),
1455            rerecord_count: 0,
1456            attestation: None,
1457        };
1458        let mut bytes = movie.serialize();
1459        // Bump the format-version field (offset 8) past what we support.
1460        bytes[8] = 0xFF;
1461        bytes[9] = 0xFF;
1462        assert!(matches!(
1463            Movie::deserialize(&bytes),
1464            Err(MovieError::UnsupportedFormat { .. })
1465        ));
1466    }
1467
1468    #[test]
1469    fn deserialize_rejects_truncated_header() {
1470        assert!(matches!(
1471            Movie::deserialize(&[0u8; 10]),
1472            Err(MovieError::HeaderTruncated { .. })
1473        ));
1474    }
1475
1476    #[test]
1477    fn deserialize_hostile_frame_count_does_not_oom() {
1478        // frame_count is the 4-byte LE field right after the 32-byte rom hash
1479        // (offset 8 + 2 + 1 + 1 + 32 = 44).
1480        const FRAME_COUNT_OFF: usize = 8 + 2 + 1 + 1 + 32;
1481        // A tiny (header-only) movie whose `frame_count` field claims ~4.3
1482        // billion frames. The old `Vec::with_capacity(frame_count)` would try to
1483        // reserve multiple gigabytes before the input-stream read failed (an OOM
1484        // DoS found by the `movie` fuzz target). It must now reject cleanly with
1485        // an EOF: the capacity is capped at the remaining bytes / width.
1486        let movie = Movie {
1487            region: Region::Ntsc,
1488            rom_sha256: [0; 32],
1489            start: StartPoint::PowerOn,
1490            frames: Vec::new(),
1491            rerecord_count: 0,
1492            attestation: None,
1493        };
1494        let mut bytes = movie.serialize();
1495        bytes[FRAME_COUNT_OFF..FRAME_COUNT_OFF + 4].copy_from_slice(&u32::MAX.to_le_bytes());
1496        // Deserialize must return promptly with an error, not exhaust memory.
1497        assert!(matches!(
1498            Movie::deserialize(&bytes),
1499            Err(MovieError::Eof(_))
1500        ));
1501    }
1502
1503    #[test]
1504    fn deserialize_rejects_truncated_input_stream() {
1505        let movie = Movie {
1506            region: Region::Ntsc,
1507            rom_sha256: [0; 32],
1508            start: StartPoint::PowerOn,
1509            frames: synthetic_inputs(10),
1510            rerecord_count: 0,
1511            attestation: None,
1512        };
1513        let bytes = movie.serialize();
1514        // Lop off the last few input bytes — must error, not panic.
1515        let truncated = &bytes[..bytes.len() - 5];
1516        assert!(matches!(
1517            Movie::deserialize(truncated),
1518            Err(MovieError::Eof(_))
1519        ));
1520    }
1521
1522    /// Drive a ROM with a fixed input sequence, recording as we go; then
1523    /// replay from the movie's start point and assert framebuffer + audio +
1524    /// cycle count are byte-identical.
1525    #[test]
1526    fn determinism_round_trip_power_on() {
1527        let rom = synth_nrom();
1528        let inputs = synthetic_inputs(30);
1529
1530        // ----- Original run (recording). -----
1531        let mut nes = Nes::from_rom(&rom).expect("boot");
1532        nes.power_cycle(); // start point a replay will reconstruct
1533        let mut rec = MovieRecorder::power_on(&nes);
1534        let mut orig_fb = 0u64;
1535        let mut orig_audio = Vec::new();
1536        for f in &inputs {
1537            nes.set_buttons(0, f.p1);
1538            nes.set_buttons(1, f.p2);
1539            rec.capture(&nes);
1540            orig_fb = fnv(nes.run_frame());
1541            orig_audio.extend(nes.drain_audio());
1542        }
1543        let orig_cycle = nes.cycle();
1544        let orig_audio_hash = audio_fnv(&orig_audio);
1545        let movie = rec.finish();
1546        assert_eq!(movie.len(), inputs.len());
1547
1548        // ----- Replay from the movie's start point. -----
1549        let mut replay = Nes::from_rom(&rom).expect("boot");
1550        movie.seek_to_start(&mut replay).expect("seek");
1551        let mut player = MoviePlayer::new(&movie);
1552        let mut replay_fb = 0u64;
1553        let mut replay_audio = Vec::new();
1554        while player.apply_next(&mut replay) {
1555            replay_fb = fnv(replay.run_frame());
1556            replay_audio.extend(replay.drain_audio());
1557        }
1558
1559        assert_eq!(orig_fb, replay_fb, "framebuffer must replay bit-identical");
1560        assert_eq!(
1561            orig_audio_hash,
1562            audio_fnv(&replay_audio),
1563            "audio must replay bit-identical"
1564        );
1565        assert_eq!(
1566            orig_cycle,
1567            replay.cycle(),
1568            "cumulative cycle count must replay bit-identical"
1569        );
1570    }
1571
1572    /// Replaying the same movie twice must yield identical output (the movie
1573    /// itself is internally deterministic).
1574    #[test]
1575    fn replay_is_internally_deterministic() {
1576        let rom = synth_nrom();
1577        let movie = Movie {
1578            region: Region::Ntsc,
1579            rom_sha256: *Nes::from_rom(&rom).unwrap().rom_sha256(),
1580            start: StartPoint::PowerOn,
1581            frames: synthetic_inputs(20),
1582            rerecord_count: 0,
1583            attestation: None,
1584        };
1585
1586        let run = |movie: &Movie| -> (u64, u64, u64) {
1587            let mut nes = Nes::from_rom(&rom).unwrap();
1588            movie.seek_to_start(&mut nes).unwrap();
1589            let mut player = MoviePlayer::new(movie);
1590            let mut fb = 0u64;
1591            let mut audio = Vec::new();
1592            while player.apply_next(&mut nes) {
1593                fb = fnv(nes.run_frame());
1594                audio.extend(nes.drain_audio());
1595            }
1596            (fb, audio_fnv(&audio), nes.cycle())
1597        };
1598
1599        assert_eq!(run(&movie), run(&movie));
1600    }
1601
1602    /// Save-state branch: run a base movie partway, snapshot, start a new
1603    /// branch recorder from that snapshot, and assert the branch replay is
1604    /// internally deterministic and reconstructs the branch start point.
1605    #[test]
1606    fn save_state_branch_round_trip() {
1607        let rom = synth_nrom();
1608
1609        // Base run: advance some frames with a fixed input, then branch.
1610        let base_inputs = synthetic_inputs(10);
1611        let mut nes = Nes::from_rom(&rom).unwrap();
1612        nes.power_cycle();
1613        for f in &base_inputs {
1614            nes.set_buttons(0, f.p1);
1615            nes.set_buttons(1, f.p2);
1616            nes.run_frame();
1617        }
1618        let branch_cycle = nes.cycle();
1619        let branch_fb = fnv(nes.framebuffer());
1620
1621        // Start a branch recorder from the current state, record more frames.
1622        let mut branch_rec = MovieRecorder::from_current_state(&nes);
1623        let branch_inputs = synthetic_inputs(15);
1624        for f in &branch_inputs {
1625            nes.set_buttons(0, f.p1);
1626            nes.set_buttons(1, f.p2);
1627            branch_rec.capture(&nes);
1628            nes.run_frame();
1629        }
1630        let branch_end_cycle = nes.cycle();
1631        let branch_end_fb = fnv(nes.framebuffer());
1632        let branch_movie = branch_rec.finish();
1633        assert!(matches!(branch_movie.start, StartPoint::SaveState(_)));
1634
1635        // Replay the branch from its embedded snapshot.
1636        let run_branch = || -> (u64, u64) {
1637            let mut replay = Nes::from_rom(&rom).unwrap();
1638            branch_movie.seek_to_start(&mut replay).unwrap();
1639            // After seeking, we are back at the branch start point.
1640            assert_eq!(replay.cycle(), branch_cycle, "branch start cycle");
1641            assert_eq!(fnv(replay.framebuffer()), branch_fb, "branch start fb");
1642            let mut player = MoviePlayer::new(&branch_movie);
1643            let mut fb = 0u64;
1644            while player.apply_next(&mut replay) {
1645                fb = fnv(replay.run_frame());
1646            }
1647            (fb, replay.cycle())
1648        };
1649
1650        let first = run_branch();
1651        let second = run_branch();
1652        assert_eq!(first, second, "branch replay internally deterministic");
1653        // And it reconstructs the live branch end state bit-identically.
1654        assert_eq!(first.0, branch_end_fb, "branch end fb matches live run");
1655        assert_eq!(
1656            first.1, branch_end_cycle,
1657            "branch end cycle matches live run"
1658        );
1659
1660        // Format round-trip survives the embedded save state.
1661        let bytes = branch_movie.serialize();
1662        let back = Movie::deserialize(&bytes).unwrap();
1663        assert_eq!(branch_movie, back);
1664    }
1665
1666    #[test]
1667    fn seek_rejects_rom_mismatch() {
1668        let rom = synth_nrom();
1669        let movie = Movie {
1670            region: Region::Ntsc,
1671            rom_sha256: [0xFF; 32], // deliberately wrong
1672            start: StartPoint::PowerOn,
1673            frames: Vec::new(),
1674            rerecord_count: 0,
1675            attestation: None,
1676        };
1677        let mut nes = Nes::from_rom(&rom).unwrap();
1678        assert!(matches!(
1679            movie.seek_to_start(&mut nes),
1680            Err(MovieError::RomMismatch)
1681        ));
1682    }
1683
1684    #[test]
1685    fn frame_input_bit_layout_matches_buttons() {
1686        // The on-wire byte for a frame is exactly Buttons::bits() (FCEUX
1687        // .fm2 layout). Verify the serialize path preserves it.
1688        let movie = Movie {
1689            region: Region::Ntsc,
1690            rom_sha256: [0; 32],
1691            start: StartPoint::PowerOn,
1692            frames: vec![FrameInput::new(
1693                Buttons::A | Buttons::RIGHT,
1694                Buttons::B | Buttons::START,
1695            )],
1696            rerecord_count: 0,
1697            attestation: None,
1698        };
1699        let bytes = movie.serialize();
1700        // Input stream begins right after the 49-byte fixed header (no
1701        // save state).
1702        let p1 = bytes[49];
1703        let p2 = bytes[50];
1704        assert_eq!(p1, (Buttons::A | Buttons::RIGHT).bits());
1705        assert_eq!(p2, (Buttons::B | Buttons::START).bits());
1706        assert_eq!(bytes[51], 0, "expansion byte reserved/zero");
1707    }
1708
1709    #[test]
1710    fn recorded_before_v2_timebase_flags_pre_promote_movies() {
1711        // ADR 0028: a freshly-serialized movie carries the current
1712        // MOVIE_FORMAT_VERSION (>= 2) and must NOT be flagged.
1713        let movie = Movie {
1714            region: Region::Ntsc,
1715            rom_sha256: [0; 32],
1716            start: StartPoint::PowerOn,
1717            frames: vec![],
1718            rerecord_count: 0,
1719            attestation: None,
1720        };
1721        let bytes = movie.serialize();
1722        assert!(matches!(recorded_before_v2_timebase(&bytes), Ok(false)));
1723
1724        // A v1-tagged blob (format_version = 1, the only value that existed
1725        // pre-v2.0.0) must be flagged, even though it still parses fine.
1726        let mut v1_bytes = bytes;
1727        v1_bytes[8..10].copy_from_slice(&1u16.to_le_bytes());
1728        assert!(matches!(recorded_before_v2_timebase(&v1_bytes), Ok(true)));
1729        assert!(
1730            Movie::deserialize(&v1_bytes).is_ok(),
1731            "a v1-tagged movie must still parse and play as input"
1732        );
1733
1734        // Malformed input still surfaces the normal header errors.
1735        assert!(matches!(
1736            recorded_before_v2_timebase(&[0u8; 4]),
1737            Err(MovieError::HeaderTruncated { .. })
1738        ));
1739        assert!(matches!(
1740            recorded_before_v2_timebase(&[0xFFu8; 10]),
1741            Err(MovieError::BadMagic { .. })
1742        ));
1743    }
1744}