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