Skip to main content

rustynes_core/
movie_interop.rs

1//! FCEUX `.fm2` movie interop: import + export of FCEUX's plain-text TAS
2//! movie format to and from the native [`Movie`] type.
3//!
4//! `.fm2` is ASCII text (see `ref-proj/fceux/documentation/fm2.txt`): a block
5//! of `key value` header lines (the first of which must be `version 3`),
6//! followed by an input-log section whose every line begins and ends with a
7//! `|` (pipe). The movie length is implicit -- it is the number of input-log
8//! lines (FCEUX `.fm2` note A).
9//!
10//! # Scope and deliberate limitations
11//!
12//! - **Standard gamepads only.** `RustyNES`'s input model here maps FCEUX
13//!   `SI_GAMEPAD` ports onto [`FrameInput`]. A `port0`/`port1` declaring a
14//!   zapper (`SI_ZAPPER = 2`) is rejected with [`Fm2Error::Unsupported`]
15//!   rather than silently mis-mapped.
16//! - **Power-on start only.** A `savestate`-anchored `.fm2` is rejected on
17//!   import (cross-emulator save-state blobs are not portable), and a
18//!   [`StartPoint::SaveState`] [`Movie`] is rejected on export. Both surface
19//!   [`Fm2Error::Unsupported`].
20//! - **Two controllers stored.** [`FrameInput`] models players 1 and 2 only.
21//!   A `fourscore` `.fm2` (four pads) is imported by keeping pads 1 and 2 and
22//!   dropping pads 3 and 4; the fourscore flag is preserved in [`Fm2Meta`] so
23//!   the caller is not silently misled. (TODO: carry P3/P4 once `FrameInput`
24//!   grows beyond two ports.)
25//! - **Soft reset has no home on [`FrameInput`].** The per-frame command
26//!   field's `MOVIECMD_RESET` bit (value 1) is parsed without error but is
27//!   *not* applied to any frame today; see [`import_fm2`].
28//!
29//! # The `RLDUTSBA` pad order (a classic footgun)
30//!
31//! Each gamepad field is exactly eight characters. Per FCEUX, the column
32//! order is the deliberately-reversed `RLDUTSBA` = Right, Left, Down, Up,
33//! sTart, Select, B, A (kept for back-compat with FCEUX's first release). So
34//! character index 0 is the Right button and index 7 is the A button. A
35//! character of `' '` (space) or `'.'` means released; any other character
36//! (conventionally the button's own mnemonic letter) means pressed.
37//!
38//! This module is `no_std`-clean: it uses only `core` + `alloc`.
39
40use alloc::string::{String, ToString};
41use alloc::vec::Vec;
42use core::fmt::Write as _;
43
44use crate::Region;
45use crate::controller::Buttons;
46use crate::movie::{FrameInput, Movie, StartPoint};
47use thiserror::Error;
48
49/// The only FCEUX `.fm2` format version this module understands.
50pub const FM2_VERSION: u32 = 3;
51
52/// FCEUX `port0`/`port1` value for a standard gamepad (`SI_GAMEPAD`).
53const SI_GAMEPAD: u32 = 1;
54
55/// FCEUX per-frame command bit: a soft reset occurred at the start of the
56/// frame (`MOVIECMD_RESET`).
57const MOVIECMD_RESET: u32 = 1;
58
59/// The eight-character gamepad column order used by `.fm2`, paired with the
60/// [`Buttons`] flag each column drives. Index 0 is the first character of a
61/// pad field. Order is FCEUX's reversed `RLDUTSBA`.
62const PAD_COLUMNS: [Buttons; 8] = [
63    Buttons::RIGHT,  // index 0: R
64    Buttons::LEFT,   // index 1: L
65    Buttons::DOWN,   // index 2: D
66    Buttons::UP,     // index 3: U
67    Buttons::START,  // index 4: T (sTart)
68    Buttons::SELECT, // index 5: S
69    Buttons::B,      // index 6: B
70    Buttons::A,      // index 7: A
71];
72
73/// Header metadata parsed from an `.fm2` that has no home on [`Movie`] yet.
74///
75/// [`Movie`] carries only the data the native `.rnm` format needs (region,
76/// ROM hash, start point, frames). The remaining `.fm2` header fields are
77/// returned here so the caller can surface or persist them.
78#[derive(Clone, Debug, Default, Eq, PartialEq)]
79pub struct Fm2Meta {
80    /// The `rerecordCount` header value (0 if absent).
81    pub rerecord_count: u64,
82    /// The movie author, taken from a `comment author <name>` line if present.
83    pub author: Option<String>,
84    /// The `romFilename` header value, if present.
85    pub rom_filename: Option<String>,
86    /// The `romChecksum` header value, stored verbatim (an MD5, `base64:`- or
87    /// hex-encoded). Not validated against the ROM -- the SHA-256 identity is
88    /// supplied separately by the caller.
89    pub rom_checksum_md5: Option<String>,
90    /// `true` if the movie declared `fourscore 1` (four controllers). When
91    /// set, only pads 1 and 2 made it into the [`Movie`]; pads 3 and 4 were
92    /// dropped (see the module docs).
93    pub fourscore: bool,
94    /// `true` if the movie declared `palFlag 1`.
95    pub pal: bool,
96}
97
98/// Errors produced by `.fm2` import / export.
99#[derive(Debug, Error)]
100#[non_exhaustive]
101pub enum Fm2Error {
102    /// The header had no `version` line, or it was not the first key.
103    #[error("fm2 missing required `version` header (must be the first key)")]
104    MissingVersion,
105
106    /// The `version` value was not [`FM2_VERSION`].
107    #[error("fm2 version {got} not supported (only version {} is)", FM2_VERSION)]
108    BadVersion {
109        /// The version value we read.
110        got: u32,
111    },
112
113    /// A header line declared an integer key whose value did not parse.
114    #[error("fm2 header key `{key}` has an invalid integer value `{value}`")]
115    BadInteger {
116        /// The offending key.
117        key: String,
118        /// The text we failed to parse as an integer.
119        value: String,
120    },
121
122    /// A structural problem with an input-log line (missing pipes, wrong
123    /// field count, or a pad field of the wrong length). `line` is the
124    /// 1-based input-log line number.
125    #[error("fm2 malformed input-log line {line}: {reason}")]
126    Malformed {
127        /// 1-based index of the offending input-log line.
128        line: usize,
129        /// Human-readable description of what was wrong.
130        reason: &'static str,
131    },
132
133    /// A feature of the `.fm2` (or of the [`Movie`] being exported) that this
134    /// module deliberately does not support.
135    #[error("fm2 unsupported: {0}")]
136    Unsupported(&'static str),
137}
138
139/// Options the caller supplies on export that the [`Movie`] itself does not
140/// carry. Mirrors the extra header fields surfaced by [`Fm2Meta`] on import.
141#[derive(Clone, Debug, Default, Eq, PartialEq)]
142pub struct Fm2ExportOpts {
143    /// Value to emit for the `rerecordCount` header.
144    pub rerecord_count: u64,
145    /// Author to emit as a `comment author <name>` line, if any.
146    pub author: Option<String>,
147    /// Value to emit for the `romFilename` header, if any.
148    pub rom_filename: Option<String>,
149    /// Value to emit for the `romChecksum` header, if any.
150    pub rom_checksum_md5: Option<String>,
151    /// Emit `fourscore 1` and four pad columns per line when `true`. The
152    /// extra pads (3 and 4) are always released, since [`FrameInput`] models
153    /// only two controllers.
154    pub fourscore: bool,
155}
156
157/// Parse `.fm2` text into a [`Movie`] plus the leftover header [`Fm2Meta`].
158///
159/// `rom_sha256` is the SHA-256 of the ROM the caller intends to replay the
160/// movie against. The `.fm2` format carries only an MD5 (`romChecksum`), so
161/// the authoritative SHA-256 ROM identity must come from the loaded ROM; it is
162/// stored verbatim on the returned [`Movie`] and is *not* validated here.
163///
164/// The returned [`Movie`] always has [`StartPoint::PowerOn`] -- FCEUX note B
165/// says movies start from power-on unless a `savestate` key is present, and
166/// such cross-emulator save-state blobs are not portable, so a `savestate`
167/// header is rejected.
168///
169/// # Soft reset handling
170///
171/// The per-frame command field's `MOVIECMD_RESET` bit (value 1) is parsed (so
172/// such lines do not error) but is **not** represented anywhere on the
173/// resulting [`Movie`], because [`FrameInput`] has no reset bit. A reset
174/// command therefore affects neither the frame count nor playback today.
175///
176/// # Errors
177///
178/// Returns [`Fm2Error`] for a missing/wrong `version`, an unparseable integer
179/// header, an unsupported device or `savestate` start point, or a malformed
180/// input-log line (bad pipes, wrong field count, wrong pad length). Never
181/// panics on malformed input.
182pub fn import_fm2(text: &str, rom_sha256: [u8; 32]) -> Result<(Movie, Fm2Meta), Fm2Error> {
183    let mut meta = Fm2Meta::default();
184    let mut saw_version = false;
185    let mut port0_gamepad = true;
186    let mut port1_gamepad = true;
187    let mut frames: Vec<FrameInput> = Vec::new();
188    let mut input_line_no = 0usize;
189
190    for raw in text.lines() {
191        // Trim a trailing '\r' so CRLF and LF both work; leave interior
192        // content alone.
193        let line = raw.strip_suffix('\r').unwrap_or(raw);
194
195        if line.starts_with('|') {
196            // Input-log line.
197            input_line_no += 1;
198            let input = parse_input_line(line, input_line_no, meta.fourscore)?;
199            frames.push(input);
200            continue;
201        }
202
203        // Header line. Blank lines in the header are tolerated.
204        if line.trim().is_empty() {
205            continue;
206        }
207
208        let (key, value) = match line.split_once(' ') {
209            Some((k, v)) => (k, v),
210            // A bare key with no value (e.g. an empty string field): treat the
211            // value as empty.
212            None => (line, ""),
213        };
214
215        // `version` must be the very first header key.
216        if !saw_version && key != "version" {
217            return Err(Fm2Error::MissingVersion);
218        }
219
220        match key {
221            "version" => {
222                let v = parse_int(key, value)?;
223                if v != FM2_VERSION {
224                    return Err(Fm2Error::BadVersion { got: v });
225                }
226                saw_version = true;
227            }
228            "rerecordCount" => meta.rerecord_count = u64::from(parse_int(key, value)?),
229            "palFlag" => meta.pal = parse_int(key, value)? != 0,
230            "fourscore" => meta.fourscore = parse_int(key, value)? != 0,
231            "port0" => port0_gamepad = parse_int(key, value)? == SI_GAMEPAD,
232            "port1" => port1_gamepad = parse_int(key, value)? == SI_GAMEPAD,
233            "port2" => {
234                // SIFC_NONE = 0 is the only expansion-port device we model.
235                let _ = parse_int(key, value)?;
236            }
237            "romFilename" => meta.rom_filename = Some(value.to_string()),
238            "romChecksum" => meta.rom_checksum_md5 = Some(value.to_string()),
239            "savestate" => {
240                return Err(Fm2Error::Unsupported(
241                    "savestate-anchored .fm2 (cross-emulator save states are not portable)",
242                ));
243            }
244            "comment" => {
245                // By convention `comment author <name>` carries the author.
246                if let Some(rest) = value.strip_prefix("author ") {
247                    meta.author = Some(rest.to_string());
248                }
249            }
250            _ => {
251                // `emuVersion`, `guid`, and any unknown header keys are
252                // ignored (forward-compatible).
253            }
254        }
255    }
256
257    if !saw_version {
258        return Err(Fm2Error::MissingVersion);
259    }
260    // Reject non-gamepad standard ports only after we know `version` was OK,
261    // so the error reflects the real obstacle. Fourscore implies all-gamepad
262    // (FCEUX note C), so the port checks only matter when not fourscore.
263    if !meta.fourscore && (!port0_gamepad || !port1_gamepad) {
264        return Err(Fm2Error::Unsupported(
265            "non-gamepad input device (only SI_GAMEPAD ports are supported)",
266        ));
267    }
268
269    let movie = Movie {
270        region: if meta.pal { Region::Pal } else { Region::Ntsc },
271        rom_sha256,
272        start: StartPoint::PowerOn,
273        frames,
274        // Carry the `.fm2` rerecordCount through (saturating into the `.rnm` u32).
275        rerecord_count: u32::try_from(meta.rerecord_count).unwrap_or(u32::MAX),
276    };
277    Ok((movie, meta))
278}
279
280/// Serialize a [`Movie`] to `.fm2` text.
281///
282/// Emits a `version 3` header, then `emuVersion`, `rerecordCount`, `palFlag`
283/// (from [`Movie::region`] -- both [`Region::Pal`] and [`Region::Dendy`] are
284/// PAL-timed, so both export `palFlag 1`), `fourscore`, `port0`/`port1`/`port2`
285/// (all gamepad / none), the optional `romFilename` / `romChecksum`, an
286/// optional `comment author` line, then the `|c|RLDUTSBA|RLDUTSBA||` input log
287/// (one line per frame, with a trailing empty `port2` field per the spec).
288///
289/// Only [`StartPoint::PowerOn`] movies export; a [`StartPoint::SaveState`]
290/// movie has no portable `.fm2` representation.
291///
292/// # Errors
293///
294/// Returns [`Fm2Error::Unsupported`] if `movie` is anchored to an embedded
295/// save state.
296pub fn export_fm2(movie: &Movie, opts: &Fm2ExportOpts) -> Result<String, Fm2Error> {
297    if !matches!(movie.start, StartPoint::PowerOn) {
298        return Err(Fm2Error::Unsupported(
299            "save-state-anchored movie has no portable .fm2 representation",
300        ));
301    }
302
303    let pal = matches!(movie.region, Region::Pal | Region::Dendy);
304    let mut out = String::new();
305
306    // Header. `version` must be first. Writing into a `String` via the
307    // `core::fmt::Write` impl is infallible, so the `write!` results are
308    // discarded.
309    out.push_str("version 3\n");
310    let _ = writeln!(out, "emuVersion {}", emu_version_tag());
311    let _ = writeln!(out, "rerecordCount {}", opts.rerecord_count);
312    let _ = writeln!(out, "palFlag {}", u8::from(pal));
313    let _ = writeln!(out, "fourscore {}", u8::from(opts.fourscore));
314    let _ = writeln!(out, "port0 {SI_GAMEPAD}");
315    let _ = writeln!(out, "port1 {SI_GAMEPAD}");
316    out.push_str("port2 0\n");
317    if let Some(name) = &opts.rom_filename {
318        let _ = writeln!(out, "romFilename {name}");
319    }
320    if let Some(sum) = &opts.rom_checksum_md5 {
321        let _ = writeln!(out, "romChecksum {sum}");
322    }
323    if let Some(author) = &opts.author {
324        let _ = writeln!(out, "comment author {author}");
325    }
326
327    // Input log: one line per frame. RustyNES movies never carry a per-frame
328    // reset command, so field `c` is always 0.
329    let mut pad = [0u8; 8];
330    for frame in &movie.frames {
331        out.push_str("|0|");
332        write_pad(frame.p1, &mut pad);
333        out.push_str(core::str::from_utf8(&pad).expect("pad bytes are ASCII"));
334        out.push('|');
335        write_pad(frame.p2, &mut pad);
336        out.push_str(core::str::from_utf8(&pad).expect("pad bytes are ASCII"));
337        out.push('|');
338        if opts.fourscore {
339            // Players 3 and 4 are always released (FrameInput has no P3/P4).
340            write_pad(Buttons::empty(), &mut pad);
341            let empty = core::str::from_utf8(&pad).expect("pad bytes are ASCII");
342            out.push_str(empty);
343            out.push('|');
344            out.push_str(empty);
345            out.push('|');
346        }
347        // Trailing empty `port2` field (SIFC_NONE is always empty).
348        out.push_str("|\n");
349    }
350
351    Ok(out)
352}
353
354/// Render `buttons` into an eight-byte `RLDUTSBA` pad field. A pressed button
355/// is written as its mnemonic letter; a released one as `'.'`.
356fn write_pad(buttons: Buttons, out: &mut [u8; 8]) {
357    // Mnemonic letters in column order, matching `PAD_COLUMNS`.
358    const LETTERS: [u8; 8] = [b'R', b'L', b'D', b'U', b'T', b'S', b'B', b'A'];
359    for i in 0..8 {
360        out[i] = if buttons.contains(PAD_COLUMNS[i]) {
361            LETTERS[i]
362        } else {
363            b'.'
364        };
365    }
366}
367
368/// Parse a single input-log line (already known to start with `|`) into a
369/// [`FrameInput`]. `line_no` is the 1-based input-log line number used in
370/// errors; `fourscore` selects the 4-pad layout.
371fn parse_input_line(line: &str, line_no: usize, fourscore: bool) -> Result<FrameInput, Fm2Error> {
372    if !line.ends_with('|') {
373        return Err(Fm2Error::Malformed {
374            line: line_no,
375            reason: "input-log line must end with `|`",
376        });
377    }
378    // `|c|p0|p1|port2|` splits (on `|`) to ["", c, p0, p1, port2, ""]; the
379    // fourscore form has p2/p3 between p1 and port2. Both leading and trailing
380    // empty strings are expected.
381    let mut fields = line.split('|');
382    // Leading empty field (before the first `|`).
383    if fields.next() != Some("") {
384        return Err(Fm2Error::Malformed {
385            line: line_no,
386            reason: "input-log line must start with `|`",
387        });
388    }
389    // Command field.
390    let cmd_field = fields.next().ok_or(Fm2Error::Malformed {
391        line: line_no,
392        reason: "missing command field",
393    })?;
394    let _cmd = parse_command(cmd_field, line_no)?;
395
396    let pad_count = if fourscore { 4 } else { 2 };
397    let mut pads = [Buttons::empty(); 4];
398    for pad in pads.iter_mut().take(pad_count) {
399        let field = fields.next().ok_or(Fm2Error::Malformed {
400            line: line_no,
401            reason: "missing gamepad field",
402        })?;
403        *pad = parse_pad(field, line_no)?;
404    }
405
406    // Remaining fields: the `port2` field then the trailing empty string. We
407    // tolerate the `port2` field being present-and-empty (SIFC_NONE) or
408    // omitted entirely, but anything non-empty there is unsupported.
409    for field in fields {
410        if !field.is_empty() {
411            return Err(Fm2Error::Malformed {
412                line: line_no,
413                reason: "unexpected non-empty trailing field (only SIFC_NONE supported)",
414            });
415        }
416    }
417
418    // pads[0] = P1, pads[1] = P2 (pads 2/3 dropped for fourscore).
419    Ok(FrameInput::new(pads[0], pads[1]))
420}
421
422/// Parse the variable-length decimal command bitfield. Returns whether the
423/// reset bit was set (currently informational only).
424fn parse_command(field: &str, line_no: usize) -> Result<bool, Fm2Error> {
425    // The command field is conventionally empty or a small decimal integer.
426    let value: u32 = if field.is_empty() {
427        0
428    } else {
429        field.parse().map_err(|_| Fm2Error::Malformed {
430            line: line_no,
431            reason: "command field is not a decimal integer",
432        })?
433    };
434    Ok(value & MOVIECMD_RESET != 0)
435}
436
437/// Parse one eight-character `RLDUTSBA` gamepad field into [`Buttons`].
438fn parse_pad(field: &str, line_no: usize) -> Result<Buttons, Fm2Error> {
439    let bytes = field.as_bytes();
440    if bytes.len() != 8 {
441        return Err(Fm2Error::Malformed {
442            line: line_no,
443            reason: "gamepad field must be exactly 8 characters",
444        });
445    }
446    let mut buttons = Buttons::empty();
447    for (i, &b) in bytes.iter().enumerate() {
448        // Space or '.' = released; anything else = pressed.
449        if b != b' ' && b != b'.' {
450            buttons |= PAD_COLUMNS[i];
451        }
452    }
453    Ok(buttons)
454}
455
456/// Parse an integer-typed header value, attaching the key for diagnostics.
457fn parse_int(key: &str, value: &str) -> Result<u32, Fm2Error> {
458    value
459        .trim()
460        .parse::<u32>()
461        .map_err(|_| Fm2Error::BadInteger {
462            key: key.to_string(),
463            value: value.to_string(),
464        })
465}
466
467/// The `emuVersion` tag emitted on export. An FCEUX-style numeric emulator
468/// version is not meaningful for a different emulator, so we emit a stable
469/// sentinel that round-trips harmlessly (the importer ignores `emuVersion`).
470const fn emu_version_tag() -> u32 {
471    // RustyNES is not FCEUX; a fixed sentinel keeps export deterministic and
472    // the field is ignored on import.
473    20000
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479    use alloc::vec;
480
481    const TEST_SHA: [u8; 32] = [0x5A; 32];
482
483    /// A fixed, varied input sequence touching every button.
484    fn varied_frames() -> Vec<FrameInput> {
485        vec![
486            FrameInput::new(Buttons::A, Buttons::B),
487            FrameInput::new(Buttons::RIGHT | Buttons::A, Buttons::LEFT | Buttons::START),
488            FrameInput::new(
489                Buttons::UP | Buttons::DOWN | Buttons::SELECT,
490                Buttons::empty(),
491            ),
492            FrameInput::new(
493                Buttons::A | Buttons::B | Buttons::SELECT | Buttons::START,
494                Buttons::UP | Buttons::DOWN | Buttons::LEFT | Buttons::RIGHT,
495            ),
496        ]
497    }
498
499    #[test]
500    fn round_trip_power_on_ntsc() {
501        let movie = Movie {
502            region: Region::Ntsc,
503            rom_sha256: TEST_SHA,
504            start: StartPoint::PowerOn,
505            frames: varied_frames(),
506            rerecord_count: 0,
507        };
508        let opts = Fm2ExportOpts {
509            rerecord_count: 42,
510            author: Some("tester".to_string()),
511            rom_filename: Some("game.nes".to_string()),
512            rom_checksum_md5: Some("base64:deadbeef".to_string()),
513            fourscore: false,
514        };
515        let text = export_fm2(&movie, &opts).expect("export");
516        let (back, meta) = import_fm2(&text, TEST_SHA).expect("import");
517
518        assert_eq!(back.frames, movie.frames, "frames survive round-trip");
519        assert_eq!(back.region, Region::Ntsc);
520        assert_eq!(back.start, StartPoint::PowerOn);
521        assert_eq!(back.rom_sha256, TEST_SHA);
522        assert!(!meta.fourscore);
523        assert!(!meta.pal);
524        assert_eq!(meta.rerecord_count, 42);
525        assert_eq!(meta.author.as_deref(), Some("tester"));
526        assert_eq!(meta.rom_filename.as_deref(), Some("game.nes"));
527        assert_eq!(meta.rom_checksum_md5.as_deref(), Some("base64:deadbeef"));
528    }
529
530    #[test]
531    fn exact_bit_and_char_mapping() {
532        // Only A set -> char index 7 pressed, others released.
533        let movie = Movie {
534            region: Region::Ntsc,
535            rom_sha256: TEST_SHA,
536            start: StartPoint::PowerOn,
537            frames: vec![
538                FrameInput::new(Buttons::A, Buttons::empty()),
539                FrameInput::new(Buttons::RIGHT, Buttons::empty()),
540            ],
541            rerecord_count: 0,
542        };
543        let text = export_fm2(&movie, &Fm2ExportOpts::default()).expect("export");
544        // Pull the two input-log lines.
545        let lines: Vec<&str> = text.lines().filter(|l| l.starts_with('|')).collect();
546        assert_eq!(lines.len(), 2);
547
548        // |0|<pad p1>|<pad p2>||  -> the first pad field is between pipe 2 & 3.
549        let p1_field_a = lines[0].split('|').nth(2).unwrap();
550        assert_eq!(p1_field_a.len(), 8);
551        for (i, c) in p1_field_a.chars().enumerate() {
552            if i == 7 {
553                assert_ne!(c, '.', "A button is char index 7 and must be pressed");
554            } else {
555                assert_eq!(c, '.', "non-A columns must be released");
556            }
557        }
558
559        let p1_field_right = lines[1].split('|').nth(2).unwrap();
560        for (i, c) in p1_field_right.chars().enumerate() {
561            if i == 0 {
562                assert_ne!(c, '.', "RIGHT button is char index 0 and must be pressed");
563            } else {
564                assert_eq!(c, '.', "non-RIGHT columns must be released");
565            }
566        }
567
568        // Import the reverse: a hand-built log with only index 0 (RIGHT) and
569        // only index 7 (A) set, assert the right Buttons come back.
570        let imported = "version 3\nport0 1\nport1 1\nport2 0\n\
571                        |0|R.......|.......A||\n";
572        let (movie, _) = import_fm2(imported, TEST_SHA).expect("import");
573        assert_eq!(movie.frames.len(), 1);
574        assert_eq!(movie.frames[0].p1, Buttons::RIGHT);
575        assert_eq!(movie.frames[0].p2, Buttons::A);
576    }
577
578    #[test]
579    fn pal_flag_maps_to_region() {
580        // Import: palFlag 1 -> Region::Pal.
581        let text = "version 3\npalFlag 1\nport0 1\nport1 1\nport2 0\n|0|........|........||\n";
582        let (movie, meta) = import_fm2(text, TEST_SHA).expect("import");
583        assert_eq!(movie.region, Region::Pal);
584        assert!(meta.pal);
585
586        // Export of a Pal movie emits palFlag 1.
587        let pal_movie = Movie {
588            region: Region::Pal,
589            rom_sha256: TEST_SHA,
590            start: StartPoint::PowerOn,
591            frames: vec![FrameInput::new(Buttons::empty(), Buttons::empty())],
592            rerecord_count: 0,
593        };
594        let out = export_fm2(&pal_movie, &Fm2ExportOpts::default()).expect("export");
595        assert!(
596            out.lines().any(|l| l == "palFlag 1"),
597            "Pal movie must export palFlag 1"
598        );
599
600        // Ntsc exports palFlag 0.
601        let ntsc_movie = Movie {
602            region: Region::Ntsc,
603            ..pal_movie
604        };
605        let out = export_fm2(&ntsc_movie, &Fm2ExportOpts::default()).expect("export");
606        assert!(out.lines().any(|l| l == "palFlag 0"));
607    }
608
609    #[test]
610    fn reset_command_parses_without_error() {
611        // c = 1 means MOVIECMD_RESET. We parse it (don't crash) but it is not
612        // represented on FrameInput, so the frame is otherwise a normal frame.
613        let text = "version 3\nport0 1\nport1 1\nport2 0\n|1|........|........||\n";
614        let (movie, _) = import_fm2(text, TEST_SHA).expect("reset command must parse");
615        assert_eq!(movie.frames.len(), 1);
616        assert_eq!(movie.frames[0].p1, Buttons::empty());
617    }
618
619    #[test]
620    fn fourscore_layout_parses_two_of_four_pads() {
621        // Four pad fields; only P1/P2 are retained. P1 = A, P2 = B, P3/P4 set
622        // (and dropped). fourscore must survive in meta.
623        let text = "version 3\nfourscore 1\nport0 1\nport1 1\nport2 0\n\
624                    |0|.......A|......B.|R.......|.L......||\n";
625        let (movie, meta) = import_fm2(text, TEST_SHA).expect("fourscore import");
626        assert!(meta.fourscore);
627        assert_eq!(movie.frames.len(), 1);
628        assert_eq!(movie.frames[0].p1, Buttons::A);
629        assert_eq!(movie.frames[0].p2, Buttons::B);
630
631        // Export with fourscore emits four pad fields.
632        let out = export_fm2(
633            &movie,
634            &Fm2ExportOpts {
635                fourscore: true,
636                ..Default::default()
637            },
638        )
639        .expect("export");
640        let log_line = out.lines().find(|l| l.starts_with('|')).unwrap();
641        // |0|p1|p2|p3|p4||  -> split has ["",0,p1,p2,p3,p4,"",""]; four of the
642        // fields are 8-char pads.
643        let pad_count = log_line.split('|').filter(|p| p.len() == 8).count();
644        assert_eq!(pad_count, 4, "fourscore export must emit four pad fields");
645    }
646
647    #[test]
648    fn malformed_inputs_never_panic() {
649        // Missing version line entirely.
650        assert!(matches!(
651            import_fm2("emuVersion 1\nport0 1\n", TEST_SHA),
652            Err(Fm2Error::MissingVersion)
653        ));
654
655        // First key is not version.
656        assert!(matches!(
657            import_fm2("palFlag 0\nversion 3\n", TEST_SHA),
658            Err(Fm2Error::MissingVersion)
659        ));
660
661        // Wrong version.
662        assert!(matches!(
663            import_fm2("version 2\nport0 1\nport1 1\nport2 0\n", TEST_SHA),
664            Err(Fm2Error::BadVersion { got: 2 })
665        ));
666
667        // A bad integer header value.
668        assert!(matches!(
669            import_fm2("version 3\npalFlag notanint\n", TEST_SHA),
670            Err(Fm2Error::BadInteger { .. })
671        ));
672
673        // An input line that starts with `|` but does not end with one.
674        assert!(matches!(
675            import_fm2(
676                "version 3\nport0 1\nport1 1\nport2 0\n|0|........|........\n",
677                TEST_SHA
678            ),
679            Err(Fm2Error::Malformed { .. })
680        ));
681
682        // A truncated pad field (7 chars).
683        assert!(matches!(
684            import_fm2(
685                "version 3\nport0 1\nport1 1\nport2 0\n|0|.......|........||\n",
686                TEST_SHA
687            ),
688            Err(Fm2Error::Malformed { .. })
689        ));
690
691        // A zapper port is unsupported.
692        assert!(matches!(
693            import_fm2("version 3\nport0 2\nport1 1\nport2 0\n", TEST_SHA),
694            Err(Fm2Error::Unsupported(_))
695        ));
696
697        // A savestate-anchored movie is unsupported.
698        assert!(matches!(
699            import_fm2("version 3\nsavestate 0xDEAD\nport0 1\n", TEST_SHA),
700            Err(Fm2Error::Unsupported(_))
701        ));
702    }
703
704    #[test]
705    fn representative_header_parses() {
706        let text = "version 3\n\
707            emuVersion 22020\n\
708            rerecordCount 1234\n\
709            palFlag 0\n\
710            fourscore 0\n\
711            port0 1\n\
712            port1 1\n\
713            port2 0\n\
714            romFilename Super Demo.nes\n\
715            romChecksum base64:abc123==\n\
716            comment author Jane Doe\n\
717            comment subject A speedrun\n\
718            guid 452DE2C3-EF43-2FA9-77AC-0677FC51543B\n\
719            |0|........|........||\n\
720            |0|.......A|........||\n";
721        let (movie, meta) = import_fm2(text, TEST_SHA).expect("header parse");
722        assert_eq!(movie.frames.len(), 2);
723        assert_eq!(movie.region, Region::Ntsc);
724        assert_eq!(meta.rerecord_count, 1234);
725        assert!(!meta.fourscore);
726        assert!(!meta.pal);
727        assert_eq!(meta.author.as_deref(), Some("Jane Doe"));
728        assert_eq!(meta.rom_filename.as_deref(), Some("Super Demo.nes"));
729        assert_eq!(meta.rom_checksum_md5.as_deref(), Some("base64:abc123=="));
730        // Frame 1 had A on P1.
731        assert_eq!(movie.frames[1].p1, Buttons::A);
732    }
733
734    #[test]
735    fn export_rejects_save_state_movie() {
736        let movie = Movie {
737            region: Region::Ntsc,
738            rom_sha256: TEST_SHA,
739            start: StartPoint::SaveState(vec![1, 2, 3]),
740            frames: vec![],
741            rerecord_count: 0,
742        };
743        assert!(matches!(
744            export_fm2(&movie, &Fm2ExportOpts::default()),
745            Err(Fm2Error::Unsupported(_))
746        ));
747    }
748}