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