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 5 = MOVIE_FORMAT_VERSION)
20//!     emulation epoch : u32 LE        (format 5+; `EMULATION_EPOCH`, ADR 0045)
21//!     region          : u8            (0 = NTSC, 1 = PAL, 2 = Dendy)
22//!     flags           : u8            (bit0 = embedded save-state start point,
23//!                                      bit1 = board description recorded)
24//!     rom sha-256     : [u8; 32]      (`Nes::rom_sha256`: the image after its
25//!                                      16-byte header — the ROM identity)
26//!     frame count     : u32 LE
27//!     bytes per frame : u8            (currently 5: P1, P2, P3, P4,
28//!                                      expansion-reserved; 3 before format 3)
29//! OPTIONS (format 3+): u32 LE length + that many bytes:
30//!     the `HardwareOptions` encoding, then (flags bit1) the
31//!     `BoardDescription` encoding — see `crate::hardware_options`
32//! START POINT (only when flags bit0 set):
33//!     length-prefixed `.rns` save-state blob (u32 LE length + bytes)
34//! INPUT STREAM:
35//!     frame_count * bytes_per_frame raw bytes; each frame =
36//!     [p1, p2, p3, p4, expansion]
37//! ```
38//!
39//! This module is `no_std`-clean: it uses only `core` + `alloc` and the
40//! `BinWriter` / `BinReader` primitives from [`crate::save_state`].
41
42use alloc::vec::Vec;
43
44use alloc::string::String;
45
46use crate::Region;
47use crate::controller::Buttons;
48use crate::hardware_options::{BoardDescription, HardwareOptions, OptionsDecodeError};
49use crate::nes::Nes;
50use crate::save_state::{BinReader, BinWriter, SnapshotError};
51use thiserror::Error;
52
53/// Magic header bytes — first 8 bytes of every `.rnm` movie file.
54pub const MOVIE_MAGIC: &[u8; 8] = b"RNESMOV1";
55
56/// Current movie container-format version.
57///
58/// - v1 (v1.1.0 ..): the format documented above.
59/// - **v2 (v2.0.0 "Timebase" rc.1, ADR 0028)**: on-wire layout unchanged —
60///   this is purely an epoch marker. A `.rnm` with `format_version < 2` was
61///   necessarily recorded on a pre-promote (pre-beta.4) build; per the
62///   determinism contract, its INPUT STREAM still replays fine (nothing
63///   about frame timing or button semantics changed), but the
64///   frame-for-frame bit-identical reproduction guarantee the movie format
65///   depends on is only proven within a single engine timebase — the
66///   one-clock promote changed how master-clock/PPU/CPU phase advances
67///   internally, so a v1-recorded movie's *exact* framebuffer/audio replay
68///   on the v2.0.0-line engine is unverified, not guaranteed. Do NOT
69///   attempt timeline transcoding (re-deriving a v2-native recording from
70///   a v1 one) — that is out of scope; the honest move is surfacing the
71///   epoch, not silently promising equivalence. See
72///   [`recorded_before_v2_timebase`] for the check callers (TAS tooling,
73///   frontend movie-load UI) should use before relying on verify-replay.
74/// - **v3 (v2.9.8, ADR 0028's epoch rule)**: an OPTIONS block follows the
75///   fixed header, carrying every emulation-affecting host option the movie
76///   was recorded with ([`HardwareOptions`]) and, for a recorded movie, the
77///   cartridge board the header described ([`BoardDescription`]). Playback
78///   applies the options before frame 0 and refuses a board or region that
79///   differs, so a replay runs the recorded machine whatever the player's own
80///   settings are. A v1 or v2 movie does not say which machine it ran on, so
81///   it is refused ([`MIN_MOVIE_FORMAT_VERSION`]) rather than replayed on a
82///   guess; the maintainer accepted breaking them (2026-10-01).
83/// - **v4 (v2.9.9, core re-audit NC-10)**: the [`BoardDescription`] gains the
84///   PRG-ROM and CHR-ROM sizes and the raw header nametable bits. v3 could
85///   not tell two headers that split one body differently, or that differ
86///   only in the bits mappers 30 and 218 wire from, so a movie replayed
87///   silently on a different machine. v3 is refused, as v1 and v2 are, under
88///   the same "enduring over compatible" rule.
89/// - **v5 (v3.0.0, ADR 0045)**: the fixed header carries the
90///   [`EMULATION_EPOCH`](crate::EMULATION_EPOCH) the movie was recorded under,
91///   straight after the format version so a tool can read it without parsing
92///   the rest. v2.9.9 and v3.0.0 emulate MMC3 games with the background at
93///   `$1000` differently (T-MMC3-BG-A12), and a format-4 movie does not say
94///   which behaviour it assumes, so v4 is refused. A v5 movie from another
95///   epoch is refused with [`MovieError::EpochMismatch`].
96/// - **v6 (v3.1.0)**: the [`crate::HardwareOptions`] record gains the
97///   CPU-multiplier overclock and the sprite-limit option (`T-CPU-OVERCLOCK`,
98///   `T-SPRITE-LIMIT`). A v5 options record is one field shorter and would
99///   decode as garbage, so v5 is refused. No replayable movie is lost: every
100///   v5 movie was recorded under epoch 1 or 2, which v3.1.0 (epoch 3) refuses
101///   anyway.
102pub const MOVIE_FORMAT_VERSION: u16 = 6;
103
104/// The oldest container version this build replays: v6.
105///
106/// v6 is the first whose options record carries the CPU overclock and the
107/// sprite-limit option (v5 first recorded the emulation epoch, v4 the board,
108/// v3 the options). Older movies fail with [`MovieError::FormatTooOld`].
109pub const MIN_MOVIE_FORMAT_VERSION: u16 = 6;
110
111/// Peek a `.rnm` blob's header to learn its recording epoch.
112///
113/// Checks whether it was recorded on a pre-v2.0.0-timebase build
114/// (`format_version < 2`), WITHOUT fully parsing the movie. Intended for
115/// tooling/UI that wants to warn before relying on the determinism
116/// (verify-replay) guarantee across the v2.0.0 engine-timebase boundary —
117/// see [`MOVIE_FORMAT_VERSION`]'s v2 doc.
118///
119/// Since v2.9.8 [`Movie::deserialize`] refuses every movie older than
120/// [`MIN_MOVIE_FORMAT_VERSION`], so a movie that parses is never pre-v2; the
121/// function survives for tooling that inspects raw files, and still answers
122/// for any header it is shown.
123///
124/// # Errors
125///
126/// Returns [`MovieError::HeaderTruncated`] or [`MovieError::BadMagic`] if
127/// the blob doesn't even have a valid movie header.
128pub fn recorded_before_v2_timebase(bytes: &[u8]) -> Result<bool, MovieError> {
129    const MIN_LEN: usize = 8 + 2;
130    if bytes.len() < MIN_LEN {
131        return Err(MovieError::HeaderTruncated {
132            expected: MIN_LEN,
133            got: bytes.len(),
134        });
135    }
136    let mut magic = [0u8; 8];
137    magic.copy_from_slice(&bytes[..8]);
138    if &magic != MOVIE_MAGIC {
139        return Err(MovieError::BadMagic { got: magic });
140    }
141    let format_version = u16::from_le_bytes([bytes[8], bytes[9]]);
142    Ok(format_version < 2)
143}
144
145/// Bytes stored per recorded frame: players 1-4, then a reserved
146/// expansion-port byte (always `0` today).
147///
148/// Format 3 (v2.9.8) widened the record from 3 bytes (P1, P2, expansion) to 5
149/// so Four Score players 3 and 4 are recorded; the order puts the players
150/// first, so a narrower record (width 2 = two pads, width 4 = four pads) reads
151/// as "the later fields absent". Stored explicitly in the header so a future
152/// device byte can grow the record without a container-version bump.
153pub const BYTES_PER_FRAME: u8 = 5;
154
155/// Header flag: an embedded `.rns` save-state start point follows the header.
156const FLAG_HAS_SAVE_STATE: u8 = 0x01;
157
158/// Header flag (format 3+): the OPTIONS block carries a [`BoardDescription`]
159/// after the [`HardwareOptions`]. Clear for a foreign import, whose source
160/// format records no header.
161const FLAG_HAS_BOARD: u8 = 0x02;
162
163/// The header flags this build understands. Any other bit set is refused, so a
164/// later format cannot be half-read.
165const KNOWN_FLAGS: u8 = FLAG_HAS_SAVE_STATE | FLAG_HAS_BOARD;
166
167/// Per-frame controller input: four controller ports plus an expansion byte.
168///
169/// Each port is a `Buttons` value. Bit layout matches FCEUX `.fm2`
170/// (`bit0=A .. bit7=Right`), which is exactly [`Buttons::bits`].
171///
172/// # Players 3 and 4 (v2.9.8)
173///
174/// `p3` / `p4` are the Four Score's players, polled through `$4016` / `$4017`
175/// only while the adapter is plugged in (which the movie's
176/// [`HardwareOptions::four_score`] records). Until v2.9.8 the struct held two
177/// ports, so a four-player recording captured half of what drove it and the
178/// `.fm2` importer dropped pads 3 and 4.
179///
180/// # Why `#[non_exhaustive]`
181///
182/// Adding `p3` / `p4` broke every caller that built the struct by literal,
183/// which is why the change waited for a breaking release. The next field is
184/// foreseeable -- an expansion-port device byte, a soft-reset command -- and
185/// `.rnm` already stores its per-frame width so the FORMAT side of such a
186/// field is additive; `#[non_exhaustive]` makes the API side additive too.
187/// Outside this crate a frame is built with [`Self::new`],
188/// [`Self::four_players`] or [`Default`] and then edited through its public
189/// fields, all of which keep compiling when a field is added.
190#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
191#[non_exhaustive]
192pub struct FrameInput {
193    /// Player 1 (`$4016`) button state.
194    pub p1: Buttons,
195    /// Player 2 (`$4017`) button state.
196    pub p2: Buttons,
197    /// Player 3 (Four Score, multiplexed on `$4016`) button state.
198    pub p3: Buttons,
199    /// Player 4 (Four Score, multiplexed on `$4017`) button state.
200    pub p4: Buttons,
201    /// Reserved expansion-port byte (currently always `0`).
202    pub expansion: u8,
203}
204
205impl FrameInput {
206    /// Build a two-controller frame (players 3/4 released, no expansion byte).
207    #[must_use]
208    pub const fn new(p1: Buttons, p2: Buttons) -> Self {
209        Self::four_players(p1, p2, Buttons::empty(), Buttons::empty())
210    }
211
212    /// v2.9.8 — build a four-controller frame (no expansion byte).
213    #[must_use]
214    pub const fn four_players(p1: Buttons, p2: Buttons, p3: Buttons, p4: Buttons) -> Self {
215        Self {
216            p1,
217            p2,
218            p3,
219            p4,
220            expansion: 0,
221        }
222    }
223
224    /// v2.9.8 — the buttons on controller `port` (0-3 = players 1-4).
225    ///
226    /// # Panics
227    ///
228    /// Panics if `port` is not in `0..=3`, matching [`Nes::set_buttons`].
229    #[must_use]
230    pub const fn port(&self, port: usize) -> Buttons {
231        match port {
232            0 => self.p1,
233            1 => self.p2,
234            2 => self.p3,
235            3 => self.p4,
236            _ => panic!("controller port out of range"),
237        }
238    }
239
240    /// v2.9.8 — mutable access to controller `port` (0-3 = players 1-4).
241    ///
242    /// # Panics
243    ///
244    /// Panics if `port` is not in `0..=3`.
245    pub const fn port_mut(&mut self, port: usize) -> &mut Buttons {
246        match port {
247            0 => &mut self.p1,
248            1 => &mut self.p2,
249            2 => &mut self.p3,
250            3 => &mut self.p4,
251            _ => panic!("controller port out of range"),
252        }
253    }
254
255    /// v2.9.8 — drive all four controller ports of `nes` from this frame.
256    ///
257    /// Ports 2 and 3 are only polled while the Four Score is plugged in, so
258    /// setting them on a two-pad machine changes nothing it reads.
259    pub const fn apply_to(&self, nes: &mut Nes) {
260        nes.set_buttons(0, self.p1);
261        nes.set_buttons(1, self.p2);
262        nes.set_buttons(2, self.p3);
263        nes.set_buttons(3, self.p4);
264    }
265
266    /// v2.9.8 — the input currently held on all four ports of `nes`.
267    #[must_use]
268    pub const fn held_on(nes: &Nes) -> Self {
269        Self::four_players(
270            nes.buttons(0),
271            nes.buttons(1),
272            nes.buttons(2),
273            nes.buttons(3),
274        )
275    }
276}
277
278/// Marker for the optional attestation tail: `"RNAT"` little-endian.
279///
280/// Read as a `u32` after the re-record count. A movie with no attestation simply
281/// ends there, so the marker is what distinguishes "no attestation recorded" from
282/// "attestation present" — absence is a fact, not a parse failure.
283pub const ATTESTATION_MAGIC: u32 = u32::from_le_bytes(*b"RNAT");
284
285/// Attestation tail schema version.
286///
287/// 2 (v2.9.8): each frame folds in all four players' bytes, not two, so a
288/// version-1 tail describes a different hash and is not compared.
289pub const ATTESTATION_VERSION: u16 = 2;
290
291/// Frames between recorded checkpoint hashes.
292///
293/// The final hash alone would answer "did this run reproduce?"; the checkpoints
294/// answer "and if not, roughly where did it stop reproducing?", which is the
295/// difference between a verdict and a diagnosis. At 8 bytes per checkpoint a
296/// ten-minute run costs about 4.5 KiB.
297pub const ATTESTATION_CHECKPOINT_INTERVAL: u32 = 64;
298
299/// A rolling hash of a run's video output, and the checkpoints along the way.
300///
301/// # What is attested
302///
303/// Per frame, **the input applied and the framebuffer it produced**, folded into
304/// one rolling hash. Both halves are load-bearing:
305///
306/// - The framebuffer is the user-visible output the determinism contract
307///   promises is bit-identical for the same ROM, seed, and input sequence.
308/// - The input is folded in because output alone does not pin the input stream.
309///   A ROM that ignores the controller — a test ROM, an attract-mode demo, a
310///   cutscene — produces identical video no matter what buttons the movie
311///   claims were pressed, so an output-only hash would confirm a tampered input
312///   log as genuine. Found by exactly that: an end-to-end tamper test flipped a
313///   button bit in a movie for an input-ignoring ROM and the run still verified.
314///
315/// Together they attest the real claim: *these inputs, applied to this ROM,
316/// produced this output.*
317///
318/// Hashing the core snapshot instead would be strictly stronger at detecting
319/// divergence, and was rejected for one reason: the snapshot schema is versioned
320/// and bumps between releases (`PPU_SNAPSHOT_VERSION` has reached 8), so every
321/// schema bump would silently invalidate every previously-recorded attestation.
322/// A 256x240 RGBA framebuffer is stable for as long as the NES is the NES. An
323/// attestation is only worth recording if it can still be checked years later.
324///
325/// Audio is **not** covered: samples are drained by the host as they are
326/// produced, so the core cannot see a whole run's audio without the frontend
327/// cooperating. Saying so is better than implying coverage that is not there.
328#[derive(Clone, Debug, Eq, PartialEq)]
329pub struct Attestation {
330    /// Number of frames the attestation covers. Cross-checked against the input
331    /// stream on load, so a tail that describes a different run is rejected
332    /// rather than compared against the wrong frame count.
333    pub frame_count: u32,
334    /// Rolling hash after the final frame.
335    pub final_hash: u64,
336    /// Rolling hash after frames `INTERVAL-1`, `2*INTERVAL-1`, ... in order.
337    pub checkpoints: Vec<u64>,
338}
339
340/// FNV-1a-style rolling hash over 64-bit words.
341///
342/// # This is a tamper-EVIDENT digest, not a cryptographic one
343///
344/// 64-bit FNV-1a is not collision resistant, and its round function is
345/// invertible (`PRIME` is odd, so multiplication is a bijection mod 2^64). It
346/// reliably detects accidental divergence — a different build, a real
347/// nondeterminism bug, a truncated file — and casual edits, which is what
348/// [`Movie::verify`] is for. It does **not** resist a motivated forger: anyone
349/// who edits the movie can recompute the digest, and nothing here binds the
350/// record to an author.
351///
352/// Say "reproduces the recorded run", not "proves the run is genuine". Making a
353/// forgery-resistant claim would need a signature over the whole record with a
354/// key the verifier trusts, which is a different feature. Flagged in review on
355/// PR #356, where the surrounding prose had drifted into the stronger claim.
356///
357/// Written here rather than reused: the two existing `fnv1a64` helpers in this
358/// workspace are behind `rustynes-ppu`'s `ppu-state-trace` feature and in the
359/// test harness respectively, and neither is reachable from `no_std` core code
360/// on the default build. It is six lines; taking a feature dependency to avoid
361/// them would cost more than it saves.
362///
363/// Words rather than bytes because a framebuffer is 245,760 bytes and this runs
364/// once per frame during both recording and verification.
365#[derive(Clone, Copy, Debug)]
366struct RollingHash(u64);
367
368impl RollingHash {
369    const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
370    const PRIME: u64 = 0x0000_0100_0000_01b3;
371
372    const fn new() -> Self {
373        Self(Self::OFFSET_BASIS)
374    }
375
376    /// Fold a byte slice in, 8 bytes at a time. A trailing partial word is
377    /// zero-padded, which is unambiguous here because every input is a
378    /// fixed-size framebuffer.
379    fn write(&mut self, bytes: &[u8]) {
380        let (words, rem) = bytes.as_chunks::<8>();
381        for w in words {
382            self.0 = (self.0 ^ u64::from_le_bytes(*w)).wrapping_mul(Self::PRIME);
383        }
384        if !rem.is_empty() {
385            let mut buf = [0u8; 8];
386            buf[..rem.len()].copy_from_slice(rem);
387            self.0 = (self.0 ^ u64::from_le_bytes(buf)).wrapping_mul(Self::PRIME);
388        }
389    }
390}
391
392/// Accumulates an [`Attestation`] one frame at a time.
393///
394/// Feed it the framebuffer after each `run_frame`; it maintains the rolling hash
395/// and emits a checkpoint every [`ATTESTATION_CHECKPOINT_INTERVAL`] frames.
396#[derive(Clone, Debug)]
397pub struct AttestationBuilder {
398    hash: RollingHash,
399    frame_count: u32,
400    checkpoints: Vec<u64>,
401}
402
403impl AttestationBuilder {
404    /// Start a fresh attestation.
405    #[must_use]
406    pub const fn new() -> Self {
407        Self {
408            hash: RollingHash::new(),
409            frame_count: 0,
410            checkpoints: Vec::new(),
411        }
412    }
413
414    /// Fold in one frame: the input applied, then the video it produced.
415    pub fn push_frame(&mut self, input: FrameInput, framebuffer: &[u8]) {
416        self.hash.write(&[
417            input.p1.bits(),
418            input.p2.bits(),
419            input.p3.bits(),
420            input.p4.bits(),
421            input.expansion,
422        ]);
423        self.hash.write(framebuffer);
424        self.frame_count = self.frame_count.saturating_add(1);
425        if self
426            .frame_count
427            .is_multiple_of(ATTESTATION_CHECKPOINT_INTERVAL)
428        {
429            self.checkpoints.push(self.hash.0);
430        }
431    }
432
433    /// Frames folded in so far.
434    #[must_use]
435    pub const fn frame_count(&self) -> u32 {
436        self.frame_count
437    }
438
439    /// The rolling hash as it stands.
440    #[must_use]
441    pub const fn current_hash(&self) -> u64 {
442        self.hash.0
443    }
444
445    /// Finish and produce the attestation.
446    #[must_use]
447    pub fn finish(self) -> Attestation {
448        Attestation {
449            frame_count: self.frame_count,
450            final_hash: self.hash.0,
451            checkpoints: self.checkpoints,
452        }
453    }
454}
455
456impl Default for AttestationBuilder {
457    fn default() -> Self {
458        Self::new()
459    }
460}
461
462/// The result of replaying an attested movie and comparing it to its record.
463#[derive(Clone, Debug, Eq, PartialEq)]
464pub enum VerifyOutcome {
465    /// The replay reproduced the recorded run exactly.
466    Match {
467        /// Frames replayed.
468        frames: u32,
469        /// The hash both the record and the replay produced.
470        hash: u64,
471    },
472    /// The replay diverged.
473    Mismatch {
474        /// Frames replayed.
475        frames: u32,
476        /// The hash the movie claims.
477        expected: u64,
478        /// The hash this replay produced.
479        got: u64,
480        /// Index of the first checkpoint that disagreed, if any did. The
481        /// divergence began somewhere in the
482        /// [`ATTESTATION_CHECKPOINT_INTERVAL`] frames ending at
483        /// `(index + 1) * INTERVAL - 1`. `None` means every recorded checkpoint
484        /// matched and only the final hash differs — i.e. the divergence is in
485        /// the tail after the last checkpoint.
486        first_bad_checkpoint: Option<u32>,
487    },
488    /// The movie carries no attestation, so there is nothing to verify against.
489    /// Not an error: most movies are recorded without one.
490    NotAttested,
491}
492
493/// Read the optional attestation tail, if one is present and coherent.
494///
495/// Returns `None` — never an error — for every way the tail can be absent or
496/// unusable: no bytes left, a different marker, a schema version this build does
497/// not know, a truncated body, or a frame count that disagrees with the input
498/// stream. A movie without a usable attestation is a perfectly good movie; the
499/// only wrong answer would be to report an attestation that does not describe
500/// this run, so a `frame_count` mismatch drops it rather than comparing against
501/// the wrong length.
502///
503/// The checkpoint count is bounded by what the remaining input could actually
504/// hold before reserving, for the same reason `frame_count` is: a hostile
505/// four-byte field must not be able to request a multi-gigabyte allocation.
506fn read_attestation(r: &mut BinReader<'_>, frames: usize) -> Option<Attestation> {
507    if r.u32().ok()? != ATTESTATION_MAGIC {
508        return None;
509    }
510    if r.u16().ok()? != ATTESTATION_VERSION {
511        return None;
512    }
513    let frame_count = r.u32().ok()?;
514    if frame_count as usize != frames {
515        return None;
516    }
517    let final_hash = r.u64().ok()?;
518    let declared = r.u32().ok()? as usize;
519    let max_plausible = r.remaining() / core::mem::size_of::<u64>();
520    let mut checkpoints = Vec::with_capacity(declared.min(max_plausible));
521    for _ in 0..declared {
522        checkpoints.push(r.u64().ok()?);
523    }
524    Some(Attestation {
525        frame_count,
526        final_hash,
527        checkpoints,
528    })
529}
530
531/// Where a movie begins. Clean-room analogue of Mesen2's `RecordMovieFrom`.
532#[derive(Clone, Debug, Eq, PartialEq)]
533pub enum StartPoint {
534    /// Power-on the ROM fresh, then apply inputs from frame 0. The most
535    /// durable start point across version transitions (depends only on the
536    /// ROM and the deterministic power-on).
537    PowerOn,
538    /// Restore this embedded `.rns` snapshot, then apply inputs from there.
539    /// Enables save-state branching (a movie that begins mid-game).
540    SaveState(Vec<u8>),
541}
542
543/// Errors produced by movie encode / decode / playback.
544#[derive(Debug, Error)]
545#[non_exhaustive]
546pub enum MovieError {
547    /// The blob is shorter than the fixed header.
548    #[error("movie truncated: header needs {expected} bytes, got {got}")]
549    HeaderTruncated {
550        /// Expected byte count.
551        expected: usize,
552        /// Actual byte count.
553        got: usize,
554    },
555
556    /// The magic prefix is wrong.
557    #[error("movie magic mismatch: expected {:?}, got {got:?}", MOVIE_MAGIC)]
558    BadMagic {
559        /// Bytes observed at the magic offset.
560        got: [u8; 8],
561    },
562
563    /// The container format version is outside the range we understand.
564    #[error("movie container format version {got} not supported (max {max})")]
565    UnsupportedFormat {
566        /// Version we read.
567        got: u16,
568        /// Highest version we accept.
569        max: u16,
570    },
571
572    /// v2.9.8 — the movie was written by an older release whose format does
573    /// not record everything a faithful replay needs: before format 3 the
574    /// emulation options, before 4 the full board, before 5 (v3.0.0) the
575    /// emulation epoch. (Until v3.0.0 the message named only the options,
576    /// which stopped being the whole story at format 4.)
577    #[error(
578        "movie format version {got} was written by an older release of RustyNES \
579         (this version replays format {min} and later), and it does not record \
580         everything a faithful replay needs; re-record it with this version"
581    )]
582    FormatTooOld {
583        /// Version we read.
584        got: u16,
585        /// Oldest version this build replays.
586        min: u16,
587    },
588
589    /// v2.9.8 — the header flags carry a bit this build does not know.
590    #[error("movie header flags {0:#04x} carry bits this build does not understand")]
591    UnknownFlags(u8),
592
593    /// v2.9.8 — the OPTIONS block is malformed (an unknown enum byte, a bad
594    /// Game Genie code, a truncated field).
595    #[error("movie emulation options are malformed: {0}")]
596    BadOptions(OptionsDecodeError),
597
598    /// v2.9.8 — the movie was recorded on another region (from a header that
599    /// said PAL, say, where this one says NTSC). A region is built into the
600    /// machine at load, so it cannot be applied; the movie is refused.
601    #[error(
602        "movie was recorded on {movie:?} timing but this ROM runs as {host:?}; \
603         load a dump whose header declares {movie:?}"
604    )]
605    RegionMismatch {
606        /// The movie's region.
607        movie: Region,
608        /// The running machine's region.
609        host: Region,
610    },
611
612    /// v2.9.8 — same ROM, different header: the cartridge the movie ran on
613    /// had another mapper, submapper, mirroring, RAM size, battery or
614    /// trainer. Since v2.9.8 the ROM identity excludes the header, so this is
615    /// what tells a re-headered dump (or a changed database correction)
616    /// apart.
617    #[error(
618        "movie was recorded on the same ROM with a different header: the {field} \
619         differs; load the dump (or game-database correction) it was made with"
620    )]
621    BoardMismatch {
622        /// The first board field that differs.
623        field: &'static str,
624    },
625
626    /// v2.9.8 — an option could not be applied to this machine (a Game Genie
627    /// code that does not decode, in a hand-built movie).
628    #[error("movie emulation option could not be applied: Game Genie code {0:?}")]
629    OptionNotApplicable(String),
630
631    /// v2.9.8 — the movie's embedded start-point save state predates the
632    /// `.rns` container epoch 3 (ADR 0042), which v2.9.8 refuses. Kept apart
633    /// from [`MovieError::BadSaveState`] so the message says what to do, in
634    /// the same words as [`MovieError::FormatTooOld`].
635    #[error(
636        "movie starts from a save state written by an older release (container \
637         version {got}, this build reads {min} and later); re-record it with this \
638         version"
639    )]
640    StartStateTooOld {
641        /// The embedded state's container version.
642        got: u16,
643        /// Oldest container version this build reads.
644        min: u16,
645    },
646
647    /// The header declared more bytes-per-frame than this build understands.
648    #[error("movie declares {got} bytes/frame; this build understands {max}")]
649    UnsupportedFrameWidth {
650        /// Declared width.
651        got: u8,
652        /// Width this build can parse.
653        max: u8,
654    },
655
656    /// The region byte is not a value this build understands.
657    #[error("movie region byte {0} is not a known region")]
658    BadRegion(u8),
659
660    /// The body (start point and/or input stream) ran past EOF.
661    #[error("movie truncated mid-body at offset {0}")]
662    Eof(usize),
663
664    /// The embedded start-point save state failed to apply.
665    #[error("movie start-point save state invalid: {0}")]
666    BadSaveState(#[from] SnapshotError),
667
668    /// The running ROM's hash does not match the movie's recorded hash.
669    #[error("movie ROM hash mismatch (this movie was recorded against a different ROM)")]
670    RomMismatch,
671
672    /// v3.0.0 (ADR 0045) — the movie was recorded by a version of `RustyNES`
673    /// that emulates differently: its [`EMULATION_EPOCH`](crate::EMULATION_EPOCH)
674    /// is not this build's. Replaying it would run the recorded inputs on
675    /// different timing, so it is refused rather than allowed to diverge.
676    #[error(
677        "movie was recorded by a version of RustyNES that emulates differently \
678         (emulation epoch {movie}; this version is epoch {core}); replay it with \
679         that version, or re-record it with this one"
680    )]
681    EpochMismatch {
682        /// The epoch the movie records.
683        movie: u32,
684        /// This build's [`EMULATION_EPOCH`](crate::EMULATION_EPOCH).
685        core: u32,
686    },
687}
688
689/// A complete TAS movie: a versioned header, a start point, and the
690/// per-frame input stream.
691///
692/// `#[non_exhaustive]` since v3.0.0 (T-API-EXTENSIBLE): build one with
693/// [`Movie::new`] (or a [`MovieRecorder`] / an importer), then set the public
694/// fields that differ from its defaults. A later field is then not a break.
695#[derive(Clone, Debug, Eq, PartialEq)]
696#[non_exhaustive]
697pub struct Movie {
698    /// Cartridge region the movie was recorded under. Checked, not applied:
699    /// [`Movie::seek_to_start`] refuses a machine of another region.
700    pub region: Region,
701    /// [`Nes::rom_sha256`] of the ROM the movie was recorded against.
702    pub rom_sha256: [u8; 32],
703    /// v3.0.0 (ADR 0045) — the [`EMULATION_EPOCH`](crate::EMULATION_EPOCH)
704    /// the movie was recorded under. [`Movie::new`] and every recorder and
705    /// importer stamp the current one; [`Movie::deserialize`] refuses
706    /// another.
707    pub epoch: u32,
708    /// v2.9.8 — every emulation-affecting host option the movie was recorded
709    /// with. [`Movie::seek_to_start`] applies them before frame 0, so the
710    /// replay does not depend on the player's settings. A foreign import
711    /// records [`HardwareOptions::default`], the stock NES.
712    pub options: HardwareOptions,
713    /// v2.9.8 — the cartridge board the recording machine was built from.
714    /// `None` for a foreign import (its format records no header), in which
715    /// case only the ROM identity and the region are checked.
716    pub board: Option<BoardDescription>,
717    /// Where playback begins.
718    pub start: StartPoint,
719    /// Per-frame controller inputs, in playback order.
720    pub frames: Vec<FrameInput>,
721    /// TAS re-record count — how many times the author re-recorded a frame
722    /// (the TAS piano-roll editor's edit tally; 0 for a straight linear
723    /// recording). Round-trips through `.rnm` (appended after the input stream,
724    /// so older readers ignore it) and the `.fm2` / `.bk2` `rerecordCount` header.
725    pub rerecord_count: u32,
726    /// Optional replay attestation (v2.3.2 "Lucid"): a rolling hash of the run's
727    /// video output plus periodic checkpoints, letting a third party replay the
728    /// movie and prove it reproduces the recorded run.
729    ///
730    /// `None` for every movie recorded without one, which is most of them.
731    /// Appended after [`Self::rerecord_count`] behind [`ATTESTATION_MAGIC`], so
732    /// an older reader stops at the re-record count and never sees it — the same
733    /// additive-tail trick that field itself used, and the reason no container
734    /// version bump was needed.
735    pub attestation: Option<Attestation>,
736}
737
738impl Movie {
739    /// v3.0.0 — a movie at the current [`EMULATION_EPOCH`](crate::EMULATION_EPOCH)
740    /// with no re-records and no attestation. Set
741    /// [`Self::rerecord_count`] or [`Self::attestation`] afterwards when they
742    /// apply; the struct is `#[non_exhaustive]`, so this is how code outside
743    /// `rustynes-core` builds one.
744    #[must_use]
745    pub const fn new(
746        region: Region,
747        rom_sha256: [u8; 32],
748        options: HardwareOptions,
749        board: Option<BoardDescription>,
750        start: StartPoint,
751        frames: Vec<FrameInput>,
752    ) -> Self {
753        Self {
754            region,
755            rom_sha256,
756            epoch: crate::EMULATION_EPOCH,
757            options,
758            board,
759            start,
760            frames,
761            rerecord_count: 0,
762            attestation: None,
763        }
764    }
765
766    /// Number of input frames in the movie.
767    #[must_use]
768    pub const fn len(&self) -> usize {
769        self.frames.len()
770    }
771
772    /// `true` if the movie has no input frames.
773    #[must_use]
774    pub const fn is_empty(&self) -> bool {
775        self.frames.is_empty()
776    }
777
778    /// Serialize the movie to its `.rnm` byte representation.
779    ///
780    /// Deterministic: the same `Movie` always produces identical bytes.
781    #[must_use]
782    pub fn serialize(&self) -> Vec<u8> {
783        let frame_count = u32::try_from(self.frames.len()).expect("frame count exceeds u32");
784        let body_hint = self.frames.len() * usize::from(BYTES_PER_FRAME);
785        let mut w = BinWriter::with_capacity(48 + body_hint);
786        w.bytes(MOVIE_MAGIC);
787        w.u16(MOVIE_FORMAT_VERSION);
788        // v3.0.0 (format 5, ADR 0045): the emulation epoch, beside the
789        // version so a tool can read both without parsing further.
790        w.u32(self.epoch);
791        w.u8(region_to_byte(self.region));
792        let mut flags = match &self.start {
793            StartPoint::PowerOn => 0,
794            StartPoint::SaveState(_) => FLAG_HAS_SAVE_STATE,
795        };
796        if self.board.is_some() {
797            flags |= FLAG_HAS_BOARD;
798        }
799        w.u8(flags);
800        w.bytes(&self.rom_sha256);
801        w.u32(frame_count);
802        w.u8(BYTES_PER_FRAME);
803        // v2.9.8 (format 3) — the OPTIONS block, length-prefixed so the
804        // decoder can bound it and confirm it consumed exactly what was
805        // written.
806        let mut opts = BinWriter::with_capacity(48);
807        self.options.write_to(&mut opts);
808        if let Some(board) = &self.board {
809            board.write_to(&mut opts);
810        }
811        w.lp_bytes(&opts.into_vec());
812        if let StartPoint::SaveState(blob) = &self.start {
813            w.lp_bytes(blob);
814        }
815        for f in &self.frames {
816            w.u8(f.p1.bits());
817            w.u8(f.p2.bits());
818            w.u8(f.p3.bits());
819            w.u8(f.p4.bits());
820            w.u8(f.expansion);
821        }
822        // Trailing re-record count (v1.8.9). Appended AFTER the fixed-count input
823        // stream so a reader that stops at `frame_count` records — including older
824        // builds — simply ignores it; deserialize below reads it when present and
825        // defaults to 0 otherwise. No format-version bump needed.
826        w.u32(self.rerecord_count);
827        // Optional attestation tail (v2.3.2 "Lucid"). Written only when present,
828        // so a movie without one is byte-for-byte what previous versions wrote.
829        if let Some(att) = &self.attestation {
830            w.u32(ATTESTATION_MAGIC);
831            w.u16(ATTESTATION_VERSION);
832            w.u32(att.frame_count);
833            w.u64(att.final_hash);
834            w.u32(u32::try_from(att.checkpoints.len()).unwrap_or(u32::MAX));
835            for &c in &att.checkpoints {
836                w.u64(c);
837            }
838        }
839        w.into_vec()
840    }
841
842    /// Parse a `.rnm` movie from its byte representation.
843    ///
844    /// # Errors
845    ///
846    /// Returns [`MovieError`] for a bad magic, an unsupported container
847    /// version, an unknown region byte, a frame width this build can't
848    /// parse, or a truncated body. Never panics on malformed input.
849    pub fn deserialize(bytes: &[u8]) -> Result<Self, MovieError> {
850        // Fixed header: magic(8) + version(2) + epoch(4, format 5+) +
851        // region(1) + flags(1) + sha256(32) + frame_count(4) +
852        // bytes_per_frame(1) = 53 bytes.
853        const HEADER_LEN: usize = 8 + 2 + 4 + 1 + 1 + 32 + 4 + 1;
854        if bytes.len() < HEADER_LEN {
855            return Err(MovieError::HeaderTruncated {
856                expected: HEADER_LEN,
857                got: bytes.len(),
858            });
859        }
860        let mut r = BinReader::new(bytes);
861        // Magic.
862        let mut magic = [0u8; 8];
863        r.read_into(&mut magic).map_err(map_eof)?;
864        if &magic != MOVIE_MAGIC {
865            return Err(MovieError::BadMagic { got: magic });
866        }
867        // Version.
868        let format_version = r.u16().map_err(map_eof)?;
869        if format_version > MOVIE_FORMAT_VERSION {
870            return Err(MovieError::UnsupportedFormat {
871                got: format_version,
872                max: MOVIE_FORMAT_VERSION,
873            });
874        }
875        if format_version < MIN_MOVIE_FORMAT_VERSION {
876            return Err(MovieError::FormatTooOld {
877                got: format_version,
878                min: MIN_MOVIE_FORMAT_VERSION,
879            });
880        }
881        // v3.0.0 (format 5, ADR 0045): the emulation epoch. Checked before
882        // anything else is parsed: a movie from a core that emulates
883        // differently is refused whatever the rest of it holds.
884        let epoch = r.u32().map_err(map_eof)?;
885        if epoch != crate::EMULATION_EPOCH {
886            return Err(MovieError::EpochMismatch {
887                movie: epoch,
888                core: crate::EMULATION_EPOCH,
889            });
890        }
891        // Region + flags.
892        let region = region_from_byte(r.u8().map_err(map_eof)?)?;
893        let flags = r.u8().map_err(map_eof)?;
894        if flags & !KNOWN_FLAGS != 0 {
895            return Err(MovieError::UnknownFlags(flags));
896        }
897        // ROM hash.
898        let mut rom_sha256 = [0u8; 32];
899        r.read_into(&mut rom_sha256).map_err(map_eof)?;
900        // Frame count + width.
901        let frame_count = r.u32().map_err(map_eof)? as usize;
902        let bytes_per_frame = r.u8().map_err(map_eof)?;
903        if bytes_per_frame == 0 || bytes_per_frame > BYTES_PER_FRAME {
904            // A newer movie packs more device bytes than we understand; we
905            // fail cleanly rather than mis-parse (the reserved byte exists
906            // precisely so this stays a graceful error, not a corruption).
907            //
908            // SECURITY: a `bytes_per_frame` of 0 is likewise rejected. With a
909            // zero-width record each frame read (`r.take(0)`) consumes no input,
910            // so the `for _ in 0..frame_count` loop below would push
911            // `frame_count` (an untrusted u32, up to ~4.3 billion) empty frames
912            // out of a finite file — an OOM DoS (found by the `movie` fuzz
913            // target). A real movie always writes the fixed `BYTES_PER_FRAME`
914            // (>= 1), so rejecting 0 costs no legitimate file.
915            return Err(MovieError::UnsupportedFrameWidth {
916                got: bytes_per_frame,
917                max: BYTES_PER_FRAME,
918            });
919        }
920        // v2.9.8 — the OPTIONS block. Decoded from its own bounded slice, and
921        // required to be consumed exactly: trailing bytes would mean a later
922        // format added a field this build would otherwise silently ignore.
923        let block = r.lp_bytes().map_err(map_eof)?;
924        let mut br = BinReader::new(block);
925        let options = HardwareOptions::read_from(&mut br).map_err(MovieError::BadOptions)?;
926        let board = if flags & FLAG_HAS_BOARD != 0 {
927            Some(BoardDescription::read_from(&mut br).map_err(MovieError::BadOptions)?)
928        } else {
929            None
930        };
931        if br.remaining() != 0 {
932            return Err(MovieError::BadOptions("unexpected bytes after the options"));
933        }
934        // Start point.
935        let start = if flags & FLAG_HAS_SAVE_STATE != 0 {
936            let blob = r.lp_bytes().map_err(map_eof)?;
937            StartPoint::SaveState(blob.to_vec())
938        } else {
939            StartPoint::PowerOn
940        };
941        // Input stream: `frame_count` records of `bytes_per_frame` bytes
942        // (`width >= 1`, enforced above).
943        let width = usize::from(bytes_per_frame);
944        // SECURITY: `frame_count` is an untrusted 4-byte field (up to ~4.3
945        // billion). Pre-sizing `Vec::with_capacity(frame_count)` from it lets a
946        // 53-byte header claim a multi-gigabyte allocation — an OOM DoS (found
947        // by the `movie` fuzz target). A real movie carries exactly
948        // `frame_count * width` more bytes, so cap the reservation at what the
949        // remaining input could actually hold: for a valid file this equals
950        // `frame_count` (identical allocation, byte-for-byte the same result),
951        // and for a truncated / hostile one the `r.take(width)` below still
952        // fails cleanly with an EOF error once the real bytes run out.
953        let max_plausible_frames = r.remaining() / width;
954        let mut frames = Vec::with_capacity(frame_count.min(max_plausible_frames));
955        for _ in 0..frame_count {
956            let rec = r.take(width).map_err(map_eof)?;
957            // Format 3: rec = [p1, p2, p3, p4, expansion]. A narrower record
958            // defaults the fields it does not reach (released pads, no
959            // expansion byte).
960            let pad = |i: usize| Buttons::from_bits_truncate(rec.get(i).copied().unwrap_or(0));
961            let mut frame = FrameInput::four_players(pad(0), pad(1), pad(2), pad(3));
962            frame.expansion = rec.get(4).copied().unwrap_or(0);
963            frames.push(frame);
964        }
965        // Optional trailing re-record count (v1.8.9). Absent in pre-v1.8.9 `.rnm`
966        // files, which stop exactly at the input stream — default to 0.
967        let rerecord_count = r.u32().unwrap_or(0);
968        // Optional attestation tail (v2.3.2 "Lucid"). Absent in every movie
969        // recorded before it existed, and in any recorded without it — so a
970        // missing or unrecognized marker yields `None` rather than an error.
971        let attestation = read_attestation(&mut r, frames.len());
972        Ok(Self {
973            region,
974            rom_sha256,
975            epoch,
976            options,
977            board,
978            start,
979            frames,
980            rerecord_count,
981            attestation,
982        })
983    }
984
985    /// Rewind a running emulator to this movie's start point, ready to replay
986    /// from frame 0.
987    ///
988    /// Checks first, then changes the machine: the ROM identity, the region
989    /// and (for a recorded movie) the [`BoardDescription`] must match, or the
990    /// call fails with `nes` untouched. Then it applies the movie's
991    /// [`HardwareOptions`] -- v2.9.8: the recorded console model, die
992    /// revisions, power-on fills, overclock, Four Score, Vs. settings,
993    /// mirroring override and Game Genie codes replace the player's -- and
994    /// moves to the start point: for [`StartPoint::PowerOn`] a power cycle
995    /// with cleared cartridge RAM ([`power_on_for_movie`]), for
996    /// [`StartPoint::SaveState`] the embedded snapshot.
997    ///
998    /// A start refused after the checks -- an old or malformed start state,
999    /// an undecodable code -- also leaves `nes` as it was: the call takes a
1000    /// rollback point first and restores it on any error.
1001    ///
1002    /// After a successful start the player's options are not restored here;
1003    /// a host that wants them back captures them first ([`HardwareOptions::capture`]) and calls
1004    /// [`HardwareOptions::restore_after_playback`] when playback ends.
1005    ///
1006    /// # Errors
1007    ///
1008    /// [`MovieError::EpochMismatch`] for a movie recorded under another
1009    /// emulation epoch (ADR 0045); [`MovieError::RomMismatch`],
1010    /// [`MovieError::RegionMismatch`] or [`MovieError::BoardMismatch`] for a
1011    /// different machine;
1012    /// [`MovieError::OptionNotApplicable`] for a Game Genie code that does
1013    /// not decode; [`MovieError::BadSaveState`] if the embedded snapshot is
1014    /// malformed.
1015    pub fn seek_to_start(&self, nes: &mut Nes) -> Result<(), MovieError> {
1016        self.check_epoch()?;
1017        if nes.rom_sha256() != &self.rom_sha256 {
1018            return Err(MovieError::RomMismatch);
1019        }
1020        if nes.region() != self.region {
1021            return Err(MovieError::RegionMismatch {
1022                movie: self.region,
1023                host: nes.region(),
1024            });
1025        }
1026        if let Some(board) = &self.board
1027            && let Some(field) = board.first_difference(&BoardDescription::capture(nes))
1028        {
1029            return Err(MovieError::BoardMismatch { field });
1030        }
1031        // A refused start must leave the player's game as it was. The options
1032        // are applied before the start point is reached, and `apply` rewrites
1033        // work RAM and palette RAM, so take a rollback point first. A
1034        // snapshot per movie start costs one serialisation; seeking is not a
1035        // per-frame call.
1036        let prior_options = HardwareOptions::capture(nes);
1037        let prior_state = nes.snapshot();
1038        let result = self.enter_start(nes);
1039        if result.is_err() {
1040            // This order: the player's `apply` rewrites the fills, then the
1041            // restore puts the running game's RAM and palette back over them.
1042            // Neither can fail: the codes decoded when they were captured,
1043            // and the blob is this machine's own snapshot.
1044            let options_back = prior_options.apply(nes);
1045            let state_back = nes.restore_quiet(&prior_state);
1046            debug_assert!(options_back.is_ok() && state_back.is_ok());
1047        }
1048        result
1049    }
1050
1051    /// v3.0.0 (ADR 0045): refuse a movie recorded under another emulation
1052    /// epoch. [`Self::deserialize`] checks the field it parses; this checks
1053    /// the field as it stands, because `epoch` is public and a [`Movie`] can
1054    /// be built or edited in memory, and playback must not trust that it came
1055    /// through `deserialize` (a review finding on #588).
1056    const fn check_epoch(&self) -> Result<(), MovieError> {
1057        if self.epoch != crate::EMULATION_EPOCH {
1058            return Err(MovieError::EpochMismatch {
1059                movie: self.epoch,
1060                core: crate::EMULATION_EPOCH,
1061            });
1062        }
1063        Ok(())
1064    }
1065
1066    /// The mutating half of [`Self::seek_to_start`], after the identity
1067    /// checks; its caller rolls the machine back if this fails.
1068    fn enter_start(&self, nes: &mut Nes) -> Result<(), MovieError> {
1069        // Applied BEFORE the start point is reached. For a power-on start the
1070        // power cycle must see the movie's stored power-on knobs (fills,
1071        // console model, die revision); for a save-state start the restore
1072        // then replaces whatever RAM / palette the fills wrote.
1073        self.options
1074            .apply(nes)
1075            .map_err(MovieError::OptionNotApplicable)?;
1076        match &self.start {
1077            StartPoint::PowerOn => power_on_for_movie(nes),
1078            StartPoint::SaveState(blob) => {
1079                nes.restore(blob).map_err(|e| match e {
1080                    SnapshotError::FormatTooOld { got, min } => {
1081                        MovieError::StartStateTooOld { got, min }
1082                    }
1083                    other => MovieError::BadSaveState(other),
1084                })?;
1085                // The options are configuration, not save-state, so a restore
1086                // leaves them alone; re-asserting the live ones is cheap and
1087                // keeps that a checked fact rather than an assumption.
1088                self.options
1089                    .apply_live(nes)
1090                    .map_err(MovieError::OptionNotApplicable)?;
1091            }
1092        }
1093        Ok(())
1094    }
1095
1096    /// v2.3.2 "Lucid" — replay this movie and check it reproduces its
1097    /// attestation.
1098    ///
1099    /// Seeks `nes` to the movie's start point, replays the whole input stream,
1100    /// and compares the resulting rolling hash (and every checkpoint along the
1101    /// way) against what the movie recorded. Anyone with the ROM and the `.rnm`
1102    /// can run it and get the same answer, so an accidental divergence — a
1103    /// different build, a nondeterminism bug, a corrupted file — or a casual
1104    /// edit to the input stream, the start point, or the claimed hash shows up
1105    /// as a [`VerifyOutcome::Mismatch`].
1106    ///
1107    /// **Reproducibility, not provenance.** The digest is a 64-bit FNV-1a
1108    /// variant: tamper-evident, not forgery-resistant. A `Match` means "these inputs,
1109    /// applied to this ROM, on a verifier configured like the recorder, produce
1110    /// this video". It does not establish who produced the movie, and a
1111    /// motivated forger can edit the movie and recompute the digest.
1112    ///
1113    /// Consumes real emulation time — it runs every frame of the movie.
1114    ///
1115    /// # Errors
1116    ///
1117    /// [`MovieError::EpochMismatch`] for a movie from another emulation epoch
1118    /// (checked first, attested or not), [`MovieError::RomMismatch`] if `nes`
1119    /// is running a different ROM, or [`MovieError::BadSaveState`] if an
1120    /// embedded start point is malformed.
1121    /// A movie with no attestation is **not** an error; it returns
1122    /// [`VerifyOutcome::NotAttested`], because "this movie makes no claim" and
1123    /// "this movie makes a false claim" are different answers.
1124    pub fn verify(&self, nes: &mut Nes) -> Result<VerifyOutcome, MovieError> {
1125        // Before the attestation question: a movie from another epoch cannot
1126        // be replayed here at all, which is a stronger answer than "makes no
1127        // claim".
1128        self.check_epoch()?;
1129        let Some(att) = self.attestation.as_ref() else {
1130            return Ok(VerifyOutcome::NotAttested);
1131        };
1132        self.seek_to_start(nes)?;
1133        let mut builder = AttestationBuilder::new();
1134        let mut first_bad_checkpoint = None;
1135        let mut player = MoviePlayer::new(self);
1136        let mut idx = 0usize;
1137        while player.apply_next(nes) {
1138            let input = self.frames.get(idx).copied().unwrap_or_default();
1139            idx += 1;
1140            let fb = nes.run_frame();
1141            builder.push_frame(input, fb);
1142            // Compare each checkpoint as it is produced rather than collecting
1143            // and diffing afterwards: the first disagreement is the useful one,
1144            // and it localizes the divergence to a 64-frame window.
1145            if builder
1146                .frame_count()
1147                .is_multiple_of(ATTESTATION_CHECKPOINT_INTERVAL)
1148                && first_bad_checkpoint.is_none()
1149            {
1150                let idx = builder.frame_count() / ATTESTATION_CHECKPOINT_INTERVAL - 1;
1151                // Compare ONLY against a checkpoint the movie actually recorded.
1152                // `get()` returning `None` means "no recorded value here", not
1153                // "mismatch": treating absence as disagreement made a short
1154                // checkpoint list report a divergence even when every hash the
1155                // movie does carry — including the final one — matched. The
1156                // final hash is the gate; the checkpoints only localize.
1157                if let Some(&want) = att.checkpoints.get(idx as usize)
1158                    && want != builder.current_hash()
1159                {
1160                    first_bad_checkpoint = Some(idx);
1161                }
1162            }
1163        }
1164        let got = builder.current_hash();
1165        let frames = builder.frame_count();
1166        if got == att.final_hash && first_bad_checkpoint.is_none() {
1167            Ok(VerifyOutcome::Match { frames, hash: got })
1168        } else {
1169            Ok(VerifyOutcome::Mismatch {
1170                frames,
1171                expected: att.final_hash,
1172                got,
1173                first_bad_checkpoint,
1174            })
1175        }
1176    }
1177}
1178
1179/// Records the per-frame input stream applied to an emulator.
1180///
1181/// Usage (caller-driven, mirrors the frontend's per-frame loop):
1182///
1183/// ```ignore
1184/// let mut rec = MovieRecorder::power_on(&nes);
1185/// loop {
1186///     nes.set_buttons(0, p1);
1187///     nes.set_buttons(1, p2);
1188///     rec.capture(&nes); // BEFORE run_frame — captures the inputs it consumes
1189///     nes.run_frame();
1190/// }
1191/// let movie = rec.finish();
1192/// ```
1193#[derive(Clone, Debug)]
1194pub struct MovieRecorder {
1195    region: Region,
1196    rom_sha256: [u8; 32],
1197    options: HardwareOptions,
1198    board: BoardDescription,
1199    start: StartPoint,
1200    frames: Vec<FrameInput>,
1201    /// v2.3.2 "Lucid" — optional attestation accumulator. `None` (the default)
1202    /// records a plain movie, byte-for-byte what previous versions produced.
1203    attestation: Option<AttestationBuilder>,
1204}
1205
1206/// v2.9.0 — the state every [`StartPoint::PowerOn`] movie starts from.
1207///
1208/// A power cycle, then cartridge RAM zeroed: what a fresh load with no save
1209/// file holds (every board allocates it zeroed).
1210///
1211/// # Why this is not just `power_cycle`
1212///
1213/// From v2.9.0 [`Nes::power_cycle`] KEEPS battery-backed RAM, as a console
1214/// does. Before, it rebuilt the mapper with all cartridge RAM cleared, which
1215/// made a Power Cycle erase the player's `.sav` once the desktop began
1216/// persisting saves (v2.7.3). A power-on movie still has to start from clean
1217/// save RAM -- the maintainer's decision of 2026-09-26, `TASVideos`'
1218/// convention -- or it would replay differently with and without a save. So
1219/// hosts call this before [`MovieRecorder::power_on`], and
1220/// [`Movie::seek_to_start`] calls it on playback.
1221///
1222/// Only the emulator's copy is cleared. The `.sav` file on disk is untouched,
1223/// but a host that persists save RAM will write the cleared contents back if
1224/// the movie session runs long enough to reach the game's own save routine --
1225/// the same as that routine running during the movie. FDS disk sides are not
1226/// cartridge RAM and are not reset by this.
1227///
1228/// # The options survive the power cycle (v2.9.8)
1229///
1230/// Before v2.9.8 [`Nes::power_cycle`] rebuilt the PPU and dropped the
1231/// PPU-held knobs (OAM decay, the overclock, the fast dot path) to their
1232/// defaults, so a recording started with OAM decay off whatever the player
1233/// had set, and nothing recorded that. The options are captured first and the
1234/// live ones re-applied after the cycle, so the machine a movie starts on is
1235/// the one its [`HardwareOptions`] describe, on the recording side and the
1236/// playback side alike. Since v2.9.8 the cycle keeps every setting itself
1237/// (the PPU and APU ones as well as the bus-held console model, die
1238/// revisions, power-on fills, Four Score, Game Genie ...), so the
1239/// re-application is a guarantee rather than a repair: it holds whatever a
1240/// future cycle might drop. The power-on fills are not re-applied: the cycle
1241/// itself already filled RAM and palette RAM from the stored selection.
1242pub fn power_on_for_movie(nes: &mut Nes) {
1243    let options = HardwareOptions::capture(nes);
1244    nes.power_cycle();
1245    // Not `sram_mut().fill(0)`: on a flash board (v2.9.6) the save is the PRG
1246    // image, and a never-saved flash is the ROM as loaded, not zeros.
1247    nes.clear_save_data();
1248    // Codes captured from this same machine always decode again.
1249    let reapplied = options.apply_live(nes);
1250    debug_assert!(reapplied.is_ok(), "captured options re-apply");
1251    // The fast dot path is not an option (it selects a code path, not a
1252    // behaviour) and the cycle keeps it since v2.9.8, so starting a movie does
1253    // not change which PPU path the player runs. Until v2.9.8 it was captured
1254    // and re-set here by hand.
1255}
1256
1257impl MovieRecorder {
1258    /// Begin recording a movie that starts from a fresh power-on of the ROM
1259    /// `nes` is running. The caller is responsible for calling
1260    /// [`power_on_for_movie`] on `nes` before the first captured frame so the
1261    /// recording starts from the same state a replay will reconstruct.
1262    ///
1263    /// v2.9.8: the machine's [`HardwareOptions`] and [`BoardDescription`] are
1264    /// captured here and written into the movie, so a host must set its
1265    /// options before this call, not after.
1266    #[must_use]
1267    pub fn power_on(nes: &Nes) -> Self {
1268        Self {
1269            region: nes.region(),
1270            rom_sha256: *nes.rom_sha256(),
1271            options: HardwareOptions::capture(nes),
1272            board: BoardDescription::capture(nes),
1273            start: StartPoint::PowerOn,
1274            frames: Vec::new(),
1275            attestation: None,
1276        }
1277    }
1278
1279    /// Begin recording a movie that starts from `nes`'s *current* state (a
1280    /// branch point). Captures a snapshot now and embeds it as the start
1281    /// point; the input stream is recorded from here forward.
1282    #[must_use]
1283    pub fn from_current_state(nes: &Nes) -> Self {
1284        Self {
1285            region: nes.region(),
1286            rom_sha256: *nes.rom_sha256(),
1287            options: HardwareOptions::capture(nes),
1288            board: BoardDescription::capture(nes),
1289            start: StartPoint::SaveState(nes.snapshot()),
1290            frames: Vec::new(),
1291            attestation: None,
1292        }
1293    }
1294
1295    /// Record the controller inputs currently held on `nes`. Call this each
1296    /// frame *before* [`Nes::run_frame`], after the frontend has applied its
1297    /// `set_buttons` calls — this captures exactly the inputs the upcoming
1298    /// frame consumes.
1299    ///
1300    /// # All four ports (v2.9.8)
1301    ///
1302    /// Reads `nes.buttons(0..=3)`. Until v2.9.8 [`FrameInput`] modelled ports
1303    /// 0 and 1 only, so a four-player session recorded half of what drove it
1304    /// and diverged on playback; the `.rnm` format 3 epoch widened the record
1305    /// and the struct together. Inputs that are not controller buttons --
1306    /// expansion devices (Zapper, Vaus, keyboards ...), the Famicom
1307    /// microphone, Vs. coins / service, FDS disk swaps -- are still not
1308    /// recorded; see `docs/frontend.md` § "What a movie records".
1309    pub fn capture(&mut self, nes: &Nes) {
1310        self.frames.push(FrameInput::held_on(nes));
1311    }
1312
1313    /// Record an explicit frame of input (for callers that drive input
1314    /// programmatically rather than through `set_buttons`).
1315    pub fn capture_input(&mut self, input: FrameInput) {
1316        self.frames.push(input);
1317    }
1318
1319    /// Number of frames captured so far.
1320    #[must_use]
1321    pub const fn len(&self) -> usize {
1322        self.frames.len()
1323    }
1324
1325    /// `true` if no frames have been captured.
1326    #[must_use]
1327    pub const fn is_empty(&self) -> bool {
1328        self.frames.is_empty()
1329    }
1330
1331    /// v2.9.8 — the options this recording was started with, which the movie
1332    /// will carry. A host holds them in place for the length of the recording
1333    /// ([`HardwareOptions::apply_live`] each frame), because the movie records
1334    /// them once, at the start.
1335    #[must_use]
1336    pub const fn options(&self) -> &HardwareOptions {
1337        &self.options
1338    }
1339
1340    /// v2.3.2 "Lucid" — start accumulating a replay attestation.
1341    ///
1342    /// Call before the first frame. The caller must then call
1343    /// [`Self::attest_frame`] after every `run_frame`, in lockstep with
1344    /// [`Self::capture`], or the recorded hash will describe a different run
1345    /// than the input stream does — which [`Movie::verify`] would then report as
1346    /// a mismatch, correctly but unhelpfully.
1347    pub fn enable_attestation(&mut self) {
1348        self.attestation = Some(AttestationBuilder::new());
1349    }
1350
1351    /// Abandon an in-progress attestation, keeping the recording itself.
1352    ///
1353    /// For a host that rewinds or otherwise moves the emulator off the timeline
1354    /// the accumulated hash describes. Once dropped it is not resumed: the
1355    /// prefix already folded in cannot be un-folded, and a partial hash that
1356    /// silently covers only part of the run would be worse than none.
1357    pub fn disable_attestation(&mut self) {
1358        self.attestation = None;
1359    }
1360
1361    /// Fold this frame's video output into the attestation.
1362    ///
1363    /// A no-op unless [`Self::enable_attestation`] was called. Pass the slice
1364    /// `Nes::run_frame` returned (or `Nes::framebuffer()`), AFTER the frame ran.
1365    ///
1366    /// Uses the input recorded by the matching [`Self::capture`], so the two
1367    /// must stay in lockstep — one `capture` then one `attest_frame` per frame.
1368    /// If they drift the recorded frame counts disagree and `Movie::deserialize`
1369    /// drops the tail, which is the safe direction.
1370    pub fn attest_frame(&mut self, framebuffer: &[u8]) {
1371        // The input for THIS frame is the one `capture` just pushed.
1372        let input = self.frames.last().copied().unwrap_or_default();
1373        if let Some(a) = self.attestation.as_mut() {
1374            a.push_frame(input, framebuffer);
1375        }
1376    }
1377
1378    /// Finish recording and produce the [`Movie`].
1379    #[must_use]
1380    pub fn finish(self) -> Movie {
1381        Movie {
1382            region: self.region,
1383            rom_sha256: self.rom_sha256,
1384            epoch: crate::EMULATION_EPOCH,
1385            options: self.options,
1386            board: Some(self.board),
1387            start: self.start,
1388            frames: self.frames,
1389            // A linear recording has no re-records by construction; TAStudio
1390            // sets a real count when it exports an edited movie.
1391            rerecord_count: 0,
1392            attestation: self.attestation.map(AttestationBuilder::finish),
1393        }
1394    }
1395}
1396
1397/// Plays a movie back, feeding its recorded inputs into an emulator one frame
1398/// at a time.
1399///
1400/// Usage (caller-driven; the player applies `set_buttons`, the caller runs
1401/// the frame):
1402///
1403/// ```ignore
1404/// movie.seek_to_start(&mut nes)?;
1405/// let mut player = MoviePlayer::new(&movie);
1406/// while player.apply_next(&mut nes) {
1407///     nes.run_frame();
1408/// }
1409/// ```
1410#[derive(Clone, Debug)]
1411pub struct MoviePlayer<'a> {
1412    movie: &'a Movie,
1413    cursor: usize,
1414}
1415
1416impl<'a> MoviePlayer<'a> {
1417    /// Create a player positioned at frame 0 of `movie`.
1418    #[must_use]
1419    pub const fn new(movie: &'a Movie) -> Self {
1420        Self { movie, cursor: 0 }
1421    }
1422
1423    /// Total frames in the movie.
1424    #[must_use]
1425    pub const fn len(&self) -> usize {
1426        self.movie.frames.len()
1427    }
1428
1429    /// `true` if the movie has no frames.
1430    #[must_use]
1431    pub const fn is_empty(&self) -> bool {
1432        self.movie.frames.is_empty()
1433    }
1434
1435    /// Index of the frame that [`Self::apply_next`] will apply next.
1436    #[must_use]
1437    pub const fn cursor(&self) -> usize {
1438        self.cursor
1439    }
1440
1441    /// `true` if every frame has been played.
1442    #[must_use]
1443    pub const fn is_finished(&self) -> bool {
1444        self.cursor >= self.movie.frames.len()
1445    }
1446
1447    /// Peek the next frame's input without advancing.
1448    #[must_use]
1449    pub fn peek(&self) -> Option<FrameInput> {
1450        self.movie.frames.get(self.cursor).copied()
1451    }
1452
1453    /// Apply the next frame's recorded input to `nes` via `set_buttons` and
1454    /// advance the cursor. Returns `false` (without applying anything) once
1455    /// the movie is exhausted — the caller stops its replay loop on `false`.
1456    ///
1457    /// Call this *before* [`Nes::run_frame`], mirroring the record-side
1458    /// `capture` ordering, so the same inputs are applied to the same frame.
1459    pub fn apply_next(&mut self, nes: &mut Nes) -> bool {
1460        let Some(input) = self.movie.frames.get(self.cursor).copied() else {
1461            return false;
1462        };
1463        input.apply_to(nes);
1464        self.cursor += 1;
1465        true
1466    }
1467
1468    /// Reset the cursor back to frame 0 (the caller is responsible for
1469    /// re-seeking `nes` via [`Movie::seek_to_start`]).
1470    pub const fn rewind(&mut self) {
1471        self.cursor = 0;
1472    }
1473}
1474
1475const fn region_to_byte(region: Region) -> u8 {
1476    match region {
1477        Region::Ntsc => 0,
1478        Region::Pal => 1,
1479        Region::Dendy => 2,
1480    }
1481}
1482
1483const fn region_from_byte(b: u8) -> Result<Region, MovieError> {
1484    match b {
1485        0 => Ok(Region::Ntsc),
1486        1 => Ok(Region::Pal),
1487        2 => Ok(Region::Dendy),
1488        other => Err(MovieError::BadRegion(other)),
1489    }
1490}
1491
1492/// Map a `SnapshotError::Eof`-style truncation reading the movie body into a
1493/// movie-level [`MovieError::Eof`]. Other snapshot errors cannot arise from
1494/// the `BinReader` calls in this module (they only read fixed primitives).
1495fn map_eof(e: SnapshotError) -> MovieError {
1496    match e {
1497        SnapshotError::Eof(off) => MovieError::Eof(off),
1498        other => MovieError::BadSaveState(other),
1499    }
1500}
1501
1502#[cfg(test)]
1503mod tests {
1504    use super::*;
1505    use crate::nes::PowerOnRam;
1506    use alloc::vec;
1507
1508    // ----------------------------------------------------------------- v2.3.2
1509    // Replay attestation ("Lucid" phase 4)
1510    // -------------------------------------------------------------------------
1511
1512    /// Record a short attested run and prove it replays to the same hash.
1513    ///
1514    /// This is the whole feature in one test: the claim is not "the hash is
1515    /// stable" but "an independent replay re-derives it", which is what makes
1516    /// the record evidence rather than decoration.
1517    #[test]
1518    fn attested_movie_verifies_against_an_independent_replay() {
1519        let rom = synth_nrom();
1520        let mut nes = Nes::from_rom(&rom).expect("parse");
1521        let mut rec = MovieRecorder::power_on(&nes);
1522        rec.enable_attestation();
1523        for _ in 0..8 {
1524            rec.capture(&nes);
1525            let fb = nes.run_frame().to_vec();
1526            rec.attest_frame(&fb);
1527        }
1528        let movie = rec.finish();
1529        let att = movie.attestation.as_ref().expect("attestation recorded");
1530        assert_eq!(att.frame_count, 8);
1531
1532        // A SEPARATE emulator instance, as a third party would use.
1533        let mut fresh = Nes::from_rom(&rom).expect("parse");
1534        match movie.verify(&mut fresh).expect("verify runs") {
1535            VerifyOutcome::Match { frames, hash } => {
1536                assert_eq!(frames, 8);
1537                assert_eq!(hash, att.final_hash);
1538            }
1539            other => panic!("expected a match, got {other:?}"),
1540        }
1541    }
1542
1543    /// The attestation survives the `.rnm` round-trip, and a movie WITHOUT one
1544    /// still serializes to exactly the bytes it did before this feature existed.
1545    #[test]
1546    fn attestation_round_trips_and_is_absent_when_not_recorded() {
1547        let rom = synth_nrom();
1548        let mut nes = Nes::from_rom(&rom).expect("parse");
1549
1550        // Plain recording: no attestation, and the tail is not written.
1551        let mut plain = MovieRecorder::power_on(&nes);
1552        plain.capture(&nes);
1553        let _ = nes.run_frame();
1554        let plain = plain.finish();
1555        assert!(plain.attestation.is_none());
1556        let plain_bytes = plain.serialize();
1557        let reparsed = Movie::deserialize(&plain_bytes).expect("round-trip");
1558        assert_eq!(reparsed, plain);
1559        assert!(reparsed.attestation.is_none());
1560
1561        // Attested recording: the tail round-trips intact.
1562        let mut nes2 = Nes::from_rom(&rom).expect("parse");
1563        let mut rec = MovieRecorder::power_on(&nes2);
1564        rec.enable_attestation();
1565        // Enough frames to cross a checkpoint boundary.
1566        for _ in 0..(ATTESTATION_CHECKPOINT_INTERVAL + 3) {
1567            rec.capture(&nes2);
1568            let fb = nes2.run_frame().to_vec();
1569            rec.attest_frame(&fb);
1570        }
1571        let attested = rec.finish();
1572        assert_eq!(attested.attestation.as_ref().unwrap().checkpoints.len(), 1);
1573        let bytes = attested.serialize();
1574        assert_eq!(Movie::deserialize(&bytes).expect("round-trip"), attested);
1575
1576        // The attested file is strictly longer, and the plain one is unchanged
1577        // by this feature existing.
1578        assert!(bytes.len() > plain_bytes.len());
1579    }
1580
1581    /// A reader that stops at the re-record count — i.e. every build before this
1582    /// feature — must still parse an attested movie as a plain one. Simulated by
1583    /// truncating the tail, which is exactly what such a reader sees.
1584    #[test]
1585    fn attested_movie_stays_readable_as_a_plain_movie() {
1586        let rom = synth_nrom();
1587        let mut nes = Nes::from_rom(&rom).expect("parse");
1588        let mut rec = MovieRecorder::power_on(&nes);
1589        rec.enable_attestation();
1590        for _ in 0..4 {
1591            rec.capture(&nes);
1592            let fb = nes.run_frame().to_vec();
1593            rec.attest_frame(&fb);
1594        }
1595        let attested = rec.finish();
1596        let full = attested.serialize();
1597
1598        // Everything an older reader consumes: header + inputs + rerecord count.
1599        // The attestation tail is 4 + 2 + 4 + 8 + 4 = 22 bytes plus checkpoints
1600        // (none here, 4 frames < the interval).
1601        let tail_len = 4 + 2 + 4 + 8 + 4;
1602        let older_view = &full[..full.len() - tail_len];
1603        let parsed = Movie::deserialize(older_view).expect("older readers still parse it");
1604        assert_eq!(parsed.frames, attested.frames);
1605        assert_eq!(parsed.rerecord_count, attested.rerecord_count);
1606        assert!(parsed.attestation.is_none());
1607    }
1608
1609    /// Tampering with the input stream must be detected. This is the property
1610    /// that makes an attestation worth anything.
1611    #[test]
1612    fn tampering_with_the_input_stream_fails_verification() {
1613        let rom = synth_nrom();
1614        let mut nes = Nes::from_rom(&rom).expect("parse");
1615        let mut rec = MovieRecorder::power_on(&nes);
1616        rec.enable_attestation();
1617        for _ in 0..6 {
1618            rec.capture(&nes);
1619            let fb = nes.run_frame().to_vec();
1620            rec.attest_frame(&fb);
1621        }
1622        let mut movie = rec.finish();
1623
1624        // Forge the claimed hash. A replay must refuse to confirm it.
1625        let real = movie.attestation.as_ref().unwrap().final_hash;
1626        movie.attestation.as_mut().unwrap().final_hash = real ^ 1;
1627        let mut fresh = Nes::from_rom(&rom).expect("parse");
1628        match movie.verify(&mut fresh).expect("verify runs") {
1629            VerifyOutcome::Mismatch { expected, got, .. } => {
1630                assert_eq!(expected, real ^ 1);
1631                assert_eq!(got, real, "the replay re-derives the TRUE hash");
1632            }
1633            other => panic!("a forged hash must not verify, got {other:?}"),
1634        }
1635    }
1636
1637    /// A flipped INPUT bit must fail verification even when the ROM ignores
1638    /// input entirely and the video output is therefore unchanged.
1639    ///
1640    /// This is the case an output-only hash gets wrong, and it is not
1641    /// hypothetical: an end-to-end `rustynes verify` run against a test ROM that
1642    /// never reads the controller happily confirmed a movie whose input log had
1643    /// been edited. The fix was to fold the per-frame input into the hash; this
1644    /// test is what keeps it folded in.
1645    #[test]
1646    fn flipped_input_fails_even_when_the_rom_ignores_input() {
1647        // `synth_nrom` is an infinite `JMP` — it never reads $4016, so its video
1648        // output is identical for every possible input stream.
1649        let rom = synth_nrom();
1650        let mut nes = Nes::from_rom(&rom).expect("parse");
1651        let mut rec = MovieRecorder::power_on(&nes);
1652        rec.enable_attestation();
1653        for _ in 0..6 {
1654            rec.capture(&nes);
1655            let fb = nes.run_frame().to_vec();
1656            rec.attest_frame(&fb);
1657        }
1658        let mut movie = rec.finish();
1659
1660        // Sanity: the honest movie verifies.
1661        let mut fresh = Nes::from_rom(&rom).expect("parse");
1662        assert!(matches!(
1663            movie.verify(&mut fresh).expect("verify runs"),
1664            VerifyOutcome::Match { .. }
1665        ));
1666
1667        // Now edit the input log. The video output will be bit-identical,
1668        // because this ROM never looks at the controller.
1669        movie.frames[3].p1 = Buttons::A;
1670        let mut fresh = Nes::from_rom(&rom).expect("parse");
1671        match movie.verify(&mut fresh).expect("verify runs") {
1672            VerifyOutcome::Mismatch { .. } => {}
1673            other => panic!("an edited input log must not verify, got {other:?}"),
1674        }
1675    }
1676
1677    /// An attestation whose frame count disagrees with the input stream
1678    /// describes a different run, so it is dropped rather than compared against
1679    /// the wrong length.
1680    #[test]
1681    fn attestation_with_a_mismatched_frame_count_is_rejected_on_load() {
1682        let rom = synth_nrom();
1683        let mut nes = Nes::from_rom(&rom).expect("parse");
1684        let mut rec = MovieRecorder::power_on(&nes);
1685        rec.enable_attestation();
1686        for _ in 0..3 {
1687            rec.capture(&nes);
1688            let fb = nes.run_frame().to_vec();
1689            rec.attest_frame(&fb);
1690        }
1691        let mut movie = rec.finish();
1692        movie.attestation.as_mut().unwrap().frame_count = 999;
1693        let bytes = movie.serialize();
1694        let parsed = Movie::deserialize(&bytes).expect("the movie itself is fine");
1695        assert_eq!(parsed.frames.len(), 3);
1696        assert!(
1697            parsed.attestation.is_none(),
1698            "a tail describing a different run must be dropped, not trusted"
1699        );
1700    }
1701
1702    /// Drive `first_bad_checkpoint` to `Some(_)` — the path bug #12 lived on,
1703    /// which no test previously exercised.
1704    #[test]
1705    fn a_corrupted_checkpoint_is_localized() {
1706        let rom = synth_nrom();
1707        let mut nes = Nes::from_rom(&rom).expect("parse");
1708        let mut rec = MovieRecorder::power_on(&nes);
1709        rec.enable_attestation();
1710        // Three checkpoint windows.
1711        for _ in 0..(ATTESTATION_CHECKPOINT_INTERVAL * 3) {
1712            rec.capture(&nes);
1713            let fb = nes.run_frame().to_vec();
1714            rec.attest_frame(&fb);
1715        }
1716        let mut movie = rec.finish();
1717        assert_eq!(movie.attestation.as_ref().unwrap().checkpoints.len(), 3);
1718
1719        // Corrupt the SECOND checkpoint only. The final hash still matches, so
1720        // the checkpoint comparison is the only thing that can catch this.
1721        movie.attestation.as_mut().unwrap().checkpoints[1] ^= 0xFF;
1722        let mut fresh = Nes::from_rom(&rom).expect("parse");
1723        match movie.verify(&mut fresh).expect("verify runs") {
1724            VerifyOutcome::Mismatch {
1725                first_bad_checkpoint,
1726                expected,
1727                got,
1728                ..
1729            } => {
1730                assert_eq!(
1731                    first_bad_checkpoint,
1732                    Some(1),
1733                    "the SECOND checkpoint is the first bad one"
1734                );
1735                assert_eq!(expected, got, "the final hash still agrees");
1736            }
1737            other => panic!("a corrupted checkpoint must not verify, got {other:?}"),
1738        }
1739    }
1740
1741    /// A short checkpoint list must NOT manufacture a mismatch.
1742    ///
1743    /// Regression for bug #12: `checkpoints.get(idx)` returning `None` was
1744    /// compared against `Some(&hash)` and read as disagreement, so an
1745    /// attestation carrying fewer checkpoints than the replay produces failed
1746    /// even when every hash it did record — and the final hash — matched.
1747    #[test]
1748    fn a_short_checkpoint_list_still_verifies() {
1749        let rom = synth_nrom();
1750        let mut nes = Nes::from_rom(&rom).expect("parse");
1751        let mut rec = MovieRecorder::power_on(&nes);
1752        rec.enable_attestation();
1753        for _ in 0..(ATTESTATION_CHECKPOINT_INTERVAL * 2) {
1754            rec.capture(&nes);
1755            let fb = nes.run_frame().to_vec();
1756            rec.attest_frame(&fb);
1757        }
1758        let mut movie = rec.finish();
1759        assert_eq!(movie.attestation.as_ref().unwrap().checkpoints.len(), 2);
1760
1761        // Drop the trailing checkpoint, leaving the final hash intact.
1762        movie.attestation.as_mut().unwrap().checkpoints.pop();
1763        let mut fresh = Nes::from_rom(&rom).expect("parse");
1764        match movie.verify(&mut fresh).expect("verify runs") {
1765            VerifyOutcome::Match { frames, .. } => {
1766                assert_eq!(frames, ATTESTATION_CHECKPOINT_INTERVAL * 2);
1767            }
1768            other => panic!(
1769                "a missing checkpoint is 'nothing recorded here', not a \
1770                 disagreement; got {other:?}"
1771            ),
1772        }
1773    }
1774
1775    /// A rewind mid-recording must drop the attestation rather than ship one
1776    /// that describes a timeline the input log no longer encodes.
1777    #[test]
1778    fn disabling_attestation_mid_recording_yields_an_unattested_movie() {
1779        let rom = synth_nrom();
1780        let mut nes = Nes::from_rom(&rom).expect("parse");
1781        let mut rec = MovieRecorder::power_on(&nes);
1782        rec.enable_attestation();
1783        for _ in 0..4 {
1784            rec.capture(&nes);
1785            let fb = nes.run_frame().to_vec();
1786            rec.attest_frame(&fb);
1787        }
1788        // What the frontend does on a successful `rewind_step_back`.
1789        rec.disable_attestation();
1790        for _ in 0..4 {
1791            rec.capture(&nes);
1792            let fb = nes.run_frame().to_vec();
1793            rec.attest_frame(&fb);
1794        }
1795        let movie = rec.finish();
1796        assert!(
1797            movie.attestation.is_none(),
1798            "a partial hash covering only part of the run is worse than none"
1799        );
1800        assert_eq!(movie.frames.len(), 8, "the recording itself is unaffected");
1801    }
1802
1803    /// A movie with no attestation reports that, rather than passing or failing.
1804    #[test]
1805    fn unattested_movie_reports_not_attested() {
1806        let rom = synth_nrom();
1807        let mut nes = Nes::from_rom(&rom).expect("parse");
1808        let mut rec = MovieRecorder::power_on(&nes);
1809        rec.capture(&nes);
1810        let _ = nes.run_frame();
1811        let movie = rec.finish();
1812        let mut fresh = Nes::from_rom(&rom).expect("parse");
1813        assert_eq!(
1814            movie.verify(&mut fresh).expect("verify runs"),
1815            VerifyOutcome::NotAttested
1816        );
1817    }
1818
1819    /// Minimal NROM ROM that runs an infinite loop (same shape as the
1820    /// `nes.rs` test fixture). Deterministic boot, no input dependence in
1821    /// the program itself — the movie machinery is what we exercise.
1822    fn synth_nrom() -> Vec<u8> {
1823        let mut bytes = Vec::new();
1824        bytes.extend_from_slice(b"NES\x1A");
1825        bytes.push(1); // 16 KiB PRG
1826        bytes.push(1); // 8 KiB CHR
1827        bytes.push(0);
1828        bytes.push(0);
1829        bytes.extend_from_slice(&[0u8; 8]);
1830        let mut prg = vec![0u8; 16 * 1024];
1831        prg[0] = 0x4C; // JMP $C000
1832        prg[1] = 0x00;
1833        prg[2] = 0xC0;
1834        let len = prg.len();
1835        prg[len - 4] = 0x00;
1836        prg[len - 3] = 0xC0;
1837        prg[len - 6] = 0x00;
1838        prg[len - 5] = 0xC0;
1839        prg[len - 2] = 0x00;
1840        prg[len - 1] = 0xC0;
1841        bytes.extend_from_slice(&prg);
1842        bytes.extend_from_slice(&vec![0u8; 8 * 1024]);
1843        bytes
1844    }
1845
1846    fn fnv(bytes: &[u8]) -> u64 {
1847        let mut h: u64 = 0xCBF2_9CE4_8422_2325;
1848        for &b in bytes {
1849            h ^= u64::from(b);
1850            h = h.wrapping_mul(0x0000_0100_0000_01B3);
1851        }
1852        h
1853    }
1854
1855    fn audio_fnv(samples: &[f32]) -> u64 {
1856        let mut h: u64 = 0xCBF2_9CE4_8422_2325;
1857        for s in samples {
1858            for &b in &s.to_le_bytes() {
1859                h ^= u64::from(b);
1860                h = h.wrapping_mul(0x0000_0100_0000_01B3);
1861            }
1862        }
1863        h
1864    }
1865
1866    /// A fixed, varied synthetic input sequence (deterministic, no RNG).
1867    fn synthetic_inputs(n: usize) -> Vec<FrameInput> {
1868        (0..n)
1869            .map(|i| {
1870                let i = u8::try_from(i % 256).unwrap();
1871                let p1 = Buttons::from_bits_truncate(i.wrapping_mul(37));
1872                let p2 = Buttons::from_bits_truncate(i.wrapping_mul(101).rotate_left(3));
1873                FrameInput::new(p1, p2)
1874            })
1875            .collect()
1876    }
1877
1878    /// v2.9.0 (maintainer decision 2026-09-26) — a power-on movie starts from
1879    /// cleared cartridge RAM. `power_cycle` keeps cartridge RAM, as a console
1880    /// keeps a battery save; since v2.7.3 the desktop also loads a `.sav` into
1881    /// it at ROM load, so a power-on movie recorded without a save diverged on
1882    /// a machine that had one. Seeking to a `PowerOn` start must therefore
1883    /// leave cartridge RAM exactly as a fresh load with no save does: zeroed.
1884    /// `synth_nrom` with the header's battery bit set. The battery matters:
1885    /// `power_cycle` keeps battery-backed RAM (v2.9.0) and clears volatile
1886    /// RAM, so only a battery cart can show whether the MOVIE clears it.
1887    fn synth_nrom_battery() -> Vec<u8> {
1888        let mut rom = synth_nrom();
1889        rom[6] |= 0x02;
1890        rom
1891    }
1892
1893    #[test]
1894    fn seeking_a_power_on_movie_clears_cartridge_ram() {
1895        let mut nes = Nes::from_rom(&synth_nrom_battery()).unwrap();
1896        assert!(nes.has_battery());
1897        assert!(!nes.sram().is_empty(), "NROM exposes its PRG-RAM");
1898        nes.sram_mut().fill(0xA5); // a loaded .sav, or a previous session
1899        let movie = Movie {
1900            epoch: crate::EMULATION_EPOCH,
1901            region: nes.region(),
1902            rom_sha256: *nes.rom_sha256(),
1903            options: crate::HardwareOptions::default(),
1904            board: None,
1905            start: StartPoint::PowerOn,
1906            frames: synthetic_inputs(1),
1907            rerecord_count: 0,
1908            attestation: None,
1909        };
1910        movie.seek_to_start(&mut nes).unwrap();
1911        assert!(nes.sram().iter().all(|&b| b == 0), "cartridge RAM cleared");
1912    }
1913
1914    /// The same rule on the RECORDING side: `power_on_for_movie` is what a
1915    /// host calls before `MovieRecorder::power_on`, so what is recorded and
1916    /// what `seek_to_start` reconstructs are the same state.
1917    #[test]
1918    fn power_on_for_movie_matches_a_fresh_load() {
1919        let fresh = Nes::from_rom(&synth_nrom_battery()).unwrap();
1920        let mut nes = Nes::from_rom(&synth_nrom_battery()).unwrap();
1921        nes.sram_mut().fill(0x5A);
1922        nes.power_cycle();
1923        assert_eq!(nes.sram()[0], 0x5A, "a plain power cycle keeps battery RAM");
1924        power_on_for_movie(&mut nes);
1925        assert_eq!(nes.sram(), fresh.sram());
1926    }
1927
1928    #[test]
1929    fn format_round_trip_power_on() {
1930        let inputs = synthetic_inputs(120);
1931        let movie = Movie {
1932            epoch: crate::EMULATION_EPOCH,
1933            region: Region::Ntsc,
1934            rom_sha256: [0xAB; 32],
1935            options: crate::HardwareOptions::default(),
1936            board: None,
1937            start: StartPoint::PowerOn,
1938            frames: inputs,
1939            rerecord_count: 0,
1940            attestation: None,
1941        };
1942        let bytes = movie.serialize();
1943        let back = Movie::deserialize(&bytes).expect("round-trip");
1944        assert_eq!(movie, back);
1945    }
1946
1947    #[test]
1948    fn rerecord_count_round_trips_and_defaults_for_legacy_rnm() {
1949        let movie = Movie {
1950            epoch: crate::EMULATION_EPOCH,
1951            region: Region::Ntsc,
1952            rom_sha256: [0x5A; 32],
1953            options: crate::HardwareOptions::default(),
1954            board: None,
1955            start: StartPoint::PowerOn,
1956            frames: synthetic_inputs(10),
1957            rerecord_count: 4242,
1958            attestation: None,
1959        };
1960        let bytes = movie.serialize();
1961        // A full round-trip preserves the count.
1962        assert_eq!(Movie::deserialize(&bytes).unwrap().rerecord_count, 4242);
1963        // A pre-v1.8.9 `.rnm` ends exactly at the input stream (no trailing
1964        // count). Dropping the appended u32 must still parse, defaulting the
1965        // count to 0 rather than erroring — the back-compat contract.
1966        let legacy = &bytes[..bytes.len() - 4];
1967        let back = Movie::deserialize(legacy).expect("legacy .rnm still parses");
1968        assert_eq!(back.rerecord_count, 0);
1969        assert_eq!(back.frames.len(), 10);
1970    }
1971
1972    #[test]
1973    fn format_round_trip_with_save_state_start() {
1974        let movie = Movie {
1975            epoch: crate::EMULATION_EPOCH,
1976            region: Region::Pal,
1977            rom_sha256: [0x11; 32],
1978            options: crate::HardwareOptions::default(),
1979            board: None,
1980            start: StartPoint::SaveState(vec![1, 2, 3, 4, 5, 6, 7, 8]),
1981            frames: synthetic_inputs(8),
1982            rerecord_count: 0,
1983            attestation: None,
1984        };
1985        let bytes = movie.serialize();
1986        let back = Movie::deserialize(&bytes).expect("round-trip");
1987        assert_eq!(movie, back);
1988    }
1989
1990    #[test]
1991    fn deserialize_rejects_bad_magic_cleanly() {
1992        let mut bytes = vec![0u8; 53];
1993        bytes[..8].copy_from_slice(b"NOTAMOVI");
1994        assert!(matches!(
1995            Movie::deserialize(&bytes),
1996            Err(MovieError::BadMagic { .. })
1997        ));
1998    }
1999
2000    #[test]
2001    fn deserialize_rejects_too_new_format_cleanly() {
2002        let movie = Movie {
2003            epoch: crate::EMULATION_EPOCH,
2004            region: Region::Ntsc,
2005            rom_sha256: [0; 32],
2006            options: crate::HardwareOptions::default(),
2007            board: None,
2008            start: StartPoint::PowerOn,
2009            frames: Vec::new(),
2010            rerecord_count: 0,
2011            attestation: None,
2012        };
2013        let mut bytes = movie.serialize();
2014        // Bump the format-version field (offset 8) past what we support.
2015        bytes[8] = 0xFF;
2016        bytes[9] = 0xFF;
2017        assert!(matches!(
2018            Movie::deserialize(&bytes),
2019            Err(MovieError::UnsupportedFormat { .. })
2020        ));
2021    }
2022
2023    #[test]
2024    fn deserialize_rejects_truncated_header() {
2025        assert!(matches!(
2026            Movie::deserialize(&[0u8; 10]),
2027            Err(MovieError::HeaderTruncated { .. })
2028        ));
2029    }
2030
2031    #[test]
2032    fn deserialize_hostile_frame_count_does_not_oom() {
2033        // frame_count is the 4-byte LE field right after the 32-byte rom hash
2034        // (offset 8 + 2 + 1 + 1 + 32 = 44).
2035        // magic + version + epoch (format 5) + region + flags + sha256.
2036        const FRAME_COUNT_OFF: usize = 8 + 2 + 4 + 1 + 1 + 32;
2037        // A tiny (header-only) movie whose `frame_count` field claims ~4.3
2038        // billion frames. The old `Vec::with_capacity(frame_count)` would try to
2039        // reserve multiple gigabytes before the input-stream read failed (an OOM
2040        // DoS found by the `movie` fuzz target). It must now reject cleanly with
2041        // an EOF: the capacity is capped at the remaining bytes / width.
2042        let movie = Movie {
2043            epoch: crate::EMULATION_EPOCH,
2044            region: Region::Ntsc,
2045            rom_sha256: [0; 32],
2046            options: crate::HardwareOptions::default(),
2047            board: None,
2048            start: StartPoint::PowerOn,
2049            frames: Vec::new(),
2050            rerecord_count: 0,
2051            attestation: None,
2052        };
2053        let mut bytes = movie.serialize();
2054        bytes[FRAME_COUNT_OFF..FRAME_COUNT_OFF + 4].copy_from_slice(&u32::MAX.to_le_bytes());
2055        // Deserialize must return promptly with an error, not exhaust memory.
2056        assert!(matches!(
2057            Movie::deserialize(&bytes),
2058            Err(MovieError::Eof(_))
2059        ));
2060    }
2061
2062    #[test]
2063    fn deserialize_rejects_truncated_input_stream() {
2064        let movie = Movie {
2065            epoch: crate::EMULATION_EPOCH,
2066            region: Region::Ntsc,
2067            rom_sha256: [0; 32],
2068            options: crate::HardwareOptions::default(),
2069            board: None,
2070            start: StartPoint::PowerOn,
2071            frames: synthetic_inputs(10),
2072            rerecord_count: 0,
2073            attestation: None,
2074        };
2075        let bytes = movie.serialize();
2076        // Lop off the last few input bytes — must error, not panic.
2077        let truncated = &bytes[..bytes.len() - 5];
2078        assert!(matches!(
2079            Movie::deserialize(truncated),
2080            Err(MovieError::Eof(_))
2081        ));
2082    }
2083
2084    /// Drive a ROM with a fixed input sequence, recording as we go; then
2085    /// replay from the movie's start point and assert framebuffer + audio +
2086    /// cycle count are byte-identical.
2087    #[test]
2088    fn determinism_round_trip_power_on() {
2089        let rom = synth_nrom();
2090        let inputs = synthetic_inputs(30);
2091
2092        // ----- Original run (recording). -----
2093        let mut nes = Nes::from_rom(&rom).expect("boot");
2094        nes.power_cycle(); // start point a replay will reconstruct
2095        let mut rec = MovieRecorder::power_on(&nes);
2096        let mut orig_fb = 0u64;
2097        let mut orig_audio = Vec::new();
2098        for f in &inputs {
2099            nes.set_buttons(0, f.p1);
2100            nes.set_buttons(1, f.p2);
2101            rec.capture(&nes);
2102            orig_fb = fnv(nes.run_frame());
2103            orig_audio.extend(nes.drain_audio());
2104        }
2105        let orig_cycle = nes.cycle();
2106        let orig_audio_hash = audio_fnv(&orig_audio);
2107        let movie = rec.finish();
2108        assert_eq!(movie.len(), inputs.len());
2109
2110        // ----- Replay from the movie's start point. -----
2111        let mut replay = Nes::from_rom(&rom).expect("boot");
2112        movie.seek_to_start(&mut replay).expect("seek");
2113        let mut player = MoviePlayer::new(&movie);
2114        let mut replay_fb = 0u64;
2115        let mut replay_audio = Vec::new();
2116        while player.apply_next(&mut replay) {
2117            replay_fb = fnv(replay.run_frame());
2118            replay_audio.extend(replay.drain_audio());
2119        }
2120
2121        assert_eq!(orig_fb, replay_fb, "framebuffer must replay bit-identical");
2122        assert_eq!(
2123            orig_audio_hash,
2124            audio_fnv(&replay_audio),
2125            "audio must replay bit-identical"
2126        );
2127        assert_eq!(
2128            orig_cycle,
2129            replay.cycle(),
2130            "cumulative cycle count must replay bit-identical"
2131        );
2132    }
2133
2134    /// Replaying the same movie twice must yield identical output (the movie
2135    /// itself is internally deterministic).
2136    #[test]
2137    fn replay_is_internally_deterministic() {
2138        let rom = synth_nrom();
2139        let movie = Movie {
2140            epoch: crate::EMULATION_EPOCH,
2141            region: Region::Ntsc,
2142            rom_sha256: *Nes::from_rom(&rom).unwrap().rom_sha256(),
2143            options: crate::HardwareOptions::default(),
2144            board: None,
2145            start: StartPoint::PowerOn,
2146            frames: synthetic_inputs(20),
2147            rerecord_count: 0,
2148            attestation: None,
2149        };
2150
2151        let run = |movie: &Movie| -> (u64, u64, u64) {
2152            let mut nes = Nes::from_rom(&rom).unwrap();
2153            movie.seek_to_start(&mut nes).unwrap();
2154            let mut player = MoviePlayer::new(movie);
2155            let mut fb = 0u64;
2156            let mut audio = Vec::new();
2157            while player.apply_next(&mut nes) {
2158                fb = fnv(nes.run_frame());
2159                audio.extend(nes.drain_audio());
2160            }
2161            (fb, audio_fnv(&audio), nes.cycle())
2162        };
2163
2164        assert_eq!(run(&movie), run(&movie));
2165    }
2166
2167    /// Save-state branch: run a base movie partway, snapshot, start a new
2168    /// branch recorder from that snapshot, and assert the branch replay is
2169    /// internally deterministic and reconstructs the branch start point.
2170    #[test]
2171    fn save_state_branch_round_trip() {
2172        let rom = synth_nrom();
2173
2174        // Base run: advance some frames with a fixed input, then branch.
2175        let base_inputs = synthetic_inputs(10);
2176        let mut nes = Nes::from_rom(&rom).unwrap();
2177        nes.power_cycle();
2178        for f in &base_inputs {
2179            nes.set_buttons(0, f.p1);
2180            nes.set_buttons(1, f.p2);
2181            nes.run_frame();
2182        }
2183        let branch_cycle = nes.cycle();
2184        let branch_fb = fnv(nes.framebuffer());
2185
2186        // Start a branch recorder from the current state, record more frames.
2187        let mut branch_rec = MovieRecorder::from_current_state(&nes);
2188        let branch_inputs = synthetic_inputs(15);
2189        for f in &branch_inputs {
2190            nes.set_buttons(0, f.p1);
2191            nes.set_buttons(1, f.p2);
2192            branch_rec.capture(&nes);
2193            nes.run_frame();
2194        }
2195        let branch_end_cycle = nes.cycle();
2196        let branch_end_fb = fnv(nes.framebuffer());
2197        let branch_movie = branch_rec.finish();
2198        assert!(matches!(branch_movie.start, StartPoint::SaveState(_)));
2199
2200        // Replay the branch from its embedded snapshot.
2201        let run_branch = || -> (u64, u64) {
2202            let mut replay = Nes::from_rom(&rom).unwrap();
2203            branch_movie.seek_to_start(&mut replay).unwrap();
2204            // After seeking, we are back at the branch start point.
2205            assert_eq!(replay.cycle(), branch_cycle, "branch start cycle");
2206            assert_eq!(fnv(replay.framebuffer()), branch_fb, "branch start fb");
2207            let mut player = MoviePlayer::new(&branch_movie);
2208            let mut fb = 0u64;
2209            while player.apply_next(&mut replay) {
2210                fb = fnv(replay.run_frame());
2211            }
2212            (fb, replay.cycle())
2213        };
2214
2215        let first = run_branch();
2216        let second = run_branch();
2217        assert_eq!(first, second, "branch replay internally deterministic");
2218        // And it reconstructs the live branch end state bit-identically.
2219        assert_eq!(first.0, branch_end_fb, "branch end fb matches live run");
2220        assert_eq!(
2221            first.1, branch_end_cycle,
2222            "branch end cycle matches live run"
2223        );
2224
2225        // Format round-trip survives the embedded save state.
2226        let bytes = branch_movie.serialize();
2227        let back = Movie::deserialize(&bytes).unwrap();
2228        assert_eq!(branch_movie, back);
2229    }
2230
2231    #[test]
2232    fn seek_rejects_rom_mismatch() {
2233        let rom = synth_nrom();
2234        let movie = Movie {
2235            epoch: crate::EMULATION_EPOCH,
2236            region: Region::Ntsc,
2237            rom_sha256: [0xFF; 32], // deliberately wrong
2238            options: crate::HardwareOptions::default(),
2239            board: None,
2240            start: StartPoint::PowerOn,
2241            frames: Vec::new(),
2242            rerecord_count: 0,
2243            attestation: None,
2244        };
2245        let mut nes = Nes::from_rom(&rom).unwrap();
2246        assert!(matches!(
2247            movie.seek_to_start(&mut nes),
2248            Err(MovieError::RomMismatch)
2249        ));
2250    }
2251
2252    #[test]
2253    fn frame_input_bit_layout_matches_buttons() {
2254        // The on-wire byte for a frame is exactly Buttons::bits() (FCEUX
2255        // .fm2 layout). Verify the serialize path preserves it.
2256        let movie = Movie {
2257            epoch: crate::EMULATION_EPOCH,
2258            region: Region::Ntsc,
2259            rom_sha256: [0; 32],
2260            options: crate::HardwareOptions::default(),
2261            board: None,
2262            start: StartPoint::PowerOn,
2263            frames: vec![FrameInput::four_players(
2264                Buttons::A | Buttons::RIGHT,
2265                Buttons::B | Buttons::START,
2266                Buttons::UP,
2267                Buttons::SELECT,
2268            )],
2269            rerecord_count: 0,
2270            attestation: None,
2271        };
2272        let bytes = movie.serialize();
2273        // Input stream begins after the 53-byte fixed header and the
2274        // length-prefixed OPTIONS block (no board, no save state).
2275        let at = 53 + 4 + crate::HardwareOptions::default().to_bytes().len();
2276        assert_eq!(bytes[at], (Buttons::A | Buttons::RIGHT).bits());
2277        assert_eq!(bytes[at + 1], (Buttons::B | Buttons::START).bits());
2278        assert_eq!(bytes[at + 2], Buttons::UP.bits(), "player 3");
2279        assert_eq!(bytes[at + 3], Buttons::SELECT.bits(), "player 4");
2280        assert_eq!(bytes[at + 4], 0, "expansion byte reserved/zero");
2281        assert_eq!(bytes[52], BYTES_PER_FRAME, "the header states the width");
2282    }
2283
2284    /// A narrower record still reads: the fields it does not reach default.
2285    /// (Format 3 writes 5 bytes; the width field is what lets a later build
2286    /// grow the record additively.)
2287    #[test]
2288    fn a_two_byte_record_defaults_players_three_and_four() {
2289        let movie = Movie {
2290            epoch: crate::EMULATION_EPOCH,
2291            region: Region::Ntsc,
2292            rom_sha256: [0; 32],
2293            options: crate::HardwareOptions::default(),
2294            board: None,
2295            start: StartPoint::PowerOn,
2296            frames: vec![FrameInput::four_players(
2297                Buttons::A,
2298                Buttons::B,
2299                Buttons::UP,
2300                Buttons::DOWN,
2301            )],
2302            rerecord_count: 0,
2303            attestation: None,
2304        };
2305        let mut bytes = movie.serialize();
2306        let at = 53 + 4 + crate::HardwareOptions::default().to_bytes().len();
2307        bytes[52] = 2; // bytes_per_frame, the fixed header's last byte
2308        bytes.drain(at + 2..at + 5);
2309        let back = Movie::deserialize(&bytes).expect("narrow record");
2310        assert_eq!(back.frames, [FrameInput::new(Buttons::A, Buttons::B)]);
2311    }
2312
2313    /// v3.0.0 (ADR 0045): a movie records the emulation epoch beside its
2314    /// format version, and a movie from another epoch is refused before
2315    /// anything else is parsed, naming both epochs. Without the check, a
2316    /// movie recorded by a core that emulates differently (v2.9.9's MMC3
2317    /// timing, say) would replay its inputs on the new timing and diverge
2318    /// with no explanation.
2319    #[test]
2320    fn a_movie_from_another_emulation_epoch_is_refused() {
2321        let movie = Movie::new(
2322            Region::Ntsc,
2323            [7; 32],
2324            crate::HardwareOptions::default(),
2325            None,
2326            StartPoint::PowerOn,
2327            vec![FrameInput::new(Buttons::A, Buttons::B)],
2328        );
2329        assert_eq!(movie.epoch, crate::EMULATION_EPOCH);
2330        let bytes = movie.serialize();
2331        // The epoch sits straight after the format version (bytes 10..14).
2332        assert_eq!(&bytes[8..10], &MOVIE_FORMAT_VERSION.to_le_bytes());
2333        assert_eq!(&bytes[10..14], &crate::EMULATION_EPOCH.to_le_bytes());
2334        assert_eq!(
2335            Movie::deserialize(&bytes).expect("same epoch").epoch,
2336            crate::EMULATION_EPOCH
2337        );
2338
2339        let mut other = bytes;
2340        other[10..14].copy_from_slice(&(crate::EMULATION_EPOCH + 1).to_le_bytes());
2341        assert!(matches!(
2342            Movie::deserialize(&other),
2343            Err(MovieError::EpochMismatch { movie, core })
2344                if movie == crate::EMULATION_EPOCH + 1 && core == crate::EMULATION_EPOCH
2345        ));
2346    }
2347
2348    /// v3.0.0: the epoch is enforced on PLAYBACK too, not only when parsing.
2349    /// `Movie::epoch` is a public field and a `Movie` can be built in memory,
2350    /// so a deserialize-only check left `seek_to_start` and `verify` open to
2351    /// a movie from another epoch (a review finding on #588). Both refuse it, with
2352    /// the machine untouched, and `verify` refuses it before reporting that
2353    /// the movie is unattested.
2354    #[test]
2355    fn playback_refuses_a_movie_from_another_epoch() {
2356        let mut nes = Nes::from_rom(&synth_nrom_battery()).unwrap();
2357        let mut movie = Movie::new(
2358            nes.region(),
2359            *nes.rom_sha256(),
2360            crate::HardwareOptions::default(),
2361            None,
2362            StartPoint::PowerOn,
2363            synthetic_inputs(1),
2364        );
2365        movie.epoch = crate::EMULATION_EPOCH + 1;
2366        nes.sram_mut().fill(0xA5);
2367        let refused = |r: &Result<(), MovieError>| {
2368            matches!(r, Err(MovieError::EpochMismatch { movie, core })
2369                if *movie == crate::EMULATION_EPOCH + 1 && *core == crate::EMULATION_EPOCH)
2370        };
2371        assert!(
2372            refused(&movie.seek_to_start(&mut nes)),
2373            "seek_to_start refuses"
2374        );
2375        assert!(
2376            nes.sram().iter().all(|&b| b == 0xA5),
2377            "the machine is untouched"
2378        );
2379        assert!(
2380            refused(&movie.verify(&mut nes).map(|_| ())),
2381            "verify refuses before NotAttested"
2382        );
2383    }
2384
2385    /// v3.0.0: a format-4 movie (v2.9.9) does not record the epoch, so it is
2386    /// refused as too old rather than replayed under a timing it may not
2387    /// have been recorded on.
2388    #[test]
2389    fn a_format_4_movie_is_refused_as_too_old() {
2390        let mut bytes = Movie::new(
2391            Region::Ntsc,
2392            [0; 32],
2393            crate::HardwareOptions::default(),
2394            None,
2395            StartPoint::PowerOn,
2396            vec![],
2397        )
2398        .serialize();
2399        bytes[8..10].copy_from_slice(&4u16.to_le_bytes());
2400        assert!(matches!(
2401            Movie::deserialize(&bytes),
2402            Err(MovieError::FormatTooOld {
2403                got: 4,
2404                min: MIN_MOVIE_FORMAT_VERSION
2405            })
2406        ));
2407    }
2408
2409    /// v3.1.0: a format-5 movie (v3.0.x) carries the options record without
2410    /// the CPU overclock and the sprite-limit option, so it is refused as too
2411    /// old rather than decoded one field short.
2412    #[test]
2413    fn a_format_5_movie_is_refused_as_too_old() {
2414        assert_eq!(MIN_MOVIE_FORMAT_VERSION, 6);
2415        let mut bytes = Movie::new(
2416            Region::Ntsc,
2417            [0; 32],
2418            crate::HardwareOptions::default(),
2419            None,
2420            StartPoint::PowerOn,
2421            vec![],
2422        )
2423        .serialize();
2424        bytes[8..10].copy_from_slice(&5u16.to_le_bytes());
2425        assert!(matches!(
2426            Movie::deserialize(&bytes),
2427            Err(MovieError::FormatTooOld { got: 5, min: 6 })
2428        ));
2429    }
2430
2431    #[test]
2432    fn recorded_before_v2_timebase_flags_pre_promote_movies() {
2433        // ADR 0028: a freshly-serialized movie carries the current
2434        // MOVIE_FORMAT_VERSION (>= 2) and must NOT be flagged.
2435        let movie = Movie {
2436            epoch: crate::EMULATION_EPOCH,
2437            region: Region::Ntsc,
2438            rom_sha256: [0; 32],
2439            options: crate::HardwareOptions::default(),
2440            board: None,
2441            start: StartPoint::PowerOn,
2442            frames: vec![],
2443            rerecord_count: 0,
2444            attestation: None,
2445        };
2446        let bytes = movie.serialize();
2447        assert!(matches!(recorded_before_v2_timebase(&bytes), Ok(false)));
2448
2449        // A v1-tagged blob (format_version = 1, the only value that existed
2450        // pre-v2.0.0) must be flagged. Since v2.9.8 it no longer parses: it
2451        // predates the options record (MIN_MOVIE_FORMAT_VERSION).
2452        let mut v1_bytes = bytes;
2453        v1_bytes[8..10].copy_from_slice(&1u16.to_le_bytes());
2454        assert!(matches!(recorded_before_v2_timebase(&v1_bytes), Ok(true)));
2455        assert!(matches!(
2456            Movie::deserialize(&v1_bytes),
2457            Err(MovieError::FormatTooOld {
2458                got: 1,
2459                min: MIN_MOVIE_FORMAT_VERSION
2460            })
2461        ));
2462
2463        // Malformed input still surfaces the normal header errors.
2464        assert!(matches!(
2465            recorded_before_v2_timebase(&[0u8; 4]),
2466            Err(MovieError::HeaderTruncated { .. })
2467        ));
2468        assert!(matches!(
2469            recorded_before_v2_timebase(&[0xFFu8; 10]),
2470            Err(MovieError::BadMagic { .. })
2471        ));
2472    }
2473
2474    // ----------------------------------------------------------------- v2.9.8
2475    // Emulation options travel with the movie (maintainer decision 2026-10-01)
2476    // -------------------------------------------------------------------------
2477
2478    /// Record `frames` frames on `nes` (already configured by the caller) from
2479    /// a power-on start, returning the movie and the machine's full snapshot at
2480    /// the end. The snapshot is the strongest available "replays identically"
2481    /// oracle: it covers work RAM, the PPU's warm-up counter and every other
2482    /// piece of serialized state, so a difference a test ROM never draws on
2483    /// screen still shows up.
2484    ///
2485    /// Returned as an FNV hash so a failing comparison prints two numbers
2486    /// rather than two multi-kilobyte byte arrays.
2487    fn record_power_on(nes: &mut Nes, frames: usize) -> (Movie, u64) {
2488        power_on_for_movie(nes);
2489        let mut rec = MovieRecorder::power_on(nes);
2490        for f in synthetic_inputs(frames) {
2491            nes.set_buttons(0, f.p1);
2492            nes.set_buttons(1, f.p2);
2493            rec.capture(nes);
2494            nes.run_frame();
2495        }
2496        (rec.finish(), fnv(&nes.snapshot()))
2497    }
2498
2499    /// Replay `movie` on `nes` from its start point and return the snapshot's
2500    /// hash.
2501    fn replay(movie: &Movie, nes: &mut Nes) -> u64 {
2502        movie.seek_to_start(nes).expect("seek");
2503        let mut player = MoviePlayer::new(movie);
2504        while player.apply_next(nes) {
2505            nes.run_frame();
2506        }
2507        fnv(&nes.snapshot())
2508    }
2509
2510    /// `synth_nrom` whose reset handler writes `$1E` to PPUMASK before it
2511    /// loops. On the NES model that write lands inside the PPU's warm-up and
2512    /// is ignored; on the Famicom model the warm-up is already over and it
2513    /// takes effect -- so the two console models leave different PPU state.
2514    fn synth_nrom_ppumask() -> Vec<u8> {
2515        let mut rom = synth_nrom();
2516        // LDA #$1E ; STA $2001 ; JMP $C005
2517        let prog = [0xA9, 0x1E, 0x8D, 0x01, 0x20, 0x4C, 0x05, 0xC0];
2518        rom[16..16 + prog.len()].copy_from_slice(&prog);
2519        rom
2520    }
2521
2522    /// A movie recorded on the Famicom console model replays identically on a
2523    /// player whose own setting is the NES model, through a `.rnm` round trip.
2524    #[test]
2525    fn a_famicom_movie_replays_identically_on_an_nes_configured_player() {
2526        let rom = synth_nrom_ppumask();
2527        let mut rec_nes = Nes::from_rom(&rom).unwrap();
2528        rec_nes.set_console_model(crate::ConsoleModel::Famicom);
2529        let (movie, recorded) = record_power_on(&mut rec_nes, 12);
2530        let movie = Movie::deserialize(&movie.serialize()).expect("round trip");
2531
2532        let mut player = Nes::from_rom(&rom).unwrap();
2533        assert_eq!(player.console_model(), crate::ConsoleModel::Nes);
2534        assert_eq!(replay(&movie, &mut player), recorded);
2535    }
2536
2537    /// A movie recorded with a seeded power-on RAM fill replays identically on
2538    /// a player configured with a different fill, through a `.rnm` round trip.
2539    #[test]
2540    fn a_seeded_power_on_ram_movie_replays_identically_on_a_differently_configured_player() {
2541        let rom = synth_nrom();
2542        let mut rec_nes = Nes::from_rom(&rom).unwrap();
2543        rec_nes.set_power_on_ram(crate::PowerOnRam::Seeded(0x5EED_1234));
2544        let (movie, recorded) = record_power_on(&mut rec_nes, 12);
2545        let movie = Movie::deserialize(&movie.serialize()).expect("round trip");
2546
2547        let mut player = Nes::from_rom(&rom).unwrap();
2548        player.set_power_on_ram(crate::PowerOnRam::Filled(0xFF));
2549        assert_eq!(replay(&movie, &mut player), recorded);
2550    }
2551
2552    /// Every other option travels too: OAM decay, both die revisions, the
2553    /// power-up palette, the overclock, the Four Score, the Zapper light model
2554    /// and a Game Genie code, recorded on one machine and replayed on a player
2555    /// whose settings are all at their defaults.
2556    #[test]
2557    fn every_recorded_option_replays_on_a_default_player() {
2558        let rom = synth_nrom();
2559        let mut rec_nes = Nes::from_rom(&rom).unwrap();
2560        rec_nes.set_oam_decay(true);
2561        rec_nes.set_ppu_revision(crate::PpuRevision::Rp2c02G);
2562        rec_nes.set_cpu_2a03_revision(crate::Cpu2A03Revision::Rp2A03H);
2563        rec_nes.set_power_up_palette(crate::PaletteInit::Blargg);
2564        rec_nes.set_extra_scanlines(20);
2565        rec_nes.set_four_score(true);
2566        rec_nes.set_zapper_temporal_light(false);
2567        rec_nes.add_genie_code("SXIOPO").unwrap();
2568        let (movie, recorded) = record_power_on(&mut rec_nes, 12);
2569        let movie = Movie::deserialize(&movie.serialize()).expect("round trip");
2570
2571        let mut player = Nes::from_rom(&rom).unwrap();
2572        assert_eq!(replay(&movie, &mut player), recorded);
2573        assert!(player.oam_decay_enabled());
2574        assert_eq!(player.extra_scanlines(), 20);
2575        assert_eq!(player.genie_codes().count(), 1);
2576    }
2577
2578    /// A movie written before the options were recorded is refused with a
2579    /// clear error rather than replayed under whatever the player has set.
2580    #[test]
2581    fn a_movie_older_than_the_options_epoch_is_rejected() {
2582        let movie = Movie {
2583            epoch: crate::EMULATION_EPOCH,
2584            region: Region::Ntsc,
2585            rom_sha256: [0; 32],
2586            options: crate::HardwareOptions::default(),
2587            board: None,
2588            start: StartPoint::PowerOn,
2589            frames: synthetic_inputs(2),
2590            rerecord_count: 0,
2591            attestation: None,
2592        };
2593        let mut bytes = movie.serialize();
2594        bytes[8..10].copy_from_slice(&2u16.to_le_bytes());
2595        let err = Movie::deserialize(&bytes).expect_err("a v2 movie must be refused");
2596        let text = alloc::format!("{err}");
2597        assert!(
2598            text.contains("re-record"),
2599            "the error says what to do: {text}"
2600        );
2601    }
2602
2603    /// v2.9.8 — a movie whose embedded start state predates the `.rns`
2604    /// epoch 3 is refused with the same "re-record" advice as an old movie.
2605    #[test]
2606    fn a_movie_with_an_old_start_state_says_to_re_record() {
2607        let rom = synth_nrom();
2608        let nes = Nes::from_rom(&rom).unwrap();
2609        let mut movie = MovieRecorder::from_current_state(&nes).finish();
2610        if let StartPoint::SaveState(blob) = &mut movie.start {
2611            // The container version follows the 8-byte "RUSTYNES" magic.
2612            blob[8..10].copy_from_slice(&2u16.to_le_bytes());
2613        }
2614        let movie = Movie::deserialize(&movie.serialize()).expect("the movie itself parses");
2615        let mut player = Nes::from_rom(&rom).unwrap();
2616        let err = movie
2617            .seek_to_start(&mut player)
2618            .expect_err("old start state");
2619        assert!(
2620            matches!(err, MovieError::StartStateTooOld { got: 2, .. }),
2621            "got {err:?}"
2622        );
2623        assert!(alloc::format!("{err}").contains("re-record"));
2624    }
2625
2626    /// v2.9.8 (`CodeRabbit` on the review slice #580) — a refused start leaves
2627    /// the player's machine as it was. `seek_to_start` applies the movie's
2628    /// options before it reaches the start point, and `apply` refills work RAM
2629    /// and palette RAM; without a rollback, a movie refused for an old start
2630    /// state or an undecodable Game Genie code still left the running game
2631    /// with the movie's RAM fill and configuration.
2632    #[test]
2633    fn a_refused_start_leaves_the_player_untouched() {
2634        let rom = synth_nrom();
2635        let nes = Nes::from_rom(&rom).unwrap();
2636        let recorded = MovieRecorder::from_current_state(&nes).finish();
2637
2638        let mut old_start = recorded.clone();
2639        if let StartPoint::SaveState(blob) = &mut old_start.start {
2640            blob[8..10].copy_from_slice(&2u16.to_le_bytes());
2641        }
2642        let mut bad_code = recorded;
2643        bad_code.options.genie_codes.push("QQQQQQ".into());
2644
2645        for (what, movie) in [("old start state", old_start), ("bad code", bad_code)] {
2646            // A player configured differently from the movie, mid-game.
2647            let mut player = Nes::from_rom(&rom).unwrap();
2648            player.set_four_score(true);
2649            player.set_power_on_ram(PowerOnRam::Filled(0xA5));
2650            player.run_frame();
2651            let before_state = player.snapshot();
2652            let before_options = HardwareOptions::capture(&player);
2653
2654            movie.seek_to_start(&mut player).expect_err(what);
2655            assert_eq!(
2656                HardwareOptions::capture(&player),
2657                before_options,
2658                "{what}: the player's options changed"
2659            );
2660            assert!(
2661                player.snapshot() == before_state,
2662                "{what}: the player's machine state changed"
2663            );
2664        }
2665    }
2666}