Skip to main content

rustynes_core/
bk2_interop.rs

1//! `BizHawk` `.bk2` movie interop.
2//!
3//! Import + export of the **text payload** of a `.bk2` archive (the
4//! `Header.txt` + `Input Log.txt` members) to and from the native [`Movie`]
5//! type. It mirrors the FCEUX [`crate::movie_interop`] `.fm2` design, with one
6//! structural difference: a `.bk2` is a **ZIP archive**, not a flat text file.
7//!
8//! # `no_std` and the ZIP split
9//!
10//! The `rustynes-core` chip stack is `#![no_std]` (`core` + `alloc` only), so it
11//! does **not** open or write ZIP containers — that needs `std` + a zip crate.
12//! Instead, the core handles the part that is `no_std`-clean and shared across
13//! every frontend: parsing / emitting the two text members. The frontend reads
14//! the two members out of the `.bk2` ZIP (and writes them back into one) and
15//! hands their string contents here. The split is the same reason `.fm2`'s text
16//! parse lives in core while file I/O lives in the frontend.
17//!
18//! # The `.bk2` text format (the subset we model)
19//!
20//! - **`Header.txt`** — `Key Value` lines (space-separated). The keys we read:
21//!   `Platform` (must be an NES family token), `rerecordCount`, `Author`,
22//!   `GameName`, `SHA1` (stored verbatim; the authoritative SHA-256 ROM identity
23//!   is supplied separately by the caller, exactly like `.fm2`'s MD5), and the
24//!   region flag `PAL`. A `StartsFromSavestate`/`StartsFromSaveRam` movie is
25//!   rejected (cross-emulator save blobs are not portable — same policy as
26//!   `.fm2`).
27//! - **`Input Log.txt`** — a `[Input]` ... `[/Input]` block. The first line is a
28//!   `LogKey:` declaration listing the `|`-separated controller column groups;
29//!   subsequent lines are per-frame input, each `|`-delimited, every button
30//!   rendered as its mnemonic letter (pressed) or `.` (released). The first
31//!   group is the console-buttons group (Reset / Power); then one group per
32//!   controller port.
33//!
34//! # The NES gamepad mnemonic order
35//!
36//! `BizHawk`'s NES standard-controller mnemonics are `U D L R S s B A`
37//! (Up, Down, Left, Right, Start, select, B, A — note the lower-case `s` for
38//! Select, distinct from the upper-case `S` for Start). Any non-`.`/non-space
39//! character in a column means that button is pressed; the column's *position*
40//! (not the specific letter) selects the button, so we tolerate either the
41//! canonical mnemonic letter or any other pressed marker.
42//!
43//! # Deliberate limitations (mirroring `.fm2`)
44//!
45//! - **Standard gamepads, players 1 and 2 only.** [`FrameInput`] models two
46//!   ports; extra controller groups are parsed but dropped (their presence is
47//!   not silently misleading — only P1/P2 are mapped). The console Reset bit is
48//!   parsed but not represented on [`FrameInput`] (it has no reset bit), exactly
49//!   as in `.fm2`.
50//! - **Power-on start only.** See above.
51//!
52//! This module is `no_std`-clean: it uses only `core` + `alloc`.
53
54use alloc::string::{String, ToString};
55use alloc::vec::Vec;
56use core::fmt::Write as _;
57
58use crate::Region;
59use crate::controller::Buttons;
60use crate::movie::{FrameInput, Movie, StartPoint};
61use thiserror::Error;
62
63/// The filename of the header member inside a `.bk2` ZIP.
64pub const BK2_HEADER_MEMBER: &str = "Header.txt";
65
66/// The filename of the input-log member inside a `.bk2` ZIP.
67pub const BK2_INPUT_LOG_MEMBER: &str = "Input Log.txt";
68
69/// The NES standard-controller mnemonic column order, paired with the
70/// [`Buttons`] flag each column drives. `BizHawk` order: `U D L R S s B A`.
71const PAD_COLUMNS: [(u8, Buttons); 8] = [
72    (b'U', Buttons::UP),
73    (b'D', Buttons::DOWN),
74    (b'L', Buttons::LEFT),
75    (b'R', Buttons::RIGHT),
76    (b'S', Buttons::START),  // upper-case S = Start
77    (b's', Buttons::SELECT), // lower-case s = Select
78    (b'B', Buttons::B),
79    (b'A', Buttons::A),
80];
81
82/// Header metadata parsed from a `.bk2` that has no home on [`Movie`].
83///
84/// Mirrors [`crate::movie_interop::Fm2Meta`]: [`Movie`] carries only what `.rnm`
85/// needs; the rest is surfaced here for the caller to display or persist.
86#[derive(Clone, Debug, Default, Eq, PartialEq)]
87pub struct Bk2Meta {
88    /// The `rerecordCount` header value (0 if absent).
89    pub rerecord_count: u64,
90    /// The movie author (`Author` header), if present.
91    pub author: Option<String>,
92    /// The `GameName` header value, if present.
93    pub game_name: Option<String>,
94    /// The `SHA1` header value, stored verbatim (a hex SHA-1). Not validated
95    /// against the ROM — the authoritative SHA-256 identity is supplied
96    /// separately by the caller.
97    pub sha1: Option<String>,
98    /// `true` if the header declared a PAL region (`PAL 1`).
99    pub pal: bool,
100}
101
102/// Options the caller supplies on export that the [`Movie`] does not carry.
103/// Mirrors the extra header fields surfaced by [`Bk2Meta`] on import.
104#[derive(Clone, Debug, Default, Eq, PartialEq)]
105pub struct Bk2ExportOpts {
106    /// Value to emit for the `rerecordCount` header.
107    pub rerecord_count: u64,
108    /// Author to emit as an `Author` line, if any.
109    pub author: Option<String>,
110    /// Value to emit for the `GameName` header, if any.
111    pub game_name: Option<String>,
112    /// Value to emit for the `SHA1` header, if any.
113    pub sha1: Option<String>,
114}
115
116/// The two text members of a `.bk2` ZIP, returned by [`export_bk2`] for the
117/// frontend to pack into the archive (and accepted by [`import_bk2`]).
118#[derive(Clone, Debug, Eq, PartialEq)]
119pub struct Bk2Text {
120    /// The `Header.txt` contents.
121    pub header: String,
122    /// The `Input Log.txt` contents.
123    pub input_log: String,
124}
125
126/// Errors produced by `.bk2` text import / export.
127#[derive(Debug, Error)]
128#[non_exhaustive]
129pub enum Bk2Error {
130    /// The header declared a platform that is not an NES family.
131    #[error("bk2 platform `{0}` is not an NES family movie")]
132    WrongPlatform(String),
133
134    /// A header line declared an integer key whose value did not parse.
135    #[error("bk2 header key `{key}` has an invalid integer value `{value}`")]
136    BadInteger {
137        /// The offending key.
138        key: String,
139        /// The text we failed to parse as an integer.
140        value: String,
141    },
142
143    /// The input log had no `LogKey:` declaration line.
144    #[error("bk2 input log missing its `LogKey:` declaration")]
145    MissingLogKey,
146
147    /// A structural problem with an input-log line. `line` is the 1-based
148    /// input-frame line number.
149    #[error("bk2 malformed input-log line {line}: {reason}")]
150    Malformed {
151        /// 1-based index of the offending input-frame line.
152        line: usize,
153        /// Human-readable description of what was wrong.
154        reason: &'static str,
155    },
156
157    /// A feature of the `.bk2` (or of the [`Movie`] being exported) that this
158    /// module deliberately does not support.
159    #[error("bk2 unsupported: {0}")]
160    Unsupported(&'static str),
161}
162
163/// Parse the `Header.txt` + `Input Log.txt` text of a `.bk2` into a [`Movie`]
164/// plus the leftover header [`Bk2Meta`].
165///
166/// `rom_sha256` is the SHA-256 of the ROM the caller intends to replay against.
167/// `.bk2` carries only a SHA-1 (`SHA1` header), so the authoritative SHA-256
168/// identity must come from the loaded ROM; it is stored verbatim on the returned
169/// [`Movie`] and is *not* validated here.
170///
171/// The returned [`Movie`] always has [`StartPoint::PowerOn`] — `.bk2` movies
172/// start from power-on unless a `StartsFromSavestate`/`StartsFromSaveRam` flag is
173/// set, and such cross-emulator save blobs are not portable, so they are
174/// rejected. This reuses the **canonical movie-import power-on alignment** the
175/// `.fm2` path established (a deterministic zeroed-RAM cold boot via
176/// [`Movie::seek_to_start`]), so imports never desync.
177///
178/// # Errors
179///
180/// Returns [`Bk2Error`] for a non-NES platform, an unparseable integer header, a
181/// missing `LogKey:`, an unsupported save-anchored start, or a malformed
182/// input-log line. Never panics on malformed input.
183pub fn import_bk2(
184    header: &str,
185    input_log: &str,
186    rom_sha256: [u8; 32],
187) -> Result<(Movie, Bk2Meta), Bk2Error> {
188    let meta = parse_header(header)?;
189    let frames = parse_input_log(input_log)?;
190    let movie = Movie {
191        region: if meta.pal { Region::Pal } else { Region::Ntsc },
192        rom_sha256,
193        start: StartPoint::PowerOn,
194        frames,
195        // Carry the `.bk2` rerecordCount through (saturating into the `.rnm` u32).
196        rerecord_count: u32::try_from(meta.rerecord_count).unwrap_or(u32::MAX),
197    };
198    Ok((movie, meta))
199}
200
201/// Serialize a [`Movie`] into the two text members of a `.bk2` ZIP.
202///
203/// Emits a `Header.txt` (`MovieVersion`, `Platform NES`, region `PAL` flag,
204/// `rerecordCount`, optional `Author` / `GameName` / `SHA1`) and an
205/// `Input Log.txt` (`[Input]`, a `LogKey:` declaration, one `|`-delimited frame
206/// line per frame, `[/Input]`). The frontend writes both into the archive.
207///
208/// Only [`StartPoint::PowerOn`] movies export; a [`StartPoint::SaveState`] movie
209/// has no portable `.bk2` representation.
210///
211/// # Errors
212///
213/// Returns [`Bk2Error::Unsupported`] if `movie` is anchored to an embedded save
214/// state.
215pub fn export_bk2(movie: &Movie, opts: &Bk2ExportOpts) -> Result<Bk2Text, Bk2Error> {
216    if !matches!(movie.start, StartPoint::PowerOn) {
217        return Err(Bk2Error::Unsupported(
218            "save-state-anchored movie has no portable .bk2 representation",
219        ));
220    }
221
222    let pal = matches!(movie.region, Region::Pal | Region::Dendy);
223
224    // --- Header.txt ---
225    let mut header = String::new();
226    header.push_str("MovieVersion BizHawk v2.0\n");
227    header.push_str("Platform NES\n");
228    if pal {
229        header.push_str("PAL 1\n");
230    }
231    let _ = writeln!(header, "rerecordCount {}", opts.rerecord_count);
232    if let Some(name) = &opts.game_name {
233        let _ = writeln!(header, "GameName {name}");
234    }
235    if let Some(sha1) = &opts.sha1 {
236        let _ = writeln!(header, "SHA1 {sha1}");
237    }
238    if let Some(author) = &opts.author {
239        let _ = writeln!(header, "Author {author}");
240    }
241
242    // --- Input Log.txt ---
243    // The console-buttons group carries Reset / Power; RustyNES movies never
244    // record either, so it is always released (`..`). Two controller groups.
245    let mut input_log = String::new();
246    input_log.push_str("[Input]\n");
247    input_log.push_str("LogKey:#Reset|Power|#P1 Up|P1 Down|P1 Left|P1 Right|P1 Start|P1 Select|P1 B|P1 A|#P2 Up|P2 Down|P2 Left|P2 Right|P2 Start|P2 Select|P2 B|P2 A|\n");
248    let mut pad = [0u8; 8];
249    for frame in &movie.frames {
250        // Console group: Reset + Power, both released.
251        input_log.push_str("|..|");
252        write_pad(frame.p1, &mut pad);
253        input_log.push_str(core::str::from_utf8(&pad).expect("pad bytes are ASCII"));
254        input_log.push('|');
255        write_pad(frame.p2, &mut pad);
256        input_log.push_str(core::str::from_utf8(&pad).expect("pad bytes are ASCII"));
257        input_log.push_str("|\n");
258    }
259    input_log.push_str("[/Input]\n");
260
261    Ok(Bk2Text { header, input_log })
262}
263
264/// Render `buttons` into an eight-byte `U D L R S s B A` pad field (mnemonic
265/// letter when pressed, `.` when released).
266fn write_pad(buttons: Buttons, out: &mut [u8; 8]) {
267    for (i, (letter, flag)) in PAD_COLUMNS.iter().enumerate() {
268        out[i] = if buttons.contains(*flag) {
269            *letter
270        } else {
271            b'.'
272        };
273    }
274}
275
276/// Parse the `Header.txt` member into a [`Bk2Meta`].
277fn parse_header(header: &str) -> Result<Bk2Meta, Bk2Error> {
278    let mut meta = Bk2Meta::default();
279    let mut saw_platform = false;
280    for raw in header.lines() {
281        let line = raw.strip_suffix('\r').unwrap_or(raw);
282        if line.trim().is_empty() {
283            continue;
284        }
285        let (key, value) = match line.split_once(' ') {
286            Some((k, v)) => (k, v.trim()),
287            None => (line, ""),
288        };
289        match key {
290            "Platform" => {
291                // Accept the NES family; reject anything else (a SNES/GB/etc.
292                // movie has the wrong controller model entirely).
293                let plat = value.to_ascii_uppercase();
294                if plat != "NES" && plat != "FAMICOM" && plat != "FDS" {
295                    return Err(Bk2Error::WrongPlatform(value.to_string()));
296                }
297                saw_platform = true;
298            }
299            "rerecordCount" => meta.rerecord_count = u64::from(parse_int(key, value)?),
300            "PAL" => meta.pal = parse_int(key, value)? != 0,
301            "Author" => meta.author = Some(value.to_string()),
302            "GameName" => meta.game_name = Some(value.to_string()),
303            "SHA1" => meta.sha1 = Some(value.to_string()),
304            "StartsFromSavestate" | "StartsFromSaveRam" if parse_int(key, value)? != 0 => {
305                return Err(Bk2Error::Unsupported(
306                    "save-anchored .bk2 (cross-emulator save blobs are not portable)",
307                ));
308            }
309            _ => {
310                // MovieVersion, Core, GUID, BoardName, FourScore, and any other
311                // header keys are ignored (forward-compatible).
312            }
313        }
314    }
315    // A `.bk2` without a Platform line is tolerated as NES (some minimal movies
316    // omit it); only an explicit non-NES platform is rejected above.
317    let _ = saw_platform;
318    Ok(meta)
319}
320
321/// Parse the `Input Log.txt` member into the per-frame [`FrameInput`] stream.
322///
323/// The first non-blank line inside `[Input]` must be a `LogKey:` declaration.
324/// Every subsequent `|`-delimited line up to `[/Input]` is one frame; the first
325/// `|`-group is the console-buttons group (Reset / Power, parsed but dropped),
326/// then one group per controller port. Only P1 and P2 are mapped.
327fn parse_input_log(input_log: &str) -> Result<Vec<FrameInput>, Bk2Error> {
328    let mut frames = Vec::new();
329    let mut saw_log_key = false;
330    let mut frame_line_no = 0usize;
331    for raw in input_log.lines() {
332        let line = raw.strip_suffix('\r').unwrap_or(raw);
333        let trimmed = line.trim();
334        if trimmed.is_empty() || trimmed == "[Input]" || trimmed == "[/Input]" {
335            continue;
336        }
337        if trimmed.starts_with("LogKey:") {
338            saw_log_key = true;
339            continue;
340        }
341        if line.starts_with('|') {
342            if !saw_log_key {
343                return Err(Bk2Error::MissingLogKey);
344            }
345            frame_line_no += 1;
346            frames.push(parse_input_line(line, frame_line_no)?);
347        }
348        // Any other line (comments / unknown sections) is ignored.
349    }
350    if !saw_log_key {
351        return Err(Bk2Error::MissingLogKey);
352    }
353    Ok(frames)
354}
355
356/// Parse a single `|`-delimited input-log line into a [`FrameInput`]. The first
357/// group is the console-buttons group (dropped); groups 2 and 3 are P1 and P2.
358fn parse_input_line(line: &str, line_no: usize) -> Result<FrameInput, Bk2Error> {
359    if !line.ends_with('|') {
360        return Err(Bk2Error::Malformed {
361            line: line_no,
362            reason: "input-log line must end with `|`",
363        });
364    }
365    // `|console|p1|p2|...|` splits (on `|`) to ["", console, p1, p2, ..., ""].
366    let mut groups = line.split('|');
367    // Leading empty field (before the first `|`).
368    if groups.next() != Some("") {
369        return Err(Bk2Error::Malformed {
370            line: line_no,
371            reason: "input-log line must start with `|`",
372        });
373    }
374    // Console-buttons group (Reset / Power); parsed-and-dropped — FrameInput has
375    // no reset bit, mirroring the `.fm2` path.
376    if groups.next().is_none() {
377        return Err(Bk2Error::Malformed {
378            line: line_no,
379            reason: "missing console-buttons group",
380        });
381    }
382    // P1 then P2 (extra controller groups, if any, are dropped).
383    let p1 = match groups.next() {
384        Some(g) => parse_pad(g, line_no)?,
385        None => {
386            return Err(Bk2Error::Malformed {
387                line: line_no,
388                reason: "missing player-1 controller group",
389            });
390        }
391    };
392    // P2 is optional (a 1-player movie); default to released when absent or an
393    // empty trailing field.
394    let p2 = match groups.next() {
395        Some(g) if !g.is_empty() => parse_pad(g, line_no)?,
396        _ => Buttons::empty(),
397    };
398    Ok(FrameInput::new(p1, p2))
399}
400
401/// Parse one eight-character `U D L R S s B A` gamepad group into [`Buttons`].
402fn parse_pad(group: &str, line_no: usize) -> Result<Buttons, Bk2Error> {
403    let bytes = group.as_bytes();
404    if bytes.len() != 8 {
405        return Err(Bk2Error::Malformed {
406            line: line_no,
407            reason: "gamepad group must be exactly 8 characters",
408        });
409    }
410    let mut buttons = Buttons::empty();
411    for (i, &b) in bytes.iter().enumerate() {
412        // Space or '.' = released; any other character = pressed. The column
413        // *position* selects the button (BizHawk uses the mnemonic letter, but
414        // we tolerate any pressed marker).
415        if b != b' ' && b != b'.' {
416            buttons |= PAD_COLUMNS[i].1;
417        }
418    }
419    Ok(buttons)
420}
421
422/// Parse an integer-typed header value, attaching the key for diagnostics.
423fn parse_int(key: &str, value: &str) -> Result<u32, Bk2Error> {
424    value
425        .trim()
426        .parse::<u32>()
427        .map_err(|_| Bk2Error::BadInteger {
428            key: key.to_string(),
429            value: value.to_string(),
430        })
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use alloc::vec;
437
438    const TEST_SHA: [u8; 32] = [0x7B; 32];
439
440    fn varied_frames() -> Vec<FrameInput> {
441        vec![
442            FrameInput::new(Buttons::A, Buttons::B),
443            FrameInput::new(Buttons::RIGHT | Buttons::A, Buttons::LEFT | Buttons::START),
444            FrameInput::new(
445                Buttons::UP | Buttons::DOWN | Buttons::SELECT,
446                Buttons::empty(),
447            ),
448            FrameInput::new(
449                Buttons::A | Buttons::B | Buttons::SELECT | Buttons::START,
450                Buttons::UP | Buttons::DOWN | Buttons::LEFT | Buttons::RIGHT,
451            ),
452        ]
453    }
454
455    #[test]
456    fn round_trip_power_on_ntsc() {
457        let movie = Movie {
458            region: Region::Ntsc,
459            rom_sha256: TEST_SHA,
460            start: StartPoint::PowerOn,
461            frames: varied_frames(),
462            rerecord_count: 0,
463        };
464        let opts = Bk2ExportOpts {
465            rerecord_count: 99,
466            author: Some("tester".to_string()),
467            game_name: Some("game".to_string()),
468            sha1: Some("abc123".to_string()),
469        };
470        let text = export_bk2(&movie, &opts).expect("export");
471        let (back, meta) = import_bk2(&text.header, &text.input_log, TEST_SHA).expect("import");
472
473        assert_eq!(back.frames, movie.frames, "frames survive round-trip");
474        assert_eq!(back.region, Region::Ntsc);
475        assert_eq!(back.start, StartPoint::PowerOn);
476        assert_eq!(back.rom_sha256, TEST_SHA);
477        assert!(!meta.pal);
478        assert_eq!(meta.rerecord_count, 99);
479        assert_eq!(meta.author.as_deref(), Some("tester"));
480        assert_eq!(meta.game_name.as_deref(), Some("game"));
481        assert_eq!(meta.sha1.as_deref(), Some("abc123"));
482    }
483
484    #[test]
485    fn exact_bit_and_char_mapping() {
486        // Only A set -> char index 7 pressed (the last column), others released.
487        let movie = Movie {
488            region: Region::Ntsc,
489            rom_sha256: TEST_SHA,
490            start: StartPoint::PowerOn,
491            frames: vec![
492                FrameInput::new(Buttons::A, Buttons::empty()),
493                FrameInput::new(Buttons::UP, Buttons::empty()),
494            ],
495            rerecord_count: 0,
496        };
497        let text = export_bk2(&movie, &Bk2ExportOpts::default()).expect("export");
498        let lines: Vec<&str> = text
499            .input_log
500            .lines()
501            .filter(|l| l.starts_with("|.."))
502            .collect();
503        assert_eq!(lines.len(), 2);
504
505        // |..|<p1>|<p2>| -> p1 group is split index 2.
506        let p1_a = lines[0].split('|').nth(2).unwrap();
507        assert_eq!(p1_a.len(), 8);
508        for (i, c) in p1_a.chars().enumerate() {
509            if i == 7 {
510                assert_eq!(c, 'A', "A is the last column");
511            } else {
512                assert_eq!(c, '.', "non-A columns released");
513            }
514        }
515        let p1_up = lines[1].split('|').nth(2).unwrap();
516        for (i, c) in p1_up.chars().enumerate() {
517            if i == 0 {
518                assert_eq!(c, 'U', "Up is the first column");
519            } else {
520                assert_eq!(c, '.');
521            }
522        }
523
524        // Start (upper S) vs Select (lower s) are distinct columns 4 and 5.
525        let hand = "[Input]\nLogKey:#Reset|Power|...\n|..|....S...|.....s..|\n[/Input]\n";
526        let (m, _) = import_bk2("Platform NES\n", hand, TEST_SHA).expect("import");
527        assert_eq!(m.frames[0].p1, Buttons::START);
528        assert_eq!(m.frames[0].p2, Buttons::SELECT);
529    }
530
531    #[test]
532    fn pal_flag_maps_to_region() {
533        let text = "Platform NES\nPAL 1\n";
534        let log = "[Input]\nLogKey:x\n|..|........|........|\n[/Input]\n";
535        let (movie, meta) = import_bk2(text, log, TEST_SHA).expect("import");
536        assert_eq!(movie.region, Region::Pal);
537        assert!(meta.pal);
538
539        let pal_movie = Movie {
540            region: Region::Pal,
541            rom_sha256: TEST_SHA,
542            start: StartPoint::PowerOn,
543            frames: vec![FrameInput::new(Buttons::empty(), Buttons::empty())],
544            rerecord_count: 0,
545        };
546        let out = export_bk2(&pal_movie, &Bk2ExportOpts::default()).expect("export");
547        assert!(out.header.lines().any(|l| l == "PAL 1"));
548
549        let ntsc_movie = Movie {
550            region: Region::Ntsc,
551            ..pal_movie
552        };
553        let out = export_bk2(&ntsc_movie, &Bk2ExportOpts::default()).expect("export");
554        assert!(!out.header.lines().any(|l| l == "PAL 1"));
555    }
556
557    #[test]
558    fn malformed_inputs_never_panic() {
559        // Non-NES platform.
560        assert!(matches!(
561            import_bk2("Platform SNES\n", "[Input]\nLogKey:x\n[/Input]\n", TEST_SHA),
562            Err(Bk2Error::WrongPlatform(_))
563        ));
564
565        // Missing LogKey.
566        assert!(matches!(
567            import_bk2(
568                "Platform NES\n",
569                "[Input]\n|..|........|........|\n",
570                TEST_SHA
571            ),
572            Err(Bk2Error::MissingLogKey)
573        ));
574
575        // Bad integer header.
576        assert!(matches!(
577            import_bk2("Platform NES\nrerecordCount nope\n", "LogKey:x\n", TEST_SHA),
578            Err(Bk2Error::BadInteger { .. })
579        ));
580
581        // Input line not ending with `|`.
582        assert!(matches!(
583            import_bk2(
584                "Platform NES\n",
585                "LogKey:x\n|..|........|........\n",
586                TEST_SHA
587            ),
588            Err(Bk2Error::Malformed { .. })
589        ));
590
591        // 7-char pad group.
592        assert!(matches!(
593            import_bk2(
594                "Platform NES\n",
595                "LogKey:x\n|..|.......|........|\n",
596                TEST_SHA
597            ),
598            Err(Bk2Error::Malformed { .. })
599        ));
600
601        // Save-anchored movie is unsupported.
602        assert!(matches!(
603            import_bk2(
604                "Platform NES\nStartsFromSavestate 1\n",
605                "LogKey:x\n",
606                TEST_SHA
607            ),
608            Err(Bk2Error::Unsupported(_))
609        ));
610    }
611
612    #[test]
613    fn one_player_movie_defaults_p2_released() {
614        // A line with only the console group + P1 (no P2 group).
615        let log = "[Input]\nLogKey:x\n|..|.......A|\n[/Input]\n";
616        let (movie, _) = import_bk2("Platform NES\n", log, TEST_SHA).expect("import");
617        assert_eq!(movie.frames.len(), 1);
618        assert_eq!(movie.frames[0].p1, Buttons::A);
619        assert_eq!(movie.frames[0].p2, Buttons::empty());
620    }
621
622    #[test]
623    fn export_rejects_save_state_movie() {
624        let movie = Movie {
625            region: Region::Ntsc,
626            rom_sha256: TEST_SHA,
627            start: StartPoint::SaveState(vec![1, 2, 3]),
628            frames: vec![],
629            rerecord_count: 0,
630        };
631        assert!(matches!(
632            export_bk2(&movie, &Bk2ExportOpts::default()),
633            Err(Bk2Error::Unsupported(_))
634        ));
635    }
636}