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        region: if pal { Region::Pal } else { Region::Ntsc },
193        rom_sha256,
194        start: StartPoint::PowerOn,
195        frames,
196        rerecord_count: u32::try_from(rerecord_count).unwrap_or(u32::MAX),
197        // Imported: no attestation (the source format has no such field, and
198        // synthesizing one would attest a run this build never performed).
199        attestation: None,
200    };
201    Ok((
202        movie,
203        LegacyMeta {
204            rerecord_count,
205            pal,
206        },
207    ))
208}
209
210/// Decode the `.fcm` toggle/delta stream into a dense per-frame input log.
211///
212/// The running state of both controllers is held across records; a controller
213/// update flips one button bit, a control command (bit7 set) is consumed but not
214/// represented. `frame_hint` is the header's frame count; we honour it as a cap
215/// (the tighter of it and the hard `1 << 24` output cap) and stop early if the
216/// stream ends. The hard cap applies even when `frame_hint` is 0, so a crafted
217/// header/stream cannot force an unbounded output allocation.
218fn decode_fcm_stream(
219    stream: &[u8],
220    frame_hint: usize,
221) -> Result<Vec<FrameInput>, LegacyMovieError> {
222    const FMT: &str = "fcm";
223    // Hard output cap (~16.7M frames ≈ 77+ hours at 60 fps): a crafted `.fcm`
224    // with a tiny stream but a huge delta-advance (or a missing/zero header
225    // frame count) must not be able to force an unbounded allocation. The cap is
226    // enforced *unconditionally* — when the header declares a frame count we use
227    // the tighter of the two, but a `frame_hint` of 0 (absent/zero header count)
228    // still falls back to the hard cap rather than running uncapped.
229    const HARD_CAP: usize = 1 << 24;
230    let cap = if frame_hint == 0 {
231        HARD_CAP
232    } else {
233        frame_hint.min(HARD_CAP)
234    };
235    let mut frames: Vec<FrameInput> = Vec::with_capacity(cap.min(4096));
236    // Running held state for P1/P2.
237    let mut held = [Buttons::empty(); 2];
238    let mut i = 0usize;
239
240    let emit = |frames: &mut Vec<FrameInput>, held: &[Buttons; 2], n: usize| {
241        for _ in 0..n {
242            if frames.len() >= cap {
243                break;
244            }
245            frames.push(FrameInput::new(held[0], held[1]));
246        }
247    };
248
249    while i < stream.len() {
250        if frames.len() >= cap {
251            break;
252        }
253        let update = stream[i];
254        i += 1;
255        // Bits 5-6: number of following delta bytes (0..=3), little-endian frame
256        // advance.
257        let delta_bytes = usize::from((update >> 5) & 0x3);
258        if i + delta_bytes > stream.len() {
259            return Err(LegacyMovieError::Malformed {
260                format: FMT,
261                reason: "delta bytes run past end of stream",
262            });
263        }
264        let mut advance: usize = 0;
265        for b in 0..delta_bytes {
266            advance |= usize::from(stream[i + b]) << (8 * b);
267        }
268        i += delta_bytes;
269        // Advance `advance` frames emitting the current held state.
270        emit(&mut frames, &held, advance);
271
272        if update & 0x80 != 0 {
273            // Control update (`1aabbbbb`): the low 5 bits are a console command
274            // (Reset / Power / FDS / VS). The byte is already consumed above, so
275            // the stream stays aligned; FrameInput has no console-command
276            // representation, so it does not alter held state. We then emit one
277            // frame (below), exactly as the `.fm2` importer treats a reset.
278        } else {
279            // Controller update (`0aabbccc`): player = ((update >> 3) & 0x3) + 1,
280            // button index = update & 0x7. The button index order is the canonical
281            // NES order, identical to RustyNES's Buttons bit layout.
282            let player = ((update >> 3) & 0x3) as usize; // 0 or 1 for P1/P2
283            let button_idx = update & 0x7;
284            if player >= 2 {
285                return Err(LegacyMovieError::Unsupported {
286                    format: FMT,
287                    reason: "four-score (>2 controllers) not supported",
288                });
289            }
290            let bit = Buttons::from_bits_truncate(1u8 << button_idx);
291            held[player] ^= bit; // toggle
292        }
293        // Each update byte is followed by one emitted frame.
294        emit(&mut frames, &held, 1);
295    }
296
297    Ok(frames)
298}
299
300/// Permute a `Famtasia` `.fmv` controller byte into `RustyNES` [`Buttons`].
301///
302/// Famtasia bit order: `Right=0, Left=1, Up=2, Down=3, B=4, A=5, Select=6,
303/// Start=7` (differs from the canonical NES order, so it cannot pass through
304/// untouched).
305#[must_use]
306pub fn fmv_byte_to_buttons(byte: u8) -> Buttons {
307    let mut b = Buttons::empty();
308    if byte & 0x01 != 0 {
309        b |= Buttons::RIGHT;
310    }
311    if byte & 0x02 != 0 {
312        b |= Buttons::LEFT;
313    }
314    if byte & 0x04 != 0 {
315        b |= Buttons::UP;
316    }
317    if byte & 0x08 != 0 {
318        b |= Buttons::DOWN;
319    }
320    if byte & 0x10 != 0 {
321        b |= Buttons::B;
322    }
323    if byte & 0x20 != 0 {
324        b |= Buttons::A;
325    }
326    if byte & 0x40 != 0 {
327        b |= Buttons::SELECT;
328    }
329    if byte & 0x80 != 0 {
330        b |= Buttons::START;
331    }
332    b
333}
334
335/// Import a `Famtasia` `.fmv` movie.
336///
337/// Fixed 144-byte header (`FMV\x1A` + flags). Flags byte 2 (`0x05`) selects which
338/// of P1 / P2 / FDS streams are present; the per-frame record is one byte per
339/// active stream in [P1, P2, FDS] order. `Famtasia` has no reliable PAL flag, so
340/// the region is reported as NTSC. A save-state-anchored movie (flags1 bit2) is
341/// rejected. The FDS byte (if present) is read to keep alignment but not decoded.
342///
343/// # Errors
344///
345/// [`LegacyMovieError`] for a bad magic, a save-state start, or a truncated body.
346pub fn import_fmv(
347    bytes: &[u8],
348    rom_sha256: [u8; 32],
349) -> Result<(Movie, LegacyMeta), LegacyMovieError> {
350    const FMT: &str = "fmv";
351    if bytes.len() < FMV_HEADER_LEN {
352        return Err(LegacyMovieError::Truncated {
353            expected: FMV_HEADER_LEN,
354            got: bytes.len(),
355        });
356    }
357    if &bytes[0..4] != FMV_MAGIC {
358        return Err(LegacyMovieError::BadMagic { format: FMT });
359    }
360    let flags1 = bytes[0x04];
361    // bit2 = save-state-based start.
362    if flags1 & 0x04 != 0 {
363        return Err(LegacyMovieError::Unsupported {
364            format: FMT,
365            reason: "begins from a save-state (cross-emulator save states are not portable)",
366        });
367    }
368    let flags2 = bytes[0x05];
369    let has_fds = flags2 & 0x20 != 0;
370    let has_p2 = flags2 & 0x40 != 0;
371    let has_p1 = flags2 & 0x80 != 0;
372    // Rerecord count is stored as (value - 1); BizHawk adds 1 back.
373    let rerecord_count = u64::from(rd_u32_le(bytes, 0x0A).unwrap_or(0)).wrapping_add(1);
374
375    // Bytes per frame = number of active streams (P1, P2, FDS).
376    let bpf = usize::from(has_p1) + usize::from(has_p2) + usize::from(has_fds);
377    if bpf == 0 {
378        return Err(LegacyMovieError::Malformed {
379            format: FMT,
380            reason: "no active controller streams declared",
381        });
382    }
383
384    let body = &bytes[FMV_HEADER_LEN..];
385    let frame_count = body.len() / bpf;
386    let mut frames = Vec::with_capacity(frame_count);
387    for f in 0..frame_count {
388        let base = f * bpf;
389        let mut p1 = Buttons::empty();
390        let mut p2 = Buttons::empty();
391        // Streams are stored in [P1, P2, FDS] order; advance `col` past each
392        // active stream. The FDS byte (if present) is consumed for alignment but
393        // not decoded (FrameInput has no FDS command).
394        let mut col = 0usize;
395        if has_p1 {
396            p1 = fmv_byte_to_buttons(body[base + col]);
397            col += 1;
398        }
399        if has_p2 {
400            p2 = fmv_byte_to_buttons(body[base + col]);
401            col += 1;
402        }
403        // Account for the FDS byte's column so the (unused) `col` reflects the
404        // full record width; silences `unused_assignments` and documents intent.
405        let _ = (col, has_fds);
406        frames.push(FrameInput::new(p1, p2));
407    }
408
409    let movie = Movie {
410        region: Region::Ntsc, // Famtasia carries no reliable PAL flag.
411        rom_sha256,
412        start: StartPoint::PowerOn,
413        frames,
414        rerecord_count: u32::try_from(rerecord_count).unwrap_or(u32::MAX),
415        // Imported: no attestation (the source format has no such field, and
416        // synthesizing one would attest a run this build never performed).
417        attestation: None,
418    };
419    Ok((
420        movie,
421        LegacyMeta {
422            rerecord_count,
423            pal: false,
424        },
425    ))
426}
427
428/// Import a `VirtuaNES` `.vmv` movie.
429///
430/// **Documentation-derived** (`TASVideos` `OtherEmulators/VMV`): `BizHawk` never
431/// shipped a `.vmv` importer. Header layout per `VirtuaNES` 0.93: 12-byte magic, a
432/// movie-data offset at `0x34`, a frame count at `0x38`, a controller-enable +
433/// reset flag word at `0x10`, and a video-mode byte (`0`=NTSC, `1`=PAL) at
434/// `0x23`. The per-frame record is one byte per enabled controller; the bit order
435/// is the canonical NES order, so each byte maps straight to [`Buttons`].
436///
437/// We seek to the movie-data offset (rather than assuming a fixed header size) so
438/// the parse is robust across the older header variants whose exact layout is not
439/// authoritatively documented.
440///
441/// # Errors
442///
443/// [`LegacyMovieError`] for a bad magic, a save-state start, a four-score
444/// (>2-controller) movie, an offset that overlaps the header, or a truncated
445/// body.
446pub fn import_vmv(
447    bytes: &[u8],
448    rom_sha256: [u8; 32],
449) -> Result<(Movie, LegacyMeta), LegacyMovieError> {
450    const FMT: &str = "vmv";
451    const MIN_HEADER: usize = 0x40;
452    if bytes.len() < MIN_HEADER {
453        return Err(LegacyMovieError::Truncated {
454            expected: MIN_HEADER,
455            got: bytes.len(),
456        });
457    }
458    if &bytes[0..12] != VMV_MAGIC {
459        return Err(LegacyMovieError::BadMagic { format: FMT });
460    }
461    let flags = rd_u32_le(bytes, 0x10).unwrap_or(0);
462    // bits 0..3 = controllers 1..4 enabled; bit6 = reset-based (1) vs
463    // save-state-based (0).
464    let reset_based = flags & (1 << 6) != 0;
465    if !reset_based {
466        return Err(LegacyMovieError::Unsupported {
467            format: FMT,
468            reason: "begins from a save-state (cross-emulator save states are not portable)",
469        });
470    }
471    let ctrl_count = (usize::from(flags & 0x1 != 0))
472        + usize::from(flags & 0x2 != 0)
473        + usize::from(flags & 0x4 != 0)
474        + usize::from(flags & 0x8 != 0);
475    // RustyNES movies model exactly the two standard NES ports ([`FrameInput`]
476    // has no Four Score / controller-3-4 representation). A `.vmv` that enables
477    // controllers 3/4 cannot be imported without silently dropping their input
478    // (which would desync replay), so reject it up front — the same stance the
479    // `.fcm` path takes for a four-score (>2-controller) update.
480    if ctrl_count > 2 {
481        return Err(LegacyMovieError::Unsupported {
482            format: FMT,
483            reason: "four-score (>2 controllers) not supported",
484        });
485    }
486    // Default to a single controller if the flag word declares none (some 0.93
487    // movies leave the bits clear and imply P1).
488    let ctrl_count = ctrl_count.max(1);
489    let rerecord_count = u64::from(rd_u32_le(bytes, 0x1C).unwrap_or(0));
490    // Video mode byte: 0 = NTSC, 1 = PAL.
491    let pal = bytes[0x23] == 1;
492    let frame_count = rd_u32_le(bytes, 0x38).unwrap_or(0) as usize;
493    let data_off = rd_u32_le(bytes, 0x34).unwrap_or(0) as usize;
494    // A non-zero offset below MIN_HEADER would overlap the header and parse
495    // header bytes as controller input — reject it. A zero (or past-EOF) offset
496    // falls back to the documented 0.93 reset-based header size.
497    if data_off != 0 && data_off < MIN_HEADER {
498        return Err(LegacyMovieError::Malformed {
499            format: FMT,
500            reason: "movie-data offset overlaps the header",
501        });
502    }
503    let data_off = if data_off == 0 || data_off > bytes.len() {
504        // Fall back to the documented 0.93 reset-based offset.
505        MIN_HEADER
506    } else {
507        data_off
508    };
509
510    let body = &bytes[data_off..];
511    // Honour the header frame count when present, else derive from the body size.
512    let derived = body.len() / ctrl_count;
513    let frame_count = if frame_count == 0 {
514        derived
515    } else {
516        frame_count.min(derived)
517    };
518    let mut frames = Vec::with_capacity(frame_count);
519    for f in 0..frame_count {
520        let base = f * ctrl_count;
521        let p1 = Buttons::from_bits_truncate(*body.get(base).unwrap_or(&0));
522        let p2 = if ctrl_count >= 2 {
523            Buttons::from_bits_truncate(*body.get(base + 1).unwrap_or(&0))
524        } else {
525            Buttons::empty()
526        };
527        frames.push(FrameInput::new(p1, p2));
528    }
529
530    let movie = Movie {
531        region: if pal { Region::Pal } else { Region::Ntsc },
532        rom_sha256,
533        start: StartPoint::PowerOn,
534        frames,
535        rerecord_count: u32::try_from(rerecord_count).unwrap_or(u32::MAX),
536        // Imported: no attestation (the source format has no such field, and
537        // synthesizing one would attest a run this build never performed).
538        attestation: None,
539    };
540    Ok((
541        movie,
542        LegacyMeta {
543            rerecord_count,
544            pal,
545        },
546    ))
547}
548
549/// "Import" a `Mednafen` `.mc2` movie — always an error.
550///
551/// `.mc2` (`PCEjin` / `Mednafen`) is a **PC Engine** movie format (PCE buttons
552/// `B1/B2/Run/Select`, platform PCE/PCECD), not an NES container. It carries no
553/// NES gamepad data, so there is nothing to map. This entry point exists so the
554/// frontend dispatcher can give a precise diagnostic instead of mis-parsing.
555///
556/// # Errors
557///
558/// Always [`LegacyMovieError::Unsupported`].
559pub const fn import_mc2(
560    _bytes: &[u8],
561    _rom_sha256: [u8; 32],
562) -> Result<(Movie, LegacyMeta), LegacyMovieError> {
563    Err(LegacyMovieError::Unsupported {
564        format: "mc2",
565        reason: "`.mc2` is a PC Engine (PCEjin/Mednafen) movie, not an NES movie",
566    })
567}
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572    use alloc::vec;
573
574    const TEST_SHA: [u8; 32] = [0x33; 32];
575
576    /// Build a minimal `.fcm` header with the given flags, frame count, and
577    /// input stream (placed right after a 0x34-byte header).
578    fn synth_fcm(flags: u8, frame_count: u32, stream: &[u8]) -> Vec<u8> {
579        let mut b = vec![0u8; 0x34];
580        b[0..4].copy_from_slice(FCM_MAGIC);
581        b[0x04..0x08].copy_from_slice(&FCM_VERSION.to_le_bytes());
582        b[0x08] = flags;
583        b[0x0C..0x10].copy_from_slice(&frame_count.to_le_bytes());
584        b[0x10..0x14].copy_from_slice(&7u32.to_le_bytes()); // rerecord
585        let first_frame = u32::try_from(b.len()).unwrap();
586        b[0x1C..0x20].copy_from_slice(&first_frame.to_le_bytes());
587        b.extend_from_slice(stream);
588        b
589    }
590
591    #[test]
592    fn fcm_rejects_bad_magic_and_version() {
593        let mut b = synth_fcm(0x02, 0, &[]);
594        b[0] = b'X';
595        assert!(matches!(
596            import_fcm(&b, TEST_SHA),
597            Err(LegacyMovieError::BadMagic { .. })
598        ));
599        let mut b = synth_fcm(0x02, 0, &[]);
600        b[0x04] = 9; // version 9
601        assert!(matches!(
602            import_fcm(&b, TEST_SHA),
603            Err(LegacyMovieError::BadVersion { .. })
604        ));
605    }
606
607    #[test]
608    fn fcm_rejects_savestate_start() {
609        // flags bit1 clear -> save-state-based.
610        let b = synth_fcm(0x00, 0, &[]);
611        assert!(matches!(
612            import_fcm(&b, TEST_SHA),
613            Err(LegacyMovieError::Unsupported { .. })
614        ));
615    }
616
617    #[test]
618    fn fcm_toggle_stream_decodes() {
619        // reset-based, NTSC. Stream:
620        //   byte 0x07 -> controller update, player 0, button idx 7 = RIGHT toggle
621        //                (delta 0). Emits 1 frame with RIGHT held.
622        //   byte 0x07 -> toggles RIGHT off again. Emits 1 frame with nothing.
623        // frame_count = 2.
624        let stream = [0x07u8, 0x07u8];
625        let b = synth_fcm(0x02, 2, &stream);
626        let (movie, meta) = import_fcm(&b, TEST_SHA).expect("fcm import");
627        assert_eq!(movie.region, Region::Ntsc);
628        assert_eq!(movie.frames.len(), 2);
629        assert_eq!(movie.frames[0].p1, Buttons::RIGHT);
630        assert_eq!(movie.frames[1].p1, Buttons::empty());
631        assert_eq!(meta.rerecord_count, 7);
632        assert_eq!(movie.start, StartPoint::PowerOn);
633    }
634
635    #[test]
636    fn fcm_delta_advances_frames() {
637        // A control byte (bit7) with delta count 1 and a 1-byte delta of 3:
638        //   update = 1010_0000 = 0xA0 -> bit7 set (command), delta_bytes=1.
639        //   delta byte 0x03 -> advance 3 frames (held = empty), then emit 1 frame
640        //   for the command. Total 4 frames.
641        let stream = [0xA0u8, 0x03u8];
642        let b = synth_fcm(0x02, 4, &stream);
643        let (movie, _) = import_fcm(&b, TEST_SHA).expect("fcm import");
644        assert_eq!(movie.frames.len(), 4);
645        assert!(movie.frames.iter().all(|f| f.p1 == Buttons::empty()));
646    }
647
648    #[test]
649    fn fcm_pal_flag() {
650        let b = synth_fcm(0x02 | 0x04, 0, &[]);
651        let (movie, meta) = import_fcm(&b, TEST_SHA).expect("fcm import");
652        assert_eq!(movie.region, Region::Pal);
653        assert!(meta.pal);
654    }
655
656    /// Build a `.fmv` with the given flags2 and a body of raw per-frame bytes.
657    fn synth_fmv(flags1: u8, flags2: u8, body: &[u8]) -> Vec<u8> {
658        let mut b = vec![0u8; FMV_HEADER_LEN];
659        b[0..4].copy_from_slice(FMV_MAGIC);
660        b[0x04] = flags1;
661        b[0x05] = flags2;
662        b[0x0A..0x0E].copy_from_slice(&4u32.to_le_bytes()); // rerecord-1 = 4 -> 5
663        b.extend_from_slice(body);
664        b
665    }
666
667    #[test]
668    fn fmv_p1_only_full_dump() {
669        // flags2 bit7 = P1 present. Two frames: A then RIGHT.
670        // Famtasia bits: A=0x20, RIGHT=0x01.
671        let body = [0x20u8, 0x01u8];
672        let b = synth_fmv(0x00, 0x80, &body);
673        let (movie, meta) = import_fmv(&b, TEST_SHA).expect("fmv import");
674        assert_eq!(movie.frames.len(), 2);
675        assert_eq!(movie.frames[0].p1, Buttons::A);
676        assert_eq!(movie.frames[1].p1, Buttons::RIGHT);
677        assert_eq!(movie.region, Region::Ntsc);
678        assert_eq!(meta.rerecord_count, 5);
679    }
680
681    #[test]
682    fn fmv_two_controllers_interleave() {
683        // P1 + P2 present (bits 7 and 6). One frame: P1=B (0x10), P2=START (0x80).
684        let body = [0x10u8, 0x80u8];
685        let b = synth_fmv(0x00, 0xC0, &body);
686        let (movie, _) = import_fmv(&b, TEST_SHA).expect("fmv import");
687        assert_eq!(movie.frames.len(), 1);
688        assert_eq!(movie.frames[0].p1, Buttons::B);
689        assert_eq!(movie.frames[0].p2, Buttons::START);
690    }
691
692    #[test]
693    fn fmv_rejects_savestate() {
694        let b = synth_fmv(0x04, 0x80, &[]);
695        assert!(matches!(
696            import_fmv(&b, TEST_SHA),
697            Err(LegacyMovieError::Unsupported { .. })
698        ));
699    }
700
701    #[test]
702    fn fmv_byte_permutation_is_correct() {
703        assert_eq!(fmv_byte_to_buttons(0x01), Buttons::RIGHT);
704        assert_eq!(fmv_byte_to_buttons(0x20), Buttons::A);
705        assert_eq!(fmv_byte_to_buttons(0x10), Buttons::B);
706        assert_eq!(fmv_byte_to_buttons(0x80), Buttons::START);
707        assert_eq!(
708            fmv_byte_to_buttons(0xFF),
709            Buttons::all(),
710            "all bits set -> all buttons"
711        );
712    }
713
714    /// Build a `.vmv` (0.93-style) with the given flag word + video mode + a
715    /// per-frame body. Data offset points right after the 0x40 header.
716    fn synth_vmv(flags: u32, video_mode: u8, frame_count: u32, body: &[u8]) -> Vec<u8> {
717        let mut b = vec![0u8; 0x40];
718        b[0..12].copy_from_slice(VMV_MAGIC);
719        b[0x10..0x14].copy_from_slice(&flags.to_le_bytes());
720        b[0x1C..0x20].copy_from_slice(&11u32.to_le_bytes()); // rerecord
721        b[0x23] = video_mode;
722        let data_off = u32::try_from(b.len()).unwrap();
723        b[0x34..0x38].copy_from_slice(&data_off.to_le_bytes());
724        b[0x38..0x3C].copy_from_slice(&frame_count.to_le_bytes());
725        b.extend_from_slice(body);
726        b
727    }
728
729    #[test]
730    fn vmv_canonical_bit_order() {
731        // reset-based (bit6) + P1 enabled (bit0). One frame: A | RIGHT.
732        // VMV canonical order = RustyNES Buttons layout: A=0x01, RIGHT=0x80.
733        let flags = (1u32 << 6) | 0x1;
734        let body = [Buttons::A.bits() | Buttons::RIGHT.bits()];
735        let b = synth_vmv(flags, 0, 1, &body);
736        let (movie, meta) = import_vmv(&b, TEST_SHA).expect("vmv import");
737        assert_eq!(movie.frames.len(), 1);
738        assert_eq!(movie.frames[0].p1, Buttons::A | Buttons::RIGHT);
739        assert_eq!(movie.region, Region::Ntsc);
740        assert_eq!(meta.rerecord_count, 11);
741    }
742
743    #[test]
744    fn vmv_pal_video_mode() {
745        let flags = (1u32 << 6) | 0x1;
746        let b = synth_vmv(flags, 1, 1, &[0u8]);
747        let (movie, meta) = import_vmv(&b, TEST_SHA).expect("vmv import");
748        assert_eq!(movie.region, Region::Pal);
749        assert!(meta.pal);
750    }
751
752    #[test]
753    fn vmv_rejects_savestate() {
754        // bit6 clear -> save-state-based.
755        let b = synth_vmv(0x1, 0, 1, &[0u8]);
756        assert!(matches!(
757            import_vmv(&b, TEST_SHA),
758            Err(LegacyMovieError::Unsupported { .. })
759        ));
760    }
761
762    #[test]
763    fn vmv_rejects_bad_magic() {
764        let mut b = synth_vmv((1u32 << 6) | 1, 0, 1, &[0u8]);
765        b[0] = b'X';
766        assert!(matches!(
767            import_vmv(&b, TEST_SHA),
768            Err(LegacyMovieError::BadMagic { .. })
769        ));
770    }
771
772    #[test]
773    fn mc2_is_rejected_as_pce() {
774        assert!(matches!(
775            import_mc2(&[0u8; 16], TEST_SHA),
776            Err(LegacyMovieError::Unsupported { format: "mc2", .. })
777        ));
778    }
779
780    #[test]
781    fn fcm_rejects_overlapping_first_frame_offset() {
782        // first_frame below MIN_HEADER (0x34) would overlap the header.
783        let mut b = synth_fcm(0x02, 0, &[]);
784        b[0x1C..0x20].copy_from_slice(&0x10u32.to_le_bytes());
785        assert!(matches!(
786            import_fcm(&b, TEST_SHA),
787            Err(LegacyMovieError::Malformed { .. })
788        ));
789    }
790
791    #[test]
792    fn fcm_oversized_advance_is_capped_not_unbounded() {
793        // frame_count = 0 (no hint) + a control byte with a 3-byte delta of
794        // 0xFFFFFF would, uncapped, try to emit ~16M frames. With the hard cap
795        // enforced regardless of frame_hint, the output stays bounded (<= 1<<24)
796        // and the import does not hang or OOM.
797        // update = 1110_0000 = 0xE0 -> bit7 set (command), delta_bytes = 3.
798        let stream = [0xE0u8, 0xFFu8, 0xFFu8, 0xFFu8];
799        let b = synth_fcm(0x02, 0, &stream);
800        let (movie, _) = import_fcm(&b, TEST_SHA).expect("fcm import");
801        assert!(
802            movie.frames.len() <= (1 << 24),
803            "output must be capped at the hard limit, got {}",
804            movie.frames.len()
805        );
806    }
807
808    #[test]
809    fn vmv_rejects_overlapping_data_offset() {
810        // data_off below MIN_HEADER (0x40) overlaps the header.
811        let mut b = synth_vmv((1u32 << 6) | 1, 0, 1, &[0u8]);
812        b[0x34..0x38].copy_from_slice(&0x20u32.to_le_bytes());
813        assert!(matches!(
814            import_vmv(&b, TEST_SHA),
815            Err(LegacyMovieError::Malformed { .. })
816        ));
817    }
818
819    #[test]
820    fn vmv_rejects_four_controllers() {
821        // reset-based (bit6) + all 4 controllers enabled (bits 0..3).
822        let flags = (1u32 << 6) | 0xF;
823        let b = synth_vmv(flags, 0, 1, &[0u8, 0u8, 0u8, 0u8]);
824        assert!(
825            matches!(
826                import_vmv(&b, TEST_SHA),
827                Err(LegacyMovieError::Unsupported { .. })
828            ),
829            "a 4-controller .vmv must be rejected rather than silently dropping P3/P4"
830        );
831    }
832
833    #[test]
834    fn vmv_two_controllers_round_trip() {
835        // reset-based + P1 & P2 enabled. One frame: P1=A, P2=START.
836        let flags = (1u32 << 6) | 0x3;
837        let body = [Buttons::A.bits(), Buttons::START.bits()];
838        let b = synth_vmv(flags, 0, 1, &body);
839        let (movie, _) = import_vmv(&b, TEST_SHA).expect("vmv import");
840        assert_eq!(movie.frames.len(), 1);
841        assert_eq!(movie.frames[0].p1, Buttons::A);
842        assert_eq!(movie.frames[0].p2, Buttons::START);
843    }
844
845    #[test]
846    fn truncated_inputs_never_panic() {
847        assert!(matches!(
848            import_fcm(&[0u8; 4], TEST_SHA),
849            Err(LegacyMovieError::Truncated { .. })
850        ));
851        assert!(matches!(
852            import_fmv(&[0u8; 4], TEST_SHA),
853            Err(LegacyMovieError::Truncated { .. })
854        ));
855        assert!(matches!(
856            import_vmv(&[0u8; 4], TEST_SHA),
857            Err(LegacyMovieError::Truncated { .. })
858        ));
859    }
860}