use std::path::{Path, PathBuf};
use std::process::ExitCode;
use clap::{Args, Parser, Subcommand};
use one_saves::{Bundle, PartKind};
use one_saves_convert::{Format, Profile, card, detect, raw};
#[derive(Parser)]
#[command(name = "1saves", version, about, long_about = None)]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Convert(Convert),
Extract(Extract),
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)]
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)]
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 = "FILE")]
rtc_file: Option<PathBuf>,
#[arg(long)]
force: bool,
}
fn main() -> ExitCode {
let cli = Cli::parse();
match run(cli) {
Ok(()) => ExitCode::SUCCESS,
Err(error) => {
eprintln!("1saves: {error}");
let mut source = std::error::Error::source(&*error);
while let Some(inner) = source {
eprintln!(" caused by: {inner}");
source = inner.source();
}
ExitCode::FAILURE
}
}
}
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::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 path.exists() && !force {
return Err(format!("{} already exists; pass --force to overwrite", path.display()).into());
}
std::fs::write(path, bytes)?;
Ok(())
}
fn convert(args: Convert) -> Fallible {
let bytes = std::fs::read(&args.input)?;
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_of(&args.input))?,
};
if format == Format::Bundle {
return Err("this file is already a bundle".into());
}
let Identified { mut game, system: rom_system } = identify(&args)?;
let sidecar_path = args.rtc_file.clone().or_else(|| {
let beside = args.input.with_extension("rtc");
beside.exists().then_some(beside)
});
if args.rtc_captured_at.is_some() && sidecar_path.is_none() {
return Err("--rtc-captured-at was given, but there is no sidecar clock to apply it to".into());
}
let sidecar = match &sidecar_path {
Some(path) => {
let bytes = std::fs::read(path)?;
let captured_at = if let Some(stated) = args.rtc_captured_at {
stated
} else {
let mtime = std::fs::metadata(path)?
.modified()?
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| format!("{} has a modification time before 1970: {e}", path.display()))?
.as_secs();
i64::try_from(mtime)?
};
Some((bytes, captured_at))
}
None => None,
};
let source = profile.as_ref().map(|p| p.source(args.app_version.clone()));
let system = args
.system
.clone()
.or_else(|| rom_system.map(ToOwned::to_owned))
.or_else(|| profile.as_ref().and_then(Profile::only_system).map(ToOwned::to_owned));
let mut bundle = 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,
..raw::RawOptions::default()
};
raw::wrap(&bytes, &options)?
} else {
let mut options = card::CardOptions { source, ..card::CardOptions::default() };
if let Some(role) = &args.role {
options.role = Some(
one_saves::Slug::parse(role).map_err(|e| format!("--role {role:?} is not a slug: {e}"))?,
);
}
card::read(format, &bytes, &options)?
};
if let Some(description) = args.description {
bundle.header.description = Some(description);
}
if bundle.header.card.is_some()
&& let Some(game) = game.take()
{
bundle.header.game = Some(game);
}
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)?;
if let Some(path) = &sidecar_path {
let how = if args.rtc_captured_at.is_some() {
"the instant given".to_owned()
} else {
"its modification time (pass --rtc-captured-at if that is not when it was written)".to_owned()
};
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");
}
let saves = bundle.parts.iter().filter(|p| p.kind == PartKind::Bundle).count();
let what = if saves > 0 { format!("{saves} save(s)") } else { format!("{} part(s)", bundle.parts.len()) };
println!(
"{} -> {} ({}, {what}, {} bytes)",
args.input.display(),
output.display(),
format.label(),
encoded.len()
);
Ok(())
}
#[derive(Default)]
struct Identified {
game: Option<one_saves::Game>,
system: Option<&'static str>,
}
#[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 system = one_saves_convert::rom::identify(&bytes).and_then(|info| info.system);
#[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.name.as_deref().unwrap_or_default());
} else {
eprintln!("note: this ROM is not in {}", dat.display());
}
}
Ok(Identified { game: Some(game), system })
}
#[cfg(not(feature = "rom"))]
#[allow(clippy::unnecessary_wraps)]
fn identify(_args: &Convert) -> Result<Identified, Box<dyn std::error::Error>> {
Ok(Identified::default())
}
fn extract(args: Extract) -> Fallible {
let bytes = std::fs::read(&args.input)?;
let bundle = Bundle::from_slice(&bytes)?;
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(());
}
let card_format = bundle.header.card.as_ref().map(|c| c.format.as_str().to_owned());
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,
},
};
let payload = match target {
Format::Raw => raw::unwrap(&bundle)?,
Format::Bundle => return Err("extracting a bundle as a bundle is a copy".into()),
card => card::write(card, &bundle)?,
};
let suffix = 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(),
target.label(),
payload.len()
);
Ok(())
}
fn inspect(path: &Path) -> Fallible {
let bytes = std::fs::read(path)?;
let bundle = Bundle::from_slice(&bytes)?;
let header = &bundle.header;
println!("{}", path.display());
println!(" {} bytes, spec version {}", bytes.len(), one_saves::SPEC_VERSION);
println!(" shape {}", bundle.shape());
if let Some(system) = &header.system {
let name = one_saves_registry::system(system.as_str()).map_or("unlisted", |s| s.name);
println!(" system {system} ({name})");
}
if let Some(card) = &header.card {
println!(" card {}, capacity {} bytes", card.format, card.capacity);
if let Some(area) = &card.system_area {
println!(" system area {} bytes", area.len());
}
}
if let Some(game) = &header.game {
let mut bits = Vec::new();
if let Some(name) = &game.name {
bits.push(name.clone());
}
if let Some(serial) = &game.serial {
bits.push(serial.clone());
}
if let Some(file) = &game.rom_filename {
bits.push(file.clone());
}
println!(" game {}", bits.join(", "));
for hash in &game.rom_hashes {
println!(" {hash}");
}
for (resolver, id) in &game.game_id {
let id = match id {
one_saves::GameId::Uint(n) => n.to_string(),
one_saves::GameId::Text(t) => t.clone(),
};
println!(" {resolver} = {id}");
}
} else {
println!(" game unidentified");
}
if let Some(source) = &header.source {
let app = source.app.as_ref().map_or_else(|| "?".to_owned(), ToString::to_string);
let kind = source.device_kind.as_ref().map_or_else(|| "?".to_owned(), ToString::to_string);
let version = source.app_version.as_deref().unwrap_or("");
println!(" source {app} {version} ({kind})");
}
if let Some(description) = &header.description {
println!(" description {description}");
}
for (key, value) in &header.extensions {
if key.as_str() == one_saves_convert::rtc::RTC_KEY {
println!(" clock {}", describe_clock(value, &header.extensions));
} else {
println!(" extension {key}");
}
}
println!(" parts {}", bundle.parts.len());
for part in &bundle.parts {
let kind = part.kind.as_str().unwrap_or("save");
let role = part.role.as_ref().map_or("primary", |r| r.as_str());
let serial = part.game.as_ref().and_then(|game| game.serial.as_deref()).unwrap_or("");
let path = 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!(
" {id:<5} {kind:10} {role:16} {:>9} bytes {} {serial:12} {slot:8} {path}",
part.payload.len(),
&short[..16]
);
println!("{}", line.trim_end());
}
Ok(())
}
fn describe_clock(value: &one_saves::dcbor::CBOR, extensions: &one_saves::Extensions) -> String {
let Some(map) = value.as_map() else {
return "unreadable".to_owned();
};
let instant = map
.get::<u64, one_saves::dcbor::CBOR>(0)
.and_then(|tagged| match tagged.as_case() {
one_saves::dcbor::CBORCase::Tagged(_, inner) => match inner.as_case() {
one_saves::dcbor::CBORCase::Unsigned(n) => Some(n.to_string()),
one_saves::dcbor::CBORCase::Negative(n) => Some(format!("-{}", i128::from(*n) + 1)),
_ => None,
},
_ => None,
})
.unwrap_or_else(|| "?".to_owned());
let chip = extensions
.keys()
.filter(|k| k.as_str() != one_saves_convert::rtc::RTC_KEY)
.find_map(|k| k.as_str().strip_prefix(&format!("{}.", one_saves_convert::rtc::RTC_KEY)));
let accuracy = map
.get::<u64, one_saves::dcbor::CBOR>(1)
.and_then(|a| match a.as_case() {
one_saves::dcbor::CBORCase::Unsigned(ms) => Some(format!(", ±{ms}ms")),
_ => None,
})
.unwrap_or_default();
match chip {
Some(chip) => format!("epoch {instant}{accuracy} ({chip})"),
None => format!("epoch {instant}{accuracy}"),
}
}
fn verify(path: &Path) -> Fallible {
let bytes = std::fs::read(path)?;
let bundle = Bundle::from_slice(&bytes)?;
let mut bad = 0;
for part in &bundle.parts {
match part.verify() {
Ok(true) => {}
Ok(false) => {
bad += 1;
eprintln!(" part {}: sha256 does not match its payload", part.id);
}
Err(e) => {
bad += 1;
eprintln!(" part {}: {e}", part.id);
}
}
}
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));
match bundle.content_hash() {
Ok(content) => println!("content {content}"),
Err(e) => println!("content unavailable: {e}"),
}
Ok(())
}
fn profiles() {
println!("{:<14} {:<18} {:<28} SYSTEMS", "NAME", "DEVICE KIND", "APP");
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} {:<28} {systems}", profile.key, profile.device_kind, profile.app);
}
println!(
"\nAny of the {} cores in the registry also works by slug or name.",
one_saves_registry::cores().len()
);
}