Ported all NUS download functions from libWiiPy and corresponding CLI commands

Also adds the basics of U8 archive packing/unpacking, however they are not in a usable state yet and there are no working CLI commands associated with them.
This commit is contained in:
Campbell 2025-04-08 20:47:35 -04:00
parent e55edc10fd
commit be9148fcfa
Signed by: NinjaCheetah
GPG Key ID: 39C2500E1778B156
13 changed files with 2126 additions and 33 deletions

1435
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -35,3 +35,4 @@ regex = "1"
clap = { version = "4", features = ["derive"] }
anyhow = "1"
thiserror = "2"
reqwest = { version = "0", features = ["blocking"] }

View File

@ -3,3 +3,206 @@
//
// Implements the structures and methods required for parsing U8 archives.
use std::io::{Cursor, Read, Seek, SeekFrom, Write};
use std::path::Path;
use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum U8Error {
#[error("invalid file name at offset {0}")]
InvalidFileName(u64),
#[error("this does not appear to be a U8 archive (missing magic number)")]
NotU8Data,
#[error("U8 data is not in a valid format")]
IO(#[from] std::io::Error),
}
#[derive(Clone, Debug)]
struct U8Node {
node_type: u8,
name_offset: u32, // This is really type u24, so the most significant byte will be ignored.
data_offset: u32,
size: u32,
}
#[derive(Debug)]
pub struct U8Archive {
u8_nodes: Vec<U8Node>,
file_names: Vec<String>,
file_data: Vec<Vec<u8>>,
root_node_offset: u32,
header_size: u32,
data_offset: u32,
padding: [u8; 16],
}
impl U8Archive {
/// Creates a new U8 instance from the binary data of a U8 file.
pub fn from_bytes(data: &[u8]) -> Result<Self, U8Error> {
let mut buf = Cursor::new(data);
let mut magic = [0u8; 4];
buf.read_exact(&mut magic)?;
// Check for an IMET header if the magic number isn't the correct value before throwing an
// error.
if &magic != b"\x55\xAA\x38\x2D" {
// Check for an IMET header immediately at the start of the file.
buf.seek(SeekFrom::Start(0x40))?;
buf.read_exact(&mut magic)?;
if &magic == b"\x49\x4D\x45\x54" {
// IMET with no build tag means the U8 archive should start at 0x600.
buf.seek(SeekFrom::Start(0x600))?;
buf.read_exact(&mut magic)?;
if &magic != b"\x55\xAA\x38\x2D" {
return Err(U8Error::NotU8Data);
}
println!("ignoring IMET header at 0x40");
}
// Check for an IMET header that comes after a built tag.
else {
buf.seek(SeekFrom::Start(0x80))?;
buf.read_exact(&mut magic)?;
if &magic == b"\x49\x4D\x45\x54" {
// IMET with a build tag means the U8 archive should start at 0x600.
buf.seek(SeekFrom::Start(0x640))?;
buf.read_exact(&mut magic)?;
if &magic != b"\x55\xAA\x38\x2D" {
return Err(U8Error::NotU8Data);
}
println!("ignoring IMET header at 0x80");
}
}
}
let root_node_offset = buf.read_u32::<BigEndian>()?;
let header_size = buf.read_u32::<BigEndian>()?;
let data_offset = buf.read_u32::<BigEndian>()?;
let mut padding = [0u8; 16];
buf.read_exact(&mut padding)?;
// Manually read the root node, since we need its size anyway to know how many nodes there
// are total.
let root_node_type = buf.read_u8()?;
let root_node_name_offset = buf.read_u24::<BigEndian>()?;
let root_node_data_offset = buf.read_u32::<BigEndian>()?;
let root_node_size = buf.read_u32::<BigEndian>()?;
let root_node = U8Node {
node_type: root_node_type,
name_offset: root_node_name_offset,
data_offset: root_node_data_offset,
size: root_node_size,
};
// Create a vec of nodes, push the root node, and then iterate over the remaining number
// of nodes in the file and push them to the vec.
let mut u8_nodes: Vec<U8Node> = Vec::new();
u8_nodes.push(root_node);
for _ in 1..root_node_size {
let node_type = buf.read_u8()?;
let name_offset = buf.read_u24::<BigEndian>()?;
let data_offset = buf.read_u32::<BigEndian>()?;
let size = buf.read_u32::<BigEndian>()?;
u8_nodes.push(U8Node { node_type, name_offset, data_offset, size })
}
// Iterate over the loaded nodes and load the file names and data associated with them.
let base_name_offset = buf.position();
let mut file_names = Vec::<String>::new();
let mut file_data = Vec::<Vec<u8>>::new();
for node in &u8_nodes {
buf.seek(SeekFrom::Start(base_name_offset + node.name_offset as u64))?;
let mut name_bin = Vec::<u8>::new();
// Read the file name one byte at a time until we find a null byte.
loop {
let byte = buf.read_u8()?;
if byte == b'\0' {
break;
}
name_bin.push(byte);
}
file_names.push(String::from_utf8(name_bin).map_err(|_| U8Error::InvalidFileName(base_name_offset + node.name_offset as u64))?.to_owned());
// If this is a file node, read the data for the file.
if node.node_type == 0 {
buf.seek(SeekFrom::Start(node.data_offset as u64))?;
let mut data = vec![0u8; node.size as usize];
buf.read_exact(&mut data)?;
file_data.push(data);
} else {
file_data.push(Vec::new());
}
}
Ok(U8Archive {
u8_nodes,
file_names,
file_data,
root_node_offset,
header_size,
data_offset,
padding,
})
}
fn pack_dir() {
todo!();
}
pub fn from_dir(_input: &Path) -> Result<Self, U8Error> {
todo!();
}
/// Dumps the data in a U8Archive instance back into binary data that can be written to a file.
pub fn to_bytes(&self) -> Result<Vec<u8>, U8Error> {
// Header size starts at 0 because the header size starts with the nodes and does not
// include the actual file header.
let mut header_size: u32 = 0;
// Add 12 bytes for each node, since that's how many bytes each one is made up of.
for _ in 0..self.u8_nodes.len() {
header_size += 12;
}
// Add the number of bytes used for each file/folder name in the string table.
for file_name in &self.file_names {
header_size += file_name.len() as u32 + 1
}
// The initial data offset is equal to the file header (32 bytes) + node data aligned to
// 64 bytes.
let data_offset: u32 = (header_size + 32 + 63) & !63;
// Adjust all nodes to place file data in the same order as the nodes. For some reason
// Nintendo-made U8 archives don't necessarily do this?
let mut current_data_offset = data_offset;
let mut current_name_offset: u32 = 0;
let mut u8_nodes = self.u8_nodes.clone();
for i in 0..u8_nodes.len() {
if u8_nodes[i].node_type == 0 {
u8_nodes[i].data_offset = (current_data_offset + 31) & !31;
current_data_offset += (u8_nodes[i].size + 31) & !31;
}
// Calculate the name offsets, including the extra 1 for the NULL byte.
u8_nodes[i].name_offset = current_name_offset;
current_name_offset += self.file_names[i].len() as u32 + 1
}
// Begin writing file data.
let mut buf: Vec<u8> = Vec::new();
buf.write_all(b"\x55\xAA\x38\x2D")?;
buf.write_u32::<BigEndian>(0x20)?; // The root node offset is always 0x20.
buf.write_u32::<BigEndian>(header_size)?;
buf.write_u32::<BigEndian>(data_offset)?;
buf.write_all(&self.padding)?;
// Iterate over nodes and write them out.
for node in &u8_nodes {
buf.write_u8(node.node_type)?;
buf.write_u24::<BigEndian>(node.name_offset)?;
buf.write_u32::<BigEndian>(node.data_offset)?;
buf.write_u32::<BigEndian>(node.size)?;
}
// Iterate over file names with a null byte at the end.
for file_name in &self.file_names {
buf.write_all(file_name.as_bytes())?;
buf.write_u8(b'\0')?;
}
// Pad to the nearest multiple of 64 bytes.
buf.resize((buf.len() + 63) & !63, 0);
// Iterate over the file data and dump it. The file needs to be aligned to 32 bytes after
// each write.
for data in &self.file_data {
buf.write_all(data)?;
buf.resize((buf.len() + 31) & !31, 0);
}
Ok(buf)
}
}

View File

@ -3,3 +3,4 @@
pub mod ash;
pub mod lz77;
pub mod u8;

View File

@ -0,0 +1,44 @@
// archive/u8.rs from rustii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustii
//
// Code for the U8 packing/unpacking commands in the rustii CLI.
use std::{str, fs};
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use clap::Subcommand;
use rustii::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,
}
}
pub fn pack_u8_archive(_input: &str, _output: &str) -> Result<()> {
todo!();
}
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.", input);
}
let u8_data = u8::U8Archive::from_bytes(&fs::read(in_path)?)?;
println!("{:?}", u8_data);
fs::write(Path::new(output), u8_data.to_bytes()?)?;
Ok(())
}

