Skip to main content

rustynes_core/
legacy_movie.rs

1//! v1.7.0 "Forge" Workstream G4 — legacy NES TAS movie import.
2//!
3//! The historical pre-`.fm2` / pre-`.bk2` `TASVideos` corpus lives in a handful
4//! of small binary containers. This module adds importers for the NES-relevant
5//! ones so `RustyNES` can "play any NES TAS":
6//!
7//! - **`.fcm`** — FCEUX / FCE Ultra legacy binary movie (`FCM\x1A`, version 2).
8//!   A *sparse toggle/delta* input stream, not a per-frame bitmask dump.
9//! - **`.fmv`** — `Famtasia` movie (`FMV\x1A`, fixed 144-byte header). A full
10//!   per-frame byte-per-controller dump with a `Famtasia`-specific bit order.
11//! - **`.vmv`** — `VirtuaNES` movie (`VirtuaNES MV`). A full per-frame dump; the
12//!   layout is documentation-derived (`TASVideos` `OtherEmulators/VMV`), since
13//!   `BizHawk` never shipped a `.vmv` importer.
14//!
15//! Each parser mirrors the existing [`crate::movie_interop`] (`.fm2`) and
16//! [`crate::bk2_interop`] (`.bk2`) design: a pure byte→[`Movie`] transform that
17//! never panics on malformed input, returns [`StartPoint::PowerOn`] only, and
18//! reuses the **canonical movie-import power-on alignment** the `.fm2` path
19//! established (a deterministic cold boot via [`Movie::seek_to_start`]), so an
20//! imported movie replays bit-for-bit.
21//!
22//! # `Mednafen` `.mc2` — deliberately rejected (it is a PC Engine format)
23//!
24//! The v1.7.0 plan lists `.mc2` under "`Mednafen` NES", but `BizHawk`'s
25//! `Mc2Import.cs` is `[ImporterFor("PCEjin/Mednafen", ".mc2")]` and targets the
26//! **PC Engine** (PCE buttons `B1/B2/Run/Select`, platform PCE/PCECD) — there is
27//! no NES gamepad data in it. Rather than mis-map PCE buttons onto NES, the
28//! `.mc2` path is a clean, documented rejection ([`import_mc2`]).
29//!
30//! # The native button bit order
31//!
32//! `RustyNES`'s [`Buttons`] bit layout is `A=0, B=1, Select=2, Start=3, Up=4,
33//! Down=5, Left=6, Right=7` — the canonical NES order. The `.fcm` button *index*
34//! order and the `.vmv` *bit* order are identical to it, so those map straight
35//! through [`Buttons::from_bits_truncate`]. `Famtasia` `.fmv` uses a different
36//! bit order (`Right=0, Left=1, Up=2, Down=3, B=4, A=5, Select=6, Start=7`) and
37//! is permuted by [`fmv_byte_to_buttons`].
38//!
39//! This module is `no_std`-clean: it uses only `core` + `alloc`.
40
41use alloc::vec::Vec;
42
43use crate::Region;
44use crate::controller::Buttons;
45use crate::movie::{FrameInput, Movie, StartPoint};
46use thiserror::Error;
47
48/// `.fcm` signature: `FCM` + the DOS EOF byte.
49const FCM_MAGIC: &[u8; 4] = b"FCM\x1A";
50/// The only `.fcm` version this module parses.
51const FCM_VERSION: u32 = 2;
52/// `.fmv` signature: `FMV` + the DOS EOF byte.
53const FMV_MAGIC: &[u8; 4] = b"FMV\x1A";
54/// `Famtasia` fixed header length; input data begins here.
55const FMV_HEADER_LEN: usize = 144;
56/// `.vmv` signature.
57const VMV_MAGIC: &[u8; 12] = b"VirtuaNES MV";
58
59/// Errors produced by the legacy movie importers.
60#[derive(Debug, Error)]
61#[non_exhaustive]
62pub enum LegacyMovieError {
63    /// The blob is shorter than the format's fixed header.
64    #[error("legacy movie truncated: need at least {expected} bytes, got {got}")]
65    Truncated {
66        /// Bytes the header needs.
67        expected: usize,
68        /// Bytes available.
69        got: usize,
70    },
71
72    /// The signature did not match the expected magic for this format.
73    #[error("legacy movie magic mismatch (not a {format} movie)")]
74    BadMagic {
75        /// The format name we were trying to parse.
76        format: &'static str,
77    },
78
79    /// The format version is outside the range we understand.
80    #[error("legacy movie {format} version {got} not supported")]
81    BadVersion {
82        /// The format name.
83        format: &'static str,
84        /// The version we read.
85        got: u32,
86    },
87
88    /// A structural problem decoding the input stream (a malformed record or an
89    /// offset that runs past EOF).
90    #[error("legacy movie {format} malformed: {reason}")]
91    Malformed {
92        /// The format name.
93        format: &'static str,
94        /// What was wrong.
95        reason: &'static str,
96    },
97
98    /// A feature we deliberately do not support (a save-state / non-reset start,
99    /// a four-score movie, or a non-NES container).
100    #[error("legacy movie {format} unsupported: {reason}")]
101    Unsupported {
102        /// The format name.
103        format: &'static str,
104        /// What is unsupported.
105        reason: &'static str,
106    },
107}
108
109/// Metadata recovered from a legacy movie that has no home on [`Movie`].
110#[derive(Clone, Debug, Default, Eq, PartialEq)]
111pub struct LegacyMeta {
112    /// Rerecord count (0 if absent / unknown).
113    pub rerecord_count: u64,
114    /// `true` if the source declared a PAL region.
115    pub pal: bool,
116}
117
118/// Read a little-endian `u32` at `off`, or `None` if it runs past the end.
119fn rd_u32_le(bytes: &[u8], off: usize) -> Option<u32> {
120    let b = bytes.get(off..off + 4)?;
121    Some(u32::from_le_bytes([b[0], b[1], b[2], b[3]]))
122}
123
124/// Import an FCEUX / FCE Ultra legacy `.fcm` movie.
125///
126/// `.fcm` is a sparse **toggle/delta** stream: each record advances some number
127/// of frames (emitting the *current* held controller state for each), then
128/// either toggles one button on one controller or issues a console command
129/// (Reset / Power / FDS / VS). `RustyNES`'s [`FrameInput`] has no console-command
130/// representation, so commands are decoded (so the stream stays in sync) but only
131/// affect the frame count, exactly as the `.fm2` importer treats `MOVIECMD_RESET`.
132///
133/// The returned [`Movie`] always uses [`StartPoint::PowerOn`]; `rom_sha256` is the
134/// authoritative ROM identity (the `.fcm`'s embedded MD5 is not validated here).
135///
136/// # Errors
137///
138/// [`LegacyMovieError`] for a bad magic / version, a save-state-anchored start, a
139/// four-score (>2-controller) update, or a truncated stream. Never panics.
140pub fn import_fcm(
141    bytes: &[u8],
142    rom_sha256: [u8; 32],
143) -> Result<(Movie, LegacyMeta), LegacyMovieError> {
144    const FMT: &str = "fcm";
145    // Fixed header up to the ROM-name string starts at 0x34; we need at least the
146    // fields we read (signature .. firstFrameOffset .. md5 .. emu-version = 0x34).
147    const MIN_HEADER: usize = 0x34;
148    if bytes.len() < MIN_HEADER {
149        return Err(LegacyMovieError::Truncated {
150            expected: MIN_HEADER,
151            got: bytes.len(),
152        });
153    }
154    if &bytes[0..4] != FCM_MAGIC {
155        return Err(LegacyMovieError::BadMagic { format: FMT });
156    }
157    let version = rd_u32_le(bytes, 0x04).unwrap_or(0);
158    if version != FCM_VERSION {
159        return Err(LegacyMovieError::BadVersion {
160            format: FMT,
161            got: version,
162        });
163    }
164    let flags = bytes[0x08];
165    // bit1: 1 = reset/power-on start, 0 = begins from an embedded quicksave.
166    let reset_based = flags & 0x02 != 0;
167    if !reset_based {
168        return Err(LegacyMovieError::Unsupported {
169            format: FMT,
170            reason: "begins from a save-state (cross-emulator save states are not portable)",
171        });
172    }
173    // bit2: 0 = NTSC, 1 = PAL.
174    let pal = flags & 0x04 != 0;
175    let frame_count = rd_u32_le(bytes, 0x0C).unwrap_or(0) as usize;
176    let rerecord_count = u64::from(rd_u32_le(bytes, 0x10).unwrap_or(0));
177    // firstFrameOffset (the absolute offset of the input data) lives at 0x1C; the
178    // 0x14 size field and the 0x18 savestate offset are read-and-discarded.
179    let first_frame = rd_u32_le(bytes, 0x1C).unwrap_or(0) as usize;
180    // The input stream must begin at or after the fixed header — an offset below
181    // MIN_HEADER would overlap the header and parse header bytes as movie input.
182    if first_frame < MIN_HEADER || first_frame > bytes.len() {
183        return Err(LegacyMovieError::Malformed {
184            format: FMT,
185            reason: "input-data offset is out of range",
186        });
187    }
188
189    let stream = &bytes[first_frame..];
190    let frames = decode_fcm_stream(stream, frame_count)?;
191    let movie = Movie {
192        epoch: crate::EMULATION_EPOCH,
193        region: if pal { Region::Pal } else { Region::Ntsc },
194        rom_sha256,
195        options: crate::HardwareOptions::default(),
196        board: None,
197        start: StartPoint::PowerOn,
198        frames,
199        rerecord_count: u32::try_from(rerecord_count).unwrap_or(u32::MAX),
200        // Imported: no attestation (the source format has no such field, and
201        // synthesizing one would attest a run this build never performed).
202        attestation: None,
203    };
204    Ok((
205        movie,
206        LegacyMeta {
207            rerecord_count,
208            pal,
209        },
210    ))
211}
212
213/// Decode the `.fcm` toggle/delta stream into a dense per-frame input log.
214///
215/// The running state of both controllers is held across records; a controller
216/// update flips one button bit, a control command (bit7 set) is consumed but not
217/// represented. `frame_hint` is the header's frame count; we honour it as a cap
218/// (the tighter of it and the hard `1 << 24` output cap) and stop early if the
219/// stream ends. The hard cap applies even when `frame_hint` is 0, so a crafted
220/// header/stream cannot force an unbounded output allocation.
221fn decode_fcm_stream(
222    stream: &[u8],
223    frame_hint: usize,
224) -> Result<Vec<FrameInput>, LegacyMovieError> {
225    const FMT: &str = "fcm";
226    // Hard output cap (~16.7M frames ≈ 77+ hours at 60 fps): a crafted `.fcm`
227    // with a tiny stream but a huge delta-advance (or a missing/zero header
228    // frame count) must not be able to force an unbounded allocation. The cap is
229    // enforced *unconditionally* — when the header declares a frame count we use
230    // the tighter of the two, but a `frame_hint` of 0 (absent/zero header count)
231    // still falls back to the hard cap rather than running uncapped.
232    const HARD_CAP: usize = 1 << 24;
233    let cap = if frame_hint == 0 {
234        HARD_CAP
235    } else {
236        frame_hint.min(HARD_CAP)
237    };
238    let mut frames: Vec<FrameInput> = Vec::with_capacity(cap.min(4096));
239    // Running held state for P1/P2.
240    let mut held = [Buttons::empty(); 2];
241    let mut i = 0usize;
242
243    let emit = |frames: &mut Vec<FrameInput>, held: &[Buttons; 2], n: usize| {
244        for _ in 0..n {
245            if frames.len() >= cap {
246                break;
247            }
248            frames.push(FrameInput::new(held[0], held[1]));
249        }
250    };
251
252    while i < stream.len() {
253        if frames.len() >= cap {
254            break;
255        }
256        let update = stream[i];
257        i += 1;
258        // Bits 5-6: number of following delta bytes (0..=3), little-endian frame
259        // advance.
260        let delta_bytes = usize::from((update >> 5) & 0x3);
261        if i + delta_bytes > stream.len() {
262            return Err(LegacyMovieError::Malformed {
263                format: FMT,
264                reason: "delta bytes run past end of stream",
265            });
266        }
267        let mut advance: usize = 0;
268        for b in 0..delta_bytes {
269            advance |= usize::from(stream[i + b]) << (8 * b);
270        }
271        i += delta_bytes;
272        // Advance `advance` frames emitting the current held state.
273        emit(&mut frames, &held, advance);
274
275        if update & 0x80 != 0 {
276            // Control update (`1aabbbbb`): the low 5 bits are a console command
277            // (Reset / Power / FDS / VS). The byte is already consumed above, so
278            // the stream stays aligned; FrameInput has no console-command
279            // representation, so it does not alter held state. We then emit one
280            // frame (below), exactly as the `.fm2` importer treats a reset.
281        } else {
282            // Controller update (`0aabbccc`): player = ((update >> 3) & 0x3) + 1,
283            // button index = update & 0x7. The button index order is the canonical
284            // NES order, identical to RustyNES's Buttons bit layout.
285            let player = ((update >> 3) & 0x3) as usize; // 0 or 1 for P1/P2
286            let button_idx = update & 0x7;
287            if player >= 2 {
288                return Err(LegacyMovieError::Unsupported {
289                    format: FMT,
290                    reason: "four-score (>2 controllers) not supported",
291                });
292            }
293            let bit = Buttons::from_bits_truncate(1u8 << button_idx);
294            held[player] ^= bit; // toggle
295        }
296        // Each update byte is followed by one emitted frame.
297        emit(&mut frames, &held, 1);
298    }
299
300    Ok(frames)
301}
302
303/// Permute a `Famtasia` `.fmv` controller byte into `RustyNES` [`Buttons`].
304///
305/// Famtasia bit order: `Right=0, Left=1, Up=2, Down=3, B=4, A=5, Select=6,
306/// Start=7` (differs from the canonical NES order, so it cannot pass through
307/// untouched).
308#[must_use]
309pub fn fmv_byte_to_buttons(byte: u8) -> Buttons {
310    let mut b = Buttons::empty();
311    if byte & 0x01 != 0 {
312        b |= Buttons::RIGHT;
313    }
314    if byte & 0x02 != 0 {
315        b |= Buttons::LEFT;
316    }
317    if byte & 0x04 != 0 {
318        b |= Buttons::UP;
319    }
320    if byte & 0x08 != 0 {
321        b |= Buttons::DOWN;
322    }
323    if byte & 0x10 != 0 {
324        b |= Buttons::B;
325    }
326    if byte & 0x20 != 0 {
327        b |= Buttons::A;
328    }
329    if byte & 0x40 != 0 {
330        b |= Buttons::SELECT;
331    }
332    if byte & 0x80 != 0 {
333        b |= Buttons::START;
334    }
335    b
336}
337
338/// Import a `Famtasia` `.fmv` movie.
339///
340/// Fixed 144-byte header (`FMV\x1A` + flags). Flags byte 2 (`0x05`) selects which
341/// of P1 / P2 / FDS streams are present; the per-frame record is one byte per
342/// active stream in [P1, P2, FDS] order. `Famtasia` has no reliable PAL flag, so
343/// the region is reported as NTSC. A save-state-anchored movie (flags1 bit2) is
344/// rejected. The FDS byte (if present) is read to keep alignment but not decoded.
345///
346/// # Errors
347///
348/// [`LegacyMovieError`] for a bad magic, a save-state start, or a truncated body.
349pub fn import_fmv(
350    bytes: &[u8],
351    rom_sha256: [u8; 32],
352) -> Result<(Movie, LegacyMeta), LegacyMovieError> {
353    const FMT: &str = "fmv";
354    if bytes.len() < FMV_HEADER_LEN {
355        return Err(LegacyMovieError::Truncated {
356            expected: FMV_HEADER_LEN,
357            got: bytes.len(),
358        });
359    }
360    if &bytes[0..4] != FMV_MAGIC {
361        return Err(LegacyMovieError::BadMagic { format: FMT });
362    }
363    let flags1 = bytes[0x04];
364    // bit2 = save-state-based start.
365    if flags1 & 0x04 != 0 {
366        return Err(LegacyMovieError::Unsupported {
367            format: FMT,
368            reason: "begins from a save-state (cross-emulator save states are not portable)",
369        });
370    }
371    let flags2 = bytes[0x05];
372    let has_fds = flags2 & 0x20 != 0;
373    let has_p2 = flags2 & 0x40 != 0;
374    let has_p1 = flags2 & 0x80 != 0;
375    // Rerecord count is stored as (value - 1); BizHawk adds 1 back.
376    let rerecord_count = u64::from(rd_u32_le(bytes, 0x0A).unwrap_or(0)).wrapping_add(1);
377
378    // Bytes per frame = number of active streams (P1, P2, FDS).
379    let bpf = usize::from(has_p1) + usize::from(has_p2) + usize::from(has_fds);
380    if bpf == 0 {
381        return Err(LegacyMovieError::Malformed {
382            format: FMT,
383            reason: "no active controller streams declared",
384        });
385    }
386
387    let body = &bytes[FMV_HEADER_LEN..];
388    let frame_count = body.len() / bpf;
389    let mut frames = Vec::with_capacity(frame_count);
390    for f in 0..frame_count {
391        let base = f * bpf;
392        let mut p1 = Buttons::empty();
393        let mut p2 = Buttons::empty();
394        // Streams are stored in [P1, P2, FDS] order; advance `col` past each
395        // active stream. The FDS byte (if present) is consumed for alignment but
396        // not decoded (FrameInput has no FDS command).
397        let mut col = 0usize;
398        if has_p1 {
399            p1 = fmv_byte_to_buttons(body[base + col]);
400            col += 1;
401        }
402        if has_p2 {
403            p2 = fmv_byte_to_buttons(body[base + col]);
404            col += 1;
405        }
406        // Account for the FDS byte's column so the (unused) `col` reflects the
407        // full record width; silences `unused_assignments` and documents intent.
408        let _ = (col, has_fds);
409        frames.push(FrameInput::new(p1, p2));
410    }
411
412    let movie = Movie {
413        epoch: crate::EMULATION_EPOCH,
414        region: Region::Ntsc, // Famtasia carries no reliable PAL flag.
415        rom_sha256,
416        options: crate::HardwareOptions::default(),
417        board: None,
418        start: StartPoint::PowerOn,
419        frames,
420        rerecord_count: u32::try_from(rerecord_count).unwrap_or(u32::MAX),
421        // Imported: no attestation (the source format has no such field, and
422        // synthesizing one would attest a run this build never performed).
423        attestation: None,
424    };
425    Ok((
426        movie,
427        LegacyMeta {
428            rerecord_count,
429            pal: false,
430        },
431    ))
432}
433
434/// Import a `VirtuaNES` `.vmv` movie.
435///
436/// **Documentation-derived** (`TASVideos` `OtherEmulators/VMV`): `BizHawk` never
437/// shipped a `.vmv` importer. Header layout per `VirtuaNES` 0.93: 12-byte magic, a
438/// movie-data offset at `0x34`, a frame count at `0x38`, a controller-enable +
439/// reset flag word at `0x10`, and a video-mode byte (`0`=NTSC, `1`=PAL) at
440/// `0x23`. The per-frame record is one byte per enabled controller; the bit order
441/// is the canonical NES order, so each byte maps straight to [`Buttons`].
442///
443/// We seek to the movie-data offset (rather than assuming a fixed header size) so
444/// the parse is robust across the older header variants whose exact layout is not
445/// authoritatively documented.
446///
447/// # Errors
448///
449/// [`LegacyMovieError`] for a bad magic, a save-state start, a four-score
450/// (>2-controller) movie, an offset that overlaps the header, or a truncated
451/// body.
452pub fn import_vmv(
453    bytes: &[u8],
454    rom_sha256: [u8; 32],
455) -> Result<(Movie, LegacyMeta), LegacyMovieError> {
456    const FMT: &str = "vmv";
457    const MIN_HEADER: usize = 0x40;
458    if bytes.len() < MIN_HEADER {
459        return Err(LegacyMovieError::Truncated {
460            expected: MIN_HEADER,
461            got: bytes.len(),
462        });
463    }
464    if &bytes[0..12] != VMV_MAGIC {
465        return Err(LegacyMovieError::BadMagic { format: FMT });
466    }
467    let flags = rd_u32_le(bytes, 0x10).unwrap_or(0);
468    // bits 0..3 = controllers 1..4 enabled; bit6 = reset-based (1) vs
469    // save-state-based (0).
470    let reset_based = flags & (1 << 6) != 0;
471    if !reset_based {
472        return Err(LegacyMovieError::Unsupported {
473            format: FMT,
474            reason: "begins from a save-state (cross-emulator save states are not portable)",
475        });
476    }
477    let ctrl_count = (usize::from(flags & 0x1 != 0))
478        + usize::from(flags & 0x2 != 0)
479        + usize::from(flags & 0x4 != 0)
480        + usize::from(flags & 0x8 != 0);
481    // RustyNES movies model exactly the two standard NES ports ([`FrameInput`]
482    // has no Four Score / controller-3-4 representation). A `.vmv` that enables
483    // controllers 3/4 cannot be imported without silently dropping their input
484    // (which would desync replay), so reject it up front — the same stance the
485    // `.fcm` path takes for a four-score (>2-controller) update.
486    if ctrl_count > 2 {
487        return Err(LegacyMovieError::Unsupported {
488            format: FMT,
489            reason: "four-score (>2 controllers) not supported",
490        });
491    }
492    // Default to a single controller if the flag word declares none (some 0.93
493    // movies leave the bits clear and imply P1).
494    let ctrl_count = ctrl_count.max(1);
495    let rerecord_count = u64::from(rd_u32_le(bytes, 0x1C).unwrap_or(0));
496    // Video mode byte: 0 = NTSC, 1 = PAL.
497    let pal = bytes[0x23] == 1;
498    let frame_count = rd_u32_le(bytes, 0x38).unwrap_or(0) as usize;
499    let data_off = rd_u32_le(bytes, 0x34).unwrap_or(0) as usize;
500    // A non-zero offset below MIN_HEADER would overlap the header and parse
501    // header bytes as controller input — reject it. A zero (or past-EOF) offset
502    // falls back to the documented 0.93 reset-based header size.
503    if data_off != 0 && data_off < MIN_HEADER {
504        return Err(LegacyMovieError::Malformed {
505            format: FMT,
506            reason: "movie-data offset overlaps the header",
507        });
508    }
509    let data_off = if data_off == 0 || data_off > bytes.len() {
510        // Fall back to the documented 0.93 reset-based offset.
511        MIN_HEADER
512    } else {
513        data_off
514    };
515
516    let body = &bytes[data_off..];
517    // Honour the header frame count when present, else derive from the body size.
518    let derived = body.len() / ctrl_count;
519    let frame_count = if frame_count == 0 {
520        derived
521    } else {
522        frame_count.min(derived)
523    };
524    let mut frames = Vec::with_capacity(frame_count);
525    for f in 0..frame_count {
526        let base = f * ctrl_count;
527        let p1 = Buttons::from_bits_truncate(*body.get(base).unwrap_or(&0));
528        let p2 = if ctrl_count >= 2 {
529            Buttons::from_bits_truncate(*body.get(base + 1).unwrap_or(&0))
530        } else {
531            Buttons::empty()
532        };
533        frames.push(FrameInput::new(p1, p2));
534    }
535
536    let movie = Movie {
537        epoch: crate::EMULATION_EPOCH,
538        region: if pal { Region::Pal } else { Region::Ntsc },
539        rom_sha256,
540        options: crate::HardwareOptions::default(),
541        board: None,
542        start: StartPoint::PowerOn,
543        frames,
544        rerecord_count: u32::try_from(rerecord_count).unwrap_or(u32::MAX),
545        // Imported: no attestation (the source format has no such field, and
546        // synthesizing one would attest a run this build never performed).
547        attestation: None,
548    };
549    Ok((
550        movie,
551        LegacyMeta {
552            rerecord_count,
553            pal,
554        },
555    ))
556}
557
558/// "Import" a `Mednafen` `.mc2` movie — always an error.
559///
560/// `.mc2` (`PCEjin` / `Mednafen`) is a **PC Engine** movie format (PCE buttons
561/// `B1/B2/Run/Select`, platform PCE/PCECD), not an NES container. It carries no
562/// NES gamepad data, so there is nothing to map. This entry point exists so the
563/// frontend dispatcher can give a precise diagnostic instead of mis-parsing.
564///
565/// # Errors
566///
567/// Always [`LegacyMovieError::Unsupported`].
568pub const fn import_mc2(
569    _bytes: &[u8],
570    _rom_sha256: [u8; 32],
571) -> Result<(Movie, LegacyMeta), LegacyMovieError> {
572    Err(LegacyMovieError::Unsupported {
573        format: "mc2",
574        reason: "`.mc2` is a PC Engine (PCEjin/Mednafen) movie, not an NES movie",
575    })
576}
577
578#[cfg(test)]
579mod tests {
580    use super::*;
581    use alloc::vec;
582
583    const TEST_SHA: [u8; 32] = [0x33; 32];
584
585    /// Build a minimal `.fcm` header with the given flags, frame count, and
586    /// input stream (placed right after a 0x34-byte header).
587    fn synth_fcm(flags: u8, frame_count: u32, stream: &[u8]) -> Vec<u8> {
588        let mut b = vec![0u8; 0x34];
589        b[0..4].copy_from_slice(FCM_MAGIC);
590        b[0x04..0x08].copy_from_slice(&FCM_VERSION.to_le_bytes());
591        b[0x08] = flags;
592        b[0x0C..0x10].copy_from_slice(&frame_count.to_le_bytes());
593        b[0x10..0x14].copy_from_slice(&7u32.to_le_bytes()); // rerecord
594        let first_frame = u32::try_from(b.len()).unwrap();
595        b[0x1C..0x20].copy_from_slice(&first_frame.to_le_bytes());
596        b.extend_from_slice(stream);
597        b
598    }
599
600    #[test]
601    fn fcm_rejects_bad_magic_and_version() {
602        let mut b = synth_fcm(0x02, 0, &[]);
603        b[0] = b'X';
604        assert!(matches!(
605            import_fcm(&b, TEST_SHA),
606            Err(LegacyMovieError::BadMagic { .. })
607        ));
608        let mut b = synth_fcm(0x02, 0, &[]);
609        b[0x04] = 9; // version 9
610        assert!(matches!(
611            import_fcm(&b, TEST_SHA),
612            Err(LegacyMovieError::BadVersion { .. })
613        ));
614    }
615
616    #[test]
617    fn fcm_rejects_savestate_start() {
618        // flags bit1 clear -> save-state-based.
619        let b = synth_fcm(0x00, 0, &[]);
620        assert!(matches!(
621            import_fcm(&b, TEST_SHA),
622            Err(LegacyMovieError::Unsupported { .. })
623        ));
624    }
625
626    #[test]
627    fn fcm_toggle_stream_decodes() {
628        // reset-based, NTSC. Stream:
629        //   byte 0x07 -> controller update, player 0, button idx 7 = RIGHT toggle
630        //                (delta 0). Emits 1 frame with RIGHT held.
631        //   byte 0x07 -> toggles RIGHT off again. Emits 1 frame with nothing.
632        // frame_count = 2.
633        let stream = [0x07u8, 0x07u8];
634        let b = synth_fcm(0x02, 2, &stream);
635        let (movie, meta) = import_fcm(&b, TEST_SHA).expect("fcm import");
636        assert_eq!(movie.region, Region::Ntsc);
637        assert_eq!(movie.frames.len(), 2);
638        assert_eq!(movie.frames[0].p1, Buttons::RIGHT);
639        assert_eq!(movie.frames[1].p1, Buttons::empty());
640        assert_eq!(meta.rerecord_count, 7);
641        assert_eq!(movie.start, StartPoint::PowerOn);
642    }
643
644    #[test]
645    fn fcm_delta_advances_frames() {
646        // A control byte (bit7) with delta count 1 and a 1-byte delta of 3:
647        //   update = 1010_0000 = 0xA0 -> bit7 set (command), delta_bytes=1.
648        //   delta byte 0x03 -> advance 3 frames (held = empty), then emit 1 frame
649        //   for the command. Total 4 frames.
650        let stream = [0xA0u8, 0x03u8];
651        let b = synth_fcm(0x02, 4, &stream);
652        let (movie, _) = import_fcm(&b, TEST_SHA).expect("fcm import");
653        assert_eq!(movie.frames.len(), 4);
654        assert!(movie.frames.iter().all(|f| f.p1 == Buttons::empty()));
655    }
656
657    #[test]
658    fn fcm_pal_flag() {
659        let b = synth_fcm(0x02 | 0x04, 0, &[]);
660        let (movie, meta) = import_fcm(&b, TEST_SHA).expect("fcm import");
661        assert_eq!(movie.region, Region::Pal);
662        assert!(meta.pal);
663    }
664
665    /// Build a `.fmv` with the given flags2 and a body of raw per-frame bytes.
666    fn synth_fmv(flags1: u8, flags2: u8, body: &[u8]) -> Vec<u8> {
667        let mut b = vec![0u8; FMV_HEADER_LEN];
668        b[0..4].copy_from_slice(FMV_MAGIC);
669        b[0x04] = flags1;
670        b[0x05] = flags2;
671        b[0x0A..0x0E].copy_from_slice(&4u32.to_le_bytes()); // rerecord-1 = 4 -> 5
672        b.extend_from_slice(body);
673        b
674    }
675
676    #[test]
677    fn fmv_p1_only_full_dump() {
678        // flags2 bit7 = P1 present. Two frames: A then RIGHT.
679        // Famtasia bits: A=0x20, RIGHT=0x01.
680        let body = [0x20u8, 0x01u8];
681        let b = synth_fmv(0x00, 0x80, &body);
682        let (movie, meta) = import_fmv(&b, TEST_SHA).expect("fmv import");
683        assert_eq!(movie.frames.len(), 2);
684        assert_eq!(movie.frames[0].p1, Buttons::A);
685        assert_eq!(movie.frames[1].p1, Buttons::RIGHT);
686        assert_eq!(movie.region, Region::Ntsc);
687        assert_eq!(meta.rerecord_count, 5);
688    }
689
690    #[test]
691    fn fmv_two_controllers_interleave() {
692        // P1 + P2 present (bits 7 and 6). One frame: P1=B (0x10), P2=START (0x80).
693        let body = [0x10u8, 0x80u8];
694        let b = synth_fmv(0x00, 0xC0, &body);
695        let (movie, _) = import_fmv(&b, TEST_SHA).expect("fmv import");
696        assert_eq!(movie.frames.len(), 1);
697        assert_eq!(movie.frames[0].p1, Buttons::B);
698        assert_eq!(movie.frames[0].p2, Buttons::START);
699    }
700
701    #[test]
702    fn fmv_rejects_savestate() {
703        let b = synth_fmv(0x04, 0x80, &[]);
704        assert!(matches!(
705            import_fmv(&b, TEST_SHA),
706            Err(LegacyMovieError::Unsupported { .. })
707        ));
708    }
709
710    #[test]
711    fn fmv_byte_permutation_is_correct() {
712        assert_eq!(fmv_byte_to_buttons(0x01), Buttons::RIGHT);
713        assert_eq!(fmv_byte_to_buttons(0x20), Buttons::A);
714        assert_eq!(fmv_byte_to_buttons(0x10), Buttons::B);
715        assert_eq!(fmv_byte_to_buttons(0x80), Buttons::START);
716        assert_eq!(
717            fmv_byte_to_buttons(0xFF),
718            Buttons::all(),
719            "all bits set -> all buttons"
720        );
721    }
722
723    /// Build a `.vmv` (0.93-style) with the given flag word + video mode + a
724    /// per-frame body. Data offset points right after the 0x40 header.
725    fn synth_vmv(flags: u32, video_mode: u8, frame_count: u32, body: &[u8]) -> Vec<u8> {
726        let mut b = vec![0u8; 0x40];
727        b[0..12].copy_from_slice(VMV_MAGIC);
728        b[0x10..0x14].copy_from_slice(&flags.to_le_bytes());
729        b[0x1C..0x20].copy_from_slice(&11u32.to_le_bytes()); // rerecord
730        b[0x23] = video_mode;
731        let data_off = u32::try_from(b.len()).unwrap();
732        b[0x34..0x38].copy_from_slice(&data_off.to_le_bytes());
733        b[0x38..0x3C].copy_from_slice(&frame_count.to_le_bytes());
734        b.extend_from_slice(body);
735        b
736    }
737
738    #[test]
739    fn vmv_canonical_bit_order() {
740        // reset-based (bit6) + P1 enabled (bit0). One frame: A | RIGHT.
741        // VMV canonical order = RustyNES Buttons layout: A=0x01, RIGHT=0x80.
742        let flags = (1u32 << 6) | 0x1;
743        let body = [Buttons::A.bits() | Buttons::RIGHT.bits()];
744        let b = synth_vmv(flags, 0, 1, &body);
745        let (movie, meta) = import_vmv(&b, TEST_SHA).expect("vmv import");
746        assert_eq!(movie.frames.len(), 1);
747        assert_eq!(movie.frames[0].p1, Buttons::A | Buttons::RIGHT);
748        assert_eq!(movie.region, Region::Ntsc);
749        assert_eq!(meta.rerecord_count, 11);
750    }
751
752    #[test]
753    fn vmv_pal_video_mode() {
754        let flags = (1u32 << 6) | 0x1;
755        let b = synth_vmv(flags, 1, 1, &[0u8]);
756        let (movie, meta) = import_vmv(&b, TEST_SHA).expect("vmv import");
757        assert_eq!(movie.region, Region::Pal);
758        assert!(meta.pal);
759    }
760
761    #[test]
762    fn vmv_rejects_savestate() {
763        // bit6 clear -> save-state-based.
764        let b = synth_vmv(0x1, 0, 1, &[0u8]);
765        assert!(matches!(
766            import_vmv(&b, TEST_SHA),
767            Err(LegacyMovieError::Unsupported { .. })
768        ));
769    }
770
771    #[test]
772    fn vmv_rejects_bad_magic() {
773        let mut b = synth_vmv((1u32 << 6) | 1, 0, 1, &[0u8]);
774        b[0] = b'X';
775        assert!(matches!(
776            import_vmv(&b, TEST_SHA),
777            Err(LegacyMovieError::BadMagic { .. })
778        ));
779    }
780
781    #[test]
782    fn mc2_is_rejected_as_pce() {
783        assert!(matches!(
784            import_mc2(&[0u8; 16], TEST_SHA),
785            Err(LegacyMovieError::Unsupported { format: "mc2", .. })
786        ));
787    }
788
789    #[test]
790    fn fcm_rejects_overlapping_first_frame_offset() {
791        // first_frame below MIN_HEADER (0x34) would overlap the header.
792        let mut b = synth_fcm(0x02, 0, &[]);
793        b[0x1C..0x20].copy_from_slice(&0x10u32.to_le_bytes());
794        assert!(matches!(
795            import_fcm(&b, TEST_SHA),
796            Err(LegacyMovieError::Malformed { .. })
797        ));
798    }
799
800    #[test]
801    fn fcm_oversized_advance_is_capped_not_unbounded() {
802        // frame_count = 0 (no hint) + a control byte with a 3-byte delta of
803        // 0xFFFFFF would, uncapped, try to emit ~16M frames. With the hard cap
804        // enforced regardless of frame_hint, the output stays bounded (<= 1<<24)
805        // and the import does not hang or OOM.
806        // update = 1110_0000 = 0xE0 -> bit7 set (command), delta_bytes = 3.
807        let stream = [0xE0u8, 0xFFu8, 0xFFu8, 0xFFu8];
808        let b = synth_fcm(0x02, 0, &stream);
809        let (movie, _) = import_fcm(&b, TEST_SHA).expect("fcm import");
810        assert!(
811            movie.frames.len() <= (1 << 24),
812            "output must be capped at the hard limit, got {}",
813            movie.frames.len()
814        );
815    }
816
817    #[test]
818    fn vmv_rejects_overlapping_data_offset() {
819        // data_off below MIN_HEADER (0x40) overlaps the header.
820        let mut b = synth_vmv((1u32 << 6) | 1, 0, 1, &[0u8]);
821        b[0x34..0x38].copy_from_slice(&0x20u32.to_le_bytes());
822        assert!(matches!(
823            import_vmv(&b, TEST_SHA),
824            Err(LegacyMovieError::Malformed { .. })
825        ));
826    }
827
828    #[test]
829    fn vmv_rejects_four_controllers() {
830        // reset-based (bit6) + all 4 controllers enabled (bits 0..3).
831        let flags = (1u32 << 6) | 0xF;
832        let b = synth_vmv(flags, 0, 1, &[0u8, 0u8, 0u8, 0u8]);
833        assert!(
834            matches!(
835                import_vmv(&b, TEST_SHA),
836                Err(LegacyMovieError::Unsupported { .. })
837            ),
838            "a 4-controller .vmv must be rejected rather than silently dropping P3/P4"
839        );
840    }
841
842    #[test]
843    fn vmv_two_controllers_round_trip() {
844        // reset-based + P1 & P2 enabled. One frame: P1=A, P2=START.
845        let flags = (1u32 << 6) | 0x3;
846        let body = [Buttons::A.bits(), Buttons::START.bits()];
847        let b = synth_vmv(flags, 0, 1, &body);
848        let (movie, _) = import_vmv(&b, TEST_SHA).expect("vmv import");
849        assert_eq!(movie.frames.len(), 1);
850        assert_eq!(movie.frames[0].p1, Buttons::A);
851        assert_eq!(movie.frames[0].p2, Buttons::START);
852    }
853
854    #[test]
855    fn truncated_inputs_never_panic() {
856        assert!(matches!(
857            import_fcm(&[0u8; 4], TEST_SHA),
858            Err(LegacyMovieError::Truncated { .. })
859        ));
860        assert!(matches!(
861            import_fmv(&[0u8; 4], TEST_SHA),
862            Err(LegacyMovieError::Truncated { .. })
863        ));
864        assert!(matches!(
865            import_vmv(&[0u8; 4], TEST_SHA),
866            Err(LegacyMovieError::Truncated { .. })
867        ));
868    }
869}