use std::fmt;
use std::fs;
use std::io::{self, IsTerminal, Write};
use std::panic;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
use anyhow::{Context, anyhow};
use clap::{CommandFactory, Parser};
use clap_complete::{Shell, generate};
mod decompress;
mod graphics;
mod maps;
mod rom;
mod sprites;
mod textures;
use rom::Rom;
#[derive(Parser)]
#[command(
name = "desmack",
version,
about = "Extracts the assets of a Super Mario Kart SNES ROM"
)]
struct Cli {
#[arg(required_unless_present = "completions", help = "ROM to extract")]
rom: Option<PathBuf>,
#[arg(long, help = "Extract driver sprites")]
drivers: bool,
#[arg(long, help = "Extract item sprites")]
items: bool,
#[arg(long, help = "Extract hazard sprites")]
hazards: bool,
#[arg(long, help = "Extract cast sprites")]
cast: bool,
#[arg(long, help = "Extract effect sprites")]
effects: bool,
#[arg(long, help = "Extract the HUD")]
hud: bool,
#[arg(long, help = "Extract the font")]
font: bool,
#[arg(long, help = "Extract the screens")]
screens: bool,
#[arg(long, help = "Extract the tilesets")]
tilesets: bool,
#[arg(long, help = "Extract the skies")]
skies: bool,
#[arg(long, help = "Extract the course maps")]
maps: bool,
#[arg(short, long, help = "Extract every category, without asking")]
all: bool,
#[arg(short, long, help = "Overwrite output folders without asking")]
force: bool,
#[arg(
short,
long,
value_name = "SHELL",
exclusive = true,
help = "Print a shell completion script"
)]
completions: Option<Shell>,
}
#[derive(Clone, Copy, PartialEq, Debug)]
enum Category {
Drivers,
Items,
Hazards,
Cast,
Effects,
Hud,
Font,
Screens,
Tilesets,
Skies,
Maps,
}
impl Category {
const ALL: [Category; 11] = [
Category::Drivers,
Category::Items,
Category::Hazards,
Category::Cast,
Category::Effects,
Category::Hud,
Category::Font,
Category::Screens,
Category::Tilesets,
Category::Skies,
Category::Maps,
];
fn group(self) -> &'static str {
match self {
Category::Drivers
| Category::Items
| Category::Hazards
| Category::Cast
| Category::Effects => "sprites",
Category::Hud | Category::Font | Category::Screens => "graphics",
Category::Tilesets | Category::Skies => "textures",
Category::Maps => "maps",
}
}
}
impl fmt::Display for Category {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str(&format!("{self:?}").to_lowercase())
}
}
impl Cli {
fn flagged(&self) -> Vec<Category> {
let flags = [
self.drivers,
self.items,
self.hazards,
self.cast,
self.effects,
self.hud,
self.font,
self.screens,
self.tilesets,
self.skies,
self.maps,
];
Category::ALL
.into_iter()
.zip(flags)
.filter_map(|(category, flag)| flag.then_some(category))
.collect()
}
}
fn choose(cli: &Cli, tty: bool) -> anyhow::Result<Option<Vec<Category>>> {
let flagged = cli.flagged();
if cli.all || (!tty && flagged.is_empty()) {
return Ok(Some(Category::ALL.to_vec()));
}
if !flagged.is_empty() {
return Ok(Some(flagged));
}
let mut dialog = clark::multiselect("What should desmack extract?").required(false);
for group in ["sprites", "graphics", "textures", "maps"] {
dialog = dialog.option(group);
}
let Some(groups) = dialog.interact_opt()? else {
return Ok(None);
};
let mut chosen = Vec::new();
for group in groups {
let categories = Category::ALL.into_iter().filter(|c| c.group() == group);
if categories.clone().count() == 1 {
chosen.extend(categories);
continue;
}
let mut dialog = clark::multiselect(format!("Which {group}?")).required(false);
for category in categories {
dialog = dialog.option(category);
}
let Some(picked) = dialog.interact_opt()? else {
return Ok(None);
};
chosen.extend(picked);
}
Ok(Some(
Category::ALL
.into_iter()
.filter(|c| chosen.contains(c))
.collect(),
))
}
fn may_write(folder: &Path, force: bool, tty: bool) -> anyhow::Result<bool> {
if force || !folder.is_dir() {
return Ok(true);
}
if !tty {
return Ok(false);
}
let answer = clark::confirm(format!("{} exists. Overwrite?", folder.display()))
.initial_value(false)
.interact_opt()?;
Ok(answer == Some(true))
}
fn extract(category: Category, rom: &Rom, folder: &Path) -> anyhow::Result<()> {
match category {
Category::Drivers => sprites::drivers(rom, folder),
Category::Items => sprites::items(rom, folder),
Category::Hazards => sprites::hazards(rom, folder),
Category::Cast => sprites::cast(rom, folder),
Category::Effects => sprites::effects(rom, folder),
Category::Hud => graphics::hud(rom, folder),
Category::Font => graphics::font(rom, folder),
Category::Screens => graphics::screens(rom, folder),
Category::Tilesets => textures::tilesets(rom, folder),
Category::Skies => textures::skies(rom, folder),
Category::Maps => maps::extract(rom, folder),
}
}
fn main() -> ExitCode {
let cli = Cli::parse();
if let Some(shell) = cli.completions {
let mut script = Vec::new();
generate(shell, &mut Cli::command(), "desmack", &mut script);
return match io::stdout().write_all(&script) {
Err(error) if error.kind() != io::ErrorKind::BrokenPipe => {
eprintln!("{error}");
ExitCode::FAILURE
}
_ => ExitCode::SUCCESS,
};
}
let path = cli.rom.as_deref().unwrap();
let rom = match fs::read(path).context("can't read it").and_then(Rom::parse) {
Ok(rom) => rom,
Err(error) => {
clark::cancel(format!("{}: {error:#}", path.display()));
return ExitCode::FAILURE;
}
};
clark::intro(rom.to_string());
if rom.beta() {
clark::cancel("betas can't be extracted yet");
return ExitCode::FAILURE;
}
let tty = io::stdin().is_terminal();
let folder = path.with_extension("");
let categories = match choose(&cli, tty) {
Ok(Some(categories)) if categories.is_empty() => {
clark::outro("skipped everything, nothing extracted");
return ExitCode::SUCCESS;
}
Ok(Some(categories)) => categories,
Ok(None) => {
clark::cancel("nothing extracted");
return ExitCode::SUCCESS;
}
Err(error) => {
clark::cancel(format!("{error:#}"));
return ExitCode::FAILURE;
}
};
match may_write(&folder, cli.force, tty) {
Ok(true) => {}
Ok(false) => {
clark::cancel(format!("skipped, {} exists", folder.display()));
return ExitCode::FAILURE;
}
Err(error) => {
clark::cancel(format!("{error:#}"));
return ExitCode::FAILURE;
}
}
if let Err(error) = fs::create_dir_all(&folder) {
clark::cancel(format!("can't write to {}: {error}", folder.display()));
return ExitCode::FAILURE;
}
let ci = !io::stdout().is_terminal();
let mut ok = true;
let hook = panic::take_hook();
panic::set_hook(Box::new(|_| {}));
for category in categories {
let spinner = clark::spinner()
.ci(ci)
.start(format!("extracting {category}"));
let result =
panic::catch_unwind(|| extract(category, &rom, &folder)).unwrap_or_else(|panic| {
let message = panic
.downcast_ref::<String>()
.map_or("it panicked", String::as_str);
Err(anyhow!("{message}"))
});
match result {
Ok(()) => spinner.stop(format!("extracted {category}")),
Err(error) => {
spinner.error(format!("{category} failed: {error:#}"));
ok = false;
}
}
}
panic::set_hook(hook);
clark::outro(format!("saved to {}", folder.display()));
if ok {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn completions_name_the_binary_and_every_flag() {
let mut script = Vec::new();
generate(Shell::Bash, &mut Cli::command(), "desmack", &mut script);
let script = String::from_utf8(script).unwrap();
for category in Category::ALL {
assert!(script.contains(&format!("--{category}")), "{category}");
}
assert!(script.contains("--completions"));
}
}