Skip to main content

rusty2600_core/
movie.rs

1//! TAS movies — a versioned, deterministic recording of a play session.
2//!
3//! A `.r26m` movie is a start point (either a fresh seeded power-on, per
4//! ADR 0006, or an embedded [`crate::SaveState`] blob — a branch point is
5//! exactly this) plus a per-frame log of every host input the 2600 exposes.
6//! Replaying a movie against the ROM it was recorded against reproduces the
7//! exact same run, by the same determinism contract save-states already rely
8//! on (ADR 0004: same seed/ROM/input ⇒ bit-identical output).
9//!
10//! Deliberately mirrors [`crate::save_state`]'s structure and header
11//! conventions (magic + format version + `rom_tag`, `postcard`-encoded, a
12//! typed decode error) rather than inventing a parallel scheme — but is its
13//! own format with its own magic and version counter, since a movie and a
14//! save-state answer different questions (a whole run vs. one instant).
15//!
16//! `MovieFrame`, this module's per-frame record, is deliberately NOT
17//! `rusty2600_frontend::input::InputState` — this crate cannot depend on the
18//! frontend crate (the crate graph is one-directional: frontend depends on
19//! core, never the reverse). The frontend converts `InputState <-> MovieFrame`
20//! when recording/replaying.
21
22use alloc::vec::Vec;
23
24use crate::save_state::{SaveState, SaveStateError};
25use crate::scheduler::System;
26
27/// The movie file magic (`"R26M"` — distinct from save-state's `"R26S"`).
28const MAGIC: [u8; 4] = *b"R26M";
29
30/// The current movie format version. Independent of save-state's own
31/// version counter (they're different formats answering different
32/// questions), but follows the same migration spirit as
33/// `docs/adr/0007-save-state-versioning.md`: same MAJOR.MINOR round-trips
34/// byte-identical; additive fields within a MINOR use `#[serde(default)]`;
35/// anything else bumps this and is handled explicitly by [`Movie::restore`].
36const FORMAT_VERSION: u16 = 1;
37
38/// The oldest format version this build can still read.
39const MIN_SUPPORTED_FORMAT_VERSION: u16 = 1;
40
41/// The TV broadcast region a movie was recorded under. A small, deliberate
42/// duplication of `rusty2600-frontend::palette::Region` (three variants, not
43/// a shared abstraction) — this crate cannot depend on the frontend crate,
44/// and region here is just a label carried for reference/playback-config
45/// purposes, not palette data, so a full shared type isn't warranted.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
47pub enum MovieRegion {
48    /// NTSC (60 Hz, 262 lines).
49    #[default]
50    Ntsc,
51    /// PAL (50 Hz, 312 lines).
52    Pal,
53    /// SECAM (50 Hz, 312 lines, 8-colour palette).
54    Secam,
55}
56
57/// One frame's worth of host input — everything a 2600 controller/console
58/// panel can drive. Console switches are per-frame fields (not header-level
59/// constants) because Select/Reset/Color/Difficulty can all change mid-run
60/// on real hardware, unlike a fixed NES controller.
61///
62/// Packed to mirror the RIOT/TIA port-byte conventions
63/// `rusty2600-frontend::input` already established, so the frontend's
64/// `InputState -> MovieFrame` conversion is a direct reuse of that existing
65/// packing logic, not a re-derivation of it.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
67pub struct MovieFrame {
68    /// Packed joystick directions for both ports, same nibble layout as the
69    /// RIOT's `SWCHA` (`rusty2600-frontend::input::Joystick::swcha_nibble`):
70    /// bits 7-4 = port 0 up/down/left/right, bits 3-0 = port 1. Active-low.
71    pub swcha: u8,
72    /// The two joystick fire buttons (TIA `INPT4`/`INPT5`, not part of
73    /// `SWCHA`): bit 0 = port 0 fire pressed, bit 1 = port 1 fire pressed.
74    pub joy_fire: u8,
75    /// Packed console switches, same layout as the RIOT's `SWCHB`
76    /// (`rusty2600-frontend::input::ConsoleSwitches::swchb`).
77    pub swchb: u8,
78    /// The four paddles' pot positions (`0` full clockwise ..= `255` full
79    /// counter-clockwise, the TIA `INPTx` dump-capacitor value).
80    pub paddle_pos: [u8; 4],
81    /// The four paddles' fire buttons, one bit each (bit N = paddle N).
82    pub paddle_fire: u8,
83}
84
85impl Default for MovieFrame {
86    /// The idle frame: no direction/fire/switch pressed. `swcha`/`swchb`
87    /// default HIGH (`0xFF`), matching real hardware's active-low pull-ups
88    /// — a naive all-zero default would instead mean "every direction and
89    /// switch held down simultaneously," the opposite of idle.
90    fn default() -> Self {
91        Self {
92            swcha: 0xFF,
93            joy_fire: 0,
94            swchb: 0xFF,
95            paddle_pos: [0; 4],
96            paddle_fire: 0,
97        }
98    }
99}
100
101/// Where a movie's recorded input starts from.
102#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
103pub enum MovieStart {
104    /// A fresh power-on with the given seed (ADR 0006: same seed ⇒
105    /// byte-identical seeded RIOT RAM / CPU `A`/`X`/`Y`).
106    PowerOn {
107        /// The `System::new` seed.
108        seed: u64,
109    },
110    /// An embedded save-state blob (`crate::SaveState::encode`'s wire
111    /// format) — a branch point is exactly a movie whose start point is the
112    /// save-state captured at the branch frame.
113    FromSaveState(Vec<u8>),
114}
115
116#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
117struct MovieHeader {
118    magic: [u8; 4],
119    format_version: u16,
120    region: MovieRegion,
121    rom_tag: u64,
122}
123
124/// Everything that can go wrong loading a movie. Mirrors
125/// [`SaveStateError`]'s shape for the same failure modes.
126#[derive(Debug)]
127pub enum MovieError {
128    /// The byte stream didn't decode as a `Movie` at all (truncated,
129    /// corrupt, or not a movie file).
130    Malformed,
131    /// The decoded header's magic didn't match — this isn't a Rusty2600
132    /// movie file.
133    BadMagic,
134    /// The movie's `rom_tag` doesn't match the ROM currently loaded.
135    RomMismatch,
136    /// The movie's format version is older than this build can read.
137    UnsupportedFormat {
138        /// The format version stored in the file.
139        file_version: u16,
140        /// The oldest format version this build supports.
141        min_supported: u16,
142    },
143    /// [`MovieStart::FromSaveState`]'s embedded blob failed to decode.
144    BadEmbeddedSaveState(SaveStateError),
145}
146
147impl core::fmt::Display for MovieError {
148    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
149        match self {
150            Self::Malformed => write!(f, "movie data is malformed or truncated"),
151            Self::BadMagic => write!(f, "not a Rusty2600 movie file"),
152            Self::RomMismatch => write!(f, "movie was recorded against a different ROM"),
153            Self::UnsupportedFormat {
154                file_version,
155                min_supported,
156            } => write!(
157                f,
158                "movie format v{file_version} is older than the minimum supported format v{min_supported}"
159            ),
160            Self::BadEmbeddedSaveState(e) => {
161                write!(f, "movie's embedded save-state is invalid: {e}")
162            }
163        }
164    }
165}
166
167/// A recorded (or in-progress) TAS movie: a start point plus a per-frame
168/// input log.
169#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
170pub struct Movie {
171    header: MovieHeader,
172    start: MovieStart,
173    frames: Vec<MovieFrame>,
174}
175
176impl Movie {
177    /// Begin a new movie from a fresh power-on.
178    #[must_use]
179    pub fn new_power_on(rom_tag: u64, region: MovieRegion, seed: u64) -> Self {
180        Self {
181            header: MovieHeader {
182                magic: MAGIC,
183                format_version: FORMAT_VERSION,
184                region,
185                rom_tag,
186            },
187            start: MovieStart::PowerOn { seed },
188            frames: Vec::new(),
189        }
190    }
191
192    /// Begin a new movie (a branch point) starting from `system`'s current
193    /// state, captured via [`SaveState::capture`].
194    #[must_use]
195    pub fn new_branch(rom_tag: u64, region: MovieRegion, system: &System) -> Self {
196        let blob = SaveState::capture(system, rom_tag).encode();
197        Self {
198            header: MovieHeader {
199                magic: MAGIC,
200                format_version: FORMAT_VERSION,
201                region,
202                rom_tag,
203            },
204            start: MovieStart::FromSaveState(blob),
205            frames: Vec::new(),
206        }
207    }
208
209    /// This movie's recorded region.
210    #[must_use]
211    pub const fn region(&self) -> MovieRegion {
212        self.header.region
213    }
214
215    /// This movie's start point.
216    #[must_use]
217    pub const fn start(&self) -> &MovieStart {
218        &self.start
219    }
220
221    /// The recorded frames so far, in playback order.
222    #[must_use]
223    pub fn frames(&self) -> &[MovieFrame] {
224        &self.frames
225    }
226
227    /// Append one frame of input to the recording.
228    pub fn record_frame(&mut self, frame: MovieFrame) {
229        self.frames.push(frame);
230    }
231
232    /// Overwrite an already-recorded frame (piano-roll editing). No-op if
233    /// `index` is out of bounds.
234    pub fn set_frame(&mut self, index: usize, frame: MovieFrame) {
235        if let Some(slot) = self.frames.get_mut(index) {
236            *slot = frame;
237        }
238    }
239
240    /// The frame at `index`, if recorded.
241    #[must_use]
242    pub fn frame_at(&self, index: usize) -> Option<MovieFrame> {
243        self.frames.get(index).copied()
244    }
245
246    /// Number of recorded frames.
247    #[must_use]
248    pub fn len(&self) -> usize {
249        self.frames.len()
250    }
251
252    /// Whether no frames have been recorded yet.
253    #[must_use]
254    pub fn is_empty(&self) -> bool {
255        self.frames.is_empty()
256    }
257
258    /// Rebuild the [`System`] this movie's start point describes. For
259    /// [`MovieStart::PowerOn`] this is a fresh, deterministically-seeded
260    /// system; for [`MovieStart::FromSaveState`] this decodes the embedded
261    /// blob (validated against `rom_tag`).
262    pub fn start_system(&self, rom_tag: u64) -> Result<System, MovieError> {
263        match &self.start {
264            MovieStart::PowerOn { seed } => Ok(System::new(*seed)),
265            MovieStart::FromSaveState(blob) => {
266                SaveState::restore(blob, rom_tag).map_err(MovieError::BadEmbeddedSaveState)
267            }
268        }
269    }
270
271    /// Encodes this movie to its binary wire format.
272    #[must_use]
273    pub fn encode(&self) -> Vec<u8> {
274        // Every field is a plain derive over data this crate itself owns
275        // (no trait objects, no external I/O) — this can't fail in practice,
276        // matching `SaveState::encode`'s same reasoning.
277        postcard::to_allocvec(self).unwrap_or_default()
278    }
279
280    /// Decodes a movie from its binary wire format, without checking it
281    /// against any particular ROM yet (see [`Self::restore`] for that).
282    pub fn decode(bytes: &[u8]) -> Result<Self, MovieError> {
283        let movie: Self = postcard::from_bytes(bytes).map_err(|_| MovieError::Malformed)?;
284        if movie.header.magic != MAGIC {
285            return Err(MovieError::BadMagic);
286        }
287        if movie.header.format_version < MIN_SUPPORTED_FORMAT_VERSION {
288            return Err(MovieError::UnsupportedFormat {
289                file_version: movie.header.format_version,
290                min_supported: MIN_SUPPORTED_FORMAT_VERSION,
291            });
292        }
293        Ok(movie)
294    }
295
296    /// Decodes and validates a movie against `rom_tag`.
297    pub fn restore(bytes: &[u8], rom_tag: u64) -> Result<Self, MovieError> {
298        let movie = Self::decode(bytes)?;
299        if movie.header.rom_tag != rom_tag {
300            return Err(MovieError::RomMismatch);
301        }
302        Ok(movie)
303    }
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    fn sample_frame(seed: u8) -> MovieFrame {
311        MovieFrame {
312            swcha: seed,
313            joy_fire: seed & 0b11,
314            swchb: seed.wrapping_add(1),
315            paddle_pos: [
316                seed,
317                seed.wrapping_add(1),
318                seed.wrapping_add(2),
319                seed.wrapping_add(3),
320            ],
321            paddle_fire: seed & 0b1111,
322        }
323    }
324
325    #[test]
326    fn round_trip_is_byte_identical() {
327        let mut movie = Movie::new_power_on(0xDEAD_BEEF, MovieRegion::Ntsc, 42);
328        movie.record_frame(sample_frame(1));
329        movie.record_frame(sample_frame(2));
330        movie.record_frame(sample_frame(3));
331
332        let bytes = movie.encode();
333        let restored = Movie::restore(&bytes, 0xDEAD_BEEF).expect("round-trip should succeed");
334
335        assert_eq!(restored.len(), 3);
336        assert_eq!(restored.frame_at(0), movie.frame_at(0));
337        assert_eq!(restored.frame_at(1), movie.frame_at(1));
338        assert_eq!(restored.frame_at(2), movie.frame_at(2));
339        assert_eq!(restored.region(), MovieRegion::Ntsc);
340    }
341
342    #[test]
343    fn rom_tag_mismatch_is_rejected() {
344        let movie = Movie::new_power_on(1, MovieRegion::Ntsc, 1);
345        let bytes = movie.encode();
346        let err = Movie::restore(&bytes, 2).expect_err("mismatched rom_tag must fail");
347        assert!(matches!(err, MovieError::RomMismatch));
348    }
349
350    #[test]
351    fn bad_magic_is_rejected() {
352        let movie = Movie::new_power_on(1, MovieRegion::Ntsc, 1);
353        let mut bytes = movie.encode();
354        bytes[0] ^= 0xFF;
355        let err = Movie::decode(&bytes);
356        assert!(err.is_err());
357    }
358
359    #[test]
360    fn truncated_bytes_are_rejected() {
361        let err = Movie::decode(&[0u8; 2]);
362        assert!(matches!(err, Err(MovieError::Malformed)));
363    }
364
365    #[test]
366    fn power_on_start_reproduces_the_seeded_system_deterministically() {
367        let movie = Movie::new_power_on(1, MovieRegion::Ntsc, 7);
368        let a = movie
369            .start_system(1)
370            .expect("power-on start always succeeds");
371        let b = movie
372            .start_system(1)
373            .expect("power-on start always succeeds");
374        assert_eq!(
375            a.bus.riot.ram, b.bus.riot.ram,
376            "same seed must seed RAM identically"
377        );
378        assert_eq!(a.cpu.a, b.cpu.a);
379        assert_eq!(a.cpu.x, b.cpu.x);
380        assert_eq!(a.cpu.y, b.cpu.y);
381    }
382
383    #[test]
384    fn branch_point_embeds_a_real_save_state() {
385        let mut system = System::new(3);
386        system.step_instruction();
387        system.step_instruction();
388
389        let movie = Movie::new_branch(0xAAAA, MovieRegion::Pal, &system);
390        let restored = movie
391            .start_system(0xAAAA)
392            .expect("branch start should decode the embedded save-state");
393        assert_eq!(restored.color_clocks(), system.color_clocks());
394        assert_eq!(restored.bus.riot.ram, system.bus.riot.ram);
395    }
396
397    #[test]
398    fn edited_frame_overwrites_the_recorded_value() {
399        let mut movie = Movie::new_power_on(1, MovieRegion::Ntsc, 1);
400        movie.record_frame(sample_frame(0));
401        movie.record_frame(sample_frame(1));
402        movie.set_frame(0, sample_frame(99));
403        assert_eq!(movie.frame_at(0), Some(sample_frame(99)));
404        assert_eq!(movie.frame_at(1), Some(sample_frame(1)));
405    }
406}