mirror of
https://github.com/NinjaCheetah/rustii.git
synced 2026-03-03 11:25:29 -05:00
Made a bunch of fields that should be private private
This commit is contained in:
53
src/bin/rustwii/archive/ash.rs
Normal file
53
src/bin/rustwii/archive/ash.rs
Normal 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(())
|
||||
}
|
||||
65
src/bin/rustwii/archive/lz77.rs
Normal file
65
src/bin/rustwii/archive/lz77.rs
Normal 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(())
|
||||
}
|
||||
6
src/bin/rustwii/archive/mod.rs
Normal file
6
src/bin/rustwii/archive/mod.rs
Normal 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;
|
||||
104
src/bin/rustwii/archive/u8.rs
Normal file
104
src/bin/rustwii/archive/u8.rs
Normal 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(())
|
||||
}
|
||||
104
src/bin/rustwii/filetypes.rs
Normal file
104
src/bin/rustwii/filetypes.rs
Normal 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
302
src/bin/rustwii/info.rs
Normal 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
185
src/bin/rustwii/main.rs
Normal 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(())
|
||||
}
|
||||
336
src/bin/rustwii/nand/emunand.rs
Normal file
336
src/bin/rustwii/nand/emunand.rs
Normal 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(())
|
||||
}
|
||||
5
src/bin/rustwii/nand/mod.rs
Normal file
5
src/bin/rustwii/nand/mod.rs
Normal 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;
|
||||
61
src/bin/rustwii/nand/setting.rs
Normal file
61
src/bin/rustwii/nand/setting.rs
Normal 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(())
|
||||
}
|
||||
65
src/bin/rustwii/title/fakesign.rs
Normal file
65
src/bin/rustwii/title/fakesign.rs
Normal 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(())
|
||||
}
|
||||
6
src/bin/rustwii/title/mod.rs
Normal file
6
src/bin/rustwii/title/mod.rs
Normal 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;
|
||||
281
src/bin/rustwii/title/nus.rs
Normal file
281
src/bin/rustwii/title/nus.rs
Normal 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(())
|
||||
}
|
||||
534
src/bin/rustwii/title/wad.rs
Normal file
534
src/bin/rustwii/title/wad.rs
Normal 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(())
|
||||
}
|
||||
Reference in New Issue
Block a user