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}