mirror of
https://github.com/NinjaCheetah/rustii.git
synced 2025-06-05 23:11:02 -04:00
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:
parent
e55edc10fd
commit
be9148fcfa
1435
Cargo.lock
generated
1435
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -35,3 +35,4 @@ regex = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
anyhow = "1"
|
||||
thiserror = "2"
|
||||
reqwest = { version = "0", features = ["blocking"] }
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -3,3 +3,4 @@
|
||||
|
||||
pub mod ash;
|
||||
pub mod lz77;
|
||||
pub mod u8;
|
||||
|
44
src/bin/rustii/archive/u8.rs
Normal file
44
src/bin/rustii/archive/u8.rs
Normal 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(())
|
||||
}
|
@ -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 {
|
||||
|
@ -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
215
src/bin/rustii/title/nus.rs
Normal 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(())
|
||||
}
|
@ -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(())
|
||||
}
|
||||
|
@ -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.
|
||||
|
@ -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
143
src/title/nus.rs
Normal 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)
|
||||
}
|
@ -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.
|
||||
|
Loading…
x
Reference in New Issue
Block a user