Made a bunch of fields that should be private private

This commit is contained in:
2026-02-22 22:21:37 -05:00
parent 94e0be0eef
commit 836d5e912a
40 changed files with 1499 additions and 929 deletions

View File

@@ -0,0 +1,53 @@
// archive/ash.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Code for the ASH decompression command in the rustii CLI.
// Might even have the compression command someday if I ever write the compression code!
use std::{str, fs};
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use clap::Subcommand;
use rustwii::archive::ash;
#[derive(Subcommand)]
#[command(arg_required_else_help = true)]
pub enum Commands {
/// Compress a file with ASH compression (NOT IMPLEMENTED)
Compress {
/// The path to the file to compress
input: String,
/// An optional output name; defaults to <input name>.ash
#[arg(short, long)]
output: Option<String>,
},
/// Decompress an ASH-compressed file
Decompress {
/// The path to the file to decompress
input: String,
/// An optional output name; defaults to <input name>.out
#[arg(short, long)]
output: Option<String>,
}
}
pub fn compress_ash(_input: &str, _output: &Option<String>) -> Result<()> {
todo!();
}
pub fn decompress_ash(input: &str, output: &Option<String>) -> Result<()> {
let in_path = Path::new(input);
if !in_path.exists() {
bail!("Compressed file \"{}\" could not be found.", in_path.display());
}
let compressed = fs::read(in_path)?;
let decompressed = ash::decompress_ash(&compressed, None, None).with_context(|| "An unknown error occurred while decompressing the data.")?;
let out_path = if output.is_some() {
PathBuf::from(output.clone().unwrap())
} else {
PathBuf::from(in_path.file_name().unwrap()).with_extension(format!("{}.out", in_path.extension().unwrap_or("".as_ref()).to_str().unwrap()))
};
fs::write(out_path.clone(), decompressed)?;
println!("Successfully decompressed ASH file to \"{}\"!", out_path.display());
Ok(())
}

View File

@@ -0,0 +1,65 @@
// archive/lz77.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Code for the LZ77 compression/decompression commands in the rustii CLI.
use std::{str, fs};
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use clap::Subcommand;
use rustwii::archive::lz77;
#[derive(Subcommand)]
#[command(arg_required_else_help = true)]
pub enum Commands {
/// Compress a file with LZ77 compression
Compress {
/// The path to the file to compress
input: String,
/// An optional output name; defaults to <input name>.lz77
#[arg(short, long)]
output: Option<String>,
},
/// Decompress an LZ77-compressed file
Decompress {
/// The path to the file to decompress
input: String,
/// An optional output name; defaults to <input name>.out
#[arg(short, long)]
output: Option<String>,
}
}
pub fn compress_lz77(input: &str, output: &Option<String>) -> Result<()> {
let in_path = Path::new(input);
if !in_path.exists() {
bail!("Input file \"{}\" could not be found.", in_path.display());
}
let decompressed = fs::read(in_path)?;
let compressed = lz77::compress_lz77(&decompressed).with_context(|| "An unknown error occurred while compressing the data.")?;
let out_path = if output.is_some() {
PathBuf::from(output.clone().unwrap())
} else {
PathBuf::from(in_path).with_extension(format!("{}.lz77", in_path.extension().unwrap_or("".as_ref()).to_str().unwrap()))
};
fs::write(out_path.clone(), compressed)?;
println!("Successfully compressed file to \"{}\"!", out_path.display());
Ok(())
}
pub fn decompress_lz77(input: &str, output: &Option<String>) -> Result<()> {
let in_path = Path::new(input);
if !in_path.exists() {
bail!("Compressed file \"{}\" could not be found.", in_path.display());
}
let compressed = fs::read(in_path)?;
let decompressed = lz77::decompress_lz77(&compressed).with_context(|| "An unknown error occurred while decompressing the data.")?;
let out_path = if output.is_some() {
PathBuf::from(output.clone().unwrap())
} else {
PathBuf::from(in_path.file_name().unwrap()).with_extension(format!("{}.out", in_path.extension().unwrap_or("".as_ref()).to_str().unwrap()))
};
fs::write(out_path.clone(), decompressed)?;
println!("Successfully decompressed LZ77 file to \"{}\"!", out_path.display());
Ok(())
}

View File

@@ -0,0 +1,6 @@
// archive/mod.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
pub mod ash;
pub mod lz77;
pub mod u8;

View File

@@ -0,0 +1,104 @@
// archive/u8.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Code for the U8 packing/unpacking commands in the rustii CLI.
use std::{str, fs};
use std::cell::RefCell;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use anyhow::{bail, Context, Result};
use clap::Subcommand;
use glob::glob;
use rustwii::archive::u8;
#[derive(Subcommand)]
#[command(arg_required_else_help = true)]
pub enum Commands {
/// Pack a directory into a U8 archive
Pack {
/// The directory to pack into a U8 archive
input: String,
/// The name of the packed U8 archive
output: String,
},
/// Unpack a U8 archive into a directory
Unpack {
/// The path to the U8 archive to unpack
input: String,
/// The directory to unpack the U8 archive to
output: String,
}
}
fn pack_dir_recursive(dir: &Rc<RefCell<u8::U8Directory>>, in_path: PathBuf) -> Result<()> {
let mut files = Vec::new();
let mut dirs = Vec::new();
for entry in glob(&format!("{}/*", in_path.display()))?.flatten() {
match fs::metadata(&entry) {
Ok(meta) if meta.is_file() => files.push(entry),
Ok(meta) if meta.is_dir() => dirs.push(entry),
_ => {} // Anything that isn't a normal file/directory just gets ignored.
}
}
for file in files {
let node = u8::U8File::new(file.file_name().unwrap().to_str().unwrap().to_owned(), fs::read(file)?);
u8::U8Directory::add_file(dir, node);
}
for child_dir in dirs {
let node = u8::U8Directory::new(child_dir.file_name().unwrap().to_str().unwrap().to_owned());
u8::U8Directory::add_dir(dir, node);
let dir = u8::U8Directory::get_child_dir(dir, child_dir.file_name().unwrap().to_str().unwrap()).unwrap();
pack_dir_recursive(&dir, child_dir)?;
}
Ok(())
}
pub fn pack_u8_archive(input: &str, output: &str) -> Result<()> {
let in_path = Path::new(input);
if !in_path.exists() {
bail!("Source directory \"{}\" could not be found.", in_path.display());
}
let out_path = PathBuf::from(output);
let node_tree = u8::U8Directory::new(String::new());
pack_dir_recursive(&node_tree, in_path.to_path_buf()).with_context(|| "A U8 archive could not be packed.")?;
let u8_archive = u8::U8Archive::from_tree(&node_tree).with_context(|| "An unknown error occurred while creating a U8 archive from the data.")?;
fs::write(&out_path, &u8_archive.to_bytes()?).with_context(|| format!("Could not open output file \"{}\" for writing.", out_path.display()))?;
println!("Successfully packed directory \"{}\" into U8 archive \"{}\"!", in_path.display(), out_path.display());
Ok(())
}
fn unpack_dir_recursive(dir: &Rc<RefCell<u8::U8Directory>>, out_path: PathBuf) -> Result<()> {
let out_path = out_path.join(&dir.borrow().name);
for file in &dir.borrow().files {
fs::write(out_path.join(&file.borrow().name), &file.borrow().data).with_context(|| format!("Failed to write output file \"{}\".", &file.borrow().name))?;
}
for dir in &dir.borrow().dirs {
if !out_path.join(&dir.borrow().name).exists() {
fs::create_dir(out_path.join(&dir.borrow().name)).with_context(|| format!("The output directory \"{}\" could not be created.", out_path.display()))?;
}
unpack_dir_recursive(dir, out_path.clone())?;
}
Ok(())
}
pub fn unpack_u8_archive(input: &str, output: &str) -> Result<()> {
let in_path = Path::new(input);
if !in_path.exists() {
bail!("Source U8 archive \"{}\" could not be found.", in_path.display());
}
let out_path = PathBuf::from(output);
if out_path.exists() {
if !out_path.is_dir() {
bail!("A file already exists with the specified directory name!");
}
} else {
fs::create_dir(&out_path).with_context(|| format!("The output directory \"{}\" could not be created.", out_path.display()))?;
}
// Extract the files and directories in the root, and then recurse over each directory to
// extract the files and directories they contain.
let u8_archive = u8::U8Archive::from_bytes(&fs::read(in_path).with_context(|| format!("Input file \"{}\" could not be read.", in_path.display()))?)?;
unpack_dir_recursive(&u8_archive.node_tree, out_path.clone())?;
println!("Successfully unpacked U8 archive to directory \"{}\"!", out_path.display());
Ok(())
}

View File

@@ -0,0 +1,104 @@
// filetypes.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Common code for identifying Wii file types.
use std::{str, fs::File};
use std::io::{Read, Seek, SeekFrom};
use std::path::Path;
use regex::RegexBuilder;
#[derive(Debug)]
#[derive(PartialEq)]
pub enum WiiFileType {
Wad,
Tmd,
Ticket,
U8,
}
pub fn identify_file_type(input: &str) -> Option<WiiFileType> {
let input = Path::new(input);
let re = RegexBuilder::new(r"tmd\.?[0-9]*").case_insensitive(true).build().unwrap();
// == TMD ==
if re.is_match(input.to_str()?) ||
input.file_name().is_some_and(|f| f.eq_ignore_ascii_case("tmd.bin")) ||
input.extension().is_some_and(|f| f.eq_ignore_ascii_case("tmd")) {
return Some(WiiFileType::Tmd);
}
// == Ticket ==
if input.extension().is_some_and(|f| f.eq_ignore_ascii_case("tik")) ||
input.file_name().is_some_and(|f| f.eq_ignore_ascii_case("ticket.bin")) ||
input.file_name().is_some_and(|f| f.eq_ignore_ascii_case("cetk")) {
return Some(WiiFileType::Ticket);
}
// == WAD ==
if input.extension().is_some_and(|f| f.eq_ignore_ascii_case("wad")) {
return Some(WiiFileType::Wad);
}
// == U8 ==
if input.extension().is_some_and(|f| f.eq_ignore_ascii_case("arc")) ||
input.extension().is_some_and(|f| f.eq_ignore_ascii_case("app")) {
return Some(WiiFileType::U8);
}
// == Advanced ==
// These require reading the magic number of the file, so we only try this after everything
// else has been tried. These are separated from the other methods of detecting these types so
// that we only have to open the file for reading once.
if input.exists() {
let mut f = File::open(input).unwrap();
// We need to read more bytes for WADs since they don't have a proper magic number.
let mut magic_number = vec![0u8; 8];
f.read_exact(&mut magic_number).unwrap();
if magic_number == b"\x00\x00\x00\x20\x49\x73\x00\x00" || magic_number == b"\x00\x00\x00\x20\x69\x62\x00\x00" {
return Some(WiiFileType::Wad);
}
let mut magic_number = vec![0u8; 4];
f.seek(SeekFrom::Start(0)).unwrap();
f.read_exact(&mut magic_number).unwrap();
if magic_number == b"\x55\xAA\x38\x2D" {
return Some(WiiFileType::U8);
}
}
// == No match found! ==
None
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_parse_tmd() {
assert_eq!(identify_file_type("tmd"), Some(WiiFileType::Tmd));
assert_eq!(identify_file_type("TMD"), Some(WiiFileType::Tmd));
assert_eq!(identify_file_type("tmd.bin"), Some(WiiFileType::Tmd));
assert_eq!(identify_file_type("TMD.BIN"), Some(WiiFileType::Tmd));
assert_eq!(identify_file_type("tmd.513"), Some(WiiFileType::Tmd));
assert_eq!(identify_file_type("0000000100000002.tmd"), Some(WiiFileType::Tmd));
assert_eq!(identify_file_type("0000000100000002.TMD"), Some(WiiFileType::Tmd));
}
#[test]
fn test_parse_tik() {
assert_eq!(identify_file_type("ticket.bin"), Some(WiiFileType::Ticket));
assert_eq!(identify_file_type("TICKET.BIN"), Some(WiiFileType::Ticket));
assert_eq!(identify_file_type("cetk"), Some(WiiFileType::Ticket));
assert_eq!(identify_file_type("CETK"), Some(WiiFileType::Ticket));
assert_eq!(identify_file_type("0000000100000002.tik"), Some(WiiFileType::Ticket));
assert_eq!(identify_file_type("0000000100000002.TIK"), Some(WiiFileType::Ticket));
}
#[test]
fn test_parse_wad() {
assert_eq!(identify_file_type("0000000100000002.wad"), Some(WiiFileType::Wad));
assert_eq!(identify_file_type("0000000100000002.WAD"), Some(WiiFileType::Wad));
}
#[test]
fn test_parse_no_match() {
assert_eq!(identify_file_type("somefile.txt"), None);
}
}

