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}