View File

@ -44,6 +44,14 @@ enum Commands {
#[command(subcommand)]
command: archive::lz77::Commands
},
Nus {
#[command(subcommand)]
command: title::nus::Commands
},
U8 {
#[command(subcommand)]
command: archive::u8::Commands
},
/// Pack/unpack/edit a WAD file
Wad {
#[command(subcommand)]
@ -68,6 +76,9 @@ fn main() -> Result<()> {
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 } => {
@ -78,8 +89,28 @@ fn main() -> Result<()> {
}
}
},
Some(Commands::Info { input }) => {
info::info(input)?
Some(Commands::Nus { command }) => {
match command {
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::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 {

View File

@ -2,4 +2,5 @@
// https://github.com/NinjaCheetah/rustii
pub mod fakesign;
pub mod nus;
pub mod wad;

215
src/bin/rustii/title/nus.rs Normal file
View File

@ -0,0 +1,215 @@
// title/nus.rs from rustii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustii
//
// 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 rustii::title::{cert, content, nus, ticket, tmd};
use rustii::title;
#[derive(Subcommand)]
#[command(arg_required_else_help = true)]
pub enum Commands {
/// 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<String>,
#[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<String>,
/// 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_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 {
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 {
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<String>, 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 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.")?)?;
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 {
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<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
};
println!("Downloading TMD for title {tid}...");
if tid.len() != 16 {
bail!("The specified Title ID is invalid!");
}
let out_path = if output.is_some() {
PathBuf::from(output.clone().unwrap())
} else if version.is_some() {
PathBuf::from(format!("{}.tmd.{}", tid, version.unwrap()))
} else {
PathBuf::from(format!("{}.tmd", tid))
};
let tid: [u8; 8] = hex::decode(tid)?.try_into().unwrap();
let tmd_data = nus::download_tmd(tid, version, true).with_context(|| "TMD data could not be downloaded.")?;
fs::write(&out_path, tmd_data)?;
println!("Successfully downloaded TMD to \"{}\"!", out_path.display());
Ok(())
}

View File

@ -138,7 +138,7 @@ pub fn convert_wad(input: &str, target: &ConvertTargets, output: &Option<String>
}
title.ticket.title_key = title_key_new;
title.fakesign()?;
fs::write(out_path.clone(), title.to_wad()?.to_bytes()?)?;
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(())
}
@ -205,7 +205,7 @@ pub fn pack_wad(input: &str, output: &str) -> Result<()> {
out_path.set_extension("wad");
}
}
fs::write(out_path.clone(), 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!");
Ok(())
}

View File

@ -6,6 +6,7 @@
use std::io::{Cursor, Read, Seek, SeekFrom, Write};
use sha1::{Sha1, Digest};
use thiserror::Error;
use crate::title::content::ContentError::MissingContents;
use crate::title::tmd::ContentRecord;
use crate::title::crypto;
@ -13,6 +14,8 @@ use crate::title::crypto;
pub enum ContentError {
#[error("requested index {index} is out of range (must not exceed {max})")]
IndexOutOfRange { index: usize, max: usize },
#[error("expected {required} contents based on content records but found {found}")]
MissingContents { required: usize, found: usize },
#[error("content with requested Content ID {0} could not be found")]
CIDNotFound(u32),
#[error("content's hash did not match the expected value (was {hash}, expected {expected})")]
@ -71,15 +74,27 @@ impl ContentRegion {
})
}
/// Creates a ContentRegion instance that can be used to parse and edit content stored in a
/// digital Wii title from a vector of contents and the ContentRecords from a TMD.
pub fn from_contents(contents: Vec<Vec<u8>>, content_records: Vec<ContentRecord>) -> Result<Self, ContentError> {
if contents.len() != content_records.len() {
return Err(MissingContents { required: content_records.len(), found: contents.len()});
}
let mut content_region = Self::new(content_records)?;
for i in 0..contents.len() {
content_region.load_enc_content(&contents[i], content_region.content_records[i].index as usize)?;
}
Ok(content_region)
}
/// Creates a ContentRegion instance from the ContentRecords of a TMD that contains no actual
/// content. This can be used to load existing content from files.
pub fn new(content_records: Vec<ContentRecord>) -> Result<Self, ContentError> {
let content_region_size: u64 = content_records.iter().map(|x| (x.content_size + 63) & !63).sum();
let content_region_size = content_region_size as u32;
let num_contents = content_records.len() as u16;
let content_start_offsets: Vec<u64> = Vec::new();
let mut contents: Vec<Vec<u8>> = Vec::new();
contents.resize(num_contents as usize, Vec::new());
let content_start_offsets: Vec<u64> = vec![0; num_contents as usize];
let contents: Vec<Vec<u8>> = vec![Vec::new(); num_contents as usize];
Ok(ContentRegion {
content_records,
content_region_size,
@ -144,6 +159,16 @@ impl ContentRegion {
}
}
/// Loads existing content into the specified index of a ContentRegion instance. This content
/// must be encrypted.
pub fn load_enc_content(&mut self, content: &[u8], index: usize) -> Result<(), ContentError> {
if index >= self.content_records.len() {
return Err(ContentError::IndexOutOfRange { index, max: self.content_records.len() - 1 });
}
self.contents[index] = Vec::from(content);
Ok(())
}
/// Loads existing content into the specified index of a ContentRegion instance. This content
/// must be decrypted and needs to match the size and hash listed in the content record at that
/// index.

View File

@ -7,6 +7,7 @@ pub mod cert;
pub mod commonkeys;
pub mod content;
pub mod crypto;
pub mod nus;
pub mod ticket;
pub mod tmd;
pub mod versions;
@ -52,15 +53,37 @@ impl Title {
let ticket = ticket::Ticket::from_bytes(&wad.ticket()).map_err(TitleError::Ticket)?;
let tmd = tmd::TMD::from_bytes(&wad.tmd()).map_err(TitleError::TMD)?;
let content = content::ContentRegion::from_bytes(&wad.content(), tmd.content_records.clone()).map_err(TitleError::Content)?;
let title = Title {
Ok(Title {
cert_chain,
crl: wad.crl(),
ticket,
tmd,
content,
meta: wad.meta(),
})
}
/// Creates a new Title instance from all of its individual components.
pub fn from_parts(cert_chain: cert::CertificateChain, crl: Option<&[u8]>, ticket: ticket::Ticket, tmd: tmd::TMD,
content: content::ContentRegion, meta: Option<&[u8]>) -> Result<Title, TitleError> {
// Create empty vecs for the CRL and meta areas if we weren't supplied with any, as they're
// optional components.
let crl = match crl {
Some(crl) => crl.to_vec(),
None => Vec::new()
};
Ok(title)
let meta = match meta {
Some(meta) => meta.to_vec(),
None => Vec::new()
};
Ok(Title {
cert_chain,
crl,
ticket,
tmd,
content,
meta
})
}
/// Converts a Title instance into a WAD, which can be used to export the Title back to a file.

143
src/title/nus.rs Normal file
View File

@ -0,0 +1,143 @@
// title/nus.rs from rustii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustii
//
// Implements the functions required for downloading data from the NUS.
use std::str;
use std::io::Write;
use reqwest;
use thiserror::Error;
use crate::title::{cert, tmd, ticket, content};
use crate::title;
use sha1::{Sha1, Digest};
const WII_NUS_ENDPOINT: &str = "http://nus.cdn.shop.wii.com/ccs/download/";
const WII_U_NUS_ENDPOINT: &str = "http://ccs.cdn.wup.shop.nintendo.net/ccs/download/";
#[derive(Debug, Error)]
pub enum NUSError {
#[error("the data returned by the NUS is not valid")]
InvalidData,
#[error("the requested Title ID or version could not be found on the NUS")]
NotFound,
#[error("Certificate processing error")]
Certificate(#[from] cert::CertificateError),
#[error("TMD processing error")]
TMD(#[from] tmd::TMDError),
#[error("Ticket processing error")]
Ticket(#[from] ticket::TicketError),
#[error("Content processing error")]
Content(#[from] content::ContentError),
#[error("an error occurred while assembling a Title from the downloaded data")]
Title(#[from] title::TitleError),
#[error("data could not be downloaded from the NUS")]
Request(#[from] reqwest::Error),
#[error("an error occurred writing NUS data")]
IO(#[from] std::io::Error),
}
/// Downloads the retail certificate chain from the NUS.
pub fn download_cert_chain(wiiu_endpoint: bool) -> Result<Vec<u8>, NUSError> {
// To build the certificate chain, we need to download both the TMD and Ticket of a title. For
// the sake of simplicity, we'll use the Wii Menu 4.3U because I already found the required TMD
// and Ticket offsets for it.
let endpoint_url = if wiiu_endpoint {
WII_U_NUS_ENDPOINT.to_owned()
} else {
WII_NUS_ENDPOINT.to_owned()
};
let tmd_url = format!("{}0000000100000002/tmd.513", endpoint_url);
let tik_url = format!("{}0000000100000002/cetk", endpoint_url);
let client = reqwest::blocking::Client::new();
let tmd = client.get(tmd_url).header(reqwest::header::USER_AGENT, "wii libnup/1.0").send()?.bytes()?;
let tik = client.get(tik_url).header(reqwest::header::USER_AGENT, "wii libnup/1.0").send()?.bytes()?;
// Assemble the certificate chain.
let mut cert_chain: Vec<u8> = Vec::new();
// Certificate Authority data.
cert_chain.write_all(&tik[0x2A4 + 768..])?;
// Certificate Policy (TMD certificate) data.
cert_chain.write_all(&tmd[0x328..0x328 + 768])?;
// XS (Ticket certificate) data.
cert_chain.write_all(&tik[0x2A4..0x2A4 + 768])?;
Ok(cert_chain)
}
/// Downloads a specified content file from the specified title from the NUS.
pub fn download_content(title_id: [u8; 8], content_id: u32, wiiu_endpoint: bool) -> Result<Vec<u8>, NUSError> {
// Build the download URL. The structure is download/<TID>/<CID>
let endpoint_url = if wiiu_endpoint {
WII_U_NUS_ENDPOINT.to_owned()
} else {
WII_NUS_ENDPOINT.to_owned()
};
let content_url = format!("{}{}/{:08X}", endpoint_url, &hex::encode(title_id), content_id);
let client = reqwest::blocking::Client::new();
let response = client.get(content_url).header(reqwest::header::USER_AGENT, "wii libnup/1.0").send()?;
if !response.status().is_success() {
return Err(NUSError::NotFound);
}
Ok(response.bytes()?.to_vec())
}
/// Downloads all contents from the specified title from the NUS.
pub fn download_contents(tmd: &tmd::TMD, wiiu_endpoint: bool) -> Result<Vec<Vec<u8>>, NUSError> {
let content_ids: Vec<u32> = tmd.content_records.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)?);
}
Ok(contents)
}
/// Downloads the Ticket for a specified Title ID from the NUS, if it's available.
pub fn download_ticket(title_id: [u8; 8], wiiu_endpoint: bool) -> Result<Vec<u8>, NUSError> {
// Build the download URL. The structure is download/<TID>/cetk.
let endpoint_url = if wiiu_endpoint {
WII_U_NUS_ENDPOINT.to_owned()
} else {
WII_NUS_ENDPOINT.to_owned()
};
let tik_url = format!("{}{}/cetk", endpoint_url, &hex::encode(title_id));
let client = reqwest::blocking::Client::new();
let response = client.get(tik_url).header(reqwest::header::USER_AGENT, "wii libnup/1.0").send()?;
if !response.status().is_success() {
return Err(NUSError::NotFound);
}
let tik = ticket::Ticket::from_bytes(&response.bytes()?).map_err(|_| NUSError::InvalidData)?;
tik.to_bytes().map_err(|_| NUSError::InvalidData)
}
/// Downloads an entire title with all of its content from the NUS and returns a Title instance.
pub fn download_title(title_id: [u8; 8], title_version: Option<u16>, wiiu_endpoint: bool) -> Result<title::Title, NUSError> {
// Download the individual components of a title and then build a title from them.
let cert_chain = cert::CertificateChain::from_bytes(&download_cert_chain(wiiu_endpoint)?)?;
let tmd = tmd::TMD::from_bytes(&download_tmd(title_id, title_version, wiiu_endpoint)?)?;
let tik = ticket::Ticket::from_bytes(&download_ticket(title_id, wiiu_endpoint)?)?;
let content_region = content::ContentRegion::from_contents(download_contents(&tmd, wiiu_endpoint)?, tmd.content_records.clone())?;
let title = title::Title::from_parts(cert_chain, None, tik, tmd, content_region, None)?;
Ok(title)
}
/// Downloads the TMD for a specified Title ID from the NUS.
pub fn download_tmd(title_id: [u8; 8], title_version: Option<u16>, wiiu_endpoint: bool) -> Result<Vec<u8>, NUSError> {
// Build the download URL. The structure is download/<TID>/tmd for latest and
// download/<TID>/tmd.<version> for when a specific version is requested.
let endpoint_url = if wiiu_endpoint {
WII_U_NUS_ENDPOINT.to_owned()
} else {
WII_NUS_ENDPOINT.to_owned()
};
let tmd_url = if title_version.is_some() {
format!("{}{}/tmd.{}", endpoint_url, &hex::encode(title_id), title_version.unwrap())
} else {
format!("{}{}/tmd", endpoint_url, &hex::encode(title_id))
};
let client = reqwest::blocking::Client::new();
let response = client.get(tmd_url).header(reqwest::header::USER_AGENT, "wii libnup/1.0").send()?;
if !response.status().is_success() {
return Err(NUSError::NotFound);
}
let tmd = tmd::TMD::from_bytes(&response.bytes()?).map_err(|_| NUSError::InvalidData)?;
tmd.to_bytes().map_err(|_| NUSError::InvalidData)
}

View File

@ -50,11 +50,11 @@ impl fmt::Display for TitleType {
#[derive(Debug, Clone)]
pub enum ContentType {
Normal,
Development,
HashTree,
DLC,
Shared,
Normal = 1,
Development = 2,
HashTree = 3,
DLC = 16385,
Shared = 32769,
}
impl fmt::Display for ContentType {
@ -70,8 +70,8 @@ impl fmt::Display for ContentType {
}
pub enum AccessRight {
AHB,
DVDVideo,
AHB = 0,
DVDVideo = 1,
}
#[derive(Debug, Clone)]
@ -332,10 +332,7 @@ impl TMD {
/// Gets whether a specified access right is enabled in a TMD.
pub fn check_access_right(&self, right: AccessRight) -> bool {
match right {
AccessRight::AHB => (self.access_rights & (1 << 0)) != 0,
AccessRight::DVDVideo => (self.access_rights & (1 << 1)) != 0,
}
self.access_rights & (1 << right as u8) != 0
}
/// Gets the name of the certificate used to sign a TMD as a string.