1use 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
63pub const BK2_HEADER_MEMBER: &str = "Header.txt";
65
66pub const BK2_INPUT_LOG_MEMBER: &str = "Input Log.txt";
68
69const 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), (b's', Buttons::SELECT), (b'B', Buttons::B),
79 (b'A', Buttons::A),
80];
81
82#[derive(Clone, Debug, Default, Eq, PartialEq)]
87pub struct Bk2Meta {
88 pub rerecord_count: u64,
90 pub author: Option<String>,
92 pub game_name: Option<String>,
94 pub sha1: Option<String>,
98 pub pal: bool,
100}
101
102#[derive(Clone, Debug, Default, Eq, PartialEq)]
105pub struct Bk2ExportOpts {
106 pub rerecord_count: u64,
108 pub author: Option<String>,
110 pub game_name: Option<String>,
112 pub sha1: Option<String>,
114}
115
116#[derive(Clone, Debug, Eq, PartialEq)]
119pub struct Bk2Text {
120 pub header: String,
122 pub input_log: String,
124}
125
126#[derive(Debug, Error)]
128#[non_exhaustive]
129pub enum Bk2Error {
130 #[error("bk2 platform `{0}` is not an NES family movie")]
132 WrongPlatform(String),
133
134 #[error("bk2 header key `{key}` has an invalid integer value `{value}`")]
136 BadInteger {
137 key: String,
139 value: String,
141 },
142
143 #[error("bk2 input log missing its `LogKey:` declaration")]
145 MissingLogKey,
146
147 #[error("bk2 malformed input-log line {line}: {reason}")]
150 Malformed {
151 line: usize,
153 reason: &'static str,
155 },
156
157 #[error("bk2 unsupported: {0}")]
160 Unsupported(&'static str),
161}
162
163pub 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 rerecord_count: u32::try_from(meta.rerecord_count).unwrap_or(u32::MAX),
197 };
198 Ok((movie, meta))
199}
200
201pub 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 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 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 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
264fn 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
276fn 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 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 }
313 }
314 }
315 let _ = saw_platform;
318 Ok(meta)
319}
320
321fn 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 }
350 if !saw_log_key {
351 return Err(Bk2Error::MissingLogKey);
352 }
353 Ok(frames)
354}
355
356fn 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 let mut groups = line.split('|');
367 if groups.next() != Some("") {
369 return Err(Bk2Error::Malformed {
370 line: line_no,
371 reason: "input-log line must start with `|`",
372 });
373 }
374 if groups.next().is_none() {
377 return Err(Bk2Error::Malformed {
378 line: line_no,
379 reason: "missing console-buttons group",
380 });
381 }
382 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 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
401fn 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 if b != b' ' && b != b'.' {
416 buttons |= PAD_COLUMNS[i].1;
417 }
418 }
419 Ok(buttons)
420}
421
422fn 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 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 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 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 assert!(matches!(
561 import_bk2("Platform SNES\n", "[Input]\nLogKey:x\n[/Input]\n", TEST_SHA),
562 Err(Bk2Error::WrongPlatform(_))
563 ));
564
565 assert!(matches!(
567 import_bk2(
568 "Platform NES\n",
569 "[Input]\n|..|........|........|\n",
570 TEST_SHA
571 ),
572 Err(Bk2Error::MissingLogKey)
573 ));
574
575 assert!(matches!(
577 import_bk2("Platform NES\nrerecordCount nope\n", "LogKey:x\n", TEST_SHA),
578 Err(Bk2Error::BadInteger { .. })
579 ));
580
581 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 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 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 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}