Compare commits

...

2 Commits

Author SHA1 Message Date
15947ceff3
Update README.md 2025-04-29 22:19:55 -04:00
a30a0f2c5b
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.
2025-04-29 22:03:55 -04:00
11 changed files with 245 additions and 87 deletions

View File

@ -5,7 +5,9 @@
[![Build rustii](https://github.com/NinjaCheetah/rustii/actions/workflows/rust.yml/badge.svg)](https://github.com/NinjaCheetah/rustii/actions/workflows/rust.yml)
rustii is a library and command line tool written in Rust for handling the various files and formats found on the Wii. rustii is a port of my other library, [libWiiPy](https://github.com/NinjaCheetah/libWiiPy), which aims to accomplish the same goal in Python. Compared to libWiiPy, rustii is in its very early stages of development and is missing most of the features present in its Python counterpart. The goal is for rustii and libWiiPy to eventually have feature parity, with the rustii CLI acting as a drop-in replacement for the (comparatively much less efficient) [WiiPy](https://github.com/NinjaCheetah/WiiPy) CLI.
rustii is a library and command line tool written in Rust for handling the various files and formats found on the Wii. rustii is a port of my other library, [libWiiPy](https://github.com/NinjaCheetah/libWiiPy), which aims to accomplish the same goal in Python. At this point, rustii should not be considered stable, however it offers most of the same core functionality as libWiiPy, and the rustii CLI offers most of the same features as WiiPy. You can check which features are available and ready for use in both the library and the CLI below. The goal is for rustii and libWiiPy to eventually have feature parity, with the rustii CLI acting as a drop-in replacement for the (comparatively much less efficient) [WiiPy](https://github.com/NinjaCheetah/WiiPy) CLI.
There is currently no public documentation for rustii, as I'm putting that off until I reach feature parity with libWiiPy so that the APIs are an equal level of stable. You can, however, reference the doc strings present on many of the structs and functions, and build them into basic documentation yourself (using `cargo doc --no-deps`). The [libWiiPy API docs](https://docs.ninjacheetah.dev) may also be helpful in some cases.
I'm still very new to Rust, so pardon any messy code or confusing API decisions you may find. libWiiPy started off like that, too.
@ -13,25 +15,29 @@ I'm still very new to Rust, so pardon any messy code or confusing API decisions
- Structs for parsing and editing WADs, TMDs, Tickets, and Certificate Chains
- Title Key and content encryption/decryption
- High-level Title struct (offering the same utility as libWiiPy's `Title`)
- Content addition/removal/replacing
- LZ77 compression/decompression
- ASH decompression
- U8 archive packing and unpacking
- NUS TMD/Ticket/certificate chain/content downloading
- A basic CLI that uses the above features to allow for packing/unpacking WADs
- The very basics of U8 archive handling (not really functional yet though)
### What's Included (CLI-Side)
- WAD packing/unpacking/converting
- NUS TMD/Ticket/Title downloading
- WAD converting/packing/unpacking
- WAD content addition/removal/replacement
- NUS TMD/Ticket/Content/Title downloading
- LZ77 compression/decompression
- ASH decompression
- Fakesigning command for WADs/TMDs/Tickets
- Info command for WADs/TMDs/Tickets
- Info command for WADs/TMDs/Tickets/U8 archives
- U8 archive packing/unpacking
To see specific usage information, check `rustii --help` and `rustii <command> --help`.
## Building
rustii is a standard Rust package. You'll need to have [Rust installed](https://www.rust-lang.org/learn/get-started), and then you can simply run:
rustii is a standard Rust crate. You'll need to have [Rust installed](https://www.rust-lang.org/learn/get-started), and then you can simply run:
```
cargo build --release
```
to compile the rustii library and CLI. The CLI can then be found at `target/release/rustii(.exe)`.
You can also download the latest nightly build from [GitHub Actions](https://github.com/NinjaCheetah/rustii/actions).

View File

@ -7,7 +7,7 @@ use rustii::title;
fn main() {
let data = fs::read("sm.wad").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();
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<()> {
// 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())?;
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") {
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());
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") {
@ -73,8 +73,8 @@ fn print_tmd_info(tmd: tmd::TMD, cert: Option<cert::Certificate>) -> Result<()>
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 != 0))
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))
.unwrap_or_default().chars().last() {
Some('U') => "USA",
Some('E') => "EUR",
@ -82,13 +82,13 @@ fn print_tmd_info(tmd: tmd::TMD, cert: Option<cert::Certificate>) -> Result<()>
Some('K') => "KOR",
_ => "None"
}
} else if matches!(tmd.title_type(), tmd::TitleType::System) {
} else if matches!(tmd.title_type(), Ok(tmd::TitleType::System)) {
"None"
} else {
tmd.region()
};
println!(" Region: {}", region);
println!(" Title Type: {}", tmd.title_type());
println!(" Title Type: {}", tmd.title_type()?);
println!(" vWii Title: {}", tmd.is_vwii != 0);
println!(" DVD Video Access: {}", tmd.check_access_right(tmd::AccessRight::DVDVideo));
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 ID: {:08X}", content.content_id);
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));
}
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<()> {
// 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)?;
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") {
@ -196,8 +196,8 @@ fn print_wad_info(wad: wad::WAD) -> Result<()> {
}
// 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.title_size_blocks(None)?;
let max_size_blocks = title.title_size_blocks(Some(true))?;
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 {

View File

@ -125,6 +125,9 @@ fn main() -> Result<()> {
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)?
},

View File

@ -22,7 +22,7 @@ pub enum Commands {
cid: String,
/// The title version that the content belongs to (only required for decryption)
#[arg(short, long)]
version: Option<String>,
version: Option<u16>,
/// An optional content file name; defaults to <cid>(.app)
#[arg(short, long)]
output: Option<String>,
@ -44,7 +44,7 @@ pub enum Commands {
tid: String,
/// The version of the Title to download
#[arg(short, long)]
version: Option<String>,
version: Option<u16>,
#[command(flatten)]
output: TitleOutputType,
},
@ -54,7 +54,7 @@ pub enum Commands {
tid: String,
/// The version of the TMD to download
#[arg(short, long)]
version: Option<String>,
version: Option<u16>,
/// An optional TMD name; defaults to <tid>.tmd
#[arg(short, long)]
output: Option<String>,
@ -73,7 +73,7 @@ pub struct TitleOutputType {
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}...");
if tid.len() != 16 {
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
// decryption. A Ticket also needs to be available, of course.
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 {
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 {
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...");
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...");
@ -186,7 +186,7 @@ fn download_title_dir_enc(tmd: tmd::TMD, content_region: content::ContentRegion,
} 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);
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...");
@ -205,11 +205,11 @@ 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());
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<String>, output: &TitleOutputType) -> Result<()> {
pub fn download_title(tid: &str, version: &Option<u16>, output: &TitleOutputType) -> Result<()> {
if tid.len() != 16 {
bail!("The specified Title ID is invalid!");
}
@ -218,14 +218,9 @@ pub fn download_title(tid: &str, version: &Option<String>, output: &TitleOutputT
} else {
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();
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...");
let tik_res = &nus::download_ticket(tid, true);
let tik = match tik_res {
@ -266,12 +261,7 @@ pub fn download_title(tid: &str, version: &Option<String>, output: &TitleOutputT
Ok(())
}
pub fn download_tmd(tid: &str, version: &Option<String>, 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
};
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!");
@ -284,7 +274,7 @@ pub fn download_tmd(tid: &str, version: &Option<String>, output: &Option<String>
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.")?;
let tmd_data = nus::download_tmd(tid, *version, true).with_context(|| "TMD data could not be downloaded.")?;
fs::write(&out_path, tmd_data)?;
println!("Successfully downloaded TMD to \"{}\"!", out_path.display());
Ok(())

View File

@ -5,10 +5,13 @@
use std::{str, fs, fmt};
use std::path::{Path, PathBuf};
use std::rc::Rc;
use anyhow::{bail, Context, Result};
use clap::{Subcommand, Args};
use glob::glob;
use hex::FromHex;
use rand::prelude::*;
use regex::RegexBuilder;
use rustii::title::{cert, crypto, tmd, ticket, content, wad};
use rustii::title;
@ -42,6 +45,16 @@ pub enum Commands {
#[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
@ -110,6 +123,23 @@ pub struct ContentIdentifier {
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,
@ -148,7 +178,7 @@ pub fn add_wad(input: &str, content: &str, output: &Option<String>, cid: &Option
"normal" => tmd::ContentType::Normal,
"shared" => tmd::ContentType::Shared,
"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 {
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 => {
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_key_new = crypto::encrypt_title_key(title_key, 0, title.ticket.title_id(), true);
title.ticket.common_key_index = 0;
title.tmd.is_vwii = 0;
},
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_key_new = crypto::encrypt_title_key(title_key, 0, title.ticket.title_id(), false);
title.ticket.common_key_index = 0;
title.tmd.is_vwii = 0;
},
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_key_new = crypto::encrypt_title_key(title_key, 2, title.ticket.title_id(), false);
title.ticket.common_key_index = 2;
title.tmd.is_vwii = 1;
}
@ -252,6 +282,70 @@ pub fn convert_wad(input: &str, target: &ConvertTargets, output: &Option<String>
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() {
@ -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.")?;
}
// Iterate over expected content and read it into a content region.
let mut content_region = content::ContentRegion::new(tmd.content_records.clone())?;
for content in tmd.content_records.borrow().iter() {
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))?;
content_region.set_content(&data, content.index as usize, None, None, tik.dec_title_key())
let mut content_region = content::ContentRegion::new(Rc::clone(&tmd.content_records))?;
let content_indexes: Vec<u16> = tmd.content_records.borrow().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.dec_title_key())
.with_context(|| "Failed to load content into the ContentRegion.")?;
}
// 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()))?;
println!("WAD file packed!");
println!("Successfully packed WAD file to \"{}\"!", out_path.display());
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 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.
let out_path = Path::new(output);
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))?;
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(())
}

View File

@ -171,13 +171,6 @@ impl Title {
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
/// is entirely valid, or false if any component of the verification fails.
pub fn verify(&self) -> Result<bool, TitleError> {
@ -194,6 +187,14 @@ impl Title {
}
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) {
self.cert_chain = cert_chain;
@ -227,3 +228,8 @@ impl Title {
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 mut contents: Vec<Vec<u8>> = Vec::new();
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)
}

View File

@ -7,6 +7,7 @@ use std::io::{Cursor, Read, Write};
use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt};
use sha1::{Sha1, Digest};
use thiserror::Error;
use crate::title::crypto;
use crate::title::crypto::decrypt_title_key;
#[derive(Debug, Error)]
@ -43,7 +44,7 @@ pub struct Ticket {
unknown1: [u8; 1],
pub ticket_id: [u8; 8],
pub console_id: [u8; 4],
pub title_id: [u8; 8],
title_id: [u8; 8],
unknown2: [u8; 2],
pub title_version: u16,
pub permitted_titles_mask: [u8; 4],
@ -232,4 +233,18 @@ impl Ticket {
self.signature_issuer = issuer.try_into().unwrap();
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,
#[error("signature issuer string must not exceed 64 characters (was {0})")]
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}`")]
InvalidContentType(u16),
#[error("encountered unknown title type `{0}`")]
InvalidTitleType(String),
#[error("TMD data is not in a valid format")]
IO(#[from] std::io::Error),
}
#[repr(u32)]
pub enum TitleType {
System,
Game,
Channel,
SystemChannel,
GameChannel,
DLC,
HiddenChannel,
Unknown,
System = 0x00000001,
Game = 0x00010000,
Channel = 0x00010001,
SystemChannel = 0x00010002,
GameChannel = 0x00010004,
DLC = 0x00010005,
HiddenChannel = 0x00010008,
}
impl fmt::Display for TitleType {
@ -45,7 +51,6 @@ impl fmt::Display for TitleType {
TitleType::GameChannel => write!(f, "GameChannel"),
TitleType::DLC => write!(f, "DLC"),
TitleType::HiddenChannel => write!(f, "HiddenChannel"),
TitleType::Unknown => write!(f, "Unknown"),
}
}
}
@ -97,9 +102,9 @@ pub struct TMD {
pub ca_crl_version: u8,
pub signer_crl_version: u8,
pub is_vwii: u8,
pub ios_tid: [u8; 8],
pub title_id: [u8; 8],
pub title_type: [u8; 4],
ios_tid: [u8; 8],
title_id: [u8; 8],
title_type: [u8; 4],
pub group_id: u16,
padding2: [u8; 2],
region: u16,
@ -301,19 +306,26 @@ impl 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() {
"00000001" => TitleType::System,
"00010000" => TitleType::Game,
"00010001" => TitleType::Channel,
"00010002" => TitleType::SystemChannel,
"00010004" => TitleType::GameChannel,
"00010005" => TitleType::DLC,
"00010008" => TitleType::HiddenChannel,
_ => TitleType::Unknown,
"00000001" => Ok(TitleType::System),
"00010000" => Ok(TitleType::Game),
"00010001" => Ok(TitleType::Channel),
"00010002" => Ok(TitleType::SystemChannel),
"00010004" => Ok(TitleType::GameChannel),
"00010005" => Ok(TitleType::DLC),
"00010008" => Ok(TitleType::HiddenChannel),
_ => 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.
pub fn content_type(&self, index: usize) -> ContentType {
// Find possible content indices, because the provided one could exist while the indices
@ -353,8 +365,39 @@ impl TMD {
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 {
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.
// 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 wad_type = match hex::encode(tmd.title_id).as_str() {
let wad_type = match hex::encode(tmd.title_id()).as_str() {
"0000000100000001" => WADType::ImportBoot,
_ => WADType::Installable,
};