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