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}