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        // An imported movie carries no attestation: the source format has no such
198        // field, and synthesizing one here would attest a run this build never
199        // performed. `Movie::verify` reports `NotAttested` for it, which is true.
200        attestation: None,
201    };
202    Ok((movie, meta))
203}
204
205/// Serialize a [`Movie`] into the two text members of a `.bk2` ZIP.
206///
207/// Emits a `Header.txt` (`MovieVersion`, `Platform NES`, region `PAL` flag,
208/// `rerecordCount`, optional `Author` / `GameName` / `SHA1`) and an
209/// `Input Log.txt` (`[Input]`, a `LogKey:` declaration, one `|`-delimited frame
210/// line per frame, `[/Input]`). The frontend writes both into the archive.
211///
212/// Only [`StartPoint::PowerOn`] movies export; a [`StartPoint::SaveState`] movie
213/// has no portable `.bk2` representation.
214///
215/// # Errors
216///
217/// Returns [`Bk2Error::Unsupported`] if `movie` is anchored to an embedded save
218/// state.
219pub fn export_bk2(movie: &Movie, opts: &Bk2ExportOpts) -> Result<Bk2Text, Bk2Error> {
220    if !matches!(movie.start, StartPoint::PowerOn) {
221        return Err(Bk2Error::Unsupported(
222            "save-state-anchored movie has no portable .bk2 representation",
223        ));
224    }
225
226    let pal = matches!(movie.region, Region::Pal | Region::Dendy);
227
228    // --- Header.txt ---
229    let mut header = String::new();
230    header.push_str("MovieVersion BizHawk v2.0\n");
231    header.push_str("Platform NES\n");
232    if pal {
233        header.push_str("PAL 1\n");
234    }
235    let _ = writeln!(header, "rerecordCount {}", opts.rerecord_count);
236    if let Some(name) = &opts.game_name {
237        let _ = writeln!(header, "GameName {name}");
238    }
239    if let Some(sha1) = &opts.sha1 {
240        let _ = writeln!(header, "SHA1 {sha1}");
241    }
242    if let Some(author) = &opts.author {
243        let _ = writeln!(header, "Author {author}");
244    }
245
246    // --- Input Log.txt ---
247    // The console-buttons group carries Reset / Power; RustyNES movies never
248    // record either, so it is always released (`..`). Two controller groups.
249    let mut input_log = String::new();
250    input_log.push_str("[Input]\n");
251    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");
252    let mut pad = [0u8; 8];
253    for frame in &movie.frames {
254        // Console group: Reset + Power, both released.
255        input_log.push_str("|..|");
256        write_pad(frame.p1, &mut pad);
257        input_log.push_str(core::str::from_utf8(&pad).expect("pad bytes are ASCII"));
258        input_log.push('|');
259        write_pad(frame.p2, &mut pad);
260        input_log.push_str(core::str::from_utf8(&pad).expect("pad bytes are ASCII"));
261        input_log.push_str("|\n");
262    }
263    input_log.push_str("[/Input]\n");
264
265    Ok(Bk2Text { header, input_log })
266}
267
268/// Render `buttons` into an eight-byte `U D L R S s B A` pad field (mnemonic
269/// letter when pressed, `.` when released).
270fn write_pad(buttons: Buttons, out: &mut [u8; 8]) {
271    for (i, (letter, flag)) in PAD_COLUMNS.iter().enumerate() {
272        out[i] = if buttons.contains(*flag) {
273            *letter
274        } else {
275            b'.'
276        };
277    }
278}
279
280/// Parse the `Header.txt` member into a [`Bk2Meta`].
281fn parse_header(header: &str) -> Result<Bk2Meta, Bk2Error> {
282    let mut meta = Bk2Meta::default();
283    let mut saw_platform = false;
284    for raw in header.lines() {
285        let line = raw.strip_suffix('\r').unwrap_or(raw);
286        if line.trim().is_empty() {
287            continue;
288        }
289        let (key, value) = match line.split_once(' ') {
290            Some((k, v)) => (k, v.trim()),
291            None => (line, ""),
292        };
293        match key {
294            "Platform" => {
295                // Accept the NES family; reject anything else (a SNES/GB/etc.
296                // movie has the wrong controller model entirely).
297                let plat = value.to_ascii_uppercase();
298                if plat != "NES" && plat != "FAMICOM" && plat != "FDS" {
299                    return Err(Bk2Error::WrongPlatform(value.to_string()));
300                }
301                saw_platform = true;
302            }
303            "rerecordCount" => meta.rerecord_count = u64::from(parse_int(key, value)?),
304            "PAL" => meta.pal = parse_int(key, value)? != 0,
305            "Author" => meta.author = Some(value.to_string()),
306            "GameName" => meta.game_name = Some(value.to_string()),
307            "SHA1" => meta.sha1 = Some(value.to_string()),
308            "StartsFromSavestate" | "StartsFromSaveRam" if parse_int(key, value)? != 0 => {
309                return Err(Bk2Error::Unsupported(
310                    "save-anchored .bk2 (cross-emulator save blobs are not portable)",
311                ));
312            }
313            _ => {
314                // MovieVersion, Core, GUID, BoardName, FourScore, and any other
315                // header keys are ignored (forward-compatible).
316            }
317        }
318    }
319    // A `.bk2` without a Platform line is tolerated as NES (some minimal movies
320    // omit it); only an explicit non-NES platform is rejected above.
321    let _ = saw_platform;
322    Ok(meta)
323}
324
325/// The standard-controller column map (`U D L R S s B A`), used as the fallback
326/// when a `LogKey:` group is absent or unrecognized. Each slot maps an input-line
327/// character *position* to the [`Buttons`] flag it drives.
328fn default_pad_columns() -> Vec<Option<Buttons>> {
329    PAD_COLUMNS.iter().map(|(_, b)| Some(*b)).collect()
330}
331
332/// Map a `LogKey:` column *name* (e.g. `"P1 Up"`, `"Up"`, `"A"`, `"Select"`) to
333/// the NES standard-controller button it drives. The `"Pn "` port label (or any
334/// other prefix) is ignored — only the final word matters. Columns that are not
335/// standard-controller buttons (`"Reset"`, `"Power"`, `"FDS Insert Disk"`, mic,
336/// …) return `None`: they still occupy a character position in the input line but
337/// drive nothing `RustyNES` models.
338fn button_for_column(name: &str) -> Option<Buttons> {
339    match name.trim().rsplit(' ').next().unwrap_or("") {
340        "Up" | "U" => Some(Buttons::UP),
341        "Down" | "D" => Some(Buttons::DOWN),
342        "Left" | "L" => Some(Buttons::LEFT),
343        "Right" | "R" => Some(Buttons::RIGHT),
344        "Start" | "S" => Some(Buttons::START),
345        "Select" | "s" => Some(Buttons::SELECT),
346        "B" => Some(Buttons::B),
347        "A" => Some(Buttons::A),
348        _ => None,
349    }
350}
351
352/// Per-port `(P1, P2)` position→button column maps parsed from a `LogKey:`.
353type PadColumnMaps = (Vec<Option<Buttons>>, Vec<Option<Buttons>>);
354
355/// Parse the `LogKey:` declaration into per-port position→button column maps.
356///
357/// The `LogKey` is `#`-separated controller groups, each a `|`-separated column
358/// list: `LogKey:#Reset|Power|#P1 Up|P1 Down|…|P1 A|#P2 Up|…|`. Group 1 is the
359/// console (dropped), group 2 is P1, group 3 is P2. Reading the *declared* order
360/// (rather than assuming the fixed `U D L R S s B A`) is what lets a `.bk2`
361/// authored with a different column order or extra columns play back correctly
362/// (the NESdev-forum "`.bk2` did not play back" report). A group that yields no
363/// recognized buttons falls back to [`default_pad_columns`], so a truncated or
364/// exotic `LogKey` still maps a standard controller.
365fn parse_log_key(log_key: &str) -> PadColumnMaps {
366    let trimmed = log_key.trim();
367    let body = trimmed.strip_prefix("LogKey:").unwrap_or(trimmed);
368    // The body opens with a single `#` delimiter, then `#`-separated groups.
369    // Strip ONLY that leading delimiter and split without dropping empties: an
370    // empty console group (`##P1...`) must keep its slot so P1/P2 don't shift
371    // left into it. groups[0] = console, groups[1] = P1, groups[2] = P2.
372    let body = body.strip_prefix('#').unwrap_or(body);
373    // Read ONLY the three groups we consume (console, P1, P2) straight from the
374    // split iterator rather than collecting every `#`-group: a hostile `.bk2`
375    // padded with `#` delimiters would otherwise allocate one `&str` slot per
376    // empty group (~16 bytes each) and could exhaust memory on import. `split`
377    // still yields empty groups, so `next()` preserves the empty console slot
378    // (`##P1...`) and keeps P1/P2 from shifting left into it.
379    let mut groups = body.split('#');
380    let _console = groups.next(); // groups[0] = console (unused)
381    let cols = |g: Option<&str>| -> Vec<Option<Buttons>> {
382        let mapped: Vec<Option<Buttons>> = g.map_or_else(Vec::new, |grp| {
383            // Strip only the trailing `|` delimiter each group carries; keep
384            // interior empty columns (`P1 Up||P1 A`) so a button's column index
385            // stays aligned with the frame-value index (else `A` would map to the
386            // empty column's slot and a frame `U.A` would replay as `Up` alone).
387            grp.strip_suffix('|')
388                .unwrap_or(grp)
389                .split('|')
390                .map(button_for_column)
391                .collect()
392        });
393        // If nothing in this group is a recognized controller button, the LogKey
394        // was truncated/exotic — fall back to the fixed standard order.
395        if mapped.iter().any(Option::is_some) {
396            mapped
397        } else {
398            default_pad_columns()
399        }
400    };
401    // groups[1] = P1; groups[2] = P2, read in order from the same iterator.
402    (cols(groups.next()), cols(groups.next()))
403}
404
405/// Parse the `Input Log.txt` member into the per-frame [`FrameInput`] stream.
406///
407/// The first non-blank line inside `[Input]` must be a `LogKey:` declaration,
408/// which supplies the per-port column order. Every subsequent `|`-delimited line
409/// up to `[/Input]` is one frame; the first `|`-group is the console-buttons group
410/// (parsed but dropped), then one group per controller port. Only P1 and P2 are
411/// mapped.
412fn parse_input_log(input_log: &str) -> Result<Vec<FrameInput>, Bk2Error> {
413    let mut frames = Vec::new();
414    let mut columns: Option<PadColumnMaps> = None;
415    let mut frame_line_no = 0usize;
416    for raw in input_log.lines() {
417        let line = raw.strip_suffix('\r').unwrap_or(raw);
418        let trimmed = line.trim();
419        if trimmed.is_empty() || trimmed == "[Input]" || trimmed == "[/Input]" {
420            continue;
421        }
422        if trimmed.starts_with("LogKey:") {
423            columns = Some(parse_log_key(trimmed));
424            continue;
425        }
426        if line.starts_with('|') {
427            let cols = columns.as_ref().ok_or(Bk2Error::MissingLogKey)?;
428            frame_line_no += 1;
429            frames.push(parse_input_line(line, &cols.0, &cols.1, frame_line_no)?);
430        }
431        // Any other line (comments / unknown sections) is ignored.
432    }
433    if columns.is_none() {
434        return Err(Bk2Error::MissingLogKey);
435    }
436    Ok(frames)
437}
438
439/// Parse a single `|`-delimited input-log line into a [`FrameInput`] using the
440/// per-port column maps from the `LogKey`. The first group is the console-buttons
441/// group (dropped); groups 2 and 3 are P1 and P2.
442fn parse_input_line(
443    line: &str,
444    p1_cols: &[Option<Buttons>],
445    p2_cols: &[Option<Buttons>],
446    line_no: usize,
447) -> Result<FrameInput, Bk2Error> {
448    if !line.ends_with('|') {
449        return Err(Bk2Error::Malformed {
450            line: line_no,
451            reason: "input-log line must end with `|`",
452        });
453    }
454    // `|console|p1|p2|...|` splits (on `|`) to ["", console, p1, p2, ..., ""].
455    let mut groups = line.split('|');
456    // Leading empty field (before the first `|`).
457    if groups.next() != Some("") {
458        return Err(Bk2Error::Malformed {
459            line: line_no,
460            reason: "input-log line must start with `|`",
461        });
462    }
463    // Console-buttons group (Reset / Power / …); parsed-and-dropped — FrameInput
464    // has no reset bit, mirroring the `.fm2` path.
465    if groups.next().is_none() {
466        return Err(Bk2Error::Malformed {
467            line: line_no,
468            reason: "missing console-buttons group",
469        });
470    }
471    // P1 then P2 (extra controller groups, if any, are dropped).
472    let p1 = match groups.next() {
473        Some(g) => parse_pad(g, p1_cols, line_no)?,
474        None => {
475            return Err(Bk2Error::Malformed {
476                line: line_no,
477                reason: "missing player-1 controller group",
478            });
479        }
480    };
481    // P2 is optional (a 1-player movie); default to released when absent or an
482    // empty trailing field.
483    let p2 = match groups.next() {
484        Some(g) if !g.is_empty() => parse_pad(g, p2_cols, line_no)?,
485        _ => Buttons::empty(),
486    };
487    Ok(FrameInput::new(p1, p2))
488}
489
490/// Parse one gamepad group into [`Buttons`] using its port's `LogKey` column map.
491///
492/// Each character *position* is the column at that index of `columns`; a pressed
493/// marker (any char other than space or `.`) sets that column's button (columns
494/// that map to `None` — non-controller buttons — are consumed but ignored). The
495/// group may be *longer* than the map (extra trailing columns we don't model are
496/// tolerated) but not shorter (a truncated line is structurally malformed).
497fn parse_pad(
498    group: &str,
499    columns: &[Option<Buttons>],
500    line_no: usize,
501) -> Result<Buttons, Bk2Error> {
502    let bytes = group.as_bytes();
503    if bytes.len() < columns.len() {
504        return Err(Bk2Error::Malformed {
505            line: line_no,
506            reason: "gamepad group shorter than its LogKey column count",
507        });
508    }
509    let mut buttons = Buttons::empty();
510    for (i, col) in columns.iter().enumerate() {
511        if let Some(flag) = col {
512            let b = bytes[i];
513            if b != b' ' && b != b'.' {
514                buttons |= *flag;
515            }
516        }
517    }
518    Ok(buttons)
519}
520
521/// Parse an integer-typed header value, attaching the key for diagnostics.
522fn parse_int(key: &str, value: &str) -> Result<u32, Bk2Error> {
523    value
524        .trim()
525        .parse::<u32>()
526        .map_err(|_| Bk2Error::BadInteger {
527            key: key.to_string(),
528            value: value.to_string(),
529        })
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535    use alloc::vec;
536
537    const TEST_SHA: [u8; 32] = [0x7B; 32];
538
539    fn varied_frames() -> Vec<FrameInput> {
540        vec![
541            FrameInput::new(Buttons::A, Buttons::B),
542            FrameInput::new(Buttons::RIGHT | Buttons::A, Buttons::LEFT | Buttons::START),
543            FrameInput::new(
544                Buttons::UP | Buttons::DOWN | Buttons::SELECT,
545                Buttons::empty(),
546            ),
547            FrameInput::new(
548                Buttons::A | Buttons::B | Buttons::SELECT | Buttons::START,
549                Buttons::UP | Buttons::DOWN | Buttons::LEFT | Buttons::RIGHT,
550            ),
551        ]
552    }
553
554    #[test]
555    fn round_trip_power_on_ntsc() {
556        let movie = Movie {
557            region: Region::Ntsc,
558            rom_sha256: TEST_SHA,
559            start: StartPoint::PowerOn,
560            frames: varied_frames(),
561            rerecord_count: 0,
562            attestation: None,
563        };
564        let opts = Bk2ExportOpts {
565            rerecord_count: 99,
566            author: Some("tester".to_string()),
567            game_name: Some("game".to_string()),
568            sha1: Some("abc123".to_string()),
569        };
570        let text = export_bk2(&movie, &opts).expect("export");
571        let (back, meta) = import_bk2(&text.header, &text.input_log, TEST_SHA).expect("import");
572
573        assert_eq!(back.frames, movie.frames, "frames survive round-trip");
574        assert_eq!(back.region, Region::Ntsc);
575        assert_eq!(back.start, StartPoint::PowerOn);
576        assert_eq!(back.rom_sha256, TEST_SHA);
577        assert!(!meta.pal);
578        assert_eq!(meta.rerecord_count, 99);
579        assert_eq!(meta.author.as_deref(), Some("tester"));
580        assert_eq!(meta.game_name.as_deref(), Some("game"));
581        assert_eq!(meta.sha1.as_deref(), Some("abc123"));
582    }
583
584    #[test]
585    fn exact_bit_and_char_mapping() {
586        // Only A set -> char index 7 pressed (the last column), others released.
587        let movie = Movie {
588            region: Region::Ntsc,
589            rom_sha256: TEST_SHA,
590            start: StartPoint::PowerOn,
591            frames: vec![
592                FrameInput::new(Buttons::A, Buttons::empty()),
593                FrameInput::new(Buttons::UP, Buttons::empty()),
594            ],
595            rerecord_count: 0,
596            attestation: None,
597        };
598        let text = export_bk2(&movie, &Bk2ExportOpts::default()).expect("export");
599        let lines: Vec<&str> = text
600            .input_log
601            .lines()
602            .filter(|l| l.starts_with("|.."))
603            .collect();
604        assert_eq!(lines.len(), 2);
605
606        // |..|<p1>|<p2>| -> p1 group is split index 2.
607        let p1_a = lines[0].split('|').nth(2).unwrap();
608        assert_eq!(p1_a.len(), 8);
609        for (i, c) in p1_a.chars().enumerate() {
610            if i == 7 {
611                assert_eq!(c, 'A', "A is the last column");
612            } else {
613                assert_eq!(c, '.', "non-A columns released");
614            }
615        }
616        let p1_up = lines[1].split('|').nth(2).unwrap();
617        for (i, c) in p1_up.chars().enumerate() {
618            if i == 0 {
619                assert_eq!(c, 'U', "Up is the first column");
620            } else {
621                assert_eq!(c, '.');
622            }
623        }
624
625        // Start (upper S) vs Select (lower s) are distinct columns 4 and 5.
626        let hand = "[Input]\nLogKey:#Reset|Power|...\n|..|....S...|.....s..|\n[/Input]\n";
627        let (m, _) = import_bk2("Platform NES\n", hand, TEST_SHA).expect("import");
628        assert_eq!(m.frames[0].p1, Buttons::START);
629        assert_eq!(m.frames[0].p2, Buttons::SELECT);
630    }
631
632    #[test]
633    fn log_key_column_order_is_honored() {
634        // v2.2.9 "Studio II": a `.bk2` whose P1 columns are declared in a
635        // NON-standard order must map by the `LogKey` order, not the fixed
636        // `U D L R S s B A` positions. Here column 0 = A and column 1 = B, so a
637        // press at character position 0 is A and at position 1 is B — the opposite
638        // of the standard layout. This is the fix for the "`.bk2` did not play
639        // back" report (a movie whose buttons all mapped to the wrong bits).
640        let log = "[Input]\n\
641            LogKey:#Reset|Power|#P1 A|P1 B|P1 Up|P1 Down|P1 Left|P1 Right|P1 Start|P1 Select|\n\
642            |..|A.......|\n\
643            |..|.B......|\n\
644            [/Input]\n";
645        let (m, _) = import_bk2("Platform NES\n", log, TEST_SHA).expect("import");
646        assert_eq!(
647            m.frames[0].p1,
648            Buttons::A,
649            "position 0 = LogKey column 0 = A"
650        );
651        assert_eq!(
652            m.frames[1].p1,
653            Buttons::B,
654            "position 1 = LogKey column 1 = B"
655        );
656        // A pad group LONGER than the modeled columns (extra buttons like a mic)
657        // is tolerated: extra trailing chars are ignored, no malformed error.
658        let extra = "[Input]\n\
659            LogKey:#Reset|Power|#P1 Up|P1 Down|P1 Left|P1 Right|P1 Start|P1 Select|P1 B|P1 A|P1 Mic|\n\
660            |..|.......AX|\n\
661            [/Input]\n";
662        let (m2, _) = import_bk2("Platform NES\n", extra, TEST_SHA).expect("import extra-col");
663        assert_eq!(
664            m2.frames[0].p1,
665            Buttons::A,
666            "column 7 = A pressed; the 9th (Mic) col is ignored"
667        );
668    }
669
670    #[test]
671    fn log_key_preserves_empty_columns_and_groups() {
672        // v2.2.9 fix: empty interior `LogKey` fields must KEEP their positions,
673        // or later columns/groups shift left and buttons re-map silently.
674        //
675        // Empty interior COLUMN (`P1 Up||P1 A`): the empty middle column is a real
676        // slot, so `A` stays at column index 2. A frame `U.A` must press Up (col 0)
677        // and A (col 2); the pre-fix filter dropped the empty column, mapping A to
678        // index 1 so `U.A` replayed as Up alone.
679        let empty_col = "[Input]\n\
680            LogKey:#Reset|Power|#P1 Up||P1 A|\n\
681            |..|U.A|\n\
682            [/Input]\n";
683        let (m, _) = import_bk2("Platform NES\n", empty_col, TEST_SHA).expect("import empty-col");
684        assert_eq!(
685            m.frames[0].p1,
686            Buttons::UP | Buttons::A,
687            "empty middle column keeps its slot: Up (col 0) + A (col 2) both press"
688        );
689
690        // Empty CONSOLE group (`##P1…`): must not shift P1's map into the dropped
691        // console slot. The pre-fix filter dropped the empty group, promoting P1
692        // into the console position and losing it entirely.
693        let empty_console = "[Input]\n\
694            LogKey:##P1 Up|P1 Down|P1 Left|P1 Right|P1 Start|P1 Select|P1 B|P1 A|\n\
695            ||U.......|\n\
696            [/Input]\n";
697        let (m2, _) =
698            import_bk2("Platform NES\n", empty_console, TEST_SHA).expect("import empty-console");
699        assert_eq!(
700            m2.frames[0].p1,
701            Buttons::UP,
702            "empty console group keeps its slot; P1 col 0 = Up still maps to P1"
703        );
704    }
705
706    #[test]
707    fn pal_flag_maps_to_region() {
708        let text = "Platform NES\nPAL 1\n";
709        let log = "[Input]\nLogKey:x\n|..|........|........|\n[/Input]\n";
710        let (movie, meta) = import_bk2(text, log, TEST_SHA).expect("import");
711        assert_eq!(movie.region, Region::Pal);
712        assert!(meta.pal);
713
714        let pal_movie = Movie {
715            region: Region::Pal,
716            rom_sha256: TEST_SHA,
717            start: StartPoint::PowerOn,
718            frames: vec![FrameInput::new(Buttons::empty(), Buttons::empty())],
719            rerecord_count: 0,
720            attestation: None,
721        };
722        let out = export_bk2(&pal_movie, &Bk2ExportOpts::default()).expect("export");
723        assert!(out.header.lines().any(|l| l == "PAL 1"));
724
725        let ntsc_movie = Movie {
726            region: Region::Ntsc,
727            ..pal_movie
728        };
729        let out = export_bk2(&ntsc_movie, &Bk2ExportOpts::default()).expect("export");
730        assert!(!out.header.lines().any(|l| l == "PAL 1"));
731    }
732
733    #[test]
734    fn malformed_inputs_never_panic() {
735        // Non-NES platform.
736        assert!(matches!(
737            import_bk2("Platform SNES\n", "[Input]\nLogKey:x\n[/Input]\n", TEST_SHA),
738            Err(Bk2Error::WrongPlatform(_))
739        ));
740
741        // Missing LogKey.
742        assert!(matches!(
743            import_bk2(
744                "Platform NES\n",
745                "[Input]\n|..|........|........|\n",
746                TEST_SHA
747            ),
748            Err(Bk2Error::MissingLogKey)
749        ));
750
751        // Bad integer header.
752        assert!(matches!(
753            import_bk2("Platform NES\nrerecordCount nope\n", "LogKey:x\n", TEST_SHA),
754            Err(Bk2Error::BadInteger { .. })
755        ));
756
757        // Input line not ending with `|`.
758        assert!(matches!(
759            import_bk2(
760                "Platform NES\n",
761                "LogKey:x\n|..|........|........\n",
762                TEST_SHA
763            ),
764            Err(Bk2Error::Malformed { .. })
765        ));
766
767        // 7-char pad group.
768        assert!(matches!(
769            import_bk2(
770                "Platform NES\n",
771                "LogKey:x\n|..|.......|........|\n",
772                TEST_SHA
773            ),
774            Err(Bk2Error::Malformed { .. })
775        ));
776
777        // Save-anchored movie is unsupported.
778        assert!(matches!(
779            import_bk2(
780                "Platform NES\nStartsFromSavestate 1\n",
781                "LogKey:x\n",
782                TEST_SHA
783            ),
784            Err(Bk2Error::Unsupported(_))
785        ));
786    }
787
788    #[test]
789    fn log_key_bounded_against_pathological_group_padding() {
790        // Hardening regression (v2.2.9): `parse_log_key` reads only the console,
791        // P1, and P2 groups straight from the `split('#')` iterator instead of
792        // collecting every `#`-group, so a hostile `.bk2` padded with a large
793        // number of `#` delimiters cannot amplify into an unbounded `Vec<&str>`
794        // on import. The trailing empty groups must be ignored and P1/P2 must
795        // still map correctly.
796        let mut log = String::from("[Input]\nLogKey:#Reset|Power|#P1 Up|P1 A|#P2 Up|P2 A|");
797        log.push_str(&"#".repeat(100_000)); // pathological trailing delimiters
798        log.push_str("\n|..|U.|.A|\n[/Input]\n");
799        let (m, _) = import_bk2("Platform NES\n", &log, TEST_SHA).expect("import padded LogKey");
800        assert_eq!(
801            m.frames[0].p1,
802            Buttons::UP,
803            "P1 col 0 = Up maps despite trailing `#` padding"
804        );
805        assert_eq!(
806            m.frames[0].p2,
807            Buttons::A,
808            "P2 col 1 = A maps despite trailing `#` padding"
809        );
810    }
811
812    #[test]
813    fn one_player_movie_defaults_p2_released() {
814        // A line with only the console group + P1 (no P2 group).
815        let log = "[Input]\nLogKey:x\n|..|.......A|\n[/Input]\n";
816        let (movie, _) = import_bk2("Platform NES\n", log, TEST_SHA).expect("import");
817        assert_eq!(movie.frames.len(), 1);
818        assert_eq!(movie.frames[0].p1, Buttons::A);
819        assert_eq!(movie.frames[0].p2, Buttons::empty());
820    }
821
822    #[test]
823    fn export_rejects_save_state_movie() {
824        let movie = Movie {
825            region: Region::Ntsc,
826            rom_sha256: TEST_SHA,
827            start: StartPoint::SaveState(vec![1, 2, 3]),
828            frames: vec![],
829            rerecord_count: 0,
830            attestation: None,
831        };
832        assert!(matches!(
833            export_bk2(&movie, &Bk2ExportOpts::default()),
834            Err(Bk2Error::Unsupported(_))
835        ));
836    }
837}