302
src/bin/rustwii/info.rs Normal file
View File

@@ -0,0 +1,302 @@
// info.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Code for the info command in the rustii CLI.
use std::{str, fs};
use std::cell::RefCell;
use std::path::Path;
use std::rc::Rc;
use anyhow::{bail, Context, Result};
use rustwii::archive::u8;
use rustwii::{title, title::cert, title::tmd, title::ticket, title::wad, title::versions};
use crate::filetypes::{WiiFileType, identify_file_type};
// Avoids duplicated code, since both TMD and Ticket info print the TID in the same way.
fn print_tid(title_id: [u8; 8]) -> Result<()> {
let ascii = String::from_utf8_lossy(&title_id[4..]).trim_end_matches('\0').trim_start_matches('\0').to_owned();
let ascii_tid = if ascii.len() == 4 {
Some(ascii)
} else {
None
};
if ascii_tid.is_some() {
println!(" Title ID: {} ({})", hex::encode(title_id).to_uppercase(), ascii_tid.unwrap());
} else {
println!(" Title ID: {}", hex::encode(title_id).to_uppercase());
}
Ok(())
}
// Same as above, both the TMD and Ticket info print the title version in the same way.
fn print_title_version(title_version: u16, title_id: [u8; 8], is_vwii: bool) -> Result<()> {
let converted_ver = versions::dec_to_standard(title_version, &hex::encode(title_id), Some(is_vwii));
if hex::encode(title_id).eq("0000000100000001") {
println!(" Title Version: {} (boot2v{})", title_version, title_version);
} else if hex::encode(title_id)[..8].eq("00000001") && converted_ver.is_some() {
println!(" Title Version: {} ({})", title_version, converted_ver.unwrap());
} else {
println!(" Title Version: {}", title_version);
}
Ok(())
}
fn print_tmd_info(tmd: tmd::TMD, cert: Option<cert::Certificate>) -> Result<()> {
// Print all important keys from the TMD.
println!("Title Info");
print_tid(tmd.title_id())?;
print_title_version(tmd.title_version(), tmd.title_id(), tmd.is_vwii())?;
println!(" TMD Version: {}", tmd.tmd_version());
if hex::encode(tmd.ios_tid()).eq("0000000000000000") {
println!(" Required IOS: N/A");
}
else if hex::encode(tmd.ios_tid()).ne(&format!("{:016X}", tmd.title_version())) {
println!(" Required IOS: IOS{} ({})", tmd.ios_tid().last().unwrap(), hex::encode(tmd.ios_tid()).to_uppercase());
}
let signature_issuer = String::from_utf8(Vec::from(tmd.signature_issuer())).unwrap_or_default();
if signature_issuer.contains("CP00000004") {
println!(" Certificate: CP00000004 (Retail)");
println!(" Certificate Issuer: Root-CA00000001 (Retail)");
}
else if signature_issuer.contains("CP00000007") {
println!(" Certificate: CP00000007 (Development)");
println!(" Certificate Issuer: Root-CA00000002 (Development)");
}
else if signature_issuer.contains("CP00000005") {
println!(" Certificate: CP00000005 (Development/Unknown)");
println!(" Certificate Issuer: Root-CA00000002 (Development)");
}
else if signature_issuer.contains("CP10000000") {
println!(" Certificate: CP10000000 (Arcade)");
println!(" Certificate Issuer: Root-CA10000000 (Arcade)");
}
else {
println!(" Certificate Info: {} (Unknown)", signature_issuer);
}
let region = if hex::encode(tmd.title_id()).eq("0000000100000002") {
match versions::dec_to_standard(tmd.title_version(), &hex::encode(tmd.title_id()), Some(tmd.is_vwii() != false))
.unwrap_or_default().chars().last() {
Some('U') => "USA",
Some('E') => "EUR",
Some('J') => "JPN",
Some('K') => "KOR",
_ => "None"
}
} else if matches!(tmd.title_type(), Ok(tmd::TitleType::System)) {
"None"
} else {
tmd.region()
};
println!(" Region: {}", region);
println!(" Title Type: {}", tmd.title_type()?);
println!(" vWii Title: {}", tmd.is_vwii() != false);
println!(" DVD Video Access: {}", tmd.check_access_right(tmd::AccessRight::DVDVideo));
println!(" AHB Access: {}", tmd.check_access_right(tmd::AccessRight::AHB));
if cert.is_some() {
let signing_str = match cert::verify_tmd(&cert.unwrap(), &tmd) {
Ok(result) => match result {
true => "Valid (Unmodified TMD)",
false => {
if tmd.is_fakesigned() {
"Fakesigned"
} else {
"Invalid (Modified TMD)"
}
},
},
Err(_) => {
if tmd.is_fakesigned() {
"Fakesigned"
} else {
"Invalid (Modified TMD)"
}
}
};
println!(" Signature: {}", signing_str);
} else {
println!(" Fakesigned: {}", tmd.is_fakesigned());
}
println!("\nContent Info");
println!(" Total Contents: {}", tmd.content_records().len());
println!(" Boot Content Index: {}", tmd.boot_index());
println!(" Content Records:");
for content in tmd.content_records().iter() {
println!(" Content Index: {}", content.index);
println!(" Content ID: {:08X}", content.content_id);
println!(" Content Type: {}", content.content_type);
println!(" Content Size: {} bytes ({} blocks)", content.content_size, title::bytes_to_blocks(content.content_size as usize));
println!(" Content Hash: {}", hex::encode(content.content_hash));
}
Ok(())
}
fn print_ticket_info(ticket: ticket::Ticket, cert: Option<cert::Certificate>) -> Result<()> {
// Print all important keys from the Ticket.
println!("Ticket Info");
print_tid(ticket.title_id())?;
print_title_version(ticket.title_version(), ticket.title_id(), ticket.common_key_index() == 2)?;
println!(" Ticket Version: {}", ticket.ticket_version());
let signature_issuer = String::from_utf8(Vec::from(ticket.signature_issuer())).unwrap_or_default();
if signature_issuer.contains("XS00000003") {
println!(" Certificate: XS00000003 (Retail)");
println!(" Certificate Issuer: Root-CA00000001 (Retail)");
} else if signature_issuer.contains("XS00000006") {
println!(" Certificate: XS00000006 (Development)");
println!(" Certificate Issuer: Root-CA00000002 (Development)");
} else if signature_issuer.contains("XS00000004") {
println!(" Certificate: XS00000004 (Development/Unknown)");
println!(" Certificate Issuer: Root-CA00000002 (Development)");
} else {
println!(" Certificate Info: {} (Unknown)", signature_issuer);
}
let key = match ticket.common_key_index() {
0 => {
if ticket.is_dev() { "Common (Development)" }
else { "Common (Retail)" }
}
1 => "Korean",
2 => "vWii",
_ => "Unknown (Likely Common)"
};
println!(" Decryption Key: {}", key);
println!(" Title Key (Encrypted): {}", hex::encode(ticket.title_key()));
println!(" Title Key (Decrypted): {}", hex::encode(ticket.title_key_dec()));
if cert.is_some() {
let signing_str = match cert::verify_ticket(&cert.unwrap(), &ticket) {
Ok(result) => match result {
true => "Valid (Unmodified Ticket)",
false => {
if ticket.is_fakesigned() {
"Fakesigned"
} else {
"Invalid (Modified Ticket)"
}
},
},
Err(_) => {
if ticket.is_fakesigned() {
"Fakesigned"
} else {
"Invalid (Modified Ticket)"
}
}
};
println!(" Signature: {}", signing_str);
} else {
println!(" Fakesigned: {}", ticket.is_fakesigned());
}
Ok(())
}
fn print_wad_info(wad: wad::WAD) -> Result<()> {
println!("WAD Info");
match wad.wad_type() {
wad::WADType::ImportBoot => { println!(" WAD Type: boot2") },
wad::WADType::Installable => { println!(" WAD Type: Standard Installable") },
}
// Create a Title for size info, signing info and TMD/Ticket info.
let title = title::Title::from_wad(&wad).with_context(|| "The provided WAD file could not be parsed, and is likely invalid.")?;
let min_size_blocks = title::bytes_to_blocks(title.title_size(None)?);
let max_size_blocks = title::bytes_to_blocks(title.title_size(Some(true))?);
if min_size_blocks == max_size_blocks {
println!(" Installed Size: {} blocks", min_size_blocks);
} else {
println!(" Installed Size: {}-{} blocks", min_size_blocks, max_size_blocks);
}
let min_size = title.title_size(None)? as f64 / 1048576.0;
let max_size = title.title_size(Some(true))? as f64 / 1048576.0;
if min_size == max_size {
println!(" Installed Size (MB): {:.2} MB", min_size);
} else {
println!(" Installed Size (MB): {:.2}-{:.2} MB", min_size, max_size);
}
println!(" Has Meta/Footer: {}", wad.meta_size() != 0);
println!(" Has CRL: {}", wad.crl_size() != 0);
let signing_str = match title.verify() {
Ok(result) => match result {
true => "Legitimate (Unmodified TMD + Ticket)",
false => {
if title.is_fakesigned() {
"Fakesigned"
} else if cert::verify_tmd(&title.cert_chain.tmd_cert(), &title.tmd)? {
"Piratelegit (Unmodified TMD, Modified Ticket)"
} else if cert::verify_ticket(&title.cert_chain.ticket_cert(), &title.ticket)? {
"Edited (Modified TMD, Unmodified Ticket)"
} else {
"Illegitimate (Modified TMD + Ticket)"
}
},
},
Err(_) => {
if title.is_fakesigned() {
"Fakesigned"
} else {
"Illegitimate (Modified TMD + Ticket)"
}
}
};
println!(" Signing Status: {}", signing_str);
println!();
print_ticket_info(title.ticket, Some(title.cert_chain.ticket_cert()))?;
println!();
print_tmd_info(title.tmd, Some(title.cert_chain.tmd_cert()))?;
Ok(())
}
fn print_full_tree(dir: &Rc<RefCell<u8::U8Directory>>, indent: usize) {
let prefix = " ".repeat(indent);
let dir_name = if !dir.borrow().name.is_empty() {
&dir.borrow().name
} else {
&String::from("root")
};
println!("{}D {}", prefix, dir_name);
// Print subdirectories
for subdir in &dir.borrow().dirs {
print_full_tree(subdir, indent + 1);
}
// Print files
for file in &dir.borrow().files {
let file_name = &file.borrow().name;
println!("{} F {}", prefix, file_name);
}
}
fn print_u8_info(u8_archive: u8::U8Archive) -> Result<()> {
println!("U8 Archive Info");
println!(" Node Count: {}", u8_archive.node_tree.borrow().count());
println!(" Archive Data:");
print_full_tree(&u8_archive.node_tree, 2);
Ok(())
}
pub fn info(input: &str) -> Result<()> {
let in_path = Path::new(input);
if !in_path.exists() {
bail!("Input file \"{}\" does not exist.", in_path.display());
}
match identify_file_type(input) {
Some(WiiFileType::Tmd) => {
let tmd = tmd::TMD::from_bytes(&fs::read(in_path)?).with_context(|| "The provided TMD file could not be parsed, and is likely invalid.")?;
print_tmd_info(tmd, None)?;
},
Some(WiiFileType::Ticket) => {
let ticket = ticket::Ticket::from_bytes(&fs::read(in_path)?).with_context(|| "The provided Ticket file could not be parsed, and is likely invalid.")?;
print_ticket_info(ticket, None)?;
},
Some(WiiFileType::Wad) => {
let wad = wad::WAD::from_bytes(&fs::read(in_path)?).with_context(|| "The provided WAD file could not be parsed, and is likely invalid.")?;
print_wad_info(wad)?;
},
Some(WiiFileType::U8) => {
let u8_archive = u8::U8Archive::from_bytes(&fs::read(in_path)?).with_context(|| "The provided U8 archive could not be parsed, and is likely invalid.")?;
print_u8_info(u8_archive)?;
}
None => {
bail!("Information cannot be displayed for this file type.");
}
}
Ok(())
}

