Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,10 @@ unsafe extern "C" fn environment_callback(cmd: u32, data: *mut c_void) -> bool {
unsafe {
let mut core = lock();
match cmd {
RETRO_ENVIRONMENT_GET_VARIABLE => {
// TODO
true
}
RETRO_ENVIRONMENT_SET_PIXEL_FORMAT => core.set_pixel_format(*data.cast()),
RETRO_ENVIRONMENT_SET_MEMORY_MAPS => core.set_memory_maps(*data.cast()),
RETRO_ENVIRONMENT_SET_TRACE_CONTEXT => core.set_trace_context(data.cast()),
Expand Down
255 changes: 254 additions & 1 deletion src/tas/movie/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ use crate::tas;
use anyhow::Context;
use flate2::bufread::{ZlibDecoder, ZlibEncoder};
use serde::{Deserialize, Serialize};
use std::{collections::HashMap, io::Read, path::PathBuf, sync::OnceLock};
use std::{
collections::HashMap,
io::{Read, Seek},
path::PathBuf,
sync::OnceLock,
};
use uuid::Uuid;

use super::Movie;
Expand Down Expand Up @@ -36,6 +41,11 @@ pub struct MovieFile {
/// The attached controllers.
pub input_devices: Vec<tas::input::InputPort>,

/// Extra libretro variables fed to the libretro core.
/// Used for sync settings.
#[serde(default)]
pub environment_variables: HashMap<String, String>,

/// The number of rerecords.
#[serde(default)]
pub rerecords: u32,
Expand Down Expand Up @@ -65,6 +75,7 @@ impl MovieFile {
rom_filename: movie.rom_filename.clone(),
rom_sha256: movie.rom_sha256,
input_devices: movie.input_ports.clone(),
environment_variables: movie.environment_variables.clone(),
rerecords: movie.rerecords,
ramwatches: movie.ramwatches.clone(),
inputs,
Expand All @@ -75,6 +86,248 @@ impl MovieFile {
ciborium::from_reader(reader).context("Failed to load movie file")
}

// ref: https://tasvideos.org/Bizhawk/BK2Format
// Made to be fairly resistant to errors, and also as forward compatible
// (should the format evolve in the future) as possible.
pub fn load_bk2<R: Read + Seek>(reader: R) -> anyhow::Result<MovieFile> {
use anyhow::bail;
use std::io::BufRead;
use std::io::BufReader;
use zip::read::ZipArchive;

let mut archive = ZipArchive::new(reader).context("failed to decompress bk2 archive")?;
let mut buf = String::new();

let mut core_id = String::new();
let mut rerecords = 0;
let mut rom_filename = String::new();
{
let header = archive
.by_name("Header.txt")
.context("missing Header.txt")?;
let mut r = BufReader::new(header);

loop {
buf.clear();
(&mut r).take(512).read_line(&mut buf)?;
if buf.is_empty() {
break;
}
let Some(buf) = buf.strip_suffix('\n') else {
// Long header line, shouldn't happen (unless bizhawk adds
// another option with long values), skip line.
eprintln!("Skipping over long line in header");
r.skip_until(b'\n')?;
continue;
};
let Some((key, value)) = buf.split_once(' ') else {
// All known header lines follow a space-separated key value
// format. Skip line for forward compatibility.
eprintln!("Skipping over invalid line {buf:?} in header");
continue;
};
match key {
//"Core" => core_id = value.to_owned(), // TODO
"GameName" => rom_filename = value.to_owned(),
"Platform" => match value {
"SNES" => core_id = "bsnes_mvi".to_owned(),
_ => bail!("unsupported platform {value:?}"),
},
"rerecordCount" => rerecords = value.parse::<u32>().unwrap_or(0),
_ => {} // Skip over unknown settings
}
}
}

let input_devices;
let mut environment_variables = HashMap::new();
{
#[derive(Deserialize)]
struct SyncSettings {
o: serde_json::Map<String, serde_json::Value>,
}

#[derive(Deserialize)]
struct Bsnes {
#[serde(default, rename = "LeftPort")]
left_port: u8,
#[serde(default, rename = "RightPort")]
right_port: u8,
#[serde(default, rename = "Entropy")]
entropy: u8,
#[serde(default, rename = "Hotfixes")]
hotfixes: bool,
#[serde(default, rename = "FastPPU")]
fast_ppu: bool,
#[serde(default, rename = "FastDSP")]
fast_dsp: bool,
#[serde(default, rename = "FastCoprocessors")]
fast_coprocessors: bool,
#[serde(default, rename = "UseSGB2")]
use_sgb2: bool,
#[serde(default, rename = "SatellaviewCartridge")]
satellaview_cartridge: u8,
}

let sync_settings = archive
.by_name("SyncSettings.json")
.context("missing SyncSettings.json")?;

let sync_settings: SyncSettings =
serde_json::from_reader(sync_settings).context("SyncSettings has invalid JSON")?;
let sync_settings = sync_settings.o;

match sync_settings
.get("$type")
.context("missing SyncSettings.o.$type")?
.as_str()
.context("invalid SyncSettings.o.$type")?
{
"BizHawk.Emulation.Cores.Nintendo.BSNES.BsnesCore+SnesSyncSettings, BizHawk.Emulation.Cores" =>
{
let bs: Bsnes =
Bsnes::deserialize(sync_settings).context("invalid bsnes sync settings")?;

const JOYPAD: tas::input::InputPort =
tas::input::InputPort::Joypad(tas::input::Joypad::Snes);
input_devices = match (bs.left_port, bs.right_port) {
(0, 0) => Vec::new(),
(1, 0) => vec![JOYPAD],
(1, 1) => vec![JOYPAD, JOYPAD],
(_, _) => bail!("unsupported bsnes joypad configuration"),
};

let entropy = match bs.entropy {
0 => "None",
1 => "Low",
2 => "High",
entropy => bail!("unsupported bsnes entropy {entropy}, expected 0, 1 or 2"),
};
environment_variables.insert("bsnes_entropy".to_owned(), entropy.to_owned());

fn bsnes_bool(b: bool) -> String {
let s = if b { "ON" } else { "OFF" };
s.to_owned()
}
environment_variables
.insert("bsnes_hotfixes".to_owned(), bsnes_bool(bs.hotfixes));
environment_variables
.insert("bsnes_ppu_fast".to_owned(), bsnes_bool(bs.fast_ppu));
environment_variables
.insert("bsnes_dsp_fast".to_owned(), bsnes_bool(bs.fast_dsp));
environment_variables.insert(
"bsnes_coprocessor_delayed_sync".to_owned(),
bsnes_bool(bs.fast_coprocessors),
);

let sgb = if bs.use_sgb2 { "SGB2.sfc" } else { "SGB1.sfc" };
environment_variables.insert("bsnes_sgb_bios".to_owned(), sgb.to_owned());
}
sstype => bail!("unsupported sync setting type {sstype:?}"),
}
}

let mut raw_inputs = Vec::new();
{
fn bsnes115_1player(line: &[u8], data: &mut Vec<u8>) -> anyhow::Result<()> {
if line.len() != 17 {
bail!("expected line length to be 17, got {}", line.len());
}
let offset = data.len();
data.resize(offset + tas::input::Joypad::Snes.frame_size(), 0);
let frame = &mut data[offset..];
tas::input::Joypad::Snes.write(frame, 4, line[4] != b'.'); // up
tas::input::Joypad::Snes.write(frame, 5, line[5] != b'.'); // down
tas::input::Joypad::Snes.write(frame, 6, line[6] != b'.'); // left
tas::input::Joypad::Snes.write(frame, 7, line[7] != b'.'); // right
tas::input::Joypad::Snes.write(frame, 2, line[8] != b'.'); // select
tas::input::Joypad::Snes.write(frame, 3, line[9] != b'.'); // start
tas::input::Joypad::Snes.write(frame, 1, line[10] != b'.'); // y
tas::input::Joypad::Snes.write(frame, 0, line[11] != b'.'); // b
tas::input::Joypad::Snes.write(frame, 9, line[12] != b'.'); // x
tas::input::Joypad::Snes.write(frame, 8, line[13] != b'.'); // a
tas::input::Joypad::Snes.write(frame, 10, line[14] != b'.'); // l
tas::input::Joypad::Snes.write(frame, 11, line[15] != b'.'); // r
Ok(())
}
fn bsnes115_2players(line: &[u8], data: &mut Vec<u8>) -> anyhow::Result<()> {
if line.len() != 30 {
bail!("expected line length to be 30, got {}", line.len());
}
let offset = data.len();
data.resize(offset + 2 * tas::input::Joypad::Snes.frame_size(), 0);
let frame = &mut data[offset..];
tas::input::Joypad::Snes.write(frame, 4, line[4] != b'.'); // up
tas::input::Joypad::Snes.write(frame, 5, line[5] != b'.'); // down
tas::input::Joypad::Snes.write(frame, 6, line[6] != b'.'); // left
tas::input::Joypad::Snes.write(frame, 7, line[7] != b'.'); // right
tas::input::Joypad::Snes.write(frame, 2, line[8] != b'.'); // select
tas::input::Joypad::Snes.write(frame, 3, line[9] != b'.'); // start
tas::input::Joypad::Snes.write(frame, 1, line[10] != b'.'); // y
tas::input::Joypad::Snes.write(frame, 0, line[11] != b'.'); // b
tas::input::Joypad::Snes.write(frame, 9, line[12] != b'.'); // x
tas::input::Joypad::Snes.write(frame, 8, line[13] != b'.'); // a
tas::input::Joypad::Snes.write(frame, 10, line[14] != b'.'); // l
tas::input::Joypad::Snes.write(frame, 11, line[15] != b'.'); // r
// TODO add player2 inputs
Ok(())
}

let parse_line = match (&*core_id, input_devices.len()) {
("bsnes_mvi", 1) => bsnes115_1player,
("bsnes_mvi", 2) => bsnes115_2players,
(core_id, input_devices_len) => bail!(
"unsupported core/input port configuration {core_id:?}/{input_devices_len}"
),
};

let input_log = archive
.by_name("Input Log.txt")
.context("missing Input Log.txt")?;
let mut r = BufReader::new(input_log);

// ref: https://github.com/TASEmulators/BizHawk/blob/9b4bc1e693715235fff9507f99b93c87065ed653/src/BizHawk.Client.Common/movie/bk2/Bk2Movie.InputLog.cs
// ref: https://github.com/TASEmulators/BizHawk/blob/9b4bc1e693715235fff9507f99b93c87065ed653/src/BizHawk.Client.Common/movie/bk2/Bk2Controller.cs
for lineno in 1_u64.. {
buf.clear();
(&mut r).take(512).read_line(&mut buf)?;
if buf.is_empty() {
break;
}
if !buf.starts_with('|') {
// Bizhawk also ignores those lines.
continue;
}
let Some(buf) = buf.strip_suffix('\n') else {
// Long line, shouldn't happen in practice
r.skip_until(b'\n')?;
continue;
};
if let Err(err) = parse_line(buf.as_bytes(), &mut raw_inputs) {
eprintln!("error in Input Log at line #{lineno}: {err:#}");
}
}
}

let mut inputs = Vec::new();
ZlibEncoder::new(raw_inputs.as_slice(), flate2::Compression::fast())
.read_to_end(&mut inputs)
.expect("compression failed");

Ok(Self {
uuid: Uuid::new_v4(), // bk2 doesn't have an equivalent identifier
core_id,
rom_filename,
rom_sha256: [0; 32],
input_devices,
system_id: None, // TODO
environment_variables,
rerecords,
ramwatches: Vec::new(),
inputs,
})
}

pub fn decompress_inputs(&self) -> anyhow::Result<Vec<u8>> {
let mut inputs = Vec::new();
ZlibDecoder::new(self.inputs.as_slice()).read_to_end(&mut inputs)?;
Expand Down
5 changes: 4 additions & 1 deletion src/tas/movie/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::rc::Rc;
use std::{collections::HashMap, rc::Rc};

use anyhow::Context;
use serde::{Deserialize, Serialize};
Expand Down Expand Up @@ -28,6 +28,7 @@ pub struct Movie {
pub system_id: Option<String>,
pub rom_filename: String,
pub rom_sha256: [u8; 32],
pub environment_variables: HashMap<String, String>,
pub rerecords: u32,
pub ramwatches: Vec<RamWatch>,
}
Expand Down Expand Up @@ -110,6 +111,7 @@ impl Movie {
rom_sha256,
core_id,
system_id,
environment_variables: HashMap::new(),
rerecords: 0,
ramwatches: vec![],
}
Expand Down Expand Up @@ -150,6 +152,7 @@ impl Movie {
rom_sha256,
core_id,
system_id: file.system_id,
environment_variables: file.environment_variables,
rerecords: file.rerecords,
ramwatches: file.ramwatches,
})
Expand Down
9 changes: 7 additions & 2 deletions src/ui/menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ impl Ui {
}
if ui.menu_item("Open Movie...")
&& let Some(path) = rfd::FileDialog::new()
.add_filter("mvi movie file", &["mvi"])
.add_filter("Movie file", &["mvi", "bk2"])
.set_title("Select a movie file")
.pick_file()
{
Expand Down Expand Up @@ -369,7 +369,12 @@ impl Ui {
fn open_movie(&mut self, path: std::path::PathBuf) {
// **1.** Deserialize the movie file from disk
self.handle_error(|ui| {
let file = tas::movie::file::MovieFile::load(std::fs::File::open(&path)?)?;
let extension = path.extension().and_then(|e| e.to_str());
let file = std::fs::File::open(&path)?;
let file = match extension {
Some("bk2") => tas::movie::file::MovieFile::load_bk2(file)?,
Some(_) | None => tas::movie::file::MovieFile::load(file)?,
};
ui.open_movie_file(file, path, true)
});
}
Expand Down