Added rustii CLI wad edit command and required library features

This required a LOT more backend work than I expected. But hey, some of this stuff is being done better than it was in libWiiPy/WiiPy, so that's a win in my book.
When changing both the Title ID and type of a WAD, the updated TID will only be written once (which also means the Title Key will only be re-encrypted once). This is an improvement over WiiPy where it will be updated as part of both changes.
Some TMD fields have been made private and moved to getter/setter methods only as they are actually in use now and should only be set through the correct means.
This commit is contained in:
Campbell 2025-04-29 22:03:55 -04:00
parent 481594345d
commit a30a0f2c5b
Signed by: NinjaCheetah
GPG Key ID: 39C2500E1778B156
10 changed files with 232 additions and 80 deletions

View File

@ -7,7 +7,7 @@ use rustii::title;
fn main() { fn main() {
let data = fs::read("sm.wad").unwrap(); let data = fs::read("sm.wad").unwrap();
let title = title::Title::from_bytes(&data).unwrap(); let title = title::Title::from_bytes(&data).unwrap();
println!("Title ID from WAD via Title object: {}", hex::encode(title.tmd.title_id)); println!("Title ID from WAD via Title object: {}", hex::encode(title.tmd.title_id()));
let wad = wad::WAD::from_bytes(&data).unwrap(); let wad = wad::WAD::from_bytes(&data).unwrap();
println!("size of tmd: {:?}", wad.tmd().len()); println!("size of tmd: {:?}", wad.tmd().len());

View File

@ -44,14 +44,14 @@ fn print_title_version(title_version: u16, title_id: [u8; 8], is_vwii: bool) ->
fn print_tmd_info(tmd: tmd::TMD, cert: Option<cert::Certificate>) -> Result<()> { fn print_tmd_info(tmd: tmd::TMD, cert: Option<cert::Certificate>) -> Result<()> {
// Print all important keys from the TMD. // Print all important keys from the TMD.
println!("Title Info"); println!("Title Info");
print_tid(tmd.title_id)?; print_tid(tmd.title_id())?;
print_title_version(tmd.title_version, tmd.title_id, tmd.is_vwii())?; print_title_version(tmd.title_version, tmd.title_id(), tmd.is_vwii())?;
println!(" TMD Version: {}", tmd.tmd_version); println!(" TMD Version: {}", tmd.tmd_version);
if hex::encode(tmd.ios_tid).eq("0000000000000000") { if hex::encode(tmd.ios_tid()).eq("0000000000000000") {
println!(" Required IOS: N/A"); println!(" Required IOS: N/A");
} }
else if hex::encode(tmd.ios_tid).ne(&format!("{:016X}", tmd.title_version)) { 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()); 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(); let signature_issuer = String::from_utf8(Vec::from(tmd.signature_issuer)).unwrap_or_default();
if signature_issuer.contains("CP00000004") { if signature_issuer.contains("CP00000004") {
@ -73,8 +73,8 @@ fn print_tmd_info(tmd: tmd::TMD, cert: Option<cert::Certificate>) -> Result<()>
else { else {
println!(" Certificate Info: {} (Unknown)", signature_issuer); println!(" Certificate Info: {} (Unknown)", signature_issuer);
} }
let region = if hex::encode(tmd.title_id).eq("0000000100000002") { 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 != 0)) match versions::dec_to_standard(tmd.title_version, &hex::encode(tmd.title_id()), Some(tmd.is_vwii != 0))
.unwrap_or_default().chars().last() { .unwrap_or_default().chars().last() {
Some('U') => "USA", Some('U') => "USA",
Some('E') => "EUR", Some('E') => "EUR",
@ -82,13 +82,13 @@ fn print_tmd_info(tmd: tmd::TMD, cert: Option<cert::Certificate>) -> Result<()>
Some('K') => "KOR", Some('K') => "KOR",
_ => "None" _ => "None"
} }
} else if matches!(tmd.title_type(), tmd::TitleType::System) { } else if matches!(tmd.title_type(), Ok(tmd::TitleType::System)) {
"None" "None"
} else { } else {
tmd.region() tmd.region()
}; };
println!(" Region: {}", region); println!(" Region: {}", region);
println!(" Title Type: {}", tmd.title_type()); println!(" Title Type: {}", tmd.title_type()?);
println!(" vWii Title: {}", tmd.is_vwii != 0); println!(" vWii Title: {}", tmd.is_vwii != 0);
println!(" DVD Video Access: {}", tmd.check_access_right(tmd::AccessRight::DVDVideo)); println!(" DVD Video Access: {}", tmd.check_access_right(tmd::AccessRight::DVDVideo));
println!(" AHB Access: {}", tmd.check_access_right(tmd::AccessRight::AHB)); println!(" AHB Access: {}", tmd.check_access_right(tmd::AccessRight::AHB));
@ -124,7 +124,7 @@ fn print_tmd_info(tmd: tmd::TMD, cert: Option<cert::Certificate>) -> Result<()>
println!(" Content Index: {}", content.index); println!(" Content Index: {}", content.index);
println!(" Content ID: {:08X}", content.content_id); println!(" Content ID: {:08X}", content.content_id);
println!(" Content Type: {}", content.content_type); println!(" Content Type: {}", content.content_type);
println!(" Content Size: {} bytes", content.content_size); 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)); println!(" Content Hash: {}", hex::encode(content.content_hash));
} }
Ok(()) Ok(())
@ -133,8 +133,8 @@ fn print_tmd_info(tmd: tmd::TMD, cert: Option<cert::Certificate>) -> Result<()>
fn print_ticket_info(ticket: ticket::Ticket, cert: Option<cert::Certificate>) -> Result<()> { fn print_ticket_info(ticket: ticket::Ticket, cert: Option<cert::Certificate>) -> Result<()> {
// Print all important keys from the Ticket. // Print all important keys from the Ticket.
println!("Ticket Info"); println!("Ticket Info");
print_tid(ticket.title_id)?; print_tid(ticket.title_id())?;
print_title_version(ticket.title_version, ticket.title_id, ticket.common_key_index == 2)?; print_title_version(ticket.title_version, ticket.title_id(), ticket.common_key_index == 2)?;
println!(" Ticket Version: {}", ticket.ticket_version); println!(" Ticket Version: {}", ticket.ticket_version);
let signature_issuer = String::from_utf8(Vec::from(ticket.signature_issuer)).unwrap_or_default(); let signature_issuer = String::from_utf8(Vec::from(ticket.signature_issuer)).unwrap_or_default();
if signature_issuer.contains("XS00000003") { if signature_issuer.contains("XS00000003") {
@ -196,8 +196,8 @@ fn print_wad_info(wad: wad::WAD) -> Result<()> {
} }
// Create a Title for size info, signing info and TMD/Ticket info. // 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 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.title_size_blocks(None)?; let min_size_blocks = title::bytes_to_blocks(title.title_size(None)?);
let max_size_blocks = title.title_size_blocks(Some(true))?; let max_size_blocks = title::bytes_to_blocks(title.title_size(Some(true))?);
if min_size_blocks == max_size_blocks { if min_size_blocks == max_size_blocks {
println!(" Installed Size: {} blocks", min_size_blocks); println!(" Installed Size: {} blocks", min_size_blocks);
} else { } else {

View File

@ -125,6 +125,9 @@ fn main() -> Result<()> {
title::wad::Commands::Convert { input, target, output } => { title::wad::Commands::Convert { input, target, output } => {
title::wad::convert_wad(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::Commands::Pack { input, output} => {
title::wad::pack_wad(input, output)? title::wad::pack_wad(input, output)?
}, },

View File

@ -22,7 +22,7 @@ pub enum Commands {
cid: String, cid: String,
/// The title version that the content belongs to (only required for decryption) /// The title version that the content belongs to (only required for decryption)
#[arg(short, long)] #[arg(short, long)]
version: Option<String>, version: Option<u16>,
/// An optional content file name; defaults to <cid>(.app) /// An optional content file name; defaults to <cid>(.app)
#[arg(short, long)] #[arg(short, long)]
output: Option<String>, output: Option<String>,
@ -44,7 +44,7 @@ pub enum Commands {
tid: String, tid: String,
/// The version of the Title to download /// The version of the Title to download
#[arg(short, long)] #[arg(short, long)]
version: Option<String>, version: Option<u16>,
#[command(flatten)] #[command(flatten)]
output: TitleOutputType, output: TitleOutputType,
}, },
@ -54,7 +54,7 @@ pub enum Commands {
tid: String, tid: String,
/// The version of the TMD to download /// The version of the TMD to download
#[arg(short, long)] #[arg(short, long)]
version: Option<String>, version: Option<u16>,
/// An optional TMD name; defaults to <tid>.tmd /// An optional TMD name; defaults to <tid>.tmd
#[arg(short, long)] #[arg(short, long)]
output: Option<String>, output: Option<String>,
@ -73,7 +73,7 @@ pub struct TitleOutputType {
wad: Option<String>, wad: Option<String>,
} }
pub fn download_content(tid: &str, cid: &str, version: &Option<String>, output: &Option<String>, decrypt: &bool) -> Result<()> { pub fn download_content(tid: &str, cid: &str, version: &Option<u16>, output: &Option<String>, decrypt: &bool) -> Result<()> {
println!("Downloading content with Content ID {cid}..."); println!("Downloading content with Content ID {cid}...");
if tid.len() != 16 { if tid.len() != 16 {
bail!("The specified Title ID is invalid!"); bail!("The specified Title ID is invalid!");
@ -92,7 +92,7 @@ pub fn download_content(tid: &str, cid: &str, version: &Option<String>, output:
// We need the version to get the correct TMD because the content's index is the IV for // 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. // decryption. A Ticket also needs to be available, of course.
let version: u16 = if version.is_some() { let version: u16 = if version.is_some() {
version.clone().unwrap().parse().with_context(|| "The specified Title version must be a valid integer!")? version.unwrap()
} else { } else {
bail!("You must specify the title version that the requested content belongs to for decryption!"); bail!("You must specify the title version that the requested content belongs to for decryption!");
}; };
@ -159,7 +159,7 @@ fn download_title_dir(title: title::Title, output: String) -> Result<()> {
} else { } else {
fs::create_dir(&out_path).with_context(|| format!("The output directory \"{}\" could not be created.", out_path.display()))?; 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); let tid = hex::encode(title.tmd.title_id());
println!(" - Saving TMD..."); 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))?; 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..."); println!(" - Saving Ticket...");
@ -186,7 +186,7 @@ fn download_title_dir_enc(tmd: tmd::TMD, content_region: content::ContentRegion,
} else { } else {
fs::create_dir(&out_path).with_context(|| format!("The output directory \"{}\" could not be created.", out_path.display()))?; 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); let tid = hex::encode(tmd.title_id());
println!(" - Saving TMD..."); 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))?; 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..."); println!(" - Saving certificate chain...");
@ -205,11 +205,11 @@ fn download_title_wad(title: title::Title, output: String) -> Result<()> {
println!(" - Packing WAD..."); println!(" - Packing WAD...");
let out_path = PathBuf::from(output).with_extension("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()))?; 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()); println!("Successfully downloaded title with Title ID {} to WAD file \"{}\"!", hex::encode(title.tmd.title_id()), out_path.display());
Ok(()) Ok(())
} }
pub fn download_title(tid: &str, version: &Option<String>, output: &TitleOutputType) -> Result<()> { pub fn download_title(tid: &str, version: &Option<u16>, output: &TitleOutputType) -> Result<()> {
if tid.len() != 16 { if tid.len() != 16 {
bail!("The specified Title ID is invalid!"); bail!("The specified Title ID is invalid!");
} }
@ -218,14 +218,9 @@ pub fn download_title(tid: &str, version: &Option<String>, output: &TitleOutputT
} else { } else {
println!("Downloading title {} vLatest, please wait...", tid); println!("Downloading title {} vLatest, please wait...", tid);
} }
let version: Option<u16> = if version.is_some() {
Some(version.clone().unwrap().parse().with_context(|| "The specified Title version must be a valid integer!")?)
} else {
None
};
let tid: [u8; 8] = hex::decode(tid)?.try_into().unwrap(); let tid: [u8; 8] = hex::decode(tid)?.try_into().unwrap();
println!(" - Downloading and parsing TMD..."); 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.")?)?; 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..."); println!(" - Downloading and parsing Ticket...");
let tik_res = &nus::download_ticket(tid, true); let tik_res = &nus::download_ticket(tid, true);
let tik = match tik_res { let tik = match tik_res {
@ -266,12 +261,7 @@ pub fn download_title(tid: &str, version: &Option<String>, output: &TitleOutputT
Ok(()) Ok(())
} }
pub fn download_tmd(tid: &str, version: &Option<String>, output: &Option<String>) -> Result<()> { pub fn download_tmd(tid: &str, version: &Option<u16>, output: &Option<String>) -> Result<()> {
let version: Option<u16> = if version.is_some() {
Some(version.clone().unwrap().parse().with_context(|| "The specified TMD version must be a valid integer!")?)
} else {
None
};
println!("Downloading TMD for title {tid}..."); println!("Downloading TMD for title {tid}...");
if tid.len() != 16 { if tid.len() != 16 {
bail!("The specified Title ID is invalid!"); bail!("The specified Title ID is invalid!");
@ -284,7 +274,7 @@ pub fn download_tmd(tid: &str, version: &Option<String>, output: &Option<String>
PathBuf::from(format!("{}.tmd", tid)) PathBuf::from(format!("{}.tmd", tid))
}; };
let tid: [u8; 8] = hex::decode(tid)?.try_into().unwrap(); 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.")?; let tmd_data = nus::download_tmd(tid, *version, true).with_context(|| "TMD data could not be downloaded.")?;
fs::write(&out_path, tmd_data)?; fs::write(&out_path, tmd_data)?;
println!("Successfully downloaded TMD to \"{}\"!", out_path.display()); println!("Successfully downloaded TMD to \"{}\"!", out_path.display());
Ok(()) Ok(())

View File

@ -5,10 +5,13 @@
use std::{str, fs, fmt}; use std::{str, fs, fmt};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::rc::Rc;
use anyhow::{bail, Context, Result}; use anyhow::{bail, Context, Result};
use clap::{Subcommand, Args}; use clap::{Subcommand, Args};
use glob::glob; use glob::glob;
use hex::FromHex;
use rand::prelude::*; use rand::prelude::*;
use regex::RegexBuilder;
use rustii::title::{cert, crypto, tmd, ticket, content, wad}; use rustii::title::{cert, crypto, tmd, ticket, content, wad};
use rustii::title; use rustii::title;
@ -42,6 +45,16 @@ pub enum Commands {
#[command(flatten)] #[command(flatten)]
target: ConvertTargets, 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 a directory into a WAD file
Pack { Pack {
/// The directory to pack into a WAD /// The directory to pack into a WAD
@ -110,6 +123,23 @@ pub struct ContentIdentifier {
cid: Option<String>, 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 { enum Target {
Retail, Retail,
Dev, Dev,
@ -148,7 +178,7 @@ pub fn add_wad(input: &str, content: &str, output: &Option<String>, cid: &Option
"normal" => tmd::ContentType::Normal, "normal" => tmd::ContentType::Normal,
"shared" => tmd::ContentType::Shared, "shared" => tmd::ContentType::Shared,
"dlc" => tmd::ContentType::DLC, "dlc" => tmd::ContentType::DLC,
_ => bail!("The specified content type \"{}\" is invalid!", ctype.clone().unwrap()), _ => bail!("The specified content type \"{}\" is invalid! Try --help to see valid types.", ctype.clone().unwrap()),
} }
} else { } else {
println!("Using default type \"Normal\" because no content type was specified."); println!("Using default type \"Normal\" because no content type was specified.");
@ -226,21 +256,21 @@ pub fn convert_wad(input: &str, target: &ConvertTargets, output: &Option<String>
Target::Dev => { Target::Dev => {
title.tmd.set_signature_issuer(String::from("Root-CA00000002-CP00000007"))?; title.tmd.set_signature_issuer(String::from("Root-CA00000002-CP00000007"))?;
title.ticket.set_signature_issuer(String::from("Root-CA00000002-XS00000006"))?; 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_key_new = crypto::encrypt_title_key(title_key, 0, title.ticket.title_id(), true);
title.ticket.common_key_index = 0; title.ticket.common_key_index = 0;
title.tmd.is_vwii = 0; title.tmd.is_vwii = 0;
}, },
Target::Retail => { Target::Retail => {
title.tmd.set_signature_issuer(String::from("Root-CA00000001-CP00000004"))?; title.tmd.set_signature_issuer(String::from("Root-CA00000001-CP00000004"))?;
title.ticket.set_signature_issuer(String::from("Root-CA00000001-XS00000003"))?; 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_key_new = crypto::encrypt_title_key(title_key, 0, title.ticket.title_id(), false);
title.ticket.common_key_index = 0; title.ticket.common_key_index = 0;
title.tmd.is_vwii = 0; title.tmd.is_vwii = 0;
}, },
Target::Vwii => { Target::Vwii => {
title.tmd.set_signature_issuer(String::from("Root-CA00000001-CP00000004"))?; title.tmd.set_signature_issuer(String::from("Root-CA00000001-CP00000004"))?;
title.ticket.set_signature_issuer(String::from("Root-CA00000001-XS00000003"))?; 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_key_new = crypto::encrypt_title_key(title_key, 2, title.ticket.title_id(), false);
title.ticket.common_key_index = 2; title.ticket.common_key_index = 2;
title.tmd.is_vwii = 1; title.tmd.is_vwii = 1;
} }
@ -252,6 +282,70 @@ pub fn convert_wad(input: &str, target: &ConvertTargets, output: &Option<String>
Ok(()) 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<()> { pub fn pack_wad(input: &str, output: &str) -> Result<()> {
let in_path = Path::new(input); let in_path = Path::new(input);
if !in_path.exists() { if !in_path.exists() {
@ -295,10 +389,11 @@ pub fn pack_wad(input: &str, output: &str) -> Result<()> {
footer = fs::read(&footer_files[0]).with_context(|| "Could not open footer file for reading.")?; 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. // Iterate over expected content and read it into a content region.
let mut content_region = content::ContentRegion::new(tmd.content_records.clone())?; let mut content_region = content::ContentRegion::new(Rc::clone(&tmd.content_records))?;
for content in tmd.content_records.borrow().iter() { let content_indexes: Vec<u16> = tmd.content_records.borrow().iter().map(|record| record.index).collect();
let data = fs::read(format!("{}/{:08X}.app", in_path.display(), content.index)).with_context(|| format!("Could not open content file \"{:08X}.app\" for reading.", content.index))?; for index in content_indexes {
content_region.set_content(&data, content.index as usize, None, None, tik.dec_title_key()) 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.dec_title_key())
.with_context(|| "Failed to load content into the ContentRegion.")?; .with_context(|| "Failed to load content into the ContentRegion.")?;
} }
// Ensure that the TMD is modified with our potentially updated content records. // Ensure that the TMD is modified with our potentially updated content records.
@ -317,7 +412,7 @@ pub fn pack_wad(input: &str, output: &str) -> Result<()> {
} }
} }
fs::write(&out_path, wad.to_bytes()?).with_context(|| format!("Could not open output file \"{}\" for writing.", out_path.display()))?; fs::write(&out_path, wad.to_bytes()?).with_context(|| format!("Could not open output file \"{}\" for writing.", out_path.display()))?;
println!("WAD file packed!"); println!("Successfully packed WAD file to \"{}\"!", out_path.display());
Ok(()) Ok(())
} }
@ -414,7 +509,7 @@ pub fn unpack_wad(input: &str, output: &str) -> Result<()> {
} }
let wad_file = fs::read(in_path).with_context(|| format!("Failed to open WAD file \"{}\" for reading.", in_path.display()))?; 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 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); let tid = hex::encode(title.tmd.title_id());
// Create output directory if it doesn't exist. // Create output directory if it doesn't exist.
let out_path = Path::new(output); let out_path = Path::new(output);
if !out_path.exists() { if !out_path.exists() {
@ -435,6 +530,6 @@ pub fn unpack_wad(input: &str, output: &str) -> Result<()> {
let dec_content = title.get_content_by_index(i).with_context(|| format!("Failed to unpack content with Content ID {:08X}.", title.content.content_records.borrow()[i].content_id))?; let dec_content = title.get_content_by_index(i).with_context(|| format!("Failed to unpack content with Content ID {:08X}.", title.content.content_records.borrow()[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.borrow()[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.borrow()[i].content_id))?;
} }
println!("WAD file unpacked!"); println!("Successfully unpacked WAD file to \"{}\"!", out_path.display());
Ok(()) Ok(())
} }

View File

@ -171,13 +171,6 @@ impl Title {
Ok(title_size) Ok(title_size)
} }
/// Gets the installed size of the title, in blocks. Use the optional parameter "absolute" to
/// set whether shared content should be included in this total or not.
pub fn title_size_blocks(&self, absolute: Option<bool>) -> Result<usize, TitleError> {
let title_size_bytes = self.title_size(absolute)?;
Ok((title_size_bytes as f64 / 131072.0).ceil() as usize)
}
/// Verifies entire certificate chain, and then the TMD and Ticket. Returns true if the title /// Verifies entire certificate chain, and then the TMD and Ticket. Returns true if the title
/// is entirely valid, or false if any component of the verification fails. /// is entirely valid, or false if any component of the verification fails.
pub fn verify(&self) -> Result<bool, TitleError> { pub fn verify(&self) -> Result<bool, TitleError> {
@ -195,6 +188,14 @@ impl Title {
Ok(true) Ok(true)
} }
/// Sets a new Title ID for the Title. This will re-encrypt the Title Key in the Ticket, since
/// the Title ID is used as the IV for decrypting the Title Key.
pub fn set_title_id(&mut self, title_id: [u8; 8]) -> Result<(), TitleError> {
self.tmd.set_title_id(title_id)?;
self.ticket.set_title_id(title_id)?;
Ok(())
}
pub fn set_cert_chain(&mut self, cert_chain: cert::CertificateChain) { pub fn set_cert_chain(&mut self, cert_chain: cert::CertificateChain) {
self.cert_chain = cert_chain; self.cert_chain = cert_chain;
} }
@ -227,3 +228,8 @@ impl Title {
self.meta = meta.to_vec(); self.meta = meta.to_vec();
} }
} }
/// Converts bytes to the Wii's storage unit, blocks.
pub fn bytes_to_blocks(size_bytes: usize) -> usize {
(size_bytes as f64 / 131072.0).ceil() as usize
}

View File

@ -83,7 +83,7 @@ pub fn download_contents(tmd: &tmd::TMD, wiiu_endpoint: bool) -> Result<Vec<Vec<
let content_ids: Vec<u32> = tmd.content_records.borrow().iter().map(|record| { record.content_id }).collect(); let content_ids: Vec<u32> = tmd.content_records.borrow().iter().map(|record| { record.content_id }).collect();
let mut contents: Vec<Vec<u8>> = Vec::new(); let mut contents: Vec<Vec<u8>> = Vec::new();
for id in content_ids { for id in content_ids {
contents.push(download_content(tmd.title_id, id, wiiu_endpoint)?); contents.push(download_content(tmd.title_id(), id, wiiu_endpoint)?);
} }
Ok(contents) Ok(contents)
} }

View File

@ -7,6 +7,7 @@ use std::io::{Cursor, Read, Write};
use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt}; use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt};
use sha1::{Sha1, Digest}; use sha1::{Sha1, Digest};
use thiserror::Error; use thiserror::Error;
use crate::title::crypto;
use crate::title::crypto::decrypt_title_key; use crate::title::crypto::decrypt_title_key;
#[derive(Debug, Error)] #[derive(Debug, Error)]
@ -43,7 +44,7 @@ pub struct Ticket {
unknown1: [u8; 1], unknown1: [u8; 1],
pub ticket_id: [u8; 8], pub ticket_id: [u8; 8],
pub console_id: [u8; 4], pub console_id: [u8; 4],
pub title_id: [u8; 8], title_id: [u8; 8],
unknown2: [u8; 2], unknown2: [u8; 2],
pub title_version: u16, pub title_version: u16,
pub permitted_titles_mask: [u8; 4], pub permitted_titles_mask: [u8; 4],
@ -232,4 +233,18 @@ impl Ticket {
self.signature_issuer = issuer.try_into().unwrap(); self.signature_issuer = issuer.try_into().unwrap();
Ok(()) Ok(())
} }
/// Gets the Title ID of the Ticket.
pub fn title_id(&self) -> [u8; 8] {
self.title_id
}
/// Sets a new Title ID for the Ticket. This will re-encrypt the Title Key, since the Title ID
/// is used as the IV for decrypting the Title Key.
pub fn set_title_id(&mut self, title_id: [u8; 8]) -> Result<(), TicketError> {
let new_enc_title_key = crypto::encrypt_title_key(self.dec_title_key(), self.common_key_index, title_id, self.is_dev());
self.title_key = new_enc_title_key;
self.title_id = title_id;
Ok(())
}
} }

View File

@ -18,21 +18,27 @@ pub enum TMDError {
CannotFakesign, CannotFakesign,
#[error("signature issuer string must not exceed 64 characters (was {0})")] #[error("signature issuer string must not exceed 64 characters (was {0})")]
IssuerTooLong(usize), IssuerTooLong(usize),
#[error("invalid IOS Title ID, IOSes must have a Title ID beginning with 00000001 (type 'System')")]
InvalidIOSTitleID,
#[error("invalid IOS version `{0}`, IOS version must be in the range 3-255")]
InvalidIOSVersion(u32),
#[error("TMD data contains content record with invalid type `{0}`")] #[error("TMD data contains content record with invalid type `{0}`")]
InvalidContentType(u16), InvalidContentType(u16),
#[error("encountered unknown title type `{0}`")]
InvalidTitleType(String),
#[error("TMD data is not in a valid format")] #[error("TMD data is not in a valid format")]
IO(#[from] std::io::Error), IO(#[from] std::io::Error),
} }
#[repr(u32)]
pub enum TitleType { pub enum TitleType {
System, System = 0x00000001,
Game, Game = 0x00010000,
Channel, Channel = 0x00010001,
SystemChannel, SystemChannel = 0x00010002,
GameChannel, GameChannel = 0x00010004,
DLC, DLC = 0x00010005,
HiddenChannel, HiddenChannel = 0x00010008,
Unknown,
} }
impl fmt::Display for TitleType { impl fmt::Display for TitleType {
@ -45,7 +51,6 @@ impl fmt::Display for TitleType {
TitleType::GameChannel => write!(f, "GameChannel"), TitleType::GameChannel => write!(f, "GameChannel"),
TitleType::DLC => write!(f, "DLC"), TitleType::DLC => write!(f, "DLC"),
TitleType::HiddenChannel => write!(f, "HiddenChannel"), TitleType::HiddenChannel => write!(f, "HiddenChannel"),
TitleType::Unknown => write!(f, "Unknown"),
} }
} }
} }
@ -97,9 +102,9 @@ pub struct TMD {
pub ca_crl_version: u8, pub ca_crl_version: u8,
pub signer_crl_version: u8, pub signer_crl_version: u8,
pub is_vwii: u8, pub is_vwii: u8,
pub ios_tid: [u8; 8], ios_tid: [u8; 8],
pub title_id: [u8; 8], title_id: [u8; 8],
pub title_type: [u8; 4], title_type: [u8; 4],
pub group_id: u16, pub group_id: u16,
padding2: [u8; 2], padding2: [u8; 2],
region: u16, region: u16,
@ -301,19 +306,26 @@ impl TMD {
} }
/// Gets the type of title described by a TMD. /// Gets the type of title described by a TMD.
pub fn title_type(&self) -> TitleType { pub fn title_type(&self) -> Result<TitleType, TMDError> {
match hex::encode(self.title_id)[..8].to_string().as_str() { match hex::encode(self.title_id)[..8].to_string().as_str() {
"00000001" => TitleType::System, "00000001" => Ok(TitleType::System),
"00010000" => TitleType::Game, "00010000" => Ok(TitleType::Game),
"00010001" => TitleType::Channel, "00010001" => Ok(TitleType::Channel),
"00010002" => TitleType::SystemChannel, "00010002" => Ok(TitleType::SystemChannel),
"00010004" => TitleType::GameChannel, "00010004" => Ok(TitleType::GameChannel),
"00010005" => TitleType::DLC, "00010005" => Ok(TitleType::DLC),
"00010008" => TitleType::HiddenChannel, "00010008" => Ok(TitleType::HiddenChannel),
_ => TitleType::Unknown, _ => Err(TMDError::InvalidTitleType(hex::encode(self.title_id)[..8].to_string())),
} }
} }
/// Sets the type of title described by a TMD.
pub fn set_title_type(&mut self, new_type: TitleType) -> Result<(), TMDError> {
let new_type: [u8; 4] = (new_type as u32).to_be_bytes();
self.title_type = new_type;
Ok(())
}
/// Gets the type of content described by a content record in a TMD. /// Gets the type of content described by a content record in a TMD.
pub fn content_type(&self, index: usize) -> ContentType { pub fn content_type(&self, index: usize) -> ContentType {
// Find possible content indices, because the provided one could exist while the indices // Find possible content indices, because the provided one could exist while the indices
@ -353,8 +365,39 @@ impl TMD {
Ok(()) Ok(())
} }
/// Gets whether this TMD describes a vWii title or not. /// Gets whether a TMD describes a vWii title or not.
pub fn is_vwii(&self) -> bool { pub fn is_vwii(&self) -> bool {
self.is_vwii == 1 self.is_vwii == 1
} }
/// Gets the Title ID of a TMD.
pub fn title_id(&self) -> [u8; 8] {
self.title_id
}
/// Sets a new Title ID for a TMD.
pub fn set_title_id(&mut self, title_id: [u8; 8]) -> Result<(), TMDError> {
self.title_id = title_id;
Ok(())
}
/// Gets the Title ID of the IOS required by a TMD.
pub fn ios_tid(&self) -> [u8; 8] {
self.ios_tid
}
/// Sets the Title ID of the IOS required by a TMD. The Title ID must be in the valid range of
/// IOS versions, from 0000000100000003 to 00000001000000FF.
pub fn set_ios_tid(&mut self, ios_tid: [u8; 8]) -> Result<(), TMDError> {
let tid_high = &ios_tid[0..4];
if hex::encode(tid_high) != "00000001" {
return Err(TMDError::InvalidIOSTitleID);
}
let ios_version = u32::from_be_bytes(ios_tid[4..8].try_into().unwrap());
if !(3..=255).contains(&ios_version) {
return Err(TMDError::InvalidIOSVersion(ios_version));
}
self.ios_tid = ios_tid;
Ok(())
}
} }

View File

@ -68,7 +68,7 @@ impl WADHeader {
// Generates a new WADHeader from a populated WADBody object. // Generates a new WADHeader from a populated WADBody object.
// Parse the TMD and use that to determine if this is a standard WAD or a boot2 WAD. // Parse the TMD and use that to determine if this is a standard WAD or a boot2 WAD.
let tmd = tmd::TMD::from_bytes(&body.tmd).map_err(WADError::TMD)?; let tmd = tmd::TMD::from_bytes(&body.tmd).map_err(WADError::TMD)?;
let wad_type = match hex::encode(tmd.title_id).as_str() { let wad_type = match hex::encode(tmd.title_id()).as_str() {
"0000000100000001" => WADType::ImportBoot, "0000000100000001" => WADType::ImportBoot,
_ => WADType::Installable, _ => WADType::Installable,
}; };