use std::fmt::Write as _;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use clap::{Args, Parser, Subcommand};
use one_saves::extension::{Civil, SHOWN_BYTES, SHOWN_CHARS};
use one_saves::{Bundle, Game, Name, Origin, Part, PartKind, Payload, Recorded, Source, TakenWith};
use one_saves_convert::{Format, Profile, card, detect, raw};
mod n64;
mod state;
#[derive(Parser)]
#[command(name = "1saves", version, about, long_about = None)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Convert(Box<Convert>),
Extract(Extract),
New(New),
Inspect(FileArg),
Verify(FileArg),
Hash(FileArg),
Profiles,
}
#[derive(Args)]
struct FileArg {
file: PathBuf,
}
#[derive(Args)]
struct Convert {
input: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long, value_name = "NAME")]
from: Option<String>,
#[arg(long, value_name = "SLUG")]
system: Option<String>,
#[arg(long, value_name = "SLUG")]
role: Option<String>,
#[arg(long, value_name = "VERSION")]
app_version: Option<String>,
#[arg(long, value_name = "NAME")]
core: Option<String>,
#[arg(long, value_name = "VERSION")]
core_version: Option<String>,
#[arg(long, value_name = "NAME")]
platform: Option<String>,
#[arg(long, value_name = "VERSION")]
platform_version: Option<String>,
#[arg(long)]
description: Option<String>,
#[cfg(feature = "rom")]
#[arg(long, value_name = "FILE")]
rom: Option<PathBuf>,
#[cfg(feature = "dat")]
#[arg(long, value_name = "FILE")]
dat: Option<PathBuf>,
#[arg(long, value_name = "FILE")]
rtc_file: Option<PathBuf>,
#[arg(long, value_name = "EPOCH")]
rtc_captured_at: Option<i64>,
#[arg(long, value_name = "CHIP")]
n64_save: Option<String>,
#[arg(long, value_name = "ORDER")]
n64_order: Option<String>,
#[arg(long, value_name = "CODE")]
n64_game: Option<String>,
#[arg(long)]
keep_rtc_inline: bool,
#[arg(long)]
force: bool,
}
#[derive(Args)]
struct Extract {
input: PathBuf,
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long, value_name = "NAME")]
to: Option<String>,
#[arg(long, value_name = "ID")]
part: Option<u64>,
#[arg(long, value_name = "TARGET")]
n64: Option<n64::Target>,
#[arg(long, value_name = "FILE")]
onto: Option<PathBuf>,
#[arg(long, value_name = "FILE")]
rtc_file: Option<PathBuf>,
#[arg(long)]
force: bool,
}
#[derive(Args)]
struct New {
kind: String,
output: PathBuf,
#[arg(long)]
size: Option<String>,
#[arg(long)]
shift_jis: bool,
#[arg(long)]
layout: Option<String>,
#[arg(long)]
force: bool,
}
fn main() -> ExitCode {
let cli = Cli::parse();
match run(cli) {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprint!("{}", report(&*error));
ExitCode::FAILURE
}
}
}
fn report(error: &dyn std::error::Error) -> String {
let mut out = format!("1saves: {}\n", printable(&error.to_string()));
let mut source = error.source();
while let Some(inner) = source {
let _ = writeln!(out, " caused by: {}", printable(&inner.to_string()));
source = inner.source();
}
out
}
type Fallible = Result<(), Box<dyn std::error::Error>>;
fn run(cli: Cli) -> Fallible {
match cli.command {
Command::Convert(args) => convert(*args),
Command::Extract(args) => extract(args),
Command::New(args) => new_card(&args),
Command::Inspect(args) => inspect(&args.file),
Command::Verify(args) => verify(&args.file),
Command::Hash(args) => hash(&args.file),
Command::Profiles => {
profiles();
Ok(())
}
}
}
fn extension_of(path: &Path) -> String {
path.extension().and_then(|e| e.to_str()).unwrap_or_default().to_ascii_lowercase()
}
fn write_out(path: &Path, bytes: &[u8], force: bool) -> Fallible {
if !force {
return create_new(path, bytes).map_err(|e| match e.kind() {
std::io::ErrorKind::AlreadyExists => format!(
"{} already exists; pass --force to overwrite",
printable(&path.display().to_string())
)
.into(),
_ => e.into(),
});
}
let name = path.file_name().ok_or_else(|| format!("{} names no file", path.display()))?;
let mut temporary = std::ffi::OsString::from(".");
temporary.push(name);
temporary.push(format!(".{}.1saves-partial", std::process::id()));
let temporary = path.with_file_name(temporary);
create_new(&temporary, bytes)?;
std::fs::rename(&temporary, path).map_err(|e| {
let _ = std::fs::remove_file(&temporary);
e.into()
})
}
fn create_new(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
use std::io::Write as _;
let mut file = std::fs::OpenOptions::new().write(true).create_new(true).open(path)?;
file.write_all(bytes).and_then(|()| file.sync_all()).inspect_err(|_| {
let _ = std::fs::remove_file(path);
})
}
fn read_beside(path: &Path, limit: u64) -> Result<Option<Vec<u8>>, String> {
use std::io::Read as _;
let shown = || printable(&path.display().to_string());
let file = match open_beside(path) {
Ok(file) => file,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) if is_a_link_refused(&e) => return Err(format!("{} is not a regular file", shown())),
Err(e) => return Err(format!("{} cannot be read: {e}", shown())),
};
let metadata = file.metadata().map_err(|e| format!("{} cannot be read: {e}", shown()))?;
if !metadata.file_type().is_file() {
return Err(format!("{} is not a regular file", shown()));
}
if metadata.len() > limit {
return Err(format!("{} is {} bytes, more than the {limit} it could be", shown(), metadata.len()));
}
let mut bytes = Vec::new();
file.take(limit + 1).read_to_end(&mut bytes).map_err(|e| format!("{} cannot be read: {e}", shown()))?;
if bytes.len() as u64 > limit {
return Err(format!("{} grew past the {limit} bytes it could be while it was read", shown()));
}
Ok(Some(bytes))
}
#[cfg(unix)]
fn open_beside(path: &Path) -> std::io::Result<std::fs::File> {
use std::os::unix::fs::OpenOptionsExt as _;
std::fs::OpenOptions::new().read(true).custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK).open(path)
}
#[cfg(windows)]
fn open_beside(path: &Path) -> std::io::Result<std::fs::File> {
use std::os::windows::fs::OpenOptionsExt as _;
const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x0200_0000;
const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
std::fs::OpenOptions::new()
.read(true)
.custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT)
.open(path)
}
#[cfg(not(any(unix, windows)))]
fn open_beside(path: &Path) -> std::io::Result<std::fs::File> {
std::fs::File::open(path)
}
#[cfg(unix)]
fn is_a_link_refused(error: &std::io::Error) -> bool {
error.raw_os_error() == Some(libc::ELOOP)
}
#[cfg(not(unix))]
fn is_a_link_refused(_: &std::io::Error) -> bool {
false
}
#[cfg(feature = "archive")]
fn unwrap_archive(
input: &std::path::Path,
bytes: Vec<u8>,
) -> Result<(Vec<u8>, String), Box<dyn std::error::Error>> {
if !one_saves_convert::archive::is_archive(&bytes) {
return Ok((bytes, extension_of(input)));
}
let unpacked = one_saves_convert::archive::unpack(&bytes)?;
if unpacked.assembled {
eprintln!("{}: built a card from {}", input.display(), printable(&unpacked.source));
}
Ok((unpacked.bytes, unpacked.extension))
}
type Sidecar = (std::path::PathBuf, Vec<u8>, Option<i64>);
const MAX_SIDECAR_CLOCK: u64 = 64 << 10;
fn sidecar_clock(args: &Convert) -> Result<Option<Sidecar>, Box<dyn std::error::Error>> {
let found = if let Some(path) = &args.rtc_file {
Some((path.clone(), std::fs::read(path)?))
} else {
let beside = args.input.with_extension("rtc");
read_beside(&beside, MAX_SIDECAR_CLOCK)
.map_err(|why| format!("{why}, so it is not read as the save's clock; name it with --rtc-file"))?
.map(|bytes| (beside, bytes))
};
let Some((path, bytes)) = found else {
if args.rtc_captured_at.is_some() {
return Err("--rtc-captured-at was given, but there is no sidecar clock to apply it to".into());
}
return Ok(None);
};
if bytes.len() != one_saves_convert::rtc::GAMBATTE_RTC_LEN {
if args.rtc_captured_at.is_some() {
return Err(format!(
"--rtc-captured-at was given, but {} records its own instant",
path.display()
)
.into());
}
return Ok(Some((path, bytes, None)));
}
let captured_at = if let Some(stated) = args.rtc_captured_at {
stated
} else {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| format!("the system clock is before 1970: {e}"))?
.as_secs();
i64::try_from(now)?
};
Ok(Some((path, bytes, Some(captured_at))))
}
fn convert(args: Convert) -> Fallible {
#[cfg(feature = "archive")]
let (bytes, extension) = unwrap_archive(&args.input, std::fs::read(&args.input)?)?;
#[cfg(not(feature = "archive"))]
let (bytes, extension) = (std::fs::read(&args.input)?, extension_of(&args.input));
if let Some(kind) = state::kind_of(&args.input, &bytes) {
return state::convert(&args, kind, &bytes);
}
let profile = args.from.as_deref().and_then(one_saves_convert::profile);
let named_format = args.from.as_deref().and_then(Format::from_name);
let format = match named_format {
Some(format) => format,
None => detect::detect(&bytes, &extension)?,
};
if format == Format::Bundle {
return Err("this file is already a bundle".into());
}
#[cfg(feature = "rom")]
let Identified { mut game, system: rom_system, clock: rom_clock, n64_save: rom_n64_save } =
identify(&args)?;
#[cfg(not(feature = "rom"))]
let Identified { mut game, system: rom_system, clock: rom_clock, n64_save: rom_n64_save } =
Identified::default();
let sidecar = sidecar_clock(&args)?;
let sidecar_path = sidecar.as_ref().map(|(path, _, at)| (path.clone(), at.is_some()));
let sidecar = sidecar.map(|(_, bytes, at)| (bytes, at.unwrap_or_default()));
let source = source_of(&args, profile.as_ref())?;
let system = system_of(&args, rom_system, profile.as_ref(), source.as_ref(), &extension);
let mut label = format.label().to_owned();
let mut bundle = if n64::is_n64(system.as_deref(), &extension, &bytes) {
let (bundle, read_as) = n64::convert(&args, &bytes, &extension, source, game.clone(), rom_n64_save)?;
label = read_as;
bundle
} else if format == Format::Raw {
let options = raw::RawOptions {
system,
role: args.role.clone(),
source,
game: game.clone(),
description: args.description.clone(),
split_rtc: !args.keep_rtc_inline,
rtc_sidecar: sidecar,
clock_chip: rom_clock.map(ToOwned::to_owned),
..raw::RawOptions::default()
};
raw::wrap(&bytes, &options)?
} else {
let options = card::CardOptions {
role: args.role.as_deref().map(|role| slug_flag("--role", role)).transpose()?,
system: system.as_deref().map(|system| slug_flag("--system", system)).transpose()?,
source,
};
card::read(format, &bytes, &options)?
};
if bundle.header.card.is_some()
&& let Some(game) = game.take()
{
bundle.header.game = Some(game);
}
if let Some(code) = args.n64_game.as_deref() {
(bundle, label) = n64::cut(&bundle, &label, code)?;
}
if let Some(description) = args.description {
bundle.header.description = Some(description);
}
let output = args.output.unwrap_or_else(|| args.input.with_extension(one_saves::EXTENSION));
let encoded = bundle.to_vec()?;
write_out(&output, &encoded, args.force)?;
report_clock(sidecar_path.as_ref(), args.rtc_captured_at.is_some(), &bundle);
let backup_ram = raw::is_segacd_bram(&bytes)
&& bundle.header.system.as_ref().is_some_and(|system| system.as_str() == "sega-cd");
if args.role.is_none() && backup_ram {
eprintln!("note: a Sega CD carries internal backup RAM and a Backup RAM Cart at once; pass");
eprintln!(" --role internal or --role ram-cart to say which socket this came out of");
}
let saves = bundle.parts.iter().filter(|p| p.kind == PartKind::Bundle).count();
let what = if *bundle.shape() == one_saves::Shape::Device {
format!("{} component(s)", bundle.parts.len())
} else if saves > 0 {
format!("{saves} save(s)")
} else {
format!("{} part(s)", bundle.parts.len())
};
println!(
"{} -> {} ({}, {what}, {} bytes)",
args.input.display(),
output.display(),
if backup_ram { "Sega CD backup RAM" } else { label.as_str() },
encoded.len()
);
Ok(())
}
fn report_clock(sidecar: Option<&(PathBuf, bool)>, instant_given: bool, bundle: &Bundle) {
if let Some((path, needed_instant)) = sidecar {
let how = if !needed_instant {
"the instant it records"
} else if instant_given {
"the instant given"
} else {
"the time it was read"
};
println!("read the clock from {}, using {how}", path.display());
} else if one_saves_convert::rtc::footer_of(bundle).is_some() {
println!("split a real-time-clock footer off the save, so its bytes hash the same over time");
}
}
fn system_of(
args: &Convert,
rom_system: Option<&str>,
profile: Option<&Profile>,
source: Option<&Source>,
extension: &str,
) -> Option<String> {
let core_system = source
.and_then(|source| source.core.as_ref())
.and_then(|core| one_saves_registry::core(core.as_str()))
.and_then(|core| match core.systems {
[only] => Some(*only),
_ => None,
});
args.system
.clone()
.or_else(|| rom_system.map(ToOwned::to_owned))
.or_else(|| profile.and_then(Profile::only_system).map(ToOwned::to_owned))
.or_else(|| core_system.map(ToOwned::to_owned))
.or_else(|| n64::system_of_extension(extension).map(ToOwned::to_owned))
}
fn slug_flag(flag: &str, text: &str) -> Result<one_saves::Slug, String> {
one_saves::Slug::parse(text).map_err(|e| format!("{flag} {text:?} is not a slug: {e}"))
}
fn source_of(
args: &Convert,
profile: Option<&Profile>,
) -> Result<Option<Source>, Box<dyn std::error::Error>> {
let mut source = profile.map(Profile::source).unwrap_or_default();
if let Some(text) = &args.core {
let core =
listed_name(text, "--core", |name| one_saves_registry::core_by_any_name(name).map(|c| c.slug))?;
if let Some(named) = &source.core
&& named != &core
{
return Err(format!("--from names the core {named}, and --core says {core}").into());
}
let app = source.app.as_ref().and_then(|app| one_saves_registry::app(app.as_str()));
if let Some(app) = app
&& let Some(listed) = one_saves_registry::core(core.as_str())
&& !app.runs(listed)
{
return Err(format!(
"{} does not run {}, so --core {text} cannot be right",
app.name, listed.name
)
.into());
}
source.core = Some(core);
}
if let Some(text) = &args.platform {
let platform = listed_name(text, "--platform", |name| {
one_saves_registry::platform_by_any_name(name).map(|p| p.slug)
})?;
source.platform = Some(platform);
}
for (flag, version, named, what) in [
("--app-version", &args.app_version, source.app.is_some(), "an app"),
("--core-version", &args.core_version, source.core.is_some(), "a core"),
("--platform-version", &args.platform_version, source.platform.is_some(), "a platform"),
] {
if version.is_some() && !named {
let hint = if flag == "--app-version" && source.core.is_some() {
"; --from names a core here, so its version is --core-version"
} else {
""
};
return Err(format!(
"{flag} was given, but nothing names {what} for it to be the version of{hint}"
)
.into());
}
}
source.app_version.clone_from(&args.app_version);
source.core_version.clone_from(&args.core_version);
source.platform_version.clone_from(&args.platform_version);
Ok((!source.is_empty()).then_some(source))
}
fn listed_name(
text: &str,
flag: &str,
lookup: impl Fn(&str) -> Option<&'static str>,
) -> Result<Name, String> {
if let Some(slug) = lookup(text) {
return Name::parse(slug).map_err(|e| format!("the registry slug {slug:?} is malformed: {e}"));
}
match Name::parse(text) {
Ok(name @ Name::ReverseDns(_)) => Ok(name),
Ok(Name::Slug(_)) => Err(format!(
"{flag} {text:?} is not one the registry lists; an unlisted one is written as its owner's \
reverse-DNS name"
)),
Err(e) => Err(format!("{flag} {text:?} is neither listed nor a well-formed name: {e}")),
}
}
#[derive(Default)]
struct Identified {
game: Option<one_saves::Game>,
system: Option<&'static str>,
clock: Option<&'static str>,
n64_save: Option<one_saves::extension::N64Save>,
}
#[cfg(feature = "rom")]
fn identify(args: &Convert) -> Result<Identified, Box<dyn std::error::Error>> {
let Some(rom_path) = args.rom.as_deref() else {
return Ok(Identified::default());
};
let bytes = std::fs::read(rom_path)?;
let filename = rom_path.file_name().and_then(|n| n.to_str());
#[cfg_attr(not(feature = "dat"), allow(unused_mut))]
let mut game = one_saves_convert::rom::game_from_rom(&bytes, filename);
let info = one_saves_convert::rom::identify(&bytes).unwrap_or_default();
#[cfg(feature = "dat")]
if let Some(dat) = args.dat.as_deref() {
let catalog = one_saves_convert::dat::Catalog::open(dat)?;
if catalog.enrich(&mut game) {
println!("identified as {:?}", game.title.as_deref().unwrap_or_default());
} else {
eprintln!("note: this ROM is not in {}", dat.display());
}
}
Ok(Identified { game: Some(game), system: info.system, clock: info.clock, n64_save: info.n64_save })
}
fn extract(args: Extract) -> Fallible {
let bytes = std::fs::read(&args.input)?;
let bundle = Bundle::from_slice(&bytes)?;
if let Some(problem) = digest_problems(&bundle, &mut MAX_INFLATED.clone()).first() {
return Err(format!("{problem}; `1saves verify` lists every problem").into());
}
if let Some(id) = args.part {
let payload = raw::unwrap_part(&bundle, id)?;
let output = args.output.unwrap_or_else(|| args.input.with_extension(format!("part{id}.bin")));
write_out(&output, &payload, args.force)?;
println!("part {id} -> {} ({} bytes)", output.display(), payload.len());
return Ok(());
}
if matches!(bundle.shape(), one_saves::Shape::State | one_saves::Shape::Screenshot) {
return state::extract(&args, &bundle);
}
if let Some(target) = args.n64 {
if !n64::is_n64_bundle(&bundle) {
return Err("--n64 writes an N64 save, and this bundle is not one".into());
}
let base = args.output.unwrap_or_else(|| args.input.with_extension(""));
return n64::extract(&bundle, target, &base, args.onto.as_deref(), args.force);
}
if n64::is_n64_bundle(&bundle) && args.to.is_none() {
return Err("an N64 save is written differently for each emulator; pass --n64 retroarch, \
mupen64plus, project64 or everdrive"
.into());
}
let card_format = card::card_format_of(&bundle)?.map(|format| format.as_str().to_owned());
let device = *bundle.shape() == one_saves::Shape::Device;
let target = match args.to.as_deref() {
Some(name) => Format::from_name(name).ok_or_else(|| format!("unknown format {name:?}"))?,
None => match card_format.as_deref() {
Some(slug) => Format::from_card_format(slug)
.ok_or_else(|| format!("this bundle is a {slug}, which this build cannot write"))?,
None => Format::Raw,
},
};
if args.onto.is_some() && target != Format::N64Pak {
return Err("--onto is for an N64 save, with --n64, or a Controller Pak".into());
}
let payload = match target {
Format::Raw => raw::unwrap(&bundle)?,
Format::Bundle => return Err("extracting a bundle as a bundle is a copy".into()),
card => match args.onto.as_deref() {
Some(onto) => card::write_onto(card, &bundle, &std::fs::read(onto)?)?,
None => card::write(card, &bundle)?,
},
};
let is_backup_ram = bundle.header.system.as_ref().is_some_and(|s| s.as_str() == "sega-cd");
let suffix = match target {
Format::Raw if is_backup_ram => "brm",
Format::NeoGeoCard if device => "sav",
_ => target.extension(),
};
let payload = match &args.rtc_file {
Some(path) => {
let sidecar = one_saves_convert::rtc::sidecar_of(&bundle)
.ok_or("this bundle carries no clock to write to a sidecar")?;
write_out(path, &sidecar, args.force)?;
println!("clock -> {} ({} bytes)", path.display(), sidecar.len());
raw::unwrap_save_only(&bundle)?
}
None => payload,
};
let output = args.output.unwrap_or_else(|| args.input.with_extension(suffix));
write_out(&output, &payload, args.force)?;
println!(
"{} -> {} ({}, {} bytes)",
args.input.display(),
output.display(),
match target {
Format::Raw if is_backup_ram => "Sega CD backup RAM",
Format::NeoGeoCard if device => "MiSTer NeoGeo save",
_ => target.label(),
},
payload.len()
);
Ok(())
}
fn new_card(args: &New) -> Fallible {
let format =
Format::from_name(&args.kind).filter(|format| !card::sizes(*format).is_empty()).ok_or_else(|| {
format!("{:?} is not a kind of memory card: ps1, ps2, gc, n64, vmu, saturn or neogeo", args.kind)
})?;
let size = match &args.size {
Some(name) => card::size(format, name).ok_or_else(|| {
let sizes: Vec<String> =
card::sizes(format).iter().map(|size| format!("{} ({})", size.name, size.label)).collect();
let (last, rest) = sizes.split_last().expect("every card has a size");
let listed =
if rest.is_empty() { last.clone() } else { format!("{} or {last}", rest.join(", ")) };
format!("a {} comes in {listed}", format.label())
})?,
None => {
card::sizes(format).iter().find(|size| size.standard).expect("every card has a standard size")
}
};
let layout = match args.layout.as_deref() {
Some(name) => card::Layout::from_name(name)
.ok_or_else(|| format!("{name:?} is not a layout: bare, wide or mister"))?,
None => card::Layout::Bare,
};
let as_bundle = extension_of(&args.output) == one_saves::EXTENSION;
if as_bundle && layout != card::Layout::Bare {
return Err(
"--layout is how an emulator's file wraps a card, and a bundle holds the card itself".into()
);
}
let options = card::BlankOptions { capacity: Some(size.capacity), shift_jis: args.shift_jis, layout };
let bytes = card::blank(format, &options)?;
let bytes =
if as_bundle { card::read(format, &bytes, &card::CardOptions::default())?.to_vec()? } else { bytes };
write_out(&args.output, &bytes, args.force)?;
println!("empty {} -> {} ({}, {} bytes)", format.label(), args.output.display(), size.label, bytes.len());
Ok(())
}
fn inspect(path: &Path) -> Fallible {
let bytes = std::fs::read(path)?;
let bundle = Bundle::from_slice(&bytes)?;
println!("{}", path.display());
println!(" {} bytes, spec version {}", bytes.len(), one_saves::SPEC_VERSION);
let mut inflatable = MAX_INFLATED;
describe(&bundle, 2, 0, &mut inflatable);
Ok(())
}
const LABEL_WIDTH: usize = 13;
const MAX_DEPTH: usize = 4;
const MAX_INFLATED: u64 = 192 << 20;
fn field(indent: usize, label: &str, value: &str) {
println!("{}", format!("{:indent$}{label:LABEL_WIDTH$}{value}", "").trim_end());
}
fn describe(bundle: &Bundle, indent: usize, depth: usize, inflatable: &mut u64) {
let header = &bundle.header;
let shape = bundle.shape();
if shape.is_known() {
field(indent, "shape", shape.as_str());
} else {
field(indent, "shape", &format!("{shape} (not a shape spec {} defines)", one_saves::SPEC_VERSION));
}
if let Some(system) = &header.system {
field(indent, "system", &system_name(system));
}
if let Some(card) = &header.card {
field(indent, "card", &format!("{}, capacity {} bytes", card.format, card.capacity));
if let Some(area) = &card.system_area {
field(indent, "", &format!("system area {}", shown_bytes(area)));
}
for line in unknown_keys(&card.unknown) {
field(indent, "", &format!("unknown {line}"));
}
}
if let Some(source) = &header.source {
describe_source(indent, source);
}
if let Some(seconds) = header.bundled_at {
field(indent, "bundled", &format!("{} UTC", Civil(seconds)));
}
if let Some(seconds) = header.taken_at {
field(indent, "taken", &format!("{} UTC", Civil(seconds)));
}
if let Some(origin) = &header.origin {
let what = match origin {
Origin::Auto => "the emulator took it to resume from".to_owned(),
Origin::Undo => "the emulator kept it to undo a save or a load".to_owned(),
Origin::Unknown(_) => format!("not an origin spec {} defines", one_saves::SPEC_VERSION),
};
field(indent, "origin", &format!("{origin} ({what})"));
}
if let Some(taken_with) = &header.taken_with {
describe_taken_with(indent, taken_with);
}
match &header.game {
Some(game) => describe_game(indent, game),
None if matches!(shape, one_saves::Shape::Device | one_saves::Shape::Collection) => {}
None => field(indent, "game", "unidentified"),
}
if let Some(description) = &header.description {
field(indent, "description", &printable(description));
}
for key in header.extensions.keys() {
field(indent, "extension", &describe_extension(&header.extensions, key));
}
for line in unknown_keys(&header.unknown) {
field(indent, "unknown", &line);
}
field(indent, "parts", &bundle.parts.len().to_string());
for part in &bundle.parts {
describe_part(part, indent + 2, depth, inflatable);
}
}
fn describe_part(part: &Part, indent: usize, depth: usize, inflatable: &mut u64) {
let kind = part.kind.as_str().unwrap_or("save");
let role = match (&part.role, &part.kind) {
(Some(role), _) => role.as_str(),
(None, PartKind::State | PartKind::Screenshot) => "",
(None, _) => "primary",
};
let serial = printable(part.game.as_ref().and_then(|game| game.serial.as_deref()).unwrap_or(""));
let path = printable(part.path.as_deref().unwrap_or(""));
let slot = part.slot.map_or_else(String::new, |s| format!("slot {s}"));
let id = format!("[{}]", part.id);
let digest = part.sha256.to_string();
let short = digest.strip_prefix("sha256:").unwrap_or(&digest);
let line = format!(
"{:indent$}{id:<5} {kind:10} {role:16} {:>9} bytes {} {serial:12} {slot:8} {path}",
"",
part.payload.len(),
&short[..16]
);
println!("{}", line.trim_end());
let indent = indent + 6;
let open = || {
part.bytes()
.and_then(|bytes| Bundle::from_slice(&bytes))
.map_err(|e| format!("cannot be opened: {}", printable(&e.to_string())))
};
let inner = match &part.payload {
_ if !matches!(part.kind, PartKind::Bundle) => None,
_ if depth >= MAX_DEPTH => Some(Err(format!("not listed, being more than {MAX_DEPTH} deep"))),
Payload::Compressed { size, .. } if *size > *inflatable => Some(Err(format!(
"not inflated, claiming {size} bytes where this listing has {inflatable} left to inflate"
))),
Payload::Compressed { size, .. } => {
*inflatable -= size;
Some(open())
}
Payload::Embedded(_) => Some(open()),
};
let held = inner.as_ref().and_then(|inner| inner.as_ref().ok()).map(|inner| &inner.header);
if let Some(source) = &part.source {
describe_source(indent, source);
}
if let Some(game) = &part.game
&& held.is_none_or(|held| held.game.as_ref() != Some(game))
{
describe_game(indent, game);
}
if let Some(system) = &part.system
&& held.is_none_or(|held| held.system.as_ref() != Some(system))
{
field(indent, "system", &system_name(system));
}
if let Some(binding) = &part.binding {
let listed = if one_saves_registry::is_binding(binding.as_str()) { "" } else { " (unlisted)" };
field(indent, "binding", &format!("{binding}{listed}"));
}
if let Some(content_type) = &part.content_type {
field(indent, "content type", &printable(content_type));
}
if let Payload::Compressed { bytes, .. } = &part.payload {
field(indent, "encoding", &format!("zstd, {} bytes stored", bytes.len()));
}
if let Some(dirent) = &part.dirent {
field(indent, "dirent", &shown_bytes(dirent));
}
for key in part.extensions.keys() {
field(indent, "extension", &describe_extension(&part.extensions, key));
}
for line in unknown_keys(&part.unknown) {
field(indent, "unknown", &line);
}
match inner {
Some(Ok(inner)) => describe(&inner, indent + 2, depth + 1, inflatable),
Some(Err(why)) => field(indent, "bundle", &why),
None => {}
}
}
fn describe_taken_with(indent: usize, taken_with: &TakenWith) {
fn entry<T>(
lines: &mut Vec<String>,
name: &str,
value: Option<&Recorded<T>>,
show: impl Fn(&T) -> Vec<String>,
) {
match value {
None => {}
Some(Recorded::Unknown) => lines.push(format!("{name} unknown")),
Some(Recorded::Known(value)) => {
lines.extend(show(value).into_iter().map(|line| format!("{name} {line}")));
}
}
}
let mut lines = Vec::new();
entry(&mut lines, "byte order", taken_with.byte_order.as_ref(), |order| vec![order.as_str().to_owned()]);
entry(&mut lines, "word size", taken_with.word_size.as_ref(), |size| {
vec![format!("{} bits", size.bits())]
});
entry(&mut lines, "firmware", taken_with.firmware.as_ref(), |files| {
files
.iter()
.map(|(file, hashes)| {
let hashes: Vec<String> = hashes.iter().map(ToString::to_string).collect();
format!("{} {}", printable(file), hashes.join(" "))
})
.collect()
});
entry(&mut lines, "language", taken_with.language.as_ref(), |language| vec![printable(language)]);
entry(&mut lines, "option", taken_with.options.as_ref(), |options| {
options.iter().map(|(name, value)| format!("{} = {}", printable(name), printable(value))).collect()
});
entry(&mut lines, "format version", taken_with.format_version.as_ref(), |version| {
vec![version.to_string()]
});
lines.extend(
taken_with
.extensions
.keys()
.map(|key| format!("extension {}", describe_extension(&taken_with.extensions, key))),
);
lines.extend(unknown_keys(&taken_with.unknown).map(|line| format!("unknown {line}")));
for (index, line) in lines.iter().enumerate() {
field(indent, if index == 0 { "taken with" } else { "" }, line);
}
}
fn describe_source(indent: usize, source: &Source) {
let app = layer("app", "", source.app.as_ref(), source.app_version.as_deref(), |slug| {
one_saves_registry::app(slug).map(|app| app.name)
});
let core = layer("core", "core ", source.core.as_ref(), source.core_version.as_deref(), |slug| {
one_saves_registry::core(slug).map(|core| core.name)
});
let platform = layer(
"platform",
"platform ",
source.platform.as_ref(),
source.platform_version.as_deref(),
|slug| one_saves_registry::platform(slug).map(|platform| platform.name),
);
let kind = source.device_kind.as_ref().map(|kind| {
let listed = if one_saves_registry::is_device_kind(kind.as_str()) { "" } else { " (unlisted)" };
format!("{kind}{listed}")
});
let bits: Vec<String> = [app, core, platform, kind].into_iter().flatten().collect();
field(indent, "source", &bits.join(", "));
if let Some(fingerprint) = &source.fingerprint {
field(indent, "", &format!("fingerprint {}", printable(fingerprint)));
}
for line in unknown_keys(&source.unknown) {
field(indent, "", &format!("unknown {line}"));
}
}
fn layer(
what: &str,
label: &str,
name: Option<&Name>,
version: Option<&str>,
lookup: impl Fn(&str) -> Option<&'static str>,
) -> Option<String> {
let name = name.map(|name| match name.as_slug() {
Some(slug) => format!("{name} ({})", lookup(slug.as_str()).unwrap_or("unlisted")),
None => name.to_string(),
});
match (name, version.map(printable)) {
(Some(name), Some(version)) => Some(format!("{label}{name} {version}")),
(Some(name), None) => Some(format!("{label}{name}")),
(None, Some(version)) => Some(format!("{what} version {version}")),
(None, None) => None,
}
}
fn describe_game(indent: usize, game: &Game) {
let bits: Vec<String> = [&game.title, &game.serial, &game.rom_filename]
.into_iter()
.flatten()
.map(|text| printable(text))
.collect();
field(indent, "game", &bits.join(", "));
if let Some(system) = &game.system {
field(indent, "", &format!("system {}", system_name(system)));
}
for hash in &game.rom_hashes {
field(indent, "", &hash.to_string());
}
for (resolver, id) in &game.game_id {
let id = match id {
one_saves::GameId::Uint(n) => n.to_string(),
one_saves::GameId::Text(t) => printable(t),
};
field(indent, "", &format!("{resolver} = {id}"));
}
for key in game.extensions.keys() {
field(indent, "", &format!("extension {}", describe_extension(&game.extensions, key)));
}
for line in unknown_keys(&game.unknown) {
field(indent, "", &format!("unknown {line}"));
}
}
fn system_name(system: &one_saves::Slug) -> String {
format!("{system} ({})", one_saves_registry::system(system.as_str()).map_or("unlisted", |s| s.name))
}
fn unknown_keys(keys: &one_saves::UnknownKeys) -> impl Iterator<Item = String> + '_ {
keys.iter().map(|(key, value)| {
let text = value.diagnostic_flat();
let text = match text.char_indices().nth(SHOWN_CHARS - 1) {
Some((cut, _)) if text.chars().nth(SHOWN_CHARS).is_some() => format!("{}…", &text[..cut]),
_ => text,
};
format!("key {key} = {}", printable(&text))
})
}
fn shown_bytes(bytes: &[u8]) -> String {
let mut head = String::new();
for byte in bytes.iter().take(SHOWN_BYTES) {
write!(head, "{byte:02x}").expect("a String takes any write");
}
let more = if bytes.len() > SHOWN_BYTES { "…" } else { "" };
format!("h'{head}{more}' ({} bytes)", bytes.len())
}
fn describe_extension(extensions: &one_saves::Extensions, key: &one_saves::ReverseDnsName) -> String {
match extensions.display(key) {
Some(value) => format!("{key} = {}", printable(&value.to_string())),
None => key.to_string(),
}
}
fn printable(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for character in text.chars() {
if character.is_control() || is_bidi_control(character) {
out.extend(character.escape_default());
} else {
out.push(character);
}
}
out
}
fn is_bidi_control(character: char) -> bool {
matches!(character, '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}')
}
fn digest_problems(bundle: &Bundle, inflatable: &mut u64) -> Vec<String> {
let mut problems = Vec::new();
check_digests(bundle, "", 0, inflatable, &mut problems);
problems
}
fn check_digests(
bundle: &Bundle,
within: &str,
depth: usize,
inflatable: &mut u64,
problems: &mut Vec<String>,
) {
for part in &bundle.parts {
let at = format!("{within}part {}", part.id);
if let Payload::Compressed { size, .. } = &part.payload {
if *size > *inflatable {
problems.push(format!(
"{at}: not inflated, claiming {size} bytes where {inflatable} are left to inflate"
));
continue;
}
*inflatable -= size;
}
let bytes = match part.bytes() {
Ok(bytes) => bytes,
Err(e) => {
problems.push(format!("{at}: {}", printable(&e.to_string())));
continue;
}
};
if one_saves::HashValue::sha256_of(&bytes) != part.sha256 {
problems.push(format!("{at}: sha256 does not match its payload"));
continue;
}
if part.kind != PartKind::Bundle {
continue;
}
if depth >= MAX_DEPTH {
problems.push(format!("{at}: not checked, being more than {MAX_DEPTH} bundles deep"));
continue;
}
match Bundle::from_slice(&bytes) {
Ok(inner) => check_digests(&inner, &format!("{at} > "), depth + 1, inflatable, problems),
Err(e) => problems.push(format!("{at}: is not a bundle: {}", printable(&e.to_string()))),
}
}
}
fn claimed_inflation(bundle: &Bundle) -> u64 {
bundle
.parts
.iter()
.filter(|part| matches!(part.payload, Payload::Compressed { .. }))
.fold(0u64, |total, part| total.saturating_add(part.payload.len()))
}
fn verify(path: &Path) -> Fallible {
let bytes = std::fs::read(path)?;
let bundle = Bundle::from_slice(&bytes)?;
let problems = digest_problems(&bundle, &mut MAX_INFLATED.clone());
for problem in &problems {
eprintln!(" {problem}");
}
let mut bad = problems.len();
if bundle.to_vec()? != bytes {
bad += 1;
eprintln!(" this file is not the deterministic encoding of what it says");
}
if bad > 0 {
return Err(format!("{bad} problem(s) in {}", path.display()).into());
}
println!("{}: ok ({} part(s))", path.display(), bundle.parts.len());
Ok(())
}
fn hash(path: &Path) -> Fallible {
let bytes = std::fs::read(path)?;
let bundle = Bundle::from_slice(&bytes)?;
println!("file {}", one_saves::HashValue::sha256_of(&bytes));
let claimed = claimed_inflation(&bundle);
if claimed > MAX_INFLATED {
println!(
"content unavailable: its parts claim {claimed} bytes inflated, past the {MAX_INFLATED} this inflates"
);
return Ok(());
}
match bundle.content_hash() {
Ok(content) => println!("content {content}"),
Err(e) => println!("content unavailable: {}", printable(&e.to_string())),
}
Ok(())
}
fn profiles() {
println!("{:<14} {:<18} {:<12} {:<18} SYSTEMS", "NAME", "DEVICE KIND", "APP", "CORE");
for profile in one_saves_convert::profile::profiles() {
let systems =
if profile.systems.is_empty() { "(any)".to_owned() } else { profile.systems.join(", ") };
println!(
"{:<14} {:<18} {:<12} {:<18} {systems}",
profile.key,
profile.device_kind,
profile.app.unwrap_or("-"),
profile.core.unwrap_or("-")
);
}
println!(
"\nA dash is a name neither registry lists, which a bundle leaves out rather than invent one for.\n\
Any of the {} apps and {} cores in the registry also works by slug or name, and --core and\n\
--platform name the core an app ran and the firmware it ran on.",
one_saves_registry::apps().len(),
one_saves_registry::cores().len()
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn text_from_a_bundle_cannot_drive_the_terminal() {
assert_eq!(printable("FINAL FANTASY VII"), "FINAL FANTASY VII");
assert_eq!(printable("TEKKEN 4"), "TEKKEN 4");
assert_eq!(printable("\u{1b}]0;owned\u{7}\u{1b}[2J"), r"\u{1b}]0;owned\u{7}\u{1b}[2J");
assert_eq!(printable("two\nlines\u{9b}"), r"two\nlines\u{9b}");
}
#[test]
fn text_from_a_bundle_cannot_reorder_itself_either() {
assert_eq!(printable("save\u{202e}vas.exe"), r"save\u{202e}vas.exe");
assert_eq!(printable("\u{2066}a\u{2069}\u{200f}\u{61c}"), r"\u{2066}a\u{2069}\u{200f}\u{61c}");
assert_eq!(printable("ポケモン"), "ポケモン");
}
#[test]
fn an_error_quoting_the_file_cannot_drive_the_terminal() {
#[derive(Debug)]
struct Quoting(&'static str, Option<Box<Quoting>>);
impl std::fmt::Display for Quoting {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.0)
}
}
impl std::error::Error for Quoting {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
self.1.as_deref().map(|inner| inner as &(dyn std::error::Error + 'static))
}
}
let error =
Quoting("`\u{1b}]0;owned\u{7}` is not an encoding", Some(Box::new(Quoting("\u{1b}[2J", None))));
let report = report(&error);
assert!(!report.contains('\u{1b}') && !report.contains('\u{7}'), "{report:?}");
assert_eq!(report, "1saves: `\\u{1b}]0;owned\\u{7}` is not an encoding\n caused by: \\u{1b}[2J\n");
}
fn scratch(name: &str) -> PathBuf {
let directory = std::env::temp_dir().join(format!("1saves-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&directory);
std::fs::create_dir_all(&directory).unwrap();
directory
}
#[test]
fn writing_refuses_whatever_is_already_there() {
let directory = scratch("write-refuses");
let path = directory.join("out.mcd");
write_out(&path, b"first", false).unwrap();
let error = write_out(&path, b"second", false).unwrap_err();
assert!(error.to_string().contains("already exists; pass --force"), "{error}");
assert_eq!(std::fs::read(&path).unwrap(), b"first");
write_out(&path, b"second", true).unwrap();
assert_eq!(std::fs::read(&path).unwrap(), b"second");
assert_eq!(std::fs::read_dir(&directory).unwrap().count(), 1, "no temporary is left behind");
}
#[cfg(unix)]
#[test]
fn writing_never_follows_a_link_planted_at_the_output() {
let directory = scratch("write-links");
let victim = directory.join("victim");
let output = directory.join("save.srm");
std::os::unix::fs::symlink(&victim, &output).unwrap();
assert!(write_out(&output, b"payload", false).is_err());
assert!(!victim.exists(), "the link's target was created");
std::fs::write(&victim, b"precious").unwrap();
write_out(&output, b"payload", true).unwrap();
assert_eq!(std::fs::read(&victim).unwrap(), b"precious");
assert!(std::fs::symlink_metadata(&output).unwrap().file_type().is_file());
assert_eq!(std::fs::read(&output).unwrap(), b"payload");
}
#[test]
fn a_file_beside_the_input_is_read_when_it_is_a_small_regular_file() {
let directory = scratch("beside-regular");
assert_eq!(read_beside(&directory.join("absent.rtc"), 64), Ok(None));
let clock = directory.join("save.rtc");
std::fs::write(&clock, [1, 2, 3, 4]).unwrap();
assert_eq!(read_beside(&clock, 64), Ok(Some(vec![1, 2, 3, 4])));
std::fs::write(&clock, [0; 65]).unwrap();
let why = read_beside(&clock, 64).unwrap_err();
assert!(why.contains("more than the 64"), "{why}");
assert!(read_beside(&directory, 64).is_err(), "a directory is not a file to read");
}
#[cfg(unix)]
#[test]
fn a_file_beside_the_input_is_not_a_link_or_a_fifo() {
let directory = scratch("beside-special");
let private = directory.join("private.png");
std::fs::write(&private, b"\x89PNG private").unwrap();
let thumbnail = directory.join("game.state.png");
std::os::unix::fs::symlink(&private, &thumbnail).unwrap();
assert!(read_beside(&thumbnail, 1 << 20).unwrap_err().contains("not a regular file"));
let fifo = directory.join("game.rtc");
let made = std::process::Command::new("mkfifo").arg(&fifo).status().unwrap();
assert!(made.success());
assert!(read_beside(&fifo, 64).unwrap_err().contains("not a regular file"));
let linked = directory.join("other.rtc");
std::os::unix::fs::symlink(&fifo, &linked).unwrap();
assert!(read_beside(&linked, 64).unwrap_err().contains("not a regular file"));
}
fn resealed_collection() -> Bundle {
let mut save = Bundle::new(one_saves::Header::default(), vec![Part::new(0, *b"SAVE")]);
save.parts[0].sha256 = one_saves::HashValue::sha256_of(b"EVAS");
let nested = Part::new(0, save.to_vec().unwrap()).kind(PartKind::Bundle);
let bundle = Bundle::new(one_saves::Header::new(one_saves::Shape::Collection), vec![nested]);
Bundle::from_slice(&bundle.to_vec().unwrap()).expect("decodes, which is the point")
}
#[test]
fn digests_are_checked_inside_nested_bundles() {
let problems = digest_problems(&resealed_collection(), &mut MAX_INFLATED.clone());
assert_eq!(problems, ["part 0 > part 0: sha256 does not match its payload"]);
}
#[test]
fn a_top_level_digest_that_does_not_match_is_a_problem() {
let mut bundle = Bundle::new(one_saves::Header::default(), vec![Part::new(0, *b"SAVE")]);
bundle.parts[0].sha256 = one_saves::HashValue::sha256_of(b"EVAS");
let problems = digest_problems(&bundle, &mut MAX_INFLATED.clone());
assert_eq!(problems, ["part 0: sha256 does not match its payload"]);
}
#[test]
fn a_claim_past_the_budget_is_not_inflated() {
let mut part = Part::new(0, *b"SAVE");
part.payload = Payload::Compressed { bytes: b"not zstd".to_vec(), size: 1 << 40 };
let bundle = Bundle::new(one_saves::Header::default(), vec![part]);
assert_eq!(claimed_inflation(&bundle), 1 << 40);
let mut inflatable = MAX_INFLATED;
let problems = digest_problems(&bundle, &mut inflatable);
assert_eq!(problems.len(), 1);
assert!(problems[0].contains("not inflated, claiming 1099511627776 bytes"), "{}", problems[0]);
assert_eq!(inflatable, MAX_INFLATED, "nothing was charged for what was not inflated");
}
fn convert_args(line: &[&str]) -> Convert {
let Command::Convert(args) = Cli::try_parse_from(line).expect("parses").command else {
panic!("not a convert");
};
*args
}
#[test]
fn a_chip_s_file_names_its_system_only_when_nothing_else_does() {
let args = convert_args(&["1saves", "convert", "--from", "mister", "Paper Mario (USA).fla"]);
assert_eq!(system_of(&args, None, None, None, "fla").as_deref(), Some("n64"));
assert_eq!(system_of(&args, None, None, None, "sav"), None);
let args = convert_args(&["1saves", "convert", "--system", "gba", "save.fla"]);
assert_eq!(system_of(&args, None, None, None, "fla").as_deref(), Some("gba"));
let args = convert_args(&["1saves", "convert", "save.fla"]);
let duckstation = one_saves_convert::profile("duckstation");
assert_eq!(system_of(&args, None, duckstation.as_ref(), None, "fla").as_deref(), Some("psx"));
}
}