185
src/bin/rustwii/main.rs Normal file
View File

@@ -0,0 +1,185 @@
// main.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Base for the rustii CLI that handles argument parsing and directs execution to the proper module.
mod archive;
mod title;
mod filetypes;
mod info;
mod nand;
use anyhow::Result;
use clap::{Subcommand, Parser};
#[derive(Parser)]
#[command(version, about, long_about = None)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
#[command(arg_required_else_help = true)]
enum Commands {
/// Decompress data using ASH compression
Ash {
#[command(subcommand)]
command: archive::ash::Commands,
},
/// Manage Wii EmuNANDs
Emunand {
#[command(subcommand)]
command: nand::emunand::Commands,
},
/// Fakesign a TMD, Ticket, or WAD (trucha bug)
Fakesign {
/// The path to a TMD, Ticket, or WAD
input: String,
/// An (optional) output name; defaults to overwriting input file if not provided
#[arg(short, long)]
output: Option<String>,
},
/// Get information about a TMD, Ticket, or WAD
Info {
/// The path to a TMD, Ticket, or WAD
input: String,
},
/// Compress/decompress data using LZ77 compression
Lz77 {
#[command(subcommand)]
command: archive::lz77::Commands
},
/// Download data from the NUS
Nus {
#[command(subcommand)]
command: title::nus::Commands
},
/// Manage setting.txt
Setting {
#[command(subcommand)]
command: nand::setting::Commands
},
/// Pack/unpack a U8 archive
U8 {
#[command(subcommand)]
command: archive::u8::Commands
},
/// Pack/unpack/edit a WAD file
Wad {
#[command(subcommand)]
command: title::wad::Commands,
},
}
fn main() -> Result<()> {
let cli = Cli::parse();
match &cli.command {
Some(Commands::Ash { command }) => {
match command {
archive::ash::Commands::Compress { input, output } => {
archive::ash::compress_ash(input, output)?
},
archive::ash::Commands::Decompress { input, output } => {
archive::ash::decompress_ash(input, output)?
}
}
},
Some(Commands::Emunand { command }) => {
match command {
nand::emunand::Commands::Info { emunand } => {
nand::emunand::info(emunand)?
},
nand::emunand::Commands::InstallMissing { emunand, vwii } => {
nand::emunand::install_missing(emunand, vwii)?
},
nand::emunand::Commands::InstallTitle { wad, emunand, override_meta} => {
nand::emunand::install_title(wad, emunand, override_meta)?
},
nand::emunand::Commands::UninstallTitle { tid, emunand, remove_ticket } => {
nand::emunand::uninstall_title(tid, emunand, remove_ticket)?
}
}
}
Some(Commands::Fakesign { input, output }) => {
title::fakesign::fakesign(input, output)?
},
Some(Commands::Info { input }) => {
info::info(input)?
},
Some(Commands::Lz77 { command }) => {
match command {
archive::lz77::Commands::Compress { input, output } => {
archive::lz77::compress_lz77(input, output)?
},
archive::lz77::Commands::Decompress { input, output } => {
archive::lz77::decompress_lz77(input, output)?
}
}
},
Some(Commands::Nus { command }) => {
match command {
title::nus::Commands::Content { tid, cid, version, output, decrypt} => {
title::nus::download_content(tid, cid, version, output, decrypt)?
},
title::nus::Commands::Ticket { tid, output } => {
title::nus::download_ticket(tid, output)?
},
title::nus::Commands::Title { tid, version, output} => {
title::nus::download_title(tid, version, output)?
}
title::nus::Commands::Tmd { tid, version, output} => {
title::nus::download_tmd(tid, version, output)?
}
}
},
Some(Commands::Setting { command }) => {
match command {
nand::setting::Commands::Decrypt { input, output } => {
nand::setting::decrypt_setting(input, output)?;
},
nand::setting::Commands::Encrypt { input, output } => {
nand::setting::encrypt_setting(input, output)?;
}
}
},
Some(Commands::U8 { command }) => {
match command {
archive::u8::Commands::Pack { input, output } => {
archive::u8::pack_u8_archive(input, output)?
},
archive::u8::Commands::Unpack { input, output } => {
archive::u8::unpack_u8_archive(input, output)?
}
}
},
Some(Commands::Wad { command }) => {
match command {
title::wad::Commands::Add { input, content, output, cid, r#type } => {
title::wad::add_wad(input, content, output, cid, r#type)?
},
title::wad::Commands::Convert { input, target, output } => {
title::wad::convert_wad(input, target, output)?
},
title::wad::Commands::Edit { input, output, edits } => {
title::wad::edit_wad(input, output, edits)?
},
title::wad::Commands::Pack { input, output} => {
title::wad::pack_wad(input, output)?
},
title::wad::Commands::Remove { input, output, identifier } => {
title::wad::remove_wad(input, output, identifier)?
},
title::wad::Commands::Set { input, content, output, identifier, r#type} => {
title::wad::set_wad(input, content, output, identifier, r#type)?
},
title::wad::Commands::Unpack { input, output } => {
title::wad::unpack_wad(input, output)?
},
}
},
None => { /* Clap handles no passed command by itself */}
}
Ok(())
}

View File

@@ -0,0 +1,336 @@
// nand/emunand.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Code for EmuNAND-related commands in the rustii CLI.
use std::{str, fs};
use std::path::{absolute, Path};
use anyhow::{bail, Context, Result};
use clap::Subcommand;
use walkdir::WalkDir;
use rustwii::nand::{emunand, setting};
use rustwii::title::{nus, tmd};
use rustwii::title;
#[derive(Subcommand)]
#[command(arg_required_else_help = true)]
pub enum Commands {
/// Display information about an EmuNAND
Info {
emunand: String,
},
/// Automatically install missing IOSes to an EmuNAND
InstallMissing {
/// The path to the target EmuNAND
emunand: String,
/// Explicitly install vWii IOSes instead of detecting the EmuNAND type automatically
#[clap(long)]
vwii: bool
},
/// Install a WAD file to an EmuNAND
InstallTitle {
/// The path to the WAD file to install
wad: String,
/// The path to the target EmuNAND
emunand: String,
/// Install the content at index 0 as title.met; this will override any meta/footer data
/// included in the WAD
#[clap(long)]
override_meta: bool,
},
/// Uninstall a title from an EmuNAND
UninstallTitle {
/// The Title ID of the title to uninstall, or the path to a WAD file to read the Title ID
/// from
tid: String,
/// The path to the target EmuNAND
emunand: String,
/// Remove the Ticket file; default behavior is to leave it intact
#[clap(long)]
remove_ticket: bool,
}
}
pub fn info(emunand: &str) -> Result<()> {
let emunand_path = Path::new(emunand);
if !emunand_path.exists() {
bail!("Target EmuNAND directory \"{}\" could not be found.", emunand_path.display());
}
let emunand = emunand::EmuNAND::open(emunand_path.to_path_buf())?;
// Summarize all the details of an EmuNAND.
println!("EmuNAND Info");
println!(" Path: {}", absolute(emunand_path)?.display());
let mut is_vwii = false;
match emunand.get_title_tmd([0, 0, 0, 1, 0, 0, 0, 2]) {
Some(tmd) => {
is_vwii = tmd.is_vwii();
println!(" System Menu Version: {}", title::versions::dec_to_standard(tmd.title_version(), "0000000100000002", Some(is_vwii)).unwrap());
},
None => {
println!(" System Menu Version: None");
}
}
let setting_path = emunand.get_emunand_dir("title").unwrap()
.join("00000001")
.join("00000002")
.join("data")
.join("setting.txt");
if setting_path.exists() {
let setting_txt = setting::SettingTxt::from_bytes(&fs::read(setting_path)?)?;
println!(" System Region: {}", setting_txt.area);
} else {
println!(" System Region: N/A");
}
if is_vwii {
println!(" Type: vWii");
} else {
println!(" Type: Wii");
}
let categories = emunand.get_installed_titles();
let mut installed_count = 0;
for category in &categories {
if category.title_type != "00010000" {
for _ in &category.titles {
installed_count += 1;
}
}
}
println!(" Installed Titles: {}", installed_count);
let total_size: u64 = WalkDir::new(emunand.get_emunand_dir("root").unwrap())
.into_iter()
.filter_map(Result::ok)
.filter(|entry| entry.file_type().is_file())
.map(|entry| fs::metadata(entry.path()).map(|m| m.len()).unwrap_or(0))
.sum();
println!(" Space Used: {} blocks ({:.2} MB)", title::bytes_to_blocks(total_size as usize), total_size as f64 / 1048576.0);
println!();
// Build a catalog of all installed titles so that we can display them.
let mut installed_ioses: Vec<String> = Vec::new();
let mut installed_titles: Vec<String> = Vec::new();
let mut disc_titles: Vec<String> = Vec::new();
for category in categories {
if category.title_type == "00000001" {
let mut ioses: Vec<u32> = Vec::new();
for title in category.titles {
if title != "00000002" {
ioses.push(u32::from_str_radix(&title, 16)?);
}
}
ioses.sort();
ioses.iter().for_each(|x| installed_ioses.push(format!("00000001{:08X}", x)));
} else if category.title_type != "00010000" {
category.titles.iter().for_each(|x| installed_titles.push(format!("{}{}", category.title_type, x).to_ascii_uppercase()));
} else if category.title_type == "00000000" {
category.titles.iter().filter(|x| x.as_str() != "48415A41")
.for_each(|x| disc_titles.push(format!("{}{}", category.title_type, x).to_ascii_uppercase()));
}
}
// Print the titles that are installed to the EmuNAND.
if !installed_ioses.is_empty() {
println!("System Titles:");
for ios in &installed_ioses {
if ["00000001", "00000100", "00000101", "00000200", "00000201"].contains(&&ios[8..16]) {
if ios[8..16].eq("00000001") {
println!(" boot2 ({})", ios.to_ascii_uppercase());
} else if ios[8..16].eq("00000100") {
println!(" BC ({})", ios.to_ascii_uppercase());
} else if ios[8..16].eq("00000101") {
println!(" MIOS ({})", ios.to_ascii_uppercase());
} else if ios[8..16].eq("00000200") {
println!(" BC-NAND ({})", ios.to_ascii_uppercase());
} else if ios[8..16].eq("00000201") {
println!(" BC-WFS ({})", ios.to_ascii_uppercase());
}
let tmd = emunand.get_title_tmd(hex::decode(ios)?.try_into().unwrap()).unwrap();
println!(" Version: {}", tmd.title_version());
}
else {
println!(" IOS{} ({})", u32::from_str_radix(&ios[8..16], 16)?, ios.to_ascii_uppercase());
let tmd = emunand.get_title_tmd(hex::decode(ios)?.try_into().unwrap()).unwrap();
println!(" Version: {} ({})", tmd.title_version(), title::versions::dec_to_standard(tmd.title_version(), ios, None).unwrap());
}
}
println!();
}
let mut missing_ioses: Vec<String> = Vec::new();
if !installed_titles.is_empty() {
println!("Installed Titles:");
for title in installed_titles {
let ascii = String::from_utf8_lossy(&hex::decode(&title[8..16])?).to_string();
let ascii_tid = if ascii.len() == 4 {
Some(ascii)
} else {
None
};
if ascii_tid.is_some() {
println!(" {} ({})", title.to_uppercase(), ascii_tid.unwrap());
} else {
println!(" {}", title.to_uppercase());
}
let tmd = emunand.get_title_tmd(hex::decode(&title)?.try_into().unwrap()).unwrap();
println!(" Version: {}", tmd.title_version());
let ios_tid = &hex::encode(tmd.ios_tid()).to_ascii_uppercase();
print!(" Required IOS: IOS{} ({})", u32::from_str_radix(&hex::encode(&tmd.ios_tid()[4..8]), 16)?, ios_tid);
if !installed_ioses.contains(ios_tid) {
println!(" *");
if !missing_ioses.contains(ios_tid) {
missing_ioses.push(String::from(ios_tid));
}
}
else {
println!();
}
}
println!();
}
if !disc_titles.is_empty() {
println!("Save data was found for the following disc titles:");
for title in disc_titles {
let ascii = String::from_utf8_lossy(&hex::decode(&title[8..16])?).to_string();
let ascii_tid = if ascii.len() == 4 {
Some(ascii)
} else {
None
};
if ascii_tid.is_some() {
println!(" {} ({})", title.to_uppercase(), ascii_tid.unwrap());
} else {
println!(" {}", title.to_uppercase());
}
}
println!();
}
// Finally, list IOSes that are required by an installed title but are not currently installed.
// This message is sponsored by `rustii emunand install-missing`.
if !missing_ioses.is_empty() {
println!("Some titles installed are missing their required IOS. These missing IOSes are \
marked with \"*\" in the title list above. If these IOSes are not installed, the titles \
requiring them will not launch. The IOSes required but not installed are:");
for missing in missing_ioses {
println!(" IOS{} ({})", u32::from_str_radix(&missing[8..16], 16)?, missing);
}
println!("Missing IOSes can be automatically installed using the install-missing command.");
}
Ok(())
}
pub fn install_missing(emunand: &str, vwii: &bool) -> Result<()> {
let emunand_path = Path::new(emunand);
if !emunand_path.exists() {
bail!("Target EmuNAND directory \"{}\" could not be found.", emunand_path.display());
}
let emunand = emunand::EmuNAND::open(emunand_path.to_path_buf())?;
// Determine Wii vs vWii EmuNAND.
let vwii = if *vwii {
true
} else {
match emunand.get_title_tmd([0, 0, 0, 1, 0, 0, 0, 2]) {
Some(tmd) => {
tmd.is_vwii()
},
None => {
false
}
}
};
// Build a list of IOSes that are required by at least one installed title but are not
// installed themselves. Then from there we can call the NUS download_title() function to
// download and trigger an EmuNAND install for each of them.
let categories = emunand.get_installed_titles();
let mut installed_ioses: Vec<String> = Vec::new();
let mut installed_titles: Vec<String> = Vec::new();
for category in categories {
if category.title_type == "00000001" {
let mut ioses: Vec<u32> = Vec::new();
for title in category.titles {
if title == "00000002" {
installed_titles.push(format!("{}{}", category.title_type, title));
} else if title != "00000001" {
ioses.push(u32::from_str_radix(&title, 16)?);
}
}
ioses.sort();
ioses.iter().for_each(|x| installed_ioses.push(format!("00000001{:08X}", x)));
} else if category.title_type != "00010000" {
category.titles.iter().for_each(|x| installed_titles.push(format!("{}{}", category.title_type, x)));
}
}
let title_tmds: Vec<tmd::TMD> = installed_titles.iter().map(|x| emunand.get_title_tmd(hex::decode(x).unwrap().try_into().unwrap()).unwrap()).collect();
let mut missing_ioses: Vec<u32> = title_tmds.iter()
.filter(|x| !installed_ioses.contains(&hex::encode(x.ios_tid()).to_ascii_uppercase()))
.map(|x| u32::from_str_radix(&hex::encode(&x.ios_tid()[4..8]), 16).unwrap()).collect();
if missing_ioses.is_empty() {
bail!("All required IOSes are already installed!");
}
missing_ioses.sort();
// Because we don't need to install the same IOS for every single title that requires it.
missing_ioses.dedup();
let missing_tids: Vec<[u8; 8]> = {
if vwii {
missing_ioses.iter().map(|x| {
let mut tid = [0u8; 8];
tid[3] = 7;
tid[4..8].copy_from_slice(&x.to_be_bytes());
tid
}).collect()
} else {
missing_ioses.iter().map(|x| {
let mut tid = [0u8; 8];
tid[3] = 1;
tid[4..8].copy_from_slice(&x.to_be_bytes());
tid
}).collect()
}
};
println!("Missing IOSes:");
for ios in &missing_tids {
println!(" IOS{} ({})", u32::from_str_radix(&hex::encode(&ios[4..8]), 16)?, hex::encode(ios).to_ascii_uppercase());
}
println!();
for ios in missing_tids {
println!("Downloading IOS{} ({})...", u32::from_str_radix(&hex::encode(&ios[4..8]), 16)?, hex::encode(ios).to_ascii_uppercase());
let title = nus::download_title(ios, None, true)?;
let version = title.tmd.title_version();
println!(" Installing IOS{} ({}) v{}...", u32::from_str_radix(&hex::encode(&ios[4..8]), 16)?, hex::encode(ios).to_ascii_uppercase(), version);
emunand.install_title(title, false)?;
println!(" Installed IOS{} ({}) v{}!", u32::from_str_radix(&hex::encode(&ios[4..8]), 16)?, hex::encode(ios).to_ascii_uppercase(), version);
}
println!("\nAll missing IOSes have been installed!");
Ok(())
}
pub fn install_title(wad: &str, emunand: &str, override_meta: &bool) -> Result<()> {
let wad_path = Path::new(wad);
if !wad_path.exists() {
bail!("Source WAD \"{}\" could not be found.", wad_path.display());
}
let emunand_path = Path::new(emunand);
if !emunand_path.exists() {
bail!("Target EmuNAND directory \"{}\" could not be found.", emunand_path.display());
}
let wad_file = fs::read(wad_path).with_context(|| format!("Failed to open WAD file \"{}\" for reading.", wad_path.display()))?;
let title = title::Title::from_bytes(&wad_file).with_context(|| format!("The provided WAD file \"{}\" appears to be invalid.", wad_path.display()))?;
let emunand = emunand::EmuNAND::open(emunand_path.to_path_buf())?;
emunand.install_title(title, *override_meta)?;
println!("Successfully installed WAD \"{}\" to EmuNAND at \"{}\"!", wad_path.display(), emunand_path.display());
Ok(())
}
pub fn uninstall_title(tid: &str, emunand: &str, remove_ticket: &bool) -> Result<()> {
let emunand_path = Path::new(emunand);
if !emunand_path.exists() {
bail!("Target EmuNAND directory \"{}\" could not be found.", emunand_path.display());
}
let tid_as_path = Path::new(&tid);
let tid_bin: [u8; 8] = if tid_as_path.exists() {
let wad_file = fs::read(tid_as_path).with_context(|| format!("Failed to open WAD file \"{}\" for reading.", tid_as_path.display()))?;
let title = title::Title::from_bytes(&wad_file).with_context(|| format!("The provided WAD file \"{}\" appears to be invalid.", tid_as_path.display()))?;
title.tmd.title_id()
} else {
hex::decode(tid).with_context(|| "The specified Title ID is not valid! The Title ID must be in hex format.")?.try_into().unwrap()
};
let emunand = emunand::EmuNAND::open(emunand_path.to_path_buf())?;
emunand.uninstall_title(tid_bin, *remove_ticket)?;
println!("Successfully uninstalled title with Title ID \"{}\" from EmuNAND at \"{}\"!", hex::encode(tid_bin).to_ascii_uppercase(), emunand_path.display());
Ok(())
}

View File

@@ -0,0 +1,5 @@
// nand/mod.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
pub mod emunand;
pub mod setting;

View File

@@ -0,0 +1,61 @@
// nand/setting.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Code for setting.txt-related commands in the rustii CLI.
use std::{str, fs};
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use clap::Subcommand;
use rustwii::nand::setting;
#[derive(Subcommand)]
#[command(arg_required_else_help = true)]
pub enum Commands {
/// Decrypt setting.txt
Decrypt {
/// The path to the setting.txt file to decrypt
input: String,
/// An optional output path; defaults to setting_dec.txt
#[arg(short, long)]
output: Option<String>,
},
/// Encrypt setting.txt
Encrypt {
/// The path to the setting.txt to encrypt
input: String,
/// An optional output path; defaults to setting_enc.txt
#[arg(short, long)]
output: Option<String>,
}
}
pub fn decrypt_setting(input: &str, output: &Option<String>) -> Result<()> {
let in_path = Path::new(input);
if !in_path.exists() {
bail!("Source file \"{}\" could not be found.", in_path.display());
}
let out_path = if output.is_some() {
PathBuf::from(output.clone().unwrap()).with_extension("txt")
} else {
PathBuf::from("setting_dec.txt")
};
let setting = setting::SettingTxt::from_bytes(&fs::read(in_path)?).with_context(|| "The provided setting.txt could not be parsed, and is likely invalid.")?;
fs::write(out_path, setting.to_string()?)?;
Ok(())
}
pub fn encrypt_setting(input: &str, output: &Option<String>) -> Result<()> {
let in_path = Path::new(input);
if !in_path.exists() {
bail!("Source file \"{}\" could not be found.", in_path.display());
}
let out_path = if output.is_some() {
PathBuf::from(output.clone().unwrap()).with_extension("txt")
} else {
PathBuf::from("setting_enc.txt")
};
let setting = setting::SettingTxt::from_string(String::from_utf8(fs::read(in_path)?).with_context(|| "Invalid characters found in input file!")?)?;
fs::write(out_path, setting.to_bytes()?)?;
Ok(())
}

View File

@@ -0,0 +1,65 @@
// title/fakesign.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Code for the fakesign command in the rustii CLI.
use std::{str, fs};
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use rustwii::{title, title::tmd, title::ticket};
use crate::filetypes::{WiiFileType, identify_file_type};
pub fn fakesign(input: &str, output: &Option<String>) -> Result<()> {
let in_path = Path::new(input);
if !in_path.exists() {
bail!("Input file \"{}\" does not exist.", in_path.display());
}
match identify_file_type(input) {
Some(WiiFileType::Wad) => {
let out_path = if output.is_some() {
PathBuf::from(output.clone().unwrap().as_str()).with_extension("wad")
} else {
PathBuf::from(input)
};
// Load WAD into a Title instance, then fakesign it.
let mut title = title::Title::from_bytes(fs::read(in_path).with_context(|| "Could not open WAD file for reading.")?.as_slice())
.with_context(|| "The provided WAD file could not be parsed, and is likely invalid.")?;
title.fakesign().with_context(|| "An unknown error occurred while fakesigning the provided WAD.")?;
// Write output file.
fs::write(out_path, title.to_wad()?.to_bytes()?).with_context(|| "Could not open output file for writing.")?;
println!("WAD fakesigned!");
},
Some(WiiFileType::Tmd) => {
let out_path = if output.is_some() {
PathBuf::from(output.clone().unwrap().as_str()).with_extension("tmd")
} else {
PathBuf::from(input)
};
// Load TMD into a TMD instance, then fakesign it.
let mut tmd = tmd::TMD::from_bytes(fs::read(in_path).with_context(|| "Could not open TMD file for reading.")?.as_slice())
.with_context(|| "The provided TMD file could not be parsed, and is likely invalid.")?;
tmd.fakesign().with_context(|| "An unknown error occurred while fakesigning the provided TMD.")?;
// Write output file.
fs::write(out_path, tmd.to_bytes()?).with_context(|| "Could not open output file for writing.")?;
println!("TMD fakesigned!");
},
Some(WiiFileType::Ticket) => {
let out_path = if output.is_some() {
PathBuf::from(output.clone().unwrap().as_str()).with_extension("tik")
} else {
PathBuf::from(input)
};
// Load Ticket into a Ticket instance, then fakesign it.
let mut ticket = ticket::Ticket::from_bytes(fs::read(in_path).with_context(|| "Could not open Ticket file for reading.")?.as_slice())
.with_context(|| "The provided Ticket file could not be parsed, and is likely invalid.")?;
ticket.fakesign().with_context(|| "An unknown error occurred while fakesigning the provided Ticket.")?;
// Write output file.
fs::write(out_path, ticket.to_bytes()?).with_context(|| "Could not open output file for writing.")?;
println!("Ticket fakesigned!");
},
_ => {
bail!("You can only fakesign TMDs, Tickets, and WADs!");
}
}
Ok(())
}

View File

@@ -0,0 +1,6 @@
// title/mod.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
pub mod fakesign;
pub mod nus;
pub mod wad;

View File

@@ -0,0 +1,281 @@
// title/nus.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Code for NUS-related commands in the rustii CLI.
use std::{str, fs};
use std::path::PathBuf;
use anyhow::{bail, Context, Result};
use clap::{Subcommand, Args};
use sha1::{Sha1, Digest};
use rustwii::title::{cert, content, crypto, nus, ticket, tmd};
use rustwii::title;
#[derive(Subcommand)]
#[command(arg_required_else_help = true)]
pub enum Commands {
/// Download specific content from the NUS
Content {
/// The Title ID that the content belongs to
tid: String,
/// The Content ID of the content (in hex format, like 000000xx)
cid: String,
/// The title version that the content belongs to (only required for decryption)
#[arg(short, long)]
version: Option<u16>,
/// An optional content file name; defaults to <cid>(.app)
#[arg(short, long)]
output: Option<String>,
/// Decrypt the content
#[arg(short, long)]
decrypt: bool,
},
/// Download a Ticket from the NUS
Ticket {
/// The Title ID that the Ticket is for
tid: String,
/// An optional Ticket name; defaults to <tid>.tik
#[arg(short, long)]
output: Option<String>,
},
/// Download a title from the NUS
Title {
/// The Title ID of the Title to download
tid: String,
/// The version of the Title to download
#[arg(short, long)]
version: Option<u16>,
#[command(flatten)]
output: TitleOutputType,
},
/// Download a TMD from the NUS
Tmd {
/// The Title ID that the TMD is for
tid: String,
/// The version of the TMD to download
#[arg(short, long)]
version: Option<u16>,
/// An optional TMD name; defaults to <tid>.tmd
#[arg(short, long)]
output: Option<String>,
}
}
#[derive(Args)]
#[clap(next_help_heading = "Output Format")]
#[group(multiple = false, required = true)]
pub struct TitleOutputType {
/// Download the Title data to the specified output directory
#[arg(short, long)]
output: Option<String>,
/// Download the Title to a WAD file
#[arg(short, long)]
wad: Option<String>,
}
pub fn download_content(tid: &str, cid: &str, version: &Option<u16>, output: &Option<String>, decrypt: &bool) -> Result<()> {
println!("Downloading content with Content ID {cid}...");
if tid.len() != 16 {
bail!("The specified Title ID is invalid!");
}
let cid = u32::from_str_radix(cid, 16).with_context(|| "The specified Content ID is invalid!")?;
let tid: [u8; 8] = hex::decode(tid)?.try_into().unwrap();
let content = nus::download_content(tid, cid, true).with_context(|| "Content data could not be downloaded.")?;
let out_path = if output.is_some() {
PathBuf::from(output.clone().unwrap())
} else if *decrypt {
PathBuf::from(format!("{:08X}.app", cid))
} else {
PathBuf::from(format!("{:08X}", cid))
};
if *decrypt {
// We need the version to get the correct TMD because the content's index is the IV for
// decryption. A Ticket also needs to be available, of course.
let version: u16 = if version.is_some() {
version.unwrap()
} else {
bail!("You must specify the title version that the requested content belongs to for decryption!");
};
let tmd_res = &nus::download_tmd(tid, Some(version), true);
println!(" - Downloading TMD...");
let tmd = match tmd_res {
Ok(tmd) => tmd::TMD::from_bytes(tmd)?,
Err(_) => bail!("No TMD could be found for the specified version! Check the version and try again.")
};
println!(" - Downloading Ticket...");
let tik_res = &nus::download_ticket(tid, true);
let tik = match tik_res {
Ok(tik) => ticket::Ticket::from_bytes(tik)?,
Err(_) => bail!("No Ticket is available for this title! The content cannot be decrypted.")
};
println!(" - Decrypting content...");
let (content_hash, content_size, content_index) = tmd.content_records().iter()
.find(|record| record.content_id == cid)
.map(|record| (record.content_hash, record.content_size, record.index))
.with_context(|| "No matching content record could be found. Please make sure the requested content is from the specified title version.")?;
let mut content_dec = crypto::decrypt_content(&content, tik.title_key_dec(), content_index);
content_dec.resize(content_size as usize, 0);
// Verify the content's hash before saving it.
let mut hasher = Sha1::new();
hasher.update(&content_dec);
let result = hasher.finalize();
if result[..] != content_hash {
bail!("The content's hash did not match the expected value. (Hash was {}, but the expected hash is {}.)",
hex::encode(result), hex::encode(content_hash));
}
fs::write(&out_path, content_dec).with_context(|| format!("Failed to open content file \"{}\" for writing.", out_path.display()))?;
} else {
// If we're not decrypting, just write the file out and call it a day.
fs::write(&out_path, content).with_context(|| format!("Failed to open content file \"{}\" for writing.", out_path.display()))?
}
println!("Successfully downloaded content with Content ID {:08X} to file \"{}\"!", cid, out_path.display());
Ok(())
}
pub fn download_ticket(tid: &str, output: &Option<String>) -> Result<()> {
println!("Downloading Ticket for title {tid}...");
if tid.len() != 16 {
bail!("The specified Title ID is invalid!");
}
let out_path = if output.is_some() {
PathBuf::from(output.clone().unwrap())
} else {
PathBuf::from(format!("{}.tik", tid))
};
let tid: [u8; 8] = hex::decode(tid)?.try_into().unwrap();
let tik_data = nus::download_ticket(tid, true).with_context(|| "Ticket data could not be downloaded.")?;
fs::write(&out_path, tik_data)?;
println!("Successfully downloaded Ticket to \"{}\"!", out_path.display());
Ok(())
}
fn download_title_dir(title: title::Title, output: String) -> Result<()> {
println!(" - Saving downloaded data...");
let out_path = PathBuf::from(output);
if out_path.exists() {
if !out_path.is_dir() {
bail!("A file already exists with the specified directory name!");
}
} else {
fs::create_dir(&out_path).with_context(|| format!("The output directory \"{}\" could not be created.", out_path.display()))?;
}
let tid = hex::encode(title.tmd.title_id());
println!(" - Saving TMD...");
fs::write(out_path.join(format!("{}.tmd", &tid)), title.tmd.to_bytes()?).with_context(|| format!("Failed to open TMD file \"{}.tmd\" for writing.", tid))?;
println!(" - Saving Ticket...");
fs::write(out_path.join(format!("{}.tik", &tid)), title.ticket.to_bytes()?).with_context(|| format!("Failed to open Ticket file \"{}.tmd\" for writing.", tid))?;
println!(" - Saving certificate chain...");
fs::write(out_path.join(format!("{}.cert", &tid)), title.cert_chain.to_bytes()?).with_context(|| format!("Failed to open certificate chain file \"{}.cert\" for writing.", tid))?;
// Iterate over the content files and write them out in encrypted form.
for record in title.content.content_records().iter() {
println!(" - Decrypting and saving content with Content ID {}...", record.content_id);
fs::write(out_path.join(format!("{:08X}.app", record.content_id)), title.get_content_by_cid(record.content_id)?)
.with_context(|| format!("Failed to open content file \"{:08X}.app\" for writing.", record.content_id))?;
}
println!("Successfully downloaded title with Title ID {} to directory \"{}\"!", tid, out_path.display());
Ok(())
}
fn download_title_dir_enc(tmd: tmd::TMD, content_region: content::ContentRegion, cert_chain: cert::CertificateChain, output: String) -> Result<()> {
println!(" - Saving downloaded data...");
let out_path = PathBuf::from(output);
if out_path.exists() {
if !out_path.is_dir() {
bail!("A file already exists with the specified directory name!");
}
} else {
fs::create_dir(&out_path).with_context(|| format!("The output directory \"{}\" could not be created.", out_path.display()))?;
}
let tid = hex::encode(tmd.title_id());
println!(" - Saving TMD...");
fs::write(out_path.join(format!("{}.tmd", &tid)), tmd.to_bytes()?).with_context(|| format!("Failed to open TMD file \"{}.tmd\" for writing.", tid))?;
println!(" - Saving certificate chain...");
fs::write(out_path.join(format!("{}.cert", &tid)), cert_chain.to_bytes()?).with_context(|| format!("Failed to open certificate chain file \"{}.cert\" for writing.", tid))?;
// Iterate over the content files and write them out in encrypted form.
for record in content_region.content_records().iter() {
println!(" - Saving content with Content ID {}...", record.content_id);
fs::write(out_path.join(format!("{:08X}", record.content_id)), content_region.get_enc_content_by_cid(record.content_id)?)
.with_context(|| format!("Failed to open content file \"{:08X}\" for writing.", record.content_id))?;
}
println!("Successfully downloaded title with Title ID {} to directory \"{}\"!", tid, out_path.display());
Ok(())
}
fn download_title_wad(title: title::Title, output: String) -> Result<()> {
println!(" - Packing WAD...");
let out_path = PathBuf::from(output).with_extension("wad");
fs::write(&out_path, title.to_wad().with_context(|| "A WAD could not be packed.")?.to_bytes()?).with_context(|| format!("Could not open WAD file \"{}\" for writing.", out_path.display()))?;
println!("Successfully downloaded title with Title ID {} to WAD file \"{}\"!", hex::encode(title.tmd.title_id()), out_path.display());
Ok(())
}
pub fn download_title(tid: &str, version: &Option<u16>, output: &TitleOutputType) -> Result<()> {
if tid.len() != 16 {
bail!("The specified Title ID is invalid!");
}
if version.is_some() {
println!("Downloading title {} v{}, please wait...", tid, version.clone().unwrap());
} else {
println!("Downloading title {} vLatest, please wait...", tid);
}
let tid: [u8; 8] = hex::decode(tid)?.try_into().unwrap();
println!(" - Downloading and parsing TMD...");
let tmd = tmd::TMD::from_bytes(&nus::download_tmd(tid, *version, true).with_context(|| "TMD data could not be downloaded.")?)?;
println!(" - Downloading and parsing Ticket...");
let tik_res = &nus::download_ticket(tid, true);
let tik = match tik_res {
Ok(tik) => Some(ticket::Ticket::from_bytes(tik)?),
Err(_) => {
if output.wad.is_some() {
bail!("--wad was specified, but this Title has no common Ticket and cannot be packed into a WAD!");
} else {
println!(" - No Ticket is available!");
None
}
}
};
// Build a vec of contents by iterating over the content records and downloading each one.
let mut contents: Vec<Vec<u8>> = Vec::new();
for record in tmd.content_records().iter() {
println!(" - Downloading content {} of {} (Content ID: {}, Size: {} bytes)...",
record.index + 1, &tmd.content_records().len(), record.content_id, record.content_size);
contents.push(nus::download_content(tid, record.content_id, true).with_context(|| format!("Content with Content ID {} could not be downloaded.", record.content_id))?);
println!(" - Done!");
}
let content_region = content::ContentRegion::from_contents(contents, tmd.content_records().clone())?;
println!(" - Building certificate chain...");
let cert_chain = cert::CertificateChain::from_bytes(&nus::download_cert_chain(true).with_context(|| "Certificate chain could not be built.")?)?;
if tik.is_some() {
// If we have a Ticket, then build a Title and jump to the output method.
let title = title::Title::from_parts(cert_chain, None, tik.unwrap(), tmd, content_region, None)?;
if output.wad.is_some() {
download_title_wad(title, output.wad.clone().unwrap())?;
} else {
download_title_dir(title, output.output.clone().unwrap())?;
}
} else {
// If we're downloading to a directory and have no Ticket, save the TMD and encrypted
// contents to the directory only.
download_title_dir_enc(tmd, content_region, cert_chain, output.output.clone().unwrap())?;
}
Ok(())
}
pub fn download_tmd(tid: &str, version: &Option<u16>, output: &Option<String>) -> Result<()> {
println!("Downloading TMD for title {tid}...");
if tid.len() != 16 {
bail!("The specified Title ID is invalid!");
}
let out_path = if output.is_some() {
PathBuf::from(output.clone().unwrap())
} else if version.is_some() {
PathBuf::from(format!("{}.tmd.{}", tid, version.unwrap()))
} else {
PathBuf::from(format!("{}.tmd", tid))
};
let tid: [u8; 8] = hex::decode(tid)?.try_into().unwrap();
let tmd_data = nus::download_tmd(tid, *version, true).with_context(|| "TMD data could not be downloaded.")?;
fs::write(&out_path, tmd_data)?;
println!("Successfully downloaded TMD to \"{}\"!", out_path.display());
Ok(())
}

View File

@@ -0,0 +1,534 @@
// title/wad.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Code for WAD-related commands in the rustii CLI.
use std::{str, fs, fmt};
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use clap::{Subcommand, Args};
use glob::glob;
use hex::FromHex;
use rand::prelude::*;
use regex::RegexBuilder;
use rustwii::title::{cert, crypto, tmd, ticket, content, wad};
use rustwii::title;
#[derive(Subcommand)]
#[command(arg_required_else_help = true)]
pub enum Commands {
/// Add new content to a WAD file
Add {
/// The path to the WAD file to modify
input: String,
/// The path to the new content to add
content: String,
/// An optional output path; defaults to overwriting input WAD file
#[arg(short, long)]
output: Option<String>,
/// An optional Content ID for the new content; defaults to being randomly assigned
#[arg(short, long)]
cid: Option<String>,
/// An optional type for the new content, can be "Normal", "Shared", or "DLC"; defaults to
/// "Normal"
#[arg(short, long)]
r#type: Option<String>,
},
/// Re-encrypt a WAD file with a different key
Convert {
/// The path to the WAD to convert
input: String,
/// An optional WAD name; defaults to <input name>_<new type>.wad
#[arg(short, long)]
output: Option<String>,
#[command(flatten)]
target: ConvertTargets,
},
/// Edit the properties of a WAD file
Edit {
/// The path to the WAD to modify
input: String,
/// An optional output path; defaults to overwriting input WAD file
#[arg(short, long)]
output: Option<String>,
#[command(flatten)]
edits: WadModifications
},
/// Pack a directory into a WAD file
Pack {
/// The directory to pack into a WAD
input: String,
/// The name of the packed WAD file
output: String
},
/// Remove content from a WAD file
Remove {
/// The path to the WAD file to modify
input: String,
/// An optional output path; defaults to overwriting input WAD file
#[arg(short, long)]
output: Option<String>,
#[command(flatten)]
identifier: ContentIdentifier,
},
/// Replace existing content in a WAD file with new data
Set {
/// The path to the WAD file to modify
input: String,
/// The path to the new content to set
content: String,
/// An optional output path; defaults to overwriting input WAD file
#[arg(short, long)]
output: Option<String>,
/// An optional new type for the content, can be "Normal", "Shared", or "DLC"
#[arg(short, long)]
r#type: Option<String>,
#[command(flatten)]
identifier: ContentIdentifier,
},
/// Unpack a WAD file into a directory
Unpack {
/// The path to the WAD to unpack
input: String,
/// The directory to extract the WAD to
output: String
},
}
#[derive(Args)]
#[clap(next_help_heading = "Encryption Targets")]
#[group(multiple = false, required = true)]
pub struct ConvertTargets {
/// Use the retail common key, allowing this WAD to be installed on retail consoles and Dolphin
#[arg(long)]
retail: bool,
/// Use the development common key, allowing this WAD to be installed on development consoles
#[arg(long)]
dev: bool,
/// Use the vWii key, allowing this WAD to theoretically be installed from Wii U mode if a Wii U mode WAD installer is created
#[arg(long)]
vwii: bool,
}
#[derive(Args)]
#[clap(next_help_heading = "Content Identifier")]
#[group(multiple = false, required = true)]
pub struct ContentIdentifier {
/// The index of the target content
#[arg(short, long)]
index: Option<usize>,
/// The Content ID of the target content
#[arg(short, long)]
cid: Option<String>,
}
#[derive(Args)]
#[clap(next_help_heading = "Possible Modifications")]
#[group(multiple = true, required = true)]
pub struct WadModifications {
/// A new IOS version for this WAD (formatted as the decimal IOS version, e.g. 58, with a valid
/// range of 3-255)
#[arg(long)]
ios: Option<u8>,
/// A new Title ID for this WAD (formatted as 4 ASCII characters, e.g. HADE)
#[arg(long)]
tid: Option<String>,
/// A new type for this WAD (valid options are "System", "Channel", "SystemChannel",
/// "GameChannel", "DLC", "HiddenChannel")
#[arg(long)]
r#type: Option<String>,
}
enum Target {
Retail,
Dev,
Vwii,
}
impl fmt::Display for Target {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Target::Retail => write!(f, "retail"),
Target::Dev => write!(f, "development"),
Target::Vwii => write!(f, "vWii"),
}
}
}
pub fn add_wad(input: &str, content: &str, output: &Option<String>, cid: &Option<String>, ctype: &Option<String>) -> Result<()> {
let in_path = Path::new(input);
if !in_path.exists() {
bail!("Source WAD \"{}\" could not be found.", in_path.display());
}
let content_path = Path::new(content);
if !content_path.exists() {
bail!("New content \"{}\" could not be found.", content_path.display());
}
let out_path = if output.is_some() {
PathBuf::from(output.clone().unwrap()).with_extension("wad")
} else {
in_path.to_path_buf()
};
// Load the WAD and parse the target type and Content ID.
let mut title = title::Title::from_bytes(&fs::read(in_path)?).with_context(|| "The provided WAD file could not be parsed, and is likely invalid.")?;
let new_content = fs::read(content_path)?;
let target_type = if ctype.is_some() {
match ctype.clone().unwrap().to_ascii_lowercase().as_str() {
"normal" => tmd::ContentType::Normal,
"shared" => tmd::ContentType::Shared,
"dlc" => tmd::ContentType::DLC,
_ => bail!("The specified content type \"{}\" is invalid! Try --help to see valid types.", ctype.clone().unwrap()),
}
} else {
println!("Using default type \"Normal\" because no content type was specified.");
tmd::ContentType::Normal
};
let target_cid = if cid.is_some() {
let cid = u32::from_str_radix(cid.clone().unwrap().as_str(), 16).with_context(|| "The specified Content ID is invalid!")?;
if title.content.content_records().iter().any(|record| record.content_id == cid) {
bail!("The specified Content ID \"{:08X}\" is already being used in this WAD!", cid);
}
cid
} else {
// Generate a random CID if one wasn't specified, and ensure that it isn't already in use.
let mut rng = rand::rng();
let mut cid: u32;
loop {
cid = rng.random_range(0..=0xFF);
if !title.content.content_records().iter().any(|record| record.content_id == cid) {
break;
}
}
println!("Generated new random Content ID \"{:08X}\" ({}) because no Content ID was specified.", cid, cid);
cid
};
title.add_content(&new_content, target_cid, target_type.clone()).with_context(|| "An unknown error occurred while setting the new content.")?;
title.fakesign().with_context(|| "An unknown error occurred while fakesigning the modified WAD.")?;
fs::write(&out_path, title.to_wad()?.to_bytes()?).with_context(|| "Could not open output file for writing.")?;
println!("Successfully added new content with Content ID \"{:08X}\" ({}) and type \"{}\" to WAD file \"{}\"!", target_cid, target_cid, target_type, out_path.display());
Ok(())
}
pub fn convert_wad(input: &str, target: &ConvertTargets, output: &Option<String>) -> Result<()> {
let in_path = Path::new(input);
if !in_path.exists() {
bail!("Source WAD \"{}\" could not be found.", in_path.display());
}
// Parse the target passed to identify the encryption target.
let target = if target.dev {
Target::Dev
} else if target.vwii {
Target::Vwii
} else {
Target::Retail
};
// Get the output name now that we know the target, if one wasn't passed.
let out_path = if output.is_some() {
PathBuf::from(output.clone().unwrap()).with_extension("wad")
} else {
match target {
Target::Retail => PathBuf::from(format!("{}_retail.wad", in_path.file_stem().unwrap().to_str().unwrap())),
Target::Dev => PathBuf::from(format!("{}_dev.wad", in_path.file_stem().unwrap().to_str().unwrap())),
Target::Vwii => PathBuf::from(format!("{}_vWii.wad", in_path.file_stem().unwrap().to_str().unwrap())),
}
};
let mut title = title::Title::from_bytes(&fs::read(in_path)?).with_context(|| "The provided WAD file could not be parsed, and is likely invalid.")?;
// Bail if the WAD is already using the selected encryption.
if matches!(target, Target::Dev) && title.ticket.is_dev() {
bail!("This is already a development WAD!");
} else if matches!(target, Target::Retail) && !title.ticket.is_dev() && !title.tmd.is_vwii() {
bail!("This is already a retail WAD!");
} else if matches!(target, Target::Vwii) && !title.ticket.is_dev() && title.tmd.is_vwii() {
bail!("This is already a vWii WAD!");
}
// Save the current encryption to display at the end.
let source = if title.ticket.is_dev() {
"development"
} else if title.tmd.is_vwii() {
"vWii"
} else {
"retail"
};
let title_key = title.ticket.title_key_dec();
let title_key_new: [u8; 16];
match target {
Target::Dev => {
title.tmd.set_signature_issuer(String::from("Root-CA00000002-CP00000007"))?;
title.ticket.set_signature_issuer(String::from("Root-CA00000002-XS00000006"))?;
title_key_new = crypto::encrypt_title_key(title_key, 0, title.ticket.title_id(), true);
title.ticket.set_common_key_index(0);
title.tmd.set_is_vwii(false);
},
Target::Retail => {
title.tmd.set_signature_issuer(String::from("Root-CA00000001-CP00000004"))?;
title.ticket.set_signature_issuer(String::from("Root-CA00000001-XS00000003"))?;
title_key_new = crypto::encrypt_title_key(title_key, 0, title.ticket.title_id(), false);
title.ticket.set_common_key_index(0);
title.tmd.set_is_vwii(false);
},
Target::Vwii => {
title.tmd.set_signature_issuer(String::from("Root-CA00000001-CP00000004"))?;
title.ticket.set_signature_issuer(String::from("Root-CA00000001-XS00000003"))?;
title_key_new = crypto::encrypt_title_key(title_key, 2, title.ticket.title_id(), false);
title.ticket.set_common_key_index(2);
title.tmd.set_is_vwii(true);
}
}
title.ticket.set_title_key(title_key_new);
title.fakesign()?;
fs::write(&out_path, title.to_wad()?.to_bytes()?)?;
println!("Successfully converted {} WAD to {} WAD \"{}\"!", source, target, out_path.file_name().unwrap().to_str().unwrap());
Ok(())
}
pub fn edit_wad(input: &str, output: &Option<String>, edits: &WadModifications) -> Result<()> {
let in_path = Path::new(input);
if !in_path.exists() {
bail!("Source directory \"{}\" does not exist.", in_path.display());
}
let out_path = if output.is_some() {
PathBuf::from(output.clone().unwrap()).with_extension("wad")
} else {
in_path.to_path_buf()
};
let mut title = title::Title::from_bytes(&fs::read(in_path)?).with_context(|| "The provided WAD file could not be parsed, and is likely invalid.")?;
// Parse possible edits and perform each one provided. Unlike WiiPy, I don't need a state bool
// here! Wow!
let mut changes_summary: Vec<String> = Vec::new();
// These are joined, because that way if both are selected we only need to set the TID (and by
// extension, re-encrypt the Title Key) a single time.
if edits.tid.is_some() || edits.r#type.is_some() {
let tid_high = if edits.r#type.is_some() {
let new_type = match edits.r#type.clone().unwrap().to_ascii_lowercase().as_str() {
"system" => tmd::TitleType::System,
"channel" => tmd::TitleType::Channel,
"systemchannel" => tmd::TitleType::SystemChannel,
"gamechannel" => tmd::TitleType::GameChannel,
"dlc" => tmd::TitleType::DLC,
"hiddenchannel" => tmd::TitleType::HiddenChannel,
_ => bail!("The specified title type \"{}\" is invalid! Try --help to see valid types.", edits.r#type.clone().unwrap()),
};
changes_summary.push(format!("Changed title type from \"{}\" to \"{}\"", title.tmd.title_type()?, new_type));
Vec::from_hex(format!("{:08X}", new_type as u32))?
} else {
title.tmd.title_id()[0..4].to_vec()
};
let tid_low = if edits.tid.is_some() {
let re = RegexBuilder::new(r"^[a-z0-9!@#$%^&*]{4}$").case_insensitive(true).build()?;
let new_tid_low = edits.tid.clone().unwrap().to_ascii_uppercase();
if !re.is_match(&new_tid_low) {
bail!("The specified Title ID is not valid! The new Title ID must be 4 characters and include only letters, numbers, and the special characters \"!@#$%&*\".");
}
changes_summary.push(format!("Changed Title ID from \"{}\" to \"{}\"", hex::encode(&title.tmd.title_id()[4..8]).to_ascii_uppercase(), hex::encode(&new_tid_low).to_ascii_uppercase()));
Vec::from_hex(hex::encode(new_tid_low))?
} else {
title.tmd.title_id()[4..8].to_vec()
};
let new_tid: Vec<u8> = tid_high.iter().chain(&tid_low).copied().collect();
title.set_title_id(new_tid.try_into().unwrap())?;
}
if edits.ios.is_some() {
let new_ios = edits.ios.unwrap();
if new_ios < 3 {
bail!("The specified IOS version is not valid! The new IOS version must be between 3 and 255.")
}
let new_ios_tid = <[u8; 8]>::from_hex(format!("00000001{:08X}", new_ios))?;
changes_summary.push(format!("Changed required IOS from IOS{} to IOS{}", title.tmd.ios_tid().last().unwrap(), new_ios));
title.tmd.set_ios_tid(new_ios_tid)?;
}
title.fakesign()?;
fs::write(&out_path, title.to_wad()?.to_bytes()?).with_context(|| format!("Could not open output file \"{}\" for writing.", out_path.display()))?;
println!("Successfully edited WAD file \"{}\"!\nSummary of changes:", out_path.display());
for change in &changes_summary {
println!(" - {}", change);
}
Ok(())
}
pub fn pack_wad(input: &str, output: &str) -> Result<()> {
let in_path = Path::new(input);
if !in_path.exists() {
bail!("Source directory \"{}\" does not exist.", in_path.display());
}
// Read TMD file (only accept one file).
let tmd_files: Vec<PathBuf> = glob(&format!("{}/*.tmd", in_path.display()))?
.filter_map(|f| f.ok()).collect();
if tmd_files.is_empty() {
bail!("No TMD file found in the source directory.");
} else if tmd_files.len() > 1 {
bail!("More than one TMD file found in the source directory.");
}
let mut tmd = tmd::TMD::from_bytes(&fs::read(&tmd_files[0]).with_context(|| "Could not open TMD file for reading.")?)
.with_context(|| "The provided TMD file appears to be invalid.")?;
// Read Ticket file (only accept one file).
let ticket_files: Vec<PathBuf> = glob(&format!("{}/*.tik", in_path.display()))?
.filter_map(|f| f.ok()).collect();
if ticket_files.is_empty() {
bail!("No Ticket file found in the source directory.");
} else if ticket_files.len() > 1 {
bail!("More than one Ticket file found in the source directory.");
}
let tik = ticket::Ticket::from_bytes(&fs::read(&ticket_files[0]).with_context(|| "Could not open Ticket file for reading.")?)
.with_context(|| "The provided Ticket file appears to be invalid.")?;
// Read cert chain (only accept one file).
let cert_files: Vec<PathBuf> = glob(&format!("{}/*.cert", in_path.display()))?
.filter_map(|f| f.ok()).collect();
if cert_files.is_empty() {
bail!("No cert file found in the source directory.");
} else if cert_files.len() > 1 {
bail!("More than one Cert file found in the source directory.");
}
let cert_chain = cert::CertificateChain::from_bytes(&fs::read(&cert_files[0]).with_context(|| "Could not open cert chain file for reading.")?)
.with_context(|| "The provided certificate chain appears to be invalid.")?;
// Read footer, if one exists (only accept one file).
let footer_files: Vec<PathBuf> = glob(&format!("{}/*.footer", in_path.display()))?
.filter_map(|f| f.ok()).collect();
let mut footer: Vec<u8> = Vec::new();
if footer_files.len() == 1 {
footer = fs::read(&footer_files[0]).with_context(|| "Could not open footer file for reading.")?;
}
// Iterate over expected content and read it into a content region.
let mut content_region = content::ContentRegion::new(tmd.content_records().clone())?;
let content_indexes: Vec<u16> = tmd.content_records().iter().map(|record| record.index).collect();
for index in content_indexes {
let data = fs::read(format!("{}/{:08X}.app", in_path.display(), index)).with_context(|| format!("Could not open content file \"{:08X}.app\" for reading.", index))?;
content_region.set_content(&data, index as usize, None, None, tik.title_key_dec())
.with_context(|| "Failed to load content into the ContentRegion.")?;
}
// Ensure that the TMD is modified with our potentially updated content records.
tmd.set_content_records(content_region.content_records());
let wad = wad::WAD::from_parts(&cert_chain, &[], &tik, &tmd, &content_region, &footer).with_context(|| "An unknown error occurred while building a WAD from the input files.")?;
// Write out WAD file.
let mut out_path = PathBuf::from(output);
match out_path.extension() {
Some(ext) => {
if ext != "wad" {
out_path.set_extension("wad");
}
},
None => {
out_path.set_extension("wad");
}
}
fs::write(&out_path, wad.to_bytes()?).with_context(|| format!("Could not open output file \"{}\" for writing.", out_path.display()))?;
println!("Successfully packed WAD file to \"{}\"!", out_path.display());
Ok(())
}
pub fn remove_wad(input: &str, output: &Option<String>, identifier: &ContentIdentifier) -> Result<()> {
let in_path = Path::new(input);
if !in_path.exists() {
bail!("Source WAD \"{}\" could not be found.", in_path.display());
}
let out_path = if output.is_some() {
PathBuf::from(output.clone().unwrap()).with_extension("wad")
} else {
in_path.to_path_buf()
};
let mut title = title::Title::from_bytes(&fs::read(in_path)?).with_context(|| "The provided WAD file could not be parsed, and is likely invalid.")?;
// Parse the identifier passed to choose how to find and remove the target.
// ...maybe don't take the above comment out of context
if identifier.index.is_some() {
title.content.remove_content(identifier.index.unwrap()).with_context(|| "The specified index does not exist in the provided WAD!")?;
println!("{:?}", title.tmd);
title.fakesign().with_context(|| "An unknown error occurred while fakesigning the modified WAD.")?;
fs::write(&out_path, title.to_wad()?.to_bytes()?).with_context(|| "Could not open output file for writing.")?;
println!("Successfully removed content at index {} in WAD file \"{}\".", identifier.index.unwrap(), out_path.display());
} else if identifier.cid.is_some() {
let cid = u32::from_str_radix(identifier.cid.clone().unwrap().as_str(), 16).with_context(|| "The specified Content ID is invalid!")?;
let index = match title.content.get_index_from_cid(cid) {
Ok(index) => index,
Err(_) => bail!("The specified Content ID \"{}\" ({}) does not exist in this WAD!", identifier.cid.clone().unwrap(), cid),
};
title.content.remove_content(index).with_context(|| "An unknown error occurred while removing content from the WAD.")?;
println!("{:?}", title.tmd);
title.fakesign().with_context(|| "An unknown error occurred while fakesigning the modified WAD.")?;
fs::write(&out_path, title.to_wad()?.to_bytes()?).with_context(|| "Could not open output file for writing.")?;
println!("Successfully removed content with Content ID \"{}\" ({}) in WAD file \"{}\".", identifier.cid.clone().unwrap(), cid, out_path.display());
}
Ok(())
}
pub fn set_wad(input: &str, content: &str, output: &Option<String>, identifier: &ContentIdentifier, ctype: &Option<String>) -> Result<()> {
let in_path = Path::new(input);
if !in_path.exists() {
bail!("Source WAD \"{}\" could not be found.", in_path.display());
}
let content_path = Path::new(content);
if !content_path.exists() {
bail!("New content \"{}\" could not be found.", content_path.display());
}
let out_path = if output.is_some() {
PathBuf::from(output.clone().unwrap()).with_extension("wad")
} else {
in_path.to_path_buf()
};
// Load the WAD and parse the new type, if one was specified.
let mut title = title::Title::from_bytes(&fs::read(in_path)?).with_context(|| "The provided WAD file could not be parsed, and is likely invalid.")?;
let new_content = fs::read(content_path)?;
let mut target_type: Option<tmd::ContentType> = None;
if ctype.is_some() {
target_type = match ctype.clone().unwrap().to_ascii_lowercase().as_str() {
"normal" => Some(tmd::ContentType::Normal),
"shared" => Some(tmd::ContentType::Shared),
"dlc" => Some(tmd::ContentType::DLC),
_ => bail!("The specified content type \"{}\" is invalid!", ctype.clone().unwrap()),
};
}
// Parse the identifier passed to choose how to do the find and replace.
if identifier.index.is_some() {
match title.set_content(&new_content, identifier.index.unwrap(), None, target_type) {
Err(title::TitleError::Content(content::ContentError::IndexOutOfRange { index, max })) => {
bail!("The specified index {} does not exist in this WAD! The maximum index is {}.", index, max)
},
Err(e) => bail!("An unknown error occurred while setting the new content: {e}"),
Ok(_) => (),
}
title.fakesign().with_context(|| "An unknown error occurred while fakesigning the modified WAD.")?;
fs::write(&out_path, title.to_wad()?.to_bytes()?).with_context(|| "Could not open output file for writing.")?;
println!("Successfully replaced content at index {} in WAD file \"{}\".", identifier.index.unwrap(), out_path.display());
} else if identifier.cid.is_some() {
let cid = u32::from_str_radix(identifier.cid.clone().unwrap().as_str(), 16).with_context(|| "The specified Content ID is invalid!")?;
let index = match title.content.get_index_from_cid(cid) {
Ok(index) => index,
Err(_) => bail!("The specified Content ID \"{}\" ({}) does not exist in this WAD!", identifier.cid.clone().unwrap(), cid),
};
title.set_content(&new_content, index, None, target_type).with_context(|| "An unknown error occurred while setting the new content.")?;
title.fakesign().with_context(|| "An unknown error occurred while fakesigning the modified WAD.")?;
fs::write(&out_path, title.to_wad()?.to_bytes()?).with_context(|| "Could not open output file for writing.")?;
println!("Successfully replaced content with Content ID \"{}\" ({}) in WAD file \"{}\".", identifier.cid.clone().unwrap(), cid, out_path.display());
}
Ok(())
}
pub fn unpack_wad(input: &str, output: &str) -> Result<()> {
let in_path = Path::new(input);
if !in_path.exists() {
bail!("Source WAD \"{}\" could not be found.", input);
}
let wad_file = fs::read(in_path).with_context(|| format!("Failed to open WAD file \"{}\" for reading.", in_path.display()))?;
let title = title::Title::from_bytes(&wad_file).with_context(|| format!("The provided WAD file \"{}\" appears to be invalid.", in_path.display()))?;
let tid = hex::encode(title.tmd.title_id());
// Create output directory if it doesn't exist.
let out_path = Path::new(output);
if !out_path.exists() {
fs::create_dir(out_path).with_context(|| format!("The output directory \"{}\" could not be created.", out_path.display()))?;
}
// Write out all WAD components.
let tmd_file_name = format!("{}.tmd", tid);
fs::write(Path::join(out_path, tmd_file_name.clone()), title.tmd.to_bytes()?).with_context(|| format!("Failed to open TMD file \"{}\" for writing.", tmd_file_name))?;
let ticket_file_name = format!("{}.tik", tid);
fs::write(Path::join(out_path, ticket_file_name.clone()), title.ticket.to_bytes()?).with_context(|| format!("Failed to open Ticket file \"{}\" for writing.", ticket_file_name))?;
let cert_file_name = format!("{}.cert", tid);
fs::write(Path::join(out_path, cert_file_name.clone()), title.cert_chain.to_bytes()?).with_context(|| format!("Failed to open certificate chain file \"{}\" for writing.", cert_file_name))?;
let meta_file_name = format!("{}.footer", tid);
fs::write(Path::join(out_path, meta_file_name.clone()), title.meta()).with_context(|| format!("Failed to open footer file \"{}\" for writing.", meta_file_name))?;
// Iterate over contents, decrypt them, and write them out.
for i in 0..title.tmd.content_records().len() {
let content_file_name = format!("{:08X}.app", title.content.content_records()[i].index);
let dec_content = title.get_content_by_index(i).with_context(|| format!("Failed to unpack content with Content ID {:08X}.", title.content.content_records()[i].content_id))?;
fs::write(Path::join(out_path, content_file_name), dec_content).with_context(|| format!("Failed to open content file \"{:08X}.app\" for writing.", title.content.content_records()[i].content_id))?;
}
println!("Successfully unpacked WAD file to \"{}\"!", out_path.display());
Ok(())
}