Compare commits

...

19 Commits

Author SHA1 Message Date
449097967c Add setting.txt gen command 2026-03-02 02:43:38 -05:00
326bb56ece Refactored entire way that title content is handled
The ContentRegion has been entirely dissolved. Its fields were not particularly useful, as the content records were just a duplicate from the TMD, the file data itself, and then two integers that were assigned during construction and then literally never referenced.
Instead, the only copy of the content records now lives in the TMD, and the content is stored within the title directly since that was the only meaningful field. All the content related methods were moved from the ContentRegion struct over to the Title struct, since the content just lives there now.
This should hopefully make things much easier to deal with as you no longer need to worry about keeping two separate copies of the content records in sync.
This also might all change again in the future idk
2026-03-02 00:31:53 -05:00
0d34fbc383 Added iospatcher to lib and CLI
Everything but the no-shared flag is working right now. Getting no-shared to work properly will take a little more work because right now there's nothing to guarantee that the content records are synced between the TMD and content objects in a title. This means that updating any content in a title will result in the records being out of sync and the written TMD will not match the actual state of the content when it was dumped.
To mitigate this, I intend on making the content records in the content struct a reference to the content records in the TMD, so that they are the same object and therefore always in sync.
2026-02-27 19:05:24 -05:00
02db260138 Added theming command to rustwii CLI
This version is more capable than the version in WiiPy, and supports extracted and repacking containers as part of theme application. This was not implemented in WiiPy because the LZ77 compression required was far too slow to make it feasible, but here in Rust the compression is totally reasonable to actually use.
2026-02-26 16:42:35 -05:00
23699a518d Overhauled U8 archive handling 2026-02-26 00:00:58 -05:00
5cc6c1c8ff Added TMD command to CLI 2026-02-23 13:19:09 -05:00
7c8484edaa Correct lots of weak warnings, fix CI 2026-02-22 23:46:49 -05:00
836d5e912a Made a bunch of fields that should be private private 2026-02-22 22:21:37 -05:00
94e0be0eef Added emunand info and install-missing commands to rustii CLI
These were grueling to port. There's just so much printing and format converting to deal with. Ugh. At least it's done now.
2025-05-03 23:43:02 -04:00
26138c02be Added base for rustii CLI EmuNAND commands (mostly library-side)
The rustii CLI now offers setting decrypt/encrypt commands, as well as a WIP emunand install-title command. This command currently only supports installing single WADs and not a folder of WADs like WiiPy.
To make EmuNANDs happen, library modules for parsing and editing setting.txt, uid.sys, and content.map have been added and have feature parity with libWiiPy. The basics of the library EmuNAND module also exist, offering the code for title installation and not much else yet.
2025-05-01 19:55:15 -04:00
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
481594345d Use Rc<RefCell<T>>> for content_records so that they sync in a Title
When using a Title, the content_records value stored in the TMD and ContentRegion instances will now point to the same data, meaning that they stay in sync. Previously, you had to manually sync the content records between them as they were modified, and not doing so would cause problems when editing a WAD.
2025-04-27 21:30:31 -04:00
277c5d6439 Added rustii CLI commands to add and remove content from a WAD
Also added required library features to make this possible, again.
2025-04-27 15:25:47 -04:00
577d5a0efa Added rustii CLI command to replace content in a WAD
Also added required library features to make this possible. Rust makes the whole "getting content's index from its CID" thing so much easier.
2025-04-25 14:45:38 -04:00
96ace71546 Added set_content/set_enc_content to load modified content into a ContentRegion
This means that the rustii CLI wad pack command is now actually useful, as it supports loading modified content.
2025-04-23 18:21:26 -04:00
66476e2c98 Added basic support for displaying info about U8 archives to rustii CLI 2025-04-17 20:33:44 -04:00
ea2e31756c Added U8 archive packing command to rustii CLI 2025-04-17 20:07:40 -04:00
52e11795d3 Redesigned how U8 archives are represented in memory
This replaces the old 1D array with an actual directory tree that can be used to make packing, unpacking, and editing U8 archives much much easier than the old libWiiPy implementation.
2025-04-17 18:30:23 -04:00
51 changed files with 5120 additions and 1689 deletions

View File

@@ -1,4 +1,4 @@
name: Build rustii
name: Build rustwii
on:
push:
@@ -19,18 +19,18 @@ jobs:
# Not sure if this is the best choice, but I'm building in release mode to produce more effective nightly binaries.
- name: Update Toolchain
run: rustup update
- name: Build rustii
- name: Build rustwii
run: cargo build --verbose --release
- name: Package rustii for Upload
- name: Package rustwii for Upload
run: |
mv target/release/rustii ~/rustii
mv target/release/rustwii ~/rustwii
cd ~
tar cvf rustii.tar rustii
- name: Upload rustii
tar cvf rustwii.tar rustwii
- name: Upload rustwii
uses: actions/upload-artifact@v4
with:
path: ~/rustii.tar
name: rustii-Linux-bin
path: ~/rustwii.tar
name: rustwii-Linux-bin
build-macos-arm64:
@@ -42,18 +42,18 @@ jobs:
run: rustup update
- name: Add ARM64 Target
run: rustup target add aarch64-apple-darwin
- name: Build rustii
- name: Build rustwii
run: cargo build --verbose --release --target aarch64-apple-darwin
- name: Package rustii for Upload
- name: Package rustwii for Upload
run: |
mv target/aarch64-apple-darwin/release/rustii ~/rustii
mv target/aarch64-apple-darwin/release/rustwii ~/rustwii
cd ~
tar cvf rustii.tar rustii
- name: Upload rustii
tar cvf rustwii.tar rustwii
- name: Upload rustwii
uses: actions/upload-artifact@v4
with:
path: ~/rustii.tar
name: rustii-macOS-arm64-bin
path: ~/rustwii.tar
name: rustwii-macOS-arm64-bin
build-macos-x86_64:
@@ -65,18 +65,18 @@ jobs:
run: rustup update
- name: Add x86_64 Target
run: rustup target add x86_64-apple-darwin
- name: Build rustii
- name: Build rustwii
run: cargo build --verbose --release --target x86_64-apple-darwin
- name: Package rustii for Upload
- name: Package rustwii for Upload
run: |
mv target/x86_64-apple-darwin/release/rustii ~/rustii
mv target/x86_64-apple-darwin/release/rustwii ~/rustwii
cd ~
tar cvf rustii.tar rustii
- name: Upload rustii
tar cvf rustwii.tar rustwii
- name: Upload rustwii
uses: actions/upload-artifact@v4
with:
path: ~/rustii.tar
name: rustii-macOS-x86_64-bin
path: ~/rustwii.tar
name: rustwii-macOS-x86_64-bin
build-windows-x86_64:
@@ -86,12 +86,12 @@ jobs:
- uses: actions/checkout@v4
- name: Update Toolchain
run: rustup update
- name: Build rustii
- name: Build rustwii
run: cargo build --verbose --release
- name: Upload rustii
- name: Upload rustwii
uses: actions/upload-artifact@v4
with:
path: D:\a\rustii\rustii\target\release\rustii.exe
name: rustii-Windows-bin
path: D:\a\rustwii\rustwii\target\release\rustwii.exe
name: rustwii-Windows-bin

1
.gitignore vendored
View File

@@ -23,3 +23,4 @@ target/
*.cert
*.footer
*.app
*.arc

1975
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,18 @@
[package]
name = "rustii"
authors = ["NinjaCheetah <ninjacheetah@ncxprogramming.com>"]
name = "rustwii"
authors = ["NinjaCheetah <campbell@ninjacheetah.dev>"]
license = "MIT"
description = "A Rust library and CLI for handling files and formats used by the Wii"
version = "0.1.0"
readme = "README.md"
homepage = "https://github.com/NinjaCheetah/rustii"
repository = "https://github.com/NinjaCheetah/rustii"
homepage = "https://github.com/NinjaCheetah/rustwii"
repository = "https://github.com/NinjaCheetah/rustwii"
edition = "2024"
default-run = "rustii"
default-run = "rustwii"
[[bin]]
name = "rustii"
path = "src/bin/rustii/main.rs"
name = "rustwii"
path = "src/bin/rustwii/main.rs"
[[bin]]
name = "playground"
@@ -29,10 +29,15 @@ cbc = "0"
aes = "0"
rsa = { version = "0", features = ["sha2"] }
hex = "0"
sha1 = { version = "0", features = ["oid"]}
sha1 = { version = "0", features = ["oid"] }
glob = "0"
regex = "1"
clap = { version = "4", features = ["derive"] }
anyhow = "1"
thiserror = "2"
reqwest = { version = "0", features = ["blocking"] }
rand = "0"
walkdir = "2"
tempfile = "3"
rust-ini = "0"
zip = "8"

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2025 NinjaCheetah
Copyright (c) 2025-2026 NinjaCheetah
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,11 +1,13 @@
![rustii-banner](https://github.com/user-attachments/assets/08a7eea1-837e-4bce-939e-13c720b35226)
# rustii
![rustwii-banner](https://github.com/user-attachments/assets/08a7eea1-837e-4bce-939e-13c720b35226)
# rustwii
*Like rusty but it's rustii because the Wii? Get it?*
*Like rusty but it's rustwii because the Wii? Get it?*
[![Build rustii](https://github.com/NinjaCheetah/rustii/actions/workflows/rust.yml/badge.svg)](https://github.com/NinjaCheetah/rustii/actions/workflows/rust.yml)
[![Build rustwii](https://github.com/NinjaCheetah/rustwii/actions/workflows/rust.yml/badge.svg)](https://github.com/NinjaCheetah/rustwii/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.
rustwii is a library and command line tool written in Rust for handling the various files and formats found on the Wii. rustwii 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, rustwii should not be considered stable, however it offers most of the same core functionality as libWiiPy, and the rustwii 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 rustwii and libWiiPy to eventually have feature parity, with the rustwii 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 rustwii, 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`.
To see specific usage information, check `rustwii --help` and `rustwii <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:
rustwii 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)`.
to compile the rustwii library and CLI. The CLI can then be found at `target/release/rustwii(.exe)`.
You can also download the latest nightly build from [GitHub Actions](https://github.com/NinjaCheetah/rustwii/actions).

View File

@@ -1,5 +1,5 @@
// archive/ash.rs from rustii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustii
// archive/ash.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Implements the decompression routines used for the Wii's ASH compression scheme.
// May someday even include the compression routines! If I ever get around to it.
@@ -45,7 +45,7 @@ fn ash_bit_reader_feed_word(reader: &mut ASHBitReader) -> Result<(), ASHError> {
Ok(())
}
fn ash_bit_reader_init(src: &[u8], size: u32, startpos: u32) -> Result<ASHBitReader, ASHError> {
fn ash_bit_reader_init(src: &'_ [u8], size: u32, startpos: u32) -> Result<ASHBitReader<'_>, ASHError> {
// Load data into a bit reader, then have it read its first word.
let mut reader = ASHBitReader {
src,

View File

@@ -1,5 +1,5 @@
// archive/lz77.rs from rustii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustii
// archive/lz77.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Implements the compression and decompression routines used for the Wii's LZ77 compression scheme.

View File

@@ -1,5 +1,5 @@
// archive/mod.rs from rustii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustii
// archive/mod.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Root for all archive-related modules.

View File

@@ -1,15 +1,18 @@
// archive/u8.rs from rustii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustii
// archive/u8.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// 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("the requested item could not be found in this U8 archive")]
ItemNotFound(String),
#[error("found invalid node type {0} while processing node at index {1}")]
InvalidNodeType(u8, usize),
#[error("invalid file name at offset {0}")]
InvalidFileName(u64),
#[error("this does not appear to be a U8 archive (missing magic number)")]
@@ -19,27 +22,36 @@ pub enum U8Error {
}
#[derive(Clone, Debug)]
pub struct U8Node {
pub struct U8Directory {
pub name: String,
pub dirs: Vec<U8Directory>,
pub files: Vec<U8File>,
}
#[derive(Clone, Debug)]
pub struct U8File {
pub name: String,
pub data: Vec<u8>,
}
#[derive(Clone, Debug)]
struct U8Node {
pub node_type: u8,
pub name_offset: u32, // This is really type u24, so the most significant byte will be ignored.
pub data_offset: u32,
pub size: u32,
}
#[derive(Debug)]
pub struct U8Archive {
pub u8_nodes: Vec<U8Node>,
pub file_names: Vec<String>,
pub file_data: Vec<Vec<u8>>,
root_node_offset: u32,
header_size: u32,
data_offset: u32,
padding: [u8; 16],
#[derive(Clone, Debug)]
struct U8Reader {
buf: Cursor<Box<[u8]>>,
u8_nodes: Vec<U8Node>,
index: usize,
base_name_offset: u64
}
impl U8Archive {
/// Creates a new U8 instance from the binary data of a U8 file.
pub fn from_bytes(data: &[u8]) -> Result<Self, U8Error> {
impl U8Reader {
fn new(data: Box<[u8]>) -> Result<Self, U8Error> {
let mut buf = Cursor::new(data);
let mut magic = [0u8; 4];
buf.read_exact(&mut magic)?;
@@ -58,7 +70,7 @@ impl U8Archive {
}
println!("ignoring IMET header at 0x40");
}
// Check for an IMET header that comes after a built tag.
// Check for an IMET header that comes after a build tag.
else {
buf.seek(SeekFrom::Start(0x80))?;
buf.read_exact(&mut magic)?;
@@ -73,11 +85,13 @@ impl U8Archive {
}
}
}
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)?;
// We're skipping the following values:
// root_node_offset (u32): constant value, always 0x20
// header_size (u32): we don't need this because we already know how long the string table is
// data_offset (u32): we don't need this because nodes provide the absolute offset to their data
// padding (u8 * 16): it's padding, I have nothing to say about it
buf.seek(SeekFrom::Start(buf.position() + 28))?;
// 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()?;
@@ -90,6 +104,7 @@ impl U8Archive {
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();
@@ -101,62 +116,152 @@ impl U8Archive {
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.
// Save the base name offset for later.
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 {
Ok(Self {
buf,
u8_nodes,
file_names,
file_data,
root_node_offset,
header_size,
data_offset,
padding,
index: 0,
base_name_offset
})
}
fn pack_dir() {
todo!();
fn file_name(&mut self, name_offset: u64) -> Result<String, U8Error> {
self.buf.seek(SeekFrom::Start(self.base_name_offset + name_offset))?;
let mut name_bin = Vec::<u8>::new();
loop {
let byte = self.buf.read_u8()?;
if byte == b'\0' {
break;
}
name_bin.push(byte);
}
Ok(String::from_utf8(name_bin)
.map_err(|_| U8Error::InvalidFileName(self.base_name_offset + name_offset))?.to_owned()
)
}
pub fn from_dir(_input: &Path) -> Result<Self, U8Error> {
todo!();
fn file_data(&mut self, data_offset: u64, size: usize) -> Result<Vec<u8>, U8Error> {
self.buf.seek(SeekFrom::Start(data_offset))?;
let mut data = vec![0u8; size];
self.buf.read_exact(&mut data)?;
Ok(data)
}
fn read_dir_recursive(&mut self) -> Result<U8Directory, U8Error> {
let mut current_dir = U8Directory::new(self.file_name(self.u8_nodes[self.index].name_offset as u64)?);
let current_dir_end = self.u8_nodes[self.index].size as usize;
self.index += 1;
while self.index < current_dir_end {
match self.u8_nodes[self.index].node_type {
1 => {
// Directory node, recursive over the child dir and then add it to the
// current one.
let child_dir = self.read_dir_recursive()?;
current_dir.add_dir(child_dir);
},
0 => {
// File node, add
current_dir.add_file(
U8File::new(
self.file_name(self.u8_nodes[self.index].name_offset as u64)?,
self.file_data(self.u8_nodes[self.index].data_offset as u64, self.u8_nodes[self.index].size as usize)?
)
);
self.index += 1;
},
x => return Err(U8Error::InvalidNodeType(x, self.index))
}
}
Ok(current_dir)
}
}
impl U8Directory {
pub fn new(name: String) -> Self {
Self {
name,
dirs: vec![],
files: vec![]
}
}
pub fn dirs(&self) -> &Vec<U8Directory> {
&self.dirs
}
pub fn set_dirs(&mut self, dirs: Vec<U8Directory>) {
self.dirs = dirs
}
pub fn files(&self) -> &Vec<U8File> {
&self.files
}
pub fn set_files(&mut self, files: Vec<U8File>) {
self.files = files
}
pub fn add_dir(&mut self, child: Self) -> &mut U8Directory {
self.dirs.push(child);
self.dirs.last_mut().unwrap()
}
pub fn add_file(&mut self, file: U8File) {
self.files.push(file);
}
/// Creates a new U8 instance from the binary data of a U8 file.
pub fn from_bytes(data: Box<[u8]>) -> Result<Self, U8Error> {
let mut u8_reader = U8Reader::new(data)?;
u8_reader.read_dir_recursive()
}
fn pack_dir_recursive(&self, file_names: &mut Vec<String>, file_data: &mut Vec<Vec<u8>>, u8_nodes: &mut Vec<U8Node>) {
// For files, read their data into the file data list, add their name into the file name
// list, then calculate the offset for their file name and create a new U8Node() for them.
// 0 values for name/data offsets are temporary and are set later.
let parent_node = u8_nodes.len() - 1;
for file in &self.files {
file_names.push(file.name.clone());
file_data.push(file.data.clone());
u8_nodes.push(U8Node { node_type: 0, name_offset: 0, data_offset: 0, size: file_data[u8_nodes.len()].len() as u32});
}
// For directories, add their name to the file name list, add empty data to the file data
// list, find the total number of files and directories inside the directory to calculate
// the final node included in it, then recursively call this function again on that
// directory to process it.
for dir in &self.dirs {
file_names.push(dir.name.clone());
file_data.push(Vec::new());
let max_node = u8_nodes.len() + dir.count();
u8_nodes.push(U8Node { node_type: 1, name_offset: 0, data_offset: parent_node as u32, size: max_node as u32});
dir.pack_dir_recursive(file_names, file_data, u8_nodes);
}
}
/// 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> {
// We need to start by rebuilding a flat list of the nodes from the directory tree.
let mut file_names: Vec<String> = vec![String::new()];
let mut file_data: Vec<Vec<u8>> = vec![Vec::new()];
let mut u8_nodes: Vec<U8Node> = Vec::new();
u8_nodes.push(U8Node { node_type: 1, name_offset: 0, data_offset: 0, size: self.count() as u32 });
self.pack_dir_recursive(&mut file_names, &mut file_data, &mut u8_nodes);
// 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() {
for _ in 0..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 {
for file_name in &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
@@ -166,7 +271,6 @@ impl U8Archive {
// 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;
@@ -174,15 +278,16 @@ impl U8Archive {
}
// 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
current_name_offset += 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)?;
buf.write_all(&[0; 16])?;
// Iterate over nodes and write them out.
for node in &u8_nodes {
buf.write_u8(node.node_type)?;
@@ -191,7 +296,7 @@ impl U8Archive {
buf.write_u32::<BigEndian>(node.size)?;
}
// Iterate over file names with a null byte at the end.
for file_name in &self.file_names {
for file_name in &file_names {
buf.write_all(file_name.as_bytes())?;
buf.write_u8(b'\0')?;
}
@@ -199,10 +304,32 @@ impl U8Archive {
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 {
for data in &file_data {
buf.write_all(data)?;
buf.resize((buf.len() + 31) & !31, 0);
}
Ok(buf)
}
fn count_recursive(&self) -> usize {
let mut count = self.files.len() + self.dirs.len();
for dir in self.dirs.iter() {
count += dir.count_recursive();
}
count
}
pub fn count(&self) -> usize {
1 + self.count_recursive()
}
}
impl U8File {
pub fn new(name: String, data: Vec<u8>) -> Self {
Self {
name,
data
}
}
}

View File

@@ -1,44 +1,58 @@
// Sample file for testing rustii library stuff.
use std::fs;
use rustii::title::{wad, cert};
use rustii::title;
use rustwii::title::{wad, cert};
use rustwii::title;
use rustwii::archive::u8;
// use rustii::title::content;
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));
let data = fs::read("ios9.wad").unwrap();
let mut title = title::Title::from_bytes(&data).unwrap();
let index = title::iospatcher::ios_find_module(String::from("ES:"), &title).unwrap();
println!("ES index: {}", index);
let patch_count = title::iospatcher::ios_patch_sigchecks(&mut title, index).unwrap();
println!("patches applied: {}", patch_count);
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());
println!("num content records: {:?}", title.tmd.content_records.len());
println!("first record data: {:?}", title.tmd.content_records.first().unwrap());
println!("TMD is fakesigned: {:?}",title.tmd.is_fakesigned());
println!("num content records: {:?}", title.tmd().content_records().len());
println!("first record data: {:?}", title.tmd().content_records().first().unwrap());
println!("TMD is fakesigned: {:?}",title.tmd().is_fakesigned());
println!("title version from ticket is: {:?}", title.ticket().title_version());
println!("title key (enc): {:?}", title.ticket().title_key());
println!("title key (dec): {:?}", title.ticket().title_key_dec());
println!("ticket is fakesigned: {:?}", title.ticket().is_fakesigned());
println!("title version from ticket is: {:?}", title.ticket.title_version);
println!("title key (enc): {:?}", title.ticket.title_key);
println!("title key (dec): {:?}", title.ticket.dec_title_key());
println!("ticket is fakesigned: {:?}", title.ticket.is_fakesigned());
println!("title is fakesigned: {:?}", title.is_fakesigned());
println!("wad header: {:?}", wad.header);
let cert_chain = &title.cert_chain;
let cert_chain = &title.cert_chain();
println!("cert chain OK");
let result = cert::verify_ca_cert(&cert_chain.ca_cert()).unwrap();
println!("CA cert {} verified successfully: {}", cert_chain.ca_cert().child_cert_identity(), result);
let result = cert::verify_child_cert(&cert_chain.ca_cert(), &cert_chain.tmd_cert()).unwrap();
println!("TMD cert {} verified successfully: {}", cert_chain.tmd_cert().child_cert_identity(), result);
let result = cert::verify_tmd(&cert_chain.tmd_cert(), &title.tmd).unwrap();
let result = cert::verify_tmd(&cert_chain.tmd_cert(), title.tmd()).unwrap();
println!("TMD verified successfully: {}", result);
let result = cert::verify_child_cert(&cert_chain.ca_cert(), &cert_chain.ticket_cert()).unwrap();
println!("Ticket cert {} verified successfully: {}", cert_chain.ticket_cert().child_cert_identity(), result);
let result = cert::verify_ticket(&cert_chain.ticket_cert(), &title.ticket).unwrap();
let result = cert::verify_ticket(&cert_chain.ticket_cert(), title.ticket()).unwrap();
println!("Ticket verified successfully: {}", result);
let result = title.verify().unwrap();
println!("full title verified successfully: {}", result);
let u8_archive = u8::U8Directory::from_bytes(fs::read("testu8.arc").unwrap().into_boxed_slice()).unwrap();
println!("{:#?}", u8_archive);
// let mut content_map = content::SharedContentMap::from_bytes(&fs::read("content.map").unwrap()).unwrap();
// content_map.add(&[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]).unwrap();
// fs::write("new.map", content_map.to_bytes().unwrap()).unwrap();
}

View File

@@ -1,6 +0,0 @@
// archive/mod.rs from rustii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustii
pub mod ash;
pub mod lz77;
pub mod u8;

View File

@@ -1,95 +0,0 @@
// 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 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 u8_archive = u8::U8Archive::from_bytes(&fs::read(in_path).with_context(|| format!("Failed to open U8 archive \"{}\" for reading.", in_path.display()))?)?;
// This stores the path we're actively writing files to.
let mut current_dir = out_path.clone();
// This is the order of directory nodes we've traversed down.
let mut parent_dirs: Vec<u32> = Vec::from([0]);
for i in 0..u8_archive.u8_nodes.len() {
match u8_archive.u8_nodes[i].node_type {
1 => {
// Code for a directory node.
if u8_archive.u8_nodes[i].name_offset != 0 {
// If we're already at the correct level, make a new directory and push it to
// the parent_dirs vec.
if u8_archive.u8_nodes[i].data_offset == *parent_dirs.last().unwrap() {
current_dir = current_dir.join(&u8_archive.file_names[i]);
if !current_dir.exists() {
fs::create_dir(&current_dir).with_context(|| format!("Failed to create directory \"{}\".", current_dir.display()))?;
}
parent_dirs.push(i as u32);
}
// Otherwise, go back up the path until we're at the correct level.
else {
while u8_archive.u8_nodes[i].data_offset != *parent_dirs.last().unwrap() {
parent_dirs.pop();
}
parent_dirs.push(i as u32);
current_dir = out_path.clone();
// Rebuild current working directory, and make sure all directories in the
// path exist.
for dir in &parent_dirs {
current_dir = current_dir.join(&u8_archive.file_names[*dir as usize]);
if !current_dir.exists() {
fs::create_dir(&current_dir).with_context(|| format!("Failed to create directory \"{}\".", current_dir.display()))?;
}
}
}
}
},
0 => {
// Code for a file node.
fs::write(current_dir.join(&u8_archive.file_names[i]), &u8_archive.file_data[i])
.with_context(|| format!("Failed to write file \"{}\" in directory \"{}\".", u8_archive.file_names[i], current_dir.display()))?;
},
_ => bail!("Node at index {} has an invalid type! U8 archive cannot be unpacked.", i)
}
}
println!("Successfully unpacked U8 archive to directory \"{}\"!", out_path.display());
Ok(())
}

View File

@@ -1,136 +0,0 @@
// main.rs from rustii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustii
//
// Base for the rustii CLI that handles argument parsing and directs execution to the proper module.
mod archive;
mod title;
mod filetypes;
mod info;
use anyhow::Result;
use clap::{Subcommand, Parser};
#[derive(Parser)]
#[command(version, about, long_about = None)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
#[command(arg_required_else_help = true)]
enum Commands {
/// Decompress data using ASH compression
Ash {
#[command(subcommand)]
command: archive::ash::Commands,
},
/// Fakesign a TMD, Ticket, or WAD (trucha bug)
Fakesign {
/// The path to a TMD, Ticket, or WAD
input: String,
/// An (optional) output name; defaults to overwriting input file if not provided
#[arg(short, long)]
output: Option<String>,
},
/// Get information about a TMD, Ticket, or WAD
Info {
/// The path to a TMD, Ticket, or WAD
input: String,
},
/// Compress/decompress data using LZ77 compression
Lz77 {
#[command(subcommand)]
command: archive::lz77::Commands
},
/// Download data from the NUS
Nus {
#[command(subcommand)]
command: title::nus::Commands
},
/// Pack/unpack a U8 archive
U8 {
#[command(subcommand)]
command: archive::u8::Commands
},
/// Pack/unpack/edit a WAD file
Wad {
#[command(subcommand)]
command: title::wad::Commands,
},
}
fn main() -> Result<()> {
let cli = Cli::parse();
match &cli.command {
Some(Commands::Ash { command }) => {
match command {
archive::ash::Commands::Compress { input, output } => {
archive::ash::compress_ash(input, output)?
},
archive::ash::Commands::Decompress { input, output } => {
archive::ash::decompress_ash(input, output)?
}
}
}
Some(Commands::Fakesign { input, output }) => {
title::fakesign::fakesign(input, output)?
},
Some(Commands::Info { input }) => {
info::info(input)?
},
Some(Commands::Lz77 { command }) => {
match command {
archive::lz77::Commands::Compress { input, output } => {
archive::lz77::compress_lz77(input, output)?
},
archive::lz77::Commands::Decompress { input, output } => {
archive::lz77::decompress_lz77(input, output)?
}
}
},
Some(Commands::Nus { command }) => {
match command {
title::nus::Commands::Content { tid, cid, version, output, decrypt} => {
title::nus::download_content(tid, cid, version, output, decrypt)?
},
title::nus::Commands::Ticket { tid, output } => {
title::nus::download_ticket(tid, output)?
},
title::nus::Commands::Title { tid, version, output} => {
title::nus::download_title(tid, version, output)?
}
title::nus::Commands::Tmd { tid, version, output} => {
title::nus::download_tmd(tid, version, output)?
}
}
}
Some(Commands::U8 { command }) => {
match command {
archive::u8::Commands::Pack { input, output } => {
archive::u8::pack_u8_archive(input, output)?
},
archive::u8::Commands::Unpack { input, output } => {
archive::u8::unpack_u8_archive(input, output)?
}
}
},
Some(Commands::Wad { command }) => {
match command {
title::wad::Commands::Convert { input, target, output } => {
title::wad::convert_wad(input, target, output)?
},
title::wad::Commands::Pack { input, output} => {
title::wad::pack_wad(input, output)?
},
title::wad::Commands::Unpack { input, output } => {
title::wad::unpack_wad(input, output)?
}
}
},
None => { /* Clap handles no passed command by itself */}
}
Ok(())
}

View File

@@ -1,6 +0,0 @@
// title/mod.rs from rustii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustii
pub mod fakesign;
pub mod nus;
pub mod wad;

View File

@@ -1,243 +0,0 @@
// title/wad.rs from rustii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustii
//
// Code for WAD-related commands in the rustii CLI.
use std::{str, fs, fmt};
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use clap::{Subcommand, Args};
use glob::glob;
use rustii::title::{cert, crypto, tmd, ticket, content, wad};
use rustii::title;
#[derive(Subcommand)]
#[command(arg_required_else_help = true)]
pub enum Commands {
/// Re-encrypt a WAD file with a different key
Convert {
/// The path to the WAD to convert
input: String,
/// An optional WAD name; defaults to <input name>_<new type>.wad
#[arg(short, long)]
output: Option<String>,
#[command(flatten)]
target: ConvertTargets,
},
/// Pack a directory into a WAD file
Pack {
/// The directory to pack into a WAD
input: String,
/// The name of the packed WAD file
output: String
},
/// Unpack a WAD file into a directory
Unpack {
/// The path to the WAD to unpack
input: String,
/// The directory to extract the WAD to
output: String
}
}
#[derive(Args)]
#[clap(next_help_heading = "Encryption Targets")]
#[group(multiple = false, required = true)]
pub struct ConvertTargets {
/// Use the retail common key, allowing this WAD to be installed on retail consoles and Dolphin
#[arg(long)]
retail: bool,
/// Use the development common key, allowing this WAD to be installed on development consoles
#[arg(long)]
dev: bool,
/// Use the vWii key, allowing this WAD to theoretically be installed from Wii U mode if a Wii U mode WAD installer is created
#[arg(long)]
vwii: bool,
}
enum Target {
Retail,
Dev,
Vwii,
}
impl fmt::Display for Target {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Target::Retail => write!(f, "retail"),
Target::Dev => write!(f, "development"),
Target::Vwii => write!(f, "vWii"),
}
}
}
pub fn convert_wad(input: &str, target: &ConvertTargets, output: &Option<String>) -> Result<()> {
let in_path = Path::new(input);
if !in_path.exists() {
bail!("Source WAD \"{}\" could not be found.", in_path.display());
}
// Parse the target passed to identify the encryption target.
let target = if target.dev {
Target::Dev
} else if target.vwii {
Target::Vwii
} else {
Target::Retail
};
// Get the output name now that we know the target, if one wasn't passed.
let out_path = if output.is_some() {
PathBuf::from(output.clone().unwrap()).with_extension("wad")
} else {
match target {
Target::Retail => PathBuf::from(format!("{}_retail.wad", in_path.file_stem().unwrap().to_str().unwrap())),
Target::Dev => PathBuf::from(format!("{}_dev.wad", in_path.file_stem().unwrap().to_str().unwrap())),
Target::Vwii => PathBuf::from(format!("{}_vWii.wad", in_path.file_stem().unwrap().to_str().unwrap())),
}
};
let mut title = title::Title::from_bytes(fs::read(in_path)?.as_slice()).with_context(|| "The provided WAD file could not be parsed, and is likely invalid.")?;
// Bail if the WAD is already using the selected encryption.
if matches!(target, Target::Dev) && title.ticket.is_dev() {
bail!("This is already a development WAD!");
} else if matches!(target, Target::Retail) && !title.ticket.is_dev() && !title.tmd.is_vwii() {
bail!("This is already a retail WAD!");
} else if matches!(target, Target::Vwii) && !title.ticket.is_dev() && title.tmd.is_vwii() {
bail!("This is already a vWii WAD!");
}
// Save the current encryption to display at the end.
let source = if title.ticket.is_dev() {
"development"
} else if title.tmd.is_vwii() {
"vWii"
} else {
"retail"
};
let title_key = title.ticket.dec_title_key();
let title_key_new: [u8; 16];
match target {
Target::Dev => {
title.tmd.set_signature_issuer(String::from("Root-CA00000002-CP00000007"))?;
title.ticket.set_signature_issuer(String::from("Root-CA00000002-XS00000006"))?;
title_key_new = crypto::encrypt_title_key(title_key, 0, title.ticket.title_id, true);
title.ticket.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.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.ticket.common_key_index = 2;
title.tmd.is_vwii = 1;
}
}
title.ticket.title_key = title_key_new;
title.fakesign()?;
fs::write(&out_path, title.to_wad()?.to_bytes()?)?;
println!("Successfully converted {} WAD to {} WAD \"{}\"!", source, target, out_path.file_name().unwrap().to_str().unwrap());
Ok(())
}
pub fn pack_wad(input: &str, output: &str) -> Result<()> {
let in_path = Path::new(input);
if !in_path.exists() {
bail!("Source directory \"{}\" does not exist.", in_path.display());
}
// Read TMD file (only accept one file).
let tmd_files: Vec<PathBuf> = glob(&format!("{}/*.tmd", in_path.display()))?
.filter_map(|f| f.ok()).collect();
if tmd_files.is_empty() {
bail!("No TMD file found in the source directory.");
} else if tmd_files.len() > 1 {
bail!("More than one TMD file found in the source directory.");
}
let tmd = tmd::TMD::from_bytes(&fs::read(&tmd_files[0]).with_context(|| "Could not open TMD file for reading.")?)
.with_context(|| "The provided TMD file appears to be invalid.")?;
// Read Ticket file (only accept one file).
let ticket_files: Vec<PathBuf> = glob(&format!("{}/*.tik", in_path.display()))?
.filter_map(|f| f.ok()).collect();
if ticket_files.is_empty() {
bail!("No Ticket file found in the source directory.");
} else if ticket_files.len() > 1 {
bail!("More than one Ticket file found in the source directory.");
}
let tik = ticket::Ticket::from_bytes(&fs::read(&ticket_files[0]).with_context(|| "Could not open Ticket file for reading.")?)
.with_context(|| "The provided Ticket file appears to be invalid.")?;
// Read cert chain (only accept one file).
let cert_files: Vec<PathBuf> = glob(&format!("{}/*.cert", in_path.display()))?
.filter_map(|f| f.ok()).collect();
if cert_files.is_empty() {
bail!("No cert file found in the source directory.");
} else if cert_files.len() > 1 {
bail!("More than one Cert file found in the source directory.");
}
let cert_chain = cert::CertificateChain::from_bytes(&fs::read(&cert_files[0]).with_context(|| "Could not open cert chain file for reading.")?)
.with_context(|| "The provided certificate chain appears to be invalid.")?;
// Read footer, if one exists (only accept one file).
let footer_files: Vec<PathBuf> = glob(&format!("{}/*.footer", in_path.display()))?
.filter_map(|f| f.ok()).collect();
let mut footer: Vec<u8> = Vec::new();
if footer_files.len() == 1 {
footer = fs::read(&footer_files[0]).with_context(|| "Could not open footer file for reading.")?;
}
// Iterate over expected content and read it into a content region.
let mut content_region = content::ContentRegion::new(tmd.content_records.clone())?;
for content in tmd.content_records.clone() {
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.load_content(&data, content.index as usize, tik.dec_title_key())
.expect("failed to load content into ContentRegion, this is probably because content was modified which isn't supported yet");
}
let wad = wad::WAD::from_parts(&cert_chain, &[], &tik, &tmd, &content_region, &footer).with_context(|| "An unknown error occurred while building a WAD from the input files.")?;
// Write out WAD file.
let mut out_path = PathBuf::from(output);
match out_path.extension() {
Some(ext) => {
if ext != "wad" {
out_path.set_extension("wad");
}
},
None => {
out_path.set_extension("wad");
}
}
fs::write(&out_path, wad.to_bytes()?).with_context(|| format!("Could not open output file \"{}\" for writing.", out_path.display()))?;
println!("WAD file packed!");
Ok(())
}
pub fn unpack_wad(input: &str, output: &str) -> Result<()> {
let in_path = Path::new(input);
if !in_path.exists() {
bail!("Source WAD \"{}\" could not be found.", input);
}
let wad_file = fs::read(in_path).with_context(|| format!("Failed to open WAD file \"{}\" for reading.", in_path.display()))?;
let title = title::Title::from_bytes(&wad_file).with_context(|| format!("The provided WAD file \"{}\" appears to be invalid.", in_path.display()))?;
let tid = hex::encode(title.tmd.title_id);
// Create output directory if it doesn't exist.
let out_path = Path::new(output);
if !out_path.exists() {
fs::create_dir(out_path).with_context(|| format!("The output directory \"{}\" could not be created.", out_path.display()))?;
}
// Write out all WAD components.
let tmd_file_name = format!("{}.tmd", tid);
fs::write(Path::join(out_path, tmd_file_name.clone()), title.tmd.to_bytes()?).with_context(|| format!("Failed to open TMD file \"{}\" for writing.", tmd_file_name))?;
let ticket_file_name = format!("{}.tik", tid);
fs::write(Path::join(out_path, ticket_file_name.clone()), title.ticket.to_bytes()?).with_context(|| format!("Failed to open Ticket file \"{}\" for writing.", ticket_file_name))?;
let cert_file_name = format!("{}.cert", tid);
fs::write(Path::join(out_path, cert_file_name.clone()), title.cert_chain.to_bytes()?).with_context(|| format!("Failed to open certificate chain file \"{}\" for writing.", cert_file_name))?;
let meta_file_name = format!("{}.footer", tid);
fs::write(Path::join(out_path, meta_file_name.clone()), title.meta()).with_context(|| format!("Failed to open footer file \"{}\" for writing.", meta_file_name))?;
// Iterate over contents, decrypt them, and write them out.
for i in 0..title.tmd.num_contents {
let content_file_name = format!("{:08X}.app", title.content.content_records[i as usize].index);
let dec_content = title.get_content_by_index(i as usize).with_context(|| format!("Failed to unpack content with Content ID {:08X}.", title.content.content_records[i as usize].content_id))?;
fs::write(Path::join(out_path, content_file_name), dec_content).with_context(|| format!("Failed to open content file \"{:08X}.app\" for writing.", title.content.content_records[i as usize].content_id))?;
}
println!("WAD file unpacked!");
Ok(())
}

View File

@@ -1,14 +1,14 @@
// archive/ash.rs from rustii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustii
// archive/ash.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Code for the ASH decompression command in the rustii CLI.
// Code for the ASH decompression command in the rustwii CLI.
// Might even have the compression command someday if I ever write the compression code!
use std::{str, fs};
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use clap::Subcommand;
use rustii::archive::ash;
use rustwii::archive::ash;
#[derive(Subcommand)]
#[command(arg_required_else_help = true)]
@@ -45,7 +45,7 @@ pub fn decompress_ash(input: &str, output: &Option<String>) -> Result<()> {
let out_path = if output.is_some() {
PathBuf::from(output.clone().unwrap())
} else {
PathBuf::from(in_path).with_extension(format!("{}.out", in_path.extension().unwrap_or("".as_ref()).to_str().unwrap()))
PathBuf::from(in_path.file_name().unwrap()).with_extension(format!("{}.out", in_path.extension().unwrap_or("".as_ref()).to_str().unwrap()))
};
fs::write(out_path.clone(), decompressed)?;
println!("Successfully decompressed ASH file to \"{}\"!", out_path.display());

View File

@@ -1,13 +1,13 @@
// archive/lz77.rs from rustii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustii
// archive/lz77.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Code for the LZ77 compression/decompression commands in the rustii CLI.
// Code for the LZ77 compression/decompression commands in the rustwii CLI.
use std::{str, fs};
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use clap::Subcommand;
use rustii::archive::lz77;
use rustwii::archive::lz77;
#[derive(Subcommand)]
#[command(arg_required_else_help = true)]
@@ -57,7 +57,7 @@ pub fn decompress_lz77(input: &str, output: &Option<String>) -> Result<()> {
let out_path = if output.is_some() {
PathBuf::from(output.clone().unwrap())
} else {
PathBuf::from(in_path).with_extension(format!("{}.out", in_path.extension().unwrap_or("".as_ref()).to_str().unwrap()))
PathBuf::from(in_path.file_name().unwrap()).with_extension(format!("{}.out", in_path.extension().unwrap_or("".as_ref()).to_str().unwrap()))
};
fs::write(out_path.clone(), decompressed)?;
println!("Successfully decompressed LZ77 file to \"{}\"!", out_path.display());

View File

@@ -0,0 +1,7 @@
// archive/mod.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
pub mod ash;
pub mod lz77;
pub mod u8;
pub mod theme;

View File

@@ -0,0 +1,173 @@
// archive/theme.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Code for the theme building commands in the rustwii CLI.
use std::collections::HashMap;
use std::fs;
use std::io::Cursor;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use clap::Subcommand;
use ini::{Ini, ParseOption};
use tempfile::Builder;
use zip::ZipArchive;
use rustwii::archive::{ash, lz77, u8};
use crate::archive::u8::{pack_dir_recursive, unpack_dir_recursive};
#[derive(Subcommand)]
#[command(arg_required_else_help = true)]
pub enum Commands {
/// Apply an MYM theme to the Wii Menu
ApplyMym {
/// The path to the source MYM file to apply
mym: String,
/// The path to the base Wii Menu asset archive (000000xx.app)
base: String,
/// The file to output the finished theme to (<filename>.csm)
output: String,
}
}
pub fn theme_apply_mym(mym: &str, base: &str, output: &str) -> Result<()> {
let mym_path = Path::new(mym);
if !mym_path.exists() {
bail!("Theme file \"{}\" could not be found.", mym);
}
let base_path = Path::new(base);
if !base_path.exists() {
bail!("Base asset file \"{}\" could not be found.", base);
}
let out_path = PathBuf::from(output);
// Create the temporary work directory and extract the mym file to it.
let work_dir = Builder::new().prefix("mym_apply_").tempdir()?;
let mym_dir = work_dir.path().join("mym_work");
let mym_buf = fs::read(mym_path).with_context(|| format!("Failed to open theme file \"{}\" for reading.", mym_path.display()))?;
ZipArchive::extract(&mut ZipArchive::new(Cursor::new(mym_buf))?, &mym_dir)?;
// Load the mym ini file. Escapes have to be disabled so that Windows-formatted paths are
// loaded correct.
let mym_ini = Ini::load_from_file_opt(
mym_dir.join("mym.ini"),
ParseOption { enabled_escape: false, ..Default::default() }
).with_context(|| "Failed to load theme config file. This theme may be invalid!")?;
// Extract the base asset archive to the temporary dir.
let base_dir = work_dir.path().join("base_work");
fs::create_dir(&base_dir)?;
let assets_u8 = u8::U8Directory::from_bytes(fs::read(base_path).with_context(|| format!("Base asset file \"{}\" could not be read.", base_path.display()))?.into_boxed_slice())?;
unpack_dir_recursive(&assets_u8, base_dir.clone()).expect("Failed to extract base assets, they may be invalid!");
// Store any nested containers that we extract so that they can be re-packed later.
let mut extracted_containers: HashMap<String, PathBuf> = HashMap::new();
// Iterate through the ini file and apply modifications as necessary.
for (sec, prop) in mym_ini.iter() {
if let Some(sec) = sec {
if sec.contains("sdta") {
// Validate that the file and source keys exist, and then build a path to the
// source file.
if !prop.contains_key("file") || !prop.contains_key("source") {
bail!("Theme config entry \"{}\" is invalid and cannot be applied.", sec)
}
let source_parts: Vec<&str> = prop.get("source").unwrap().split("\\").collect();
let mut source_path = mym_dir.clone();
source_path.extend(source_parts);
if !source_path.exists() {
bail!("Required source file \"{}\" could not be found! This theme may be invalid.", prop.get("source").unwrap())
}
println!("Applying static data file \"{}\" from theme...", source_path.file_name().unwrap().to_str().unwrap());
let target_parts: Vec<&str> = prop.get("file").unwrap().split("\\").collect();
let mut target_path = base_dir.clone();
target_path.extend(target_parts);
fs::copy(source_path, target_path).expect("Failed to copy asset from theme.");
} else if sec.contains("cont") {
// Validate that the file key exists and that container specified exists.
if !prop.contains_key("file") {
bail!("Theme config entry \"{}\" is invalid and cannot be applied.", sec)
}
let container_parts: Vec<&str> = prop.get("file").unwrap().split("\\").collect();
let mut container_path = base_dir.clone();
container_path.extend(container_parts);
if !container_path.exists() {
bail!("Required base container \"{}\" could not be found! The base assets or theme may be invalid.", prop.get("file").unwrap())
}
// Buffer in the container file, check its magic number, and decompress it if
// necessary.
println!("Unpacking base container \"{}\" for modification...", container_path.file_name().unwrap().to_str().unwrap());
let container_data = fs::read(&container_path)?;
let decompressed_container = if &container_data[0..4] == b"LZ77" {
println!(" - Decompressing LZ77 data...");
lz77::decompress_lz77(&container_data)?
} else if &container_data[0..4] == b"ASH0" {
println!(" - Decompressing ASH data...");
ash::decompress_ash(&container_data, None, None)?
} else {
container_data
};
// Load the unpacked archive, bailing if it still isn't a U8 archive.
if &decompressed_container[0..4] != b"\x55\xAA\x38\x2D" {
bail!("Required base container \"{}\" is not a U8 archive. The base assets may be invalid.", container_path.file_name().unwrap().display())
}
// Extracted container name should follow the format:
// <file_name>_<extension>_out
let extracted_container_name = container_path
.file_name().unwrap()
.to_str().unwrap().replace(".", "_")
+ "_out";
let extracted_container_path = container_path.parent().unwrap().join(extracted_container_name);
fs::create_dir(&extracted_container_path)?;
let u8_root = u8::U8Directory::from_bytes(decompressed_container.into_boxed_slice()).with_context(|| "Failed to extract base container! The base assets may be invalid.")?;
// Finally, unpack the specified container to the created path and register it as
// an extracted container so that we can repack it later.
unpack_dir_recursive(&u8_root, extracted_container_path.clone())?;
extracted_containers.insert(
container_path.file_name().unwrap().to_str().unwrap().to_owned(),
extracted_container_path
);
println!(" - Done.");
} else {
bail!("Theme config file contains unknown or unsupported key \"{}\"!", sec)
}
}
}
// Iterate over any containers we unpacked so we can repack them and clean up the unpacked
// folder.
println!("Repacking extracted containers...");
for container in extracted_containers {
// Add the original file name to the parent of the extracted dir, and that's where the
// repacked container should go.
println!(" - Repacking container \"{}\"...", container.0);
let repacked_container_path = container.1.parent().unwrap().join(container.0.clone());
let mut u8_root = u8::U8Directory::new(String::new());
pack_dir_recursive(&mut u8_root, container.1.clone()).with_context(|| format!("Failed to repack extracted base container \"{}\". An unknown error occurred.", container.0))?;
// Always compress the repacked archive with LZ77 compression.
let compressed_container = lz77::compress_lz77(&u8_root.to_bytes()?)?;
fs::write(repacked_container_path, compressed_container)?;
// Erase the extracted container directory so it doesn't get packed into the final themed
// archive.
fs::remove_dir_all(container.1)?;
println!(" - Done.");
}
// Theme applied, re-pack the base dir and write it out to the specified path.
let mut finished_u8 = u8::U8Directory::new(String::new());
pack_dir_recursive(&mut finished_u8, base_dir).expect("Failed to pack finalized theme!");
fs::write(&out_path, &finished_u8.to_bytes()?).with_context(|| format!("Could not open output file \"{}\" for writing.", out_path.display()))?;
println!("\nSuccessfully applied theme \"{}\" to output file \"{}\"!", mym, output);
Ok(())
}

View File

@@ -0,0 +1,100 @@
// archive/u8.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Code for the U8 packing/unpacking commands in the rustwii CLI.
use std::{str, fs};
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use clap::Subcommand;
use glob::glob;
use rustwii::archive::u8;
#[derive(Subcommand)]
#[command(arg_required_else_help = true)]
pub enum Commands {
/// Pack a directory into a U8 archive
Pack {
/// The directory to pack into a U8 archive
input: String,
/// The name of the packed U8 archive
output: String,
},
/// Unpack a U8 archive into a directory
Unpack {
/// The path to the U8 archive to unpack
input: String,
/// The directory to unpack the U8 archive to
output: String,
}
}
pub fn pack_dir_recursive(dir: &mut u8::U8Directory, in_path: PathBuf) -> Result<()> {
let mut files = Vec::new();
let mut dirs = Vec::new();
for entry in glob(&format!("{}/*", in_path.display()))?.flatten() {
match fs::metadata(&entry) {
Ok(meta) if meta.is_file() => files.push(entry),
Ok(meta) if meta.is_dir() => dirs.push(entry),
_ => {} // Anything that isn't a normal file/directory just gets ignored.
}
}
for file in files {
let node = u8::U8File::new(file.file_name().unwrap().to_str().unwrap().to_owned(), fs::read(file)?);
dir.add_file(node);
}
for child_dir in dirs {
let node = u8::U8Directory::new(child_dir.file_name().unwrap().to_str().unwrap().to_owned());
let dir = dir.add_dir(node);
pack_dir_recursive(dir, child_dir)?;
}
Ok(())
}
pub fn pack_u8_archive(input: &str, output: &str) -> Result<()> {
let in_path = Path::new(input);
if !in_path.exists() {
bail!("Source directory \"{}\" could not be found.", in_path.display());
}
let out_path = PathBuf::from(output);
let mut root_dir = u8::U8Directory::new(String::new());
pack_dir_recursive(&mut root_dir, in_path.to_path_buf()).with_context(|| "A U8 archive could not be packed.")?;
fs::write(&out_path, &root_dir.to_bytes()?).with_context(|| format!("Could not open output file \"{}\" for writing.", out_path.display()))?;
println!("Successfully packed directory \"{}\" into U8 archive \"{}\"!", in_path.display(), out_path.display());
Ok(())
}
pub fn unpack_dir_recursive(dir: &u8::U8Directory, out_path: PathBuf) -> Result<()> {
let out_path = out_path.join(&dir.name);
for file in &dir.files {
fs::write(out_path.join(&file.name), &file.data).with_context(|| format!("Failed to write output file \"{}\".", &file.name))?;
}
for dir in &dir.dirs {
if !out_path.join(&dir.name).exists() {
fs::create_dir(out_path.join(&dir.name)).with_context(|| format!("The output directory \"{}\" could not be created.", out_path.display()))?;
}
unpack_dir_recursive(dir, out_path.clone())?;
}
Ok(())
}
pub fn unpack_u8_archive(input: &str, output: &str) -> Result<()> {
let in_path = Path::new(input);
if !in_path.exists() {
bail!("Source U8 archive \"{}\" could not be found.", in_path.display());
}
let out_path = PathBuf::from(output);
if out_path.exists() {
if !out_path.is_dir() {
bail!("A file already exists with the specified directory name!");
}
} else {
fs::create_dir(&out_path).with_context(|| format!("The output directory \"{}\" could not be created.", out_path.display()))?;
}
// Extract the files and directories in the root, and then recurse over each directory to
// extract the files and directories they contain.
let root_dir = u8::U8Directory::from_bytes(fs::read(in_path).with_context(|| format!("Input file \"{}\" could not be read.", in_path.display()))?.into_boxed_slice())?;
unpack_dir_recursive(&root_dir, out_path.clone())?;
println!("Successfully unpacked U8 archive to directory \"{}\"!", out_path.display());
Ok(())
}

View File

@@ -1,10 +1,10 @@
// filetypes.rs from rustii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustii
// filetypes.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Common code for identifying Wii file types.
use std::{str, fs::File};
use std::io::Read;
use std::io::{Read, Seek, SeekFrom};
use std::path::Path;
use regex::RegexBuilder;
@@ -13,7 +13,8 @@ use regex::RegexBuilder;
pub enum WiiFileType {
Wad,
Tmd,
Ticket
Ticket,
U8,
}
pub fn identify_file_type(input: &str) -> Option<WiiFileType> {
@@ -35,14 +36,30 @@ pub fn identify_file_type(input: &str) -> Option<WiiFileType> {
if input.extension().is_some_and(|f| f.eq_ignore_ascii_case("wad")) {
return Some(WiiFileType::Wad);
}
// Advanced WAD detection, where we read and compare the first 8 bytes (only if the path exists.)
// == U8 ==
if input.extension().is_some_and(|f| f.eq_ignore_ascii_case("arc")) ||
input.extension().is_some_and(|f| f.eq_ignore_ascii_case("app")) {
return Some(WiiFileType::U8);
}
// == Advanced ==
// These require reading the magic number of the file, so we only try this after everything
// else has been tried. These are separated from the other methods of detecting these types so
// that we only have to open the file for reading once.
if input.exists() {
let mut f = File::open(input).unwrap();
// We need to read more bytes for WADs since they don't have a proper magic number.
let mut magic_number = vec![0u8; 8];
f.read_exact(&mut magic_number).unwrap();
if magic_number == b"\x00\x00\x00\x20\x49\x73\x00\x00" || magic_number == b"\x00\x00\x00\x20\x69\x62\x00\x00" {
return Some(WiiFileType::Wad);
}
let mut magic_number = vec![0u8; 4];
f.seek(SeekFrom::Start(0)).unwrap();
f.read_exact(&mut magic_number).unwrap();
if magic_number == b"\x55\xAA\x38\x2D" {
return Some(WiiFileType::U8);
}
}
// == No match found! ==

View File

@@ -1,12 +1,13 @@
// info.rs from rustii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustii
// info.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Code for the info command in the rustii CLI.
// Code for the info command in the rustwii CLI.
use std::{str, fs};
use std::path::Path;
use anyhow::{bail, Context, Result};
use rustii::{title, title::cert, title::tmd, title::ticket, title::wad, title::versions};
use rustwii::archive::u8;
use rustwii::{title, title::cert, title::tmd, title::ticket, title::wad, title::versions};
use crate::filetypes::{WiiFileType, identify_file_type};
// Avoids duplicated code, since both TMD and Ticket info print the TID in the same way.
@@ -17,8 +18,8 @@ fn print_tid(title_id: [u8; 8]) -> Result<()> {
} else {
None
};
if ascii_tid.is_some() {
println!(" Title ID: {} ({})", hex::encode(title_id).to_uppercase(), ascii_tid.unwrap());
if let Some(ascii_tid) = ascii_tid {
println!(" Title ID: {} ({})", hex::encode(title_id).to_uppercase(), ascii_tid);
} else {
println!(" Title ID: {}", hex::encode(title_id).to_uppercase());
}
@@ -38,19 +39,19 @@ fn print_title_version(title_version: u16, title_id: [u8; 8], is_vwii: bool) ->
Ok(())
}
fn print_tmd_info(tmd: tmd::TMD, cert: Option<cert::Certificate>) -> Result<()> {
fn print_tmd_info(tmd: &tmd::TMD, cert: Option<cert::Certificate>) -> Result<()> {
// Print all important keys from the TMD.
println!("Title Info");
print_tid(tmd.title_id)?;
print_title_version(tmd.title_version, tmd.title_id, tmd.is_vwii())?;
println!(" TMD Version: {}", tmd.tmd_version);
if hex::encode(tmd.ios_tid).eq("0000000000000000") {
print_tid(tmd.title_id())?;
print_title_version(tmd.title_version(), tmd.title_id(), tmd.is_vwii())?;
println!(" TMD Version: {}", tmd.tmd_version());
if hex::encode(tmd.ios_tid()).eq("0000000000000000") {
println!(" Required IOS: N/A");
}
else if hex::encode(tmd.ios_tid).ne(&format!("{:016X}", tmd.title_version)) {
println!(" Required IOS: IOS{} ({})", tmd.ios_tid.last().unwrap(), hex::encode(tmd.ios_tid).to_uppercase());
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();
let signature_issuer = String::from_utf8(Vec::from(tmd.signature_issuer())).unwrap_or_default();
if signature_issuer.contains("CP00000004") {
println!(" Certificate: CP00000004 (Retail)");
println!(" Certificate Issuer: Root-CA00000001 (Retail)");
@@ -70,8 +71,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()))
.unwrap_or_default().chars().last() {
Some('U') => "USA",
Some('E') => "EUR",
@@ -79,18 +80,18 @@ 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!(" vWii Title: {}", tmd.is_vwii != 0);
println!(" Title Type: {}", tmd.title_type()?);
println!(" vWii Title: {}", tmd.is_vwii());
println!(" DVD Video Access: {}", tmd.check_access_right(tmd::AccessRight::DVDVideo));
println!(" AHB Access: {}", tmd.check_access_right(tmd::AccessRight::AHB));
if cert.is_some() {
let signing_str = match cert::verify_tmd(&cert.unwrap(), &tmd) {
if let Some(cert) = cert {
let signing_str = match cert::verify_tmd(&cert, tmd) {
Ok(result) => match result {
true => "Valid (Unmodified TMD)",
false => {
@@ -114,26 +115,26 @@ fn print_tmd_info(tmd: tmd::TMD, cert: Option<cert::Certificate>) -> Result<()>
println!(" Fakesigned: {}", tmd.is_fakesigned());
}
println!("\nContent Info");
println!(" Total Contents: {}", tmd.num_contents);
println!(" Boot Content Index: {}", tmd.boot_index);
println!(" Total Contents: {}", tmd.content_records().len());
println!(" Boot Content Index: {}", tmd.boot_index());
println!(" Content Records:");
for content in tmd.content_records {
for content in tmd.content_records().iter() {
println!(" Content Index: {}", content.index);
println!(" Content ID: {:08X}", content.content_id);
println!(" Content Type: {}", content.content_type);
println!(" Content Size: {} bytes", 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(())
}
fn print_ticket_info(ticket: ticket::Ticket, cert: Option<cert::Certificate>) -> Result<()> {
fn print_ticket_info(ticket: &ticket::Ticket, cert: Option<cert::Certificate>) -> Result<()> {
// Print all important keys from the Ticket.
println!("Ticket Info");
print_tid(ticket.title_id)?;
print_title_version(ticket.title_version, ticket.title_id, ticket.common_key_index == 2)?;
println!(" Ticket Version: {}", ticket.ticket_version);
let signature_issuer = String::from_utf8(Vec::from(ticket.signature_issuer)).unwrap_or_default();
print_tid(ticket.title_id())?;
print_title_version(ticket.title_version(), ticket.title_id(), ticket.common_key_index() == 2)?;
println!(" Ticket Version: {}", ticket.ticket_version());
let signature_issuer = String::from_utf8(Vec::from(ticket.signature_issuer())).unwrap_or_default();
if signature_issuer.contains("XS00000003") {
println!(" Certificate: XS00000003 (Retail)");
println!(" Certificate Issuer: Root-CA00000001 (Retail)");
@@ -146,7 +147,7 @@ fn print_ticket_info(ticket: ticket::Ticket, cert: Option<cert::Certificate>) ->
} else {
println!(" Certificate Info: {} (Unknown)", signature_issuer);
}
let key = match ticket.common_key_index {
let key = match ticket.common_key_index() {
0 => {
if ticket.is_dev() { "Common (Development)" }
else { "Common (Retail)" }
@@ -156,10 +157,10 @@ fn print_ticket_info(ticket: ticket::Ticket, cert: Option<cert::Certificate>) ->
_ => "Unknown (Likely Common)"
};
println!(" Decryption Key: {}", key);
println!(" Title Key (Encrypted): {}", hex::encode(ticket.title_key));
println!(" Title Key (Decrypted): {}", hex::encode(ticket.dec_title_key()));
if cert.is_some() {
let signing_str = match cert::verify_ticket(&cert.unwrap(), &ticket) {
println!(" Title Key (Encrypted): {}", hex::encode(ticket.title_key()));
println!(" Title Key (Decrypted): {}", hex::encode(ticket.title_key_dec()));
if let Some(cert) = cert {
let signing_str = match cert::verify_ticket(&cert, ticket) {
Ok(result) => match result {
true => "Valid (Unmodified Ticket)",
false => {
@@ -187,14 +188,14 @@ fn print_ticket_info(ticket: ticket::Ticket, cert: Option<cert::Certificate>) ->
fn print_wad_info(wad: wad::WAD) -> Result<()> {
println!("WAD Info");
match wad.header.wad_type {
match wad.wad_type() {
wad::WADType::ImportBoot => { println!(" WAD Type: boot2") },
wad::WADType::Installable => { println!(" WAD Type: Standard Installable") },
}
// Create a Title for size info, signing info and TMD/Ticket info.
let title = title::Title::from_wad(&wad).with_context(|| "The provided WAD file could not be parsed, and is likely invalid.")?;
let min_size_blocks = title.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 {
@@ -215,9 +216,9 @@ fn print_wad_info(wad: wad::WAD) -> Result<()> {
false => {
if title.is_fakesigned() {
"Fakesigned"
} else if cert::verify_tmd(&title.cert_chain.tmd_cert(), &title.tmd)? {
} else if cert::verify_tmd(&title.cert_chain().tmd_cert(), title.tmd())? {
"Piratelegit (Unmodified TMD, Modified Ticket)"
} else if cert::verify_ticket(&title.cert_chain.ticket_cert(), &title.ticket)? {
} else if cert::verify_ticket(&title.cert_chain().ticket_cert(), title.ticket())? {
"Edited (Modified TMD, Unmodified Ticket)"
} else {
"Illegitimate (Modified TMD + Ticket)"
@@ -234,9 +235,38 @@ fn print_wad_info(wad: wad::WAD) -> Result<()> {
};
println!(" Signing Status: {}", signing_str);
println!();
print_ticket_info(title.ticket, Some(title.cert_chain.ticket_cert()))?;
print_ticket_info(title.ticket(), Some(title.cert_chain().ticket_cert()))?;
println!();
print_tmd_info(title.tmd, Some(title.cert_chain.tmd_cert()))?;
print_tmd_info(title.tmd(), Some(title.cert_chain().tmd_cert()))?;
Ok(())
}
fn print_full_tree(dir: &u8::U8Directory, indent: usize) {
let prefix = " ".repeat(indent);
let dir_name = if !dir.name.is_empty() {
&dir.name
} else {
&String::from("root")
};
println!("{}D {}", prefix, dir_name);
// Print subdirectories
for subdir in &dir.dirs {
print_full_tree(subdir, indent + 1);
}
// Print files
for file in &dir.files {
let file_name = &file.name;
println!("{} F {}", prefix, file_name);
}
}
fn print_u8_info(root_dir: u8::U8Directory) -> Result<()> {
println!("U8 Archive Info");
println!(" Node Count: {}", root_dir.count());
println!(" Archive Data:");
print_full_tree(&root_dir, 2);
Ok(())
}
@@ -247,17 +277,21 @@ pub fn info(input: &str) -> Result<()> {
}
match identify_file_type(input) {
Some(WiiFileType::Tmd) => {
let tmd = tmd::TMD::from_bytes(fs::read(in_path)?.as_slice()).with_context(|| "The provided TMD file could not be parsed, and is likely invalid.")?;
print_tmd_info(tmd, None)?;
let tmd = tmd::TMD::from_bytes(&fs::read(in_path)?).with_context(|| "The provided TMD file could not be parsed, and is likely invalid.")?;
print_tmd_info(&tmd, None)?;
},
Some(WiiFileType::Ticket) => {
let ticket = ticket::Ticket::from_bytes(fs::read(in_path)?.as_slice()).with_context(|| "The provided Ticket file could not be parsed, and is likely invalid.")?;
print_ticket_info(ticket, None)?;
let ticket = ticket::Ticket::from_bytes(&fs::read(in_path)?).with_context(|| "The provided Ticket file could not be parsed, and is likely invalid.")?;
print_ticket_info(&ticket, None)?;
},
Some(WiiFileType::Wad) => {
let wad = wad::WAD::from_bytes(fs::read(in_path)?.as_slice()).with_context(|| "The provided WAD file could not be parsed, and is likely invalid.")?;
let wad = wad::WAD::from_bytes(&fs::read(in_path)?).with_context(|| "The provided WAD file could not be parsed, and is likely invalid.")?;
print_wad_info(wad)?;
},
Some(WiiFileType::U8) => {
let u8_archive = u8::U8Directory::from_bytes(fs::read(in_path)?.into_boxed_slice()).with_context(|| "The provided U8 archive could not be parsed, and is likely invalid.")?;
print_u8_info(u8_archive)?;
}
None => {
bail!("Information cannot be displayed for this file type.");
}

252
src/bin/rustwii/main.rs Normal file
View File

@@ -0,0 +1,252 @@
// main.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Base for the rustwii CLI that handles argument parsing and directs execution to the proper module.
mod archive;
mod title;
mod filetypes;
mod info;
mod nand;
use anyhow::Result;
use clap::{Subcommand, Parser};
#[derive(Parser)]
#[command(version, about, long_about = None)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
#[command(arg_required_else_help = true)]
enum Commands {
/// Decompress data using ASH compression
Ash {
#[command(subcommand)]
command: archive::ash::Commands,
},
/// Manage Wii EmuNANDs
Emunand {
#[command(subcommand)]
command: nand::emunand::Commands,
},
/// Fakesign a TMD, Ticket, or WAD (trucha bug)
Fakesign {
/// The path to a TMD, Ticket, or WAD
input: String,
/// An optional output path; defaults to overwriting input file if not provided
#[arg(short, long)]
output: Option<String>,
},
/// Get information about a TMD, Ticket, or WAD
Info {
/// The path to a TMD, Ticket, or WAD
input: String,
},
/// Apply patches to an IOS
IosPatch {
/// The IOS WAD to apply patches to
input: String,
#[arg(short, long)]
/// An optional output path; default to overwriting input file if not provided
output: Option<String>,
/// Set a new IOS version (0-65535)
#[arg(short, long)]
version: Option<u16>,
/// Set the slot that this IOS will install into
#[arg(short, long)]
slot: Option<u8>,
/// Set all patched content to be non-shared
#[arg(short, long, action)]
no_shared: bool,
#[command(flatten)]
enabled_patches: title::iospatcher::EnabledPatches,
},
/// Compress/decompress data using LZ77 compression
Lz77 {
#[command(subcommand)]
command: archive::lz77::Commands
},
/// Download data from the NUS
Nus {
#[command(subcommand)]
command: title::nus::Commands
},
/// Manage setting.txt
Setting {
#[command(subcommand)]
command: nand::setting::Commands
},
/// Apply custom themes to the Wii Menu
Theme {
#[command(subcommand)]
command: archive::theme::Commands
},
/// Edit a TMD file
Tmd {
#[command(subcommand)]
command: title::tmd::Commands
},
/// Pack/unpack a U8 archive
U8 {
#[command(subcommand)]
command: archive::u8::Commands
},
/// Pack/unpack/edit a WAD file
Wad {
#[command(subcommand)]
command: title::wad::Commands,
},
}
fn main() -> Result<()> {
let cli = Cli::parse();
match &cli.command {
Some(Commands::Ash { command }) => {
match command {
archive::ash::Commands::Compress { input, output } => {
archive::ash::compress_ash(input, output)?
},
archive::ash::Commands::Decompress { input, output } => {
archive::ash::decompress_ash(input, output)?
}
}
},
Some(Commands::Emunand { command }) => {
match command {
nand::emunand::Commands::Info { emunand } => {
nand::emunand::info(emunand)?
},
nand::emunand::Commands::InstallMissing { emunand, vwii } => {
nand::emunand::install_missing(emunand, vwii)?
},
nand::emunand::Commands::InstallTitle { wad, emunand, override_meta} => {
nand::emunand::install_title(wad, emunand, override_meta)?
},
nand::emunand::Commands::UninstallTitle { tid, emunand, remove_ticket } => {
nand::emunand::uninstall_title(tid, emunand, remove_ticket)?
}
}
}
Some(Commands::Fakesign { input, output }) => {
title::fakesign::fakesign(input, output)?
},
Some(Commands::Info { input }) => {
info::info(input)?
},
Some(Commands::IosPatch {
input,
output,
version,
slot,
no_shared,
enabled_patches
}
) => {
title::iospatcher::patch_ios(
input,
output,
version,
slot,
no_shared,
enabled_patches,
)?
}
Some(Commands::Lz77 { command }) => {
match command {
archive::lz77::Commands::Compress { input, output } => {
archive::lz77::compress_lz77(input, output)?
},
archive::lz77::Commands::Decompress { input, output } => {
archive::lz77::decompress_lz77(input, output)?
}
}
},
Some(Commands::Nus { command }) => {
match command {
title::nus::Commands::Content { tid, cid, version, output, decrypt} => {
title::nus::download_content(tid, cid, version, output, decrypt)?
},
title::nus::Commands::Ticket { tid, output } => {
title::nus::download_ticket(tid, output)?
},
title::nus::Commands::Title { tid, version, output} => {
title::nus::download_title(tid, version, output)?
}
title::nus::Commands::Tmd { tid, version, output} => {
title::nus::download_tmd(tid, version, output)?
}
}
},
Some(Commands::Setting { command }) => {
match command {
nand::setting::Commands::Decrypt { input, output } => {
nand::setting::decrypt_setting(input, output)?;
},
nand::setting::Commands::Encrypt { input, output } => {
nand::setting::encrypt_setting(input, output)?;
},
nand::setting::Commands::Gen { serno, region } => {
nand::setting::generate_setting(serno, region)?;
}
}
},
Some(Commands::Theme { command }) => {
match command {
archive::theme::Commands::ApplyMym { mym, base, output } => {
archive::theme::theme_apply_mym(mym, base, output)?
}
}
},
Some(Commands::Tmd { command }) => {
match command {
title::tmd::Commands::Edit { input, output, edits} => {
title::tmd::tmd_edit(input, output, edits)?
},
title::tmd::Commands::Remove { input, output, identifier } => {
title::tmd::tmd_remove(input, output, identifier)?
}
}
},
Some(Commands::U8 { command }) => {
match command {
archive::u8::Commands::Pack { input, output } => {
archive::u8::pack_u8_archive(input, output)?
},
archive::u8::Commands::Unpack { input, output } => {
archive::u8::unpack_u8_archive(input, output)?
}
}
},
Some(Commands::Wad { command }) => {
match command {
title::wad::Commands::Add { input, content, output, cid, r#type } => {
title::wad::wad_add(input, content, output, cid, r#type)?
},
title::wad::Commands::Convert { input, target, output } => {
title::wad::wad_convert(input, target, output)?
},
title::wad::Commands::Edit { input, output, edits } => {
title::wad::wad_edit(input, output, edits)?
},
title::wad::Commands::Pack { input, output} => {
title::wad::wad_pack(input, output)?
},
title::wad::Commands::Remove { input, output, identifier } => {
title::wad::wad_remove(input, output, identifier)?
},
title::wad::Commands::Set { input, content, output, identifier, r#type} => {
title::wad::wad_set(input, content, output, identifier, r#type)?
},
title::wad::Commands::Unpack { input, output } => {
title::wad::wad_unpack(input, output)?
},
}
},
None => { /* Clap handles no passed command by itself */}
}
Ok(())
}

View File

@@ -0,0 +1,336 @@
// nand/emunand.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Code for EmuNAND-related commands in the rustwii CLI.
use std::{str, fs};
use std::path::{absolute, Path};
use anyhow::{bail, Context, Result};
use clap::Subcommand;
use walkdir::WalkDir;
use rustwii::nand::{emunand, setting};
use rustwii::title::{nus, tmd};
use rustwii::title;
#[derive(Subcommand)]
#[command(arg_required_else_help = true)]
pub enum Commands {
/// Display information about an EmuNAND
Info {
emunand: String,
},
/// Automatically install missing IOSes to an EmuNAND
InstallMissing {
/// The path to the target EmuNAND
emunand: String,
/// Explicitly install vWii IOSes instead of detecting the EmuNAND type automatically
#[clap(long)]
vwii: bool
},
/// Install a WAD file to an EmuNAND
InstallTitle {
/// The path to the WAD file to install
wad: String,
/// The path to the target EmuNAND
emunand: String,
/// Install the content at index 0 as title.met; this will override any meta/footer data
/// included in the WAD
#[clap(long)]
override_meta: bool,
},
/// Uninstall a title from an EmuNAND
UninstallTitle {
/// The Title ID of the title to uninstall, or the path to a WAD file to read the Title ID
/// from
tid: String,
/// The path to the target EmuNAND
emunand: String,
/// Remove the Ticket file; default behavior is to leave it intact
#[clap(long)]
remove_ticket: bool,
}
}
pub fn info(emunand: &str) -> Result<()> {
let emunand_path = Path::new(emunand);
if !emunand_path.exists() {
bail!("Target EmuNAND directory \"{}\" could not be found.", emunand_path.display());
}
let emunand = emunand::EmuNAND::open(emunand_path.to_path_buf())?;
// Summarize all the details of an EmuNAND.
println!("EmuNAND Info");
println!(" Path: {}", absolute(emunand_path)?.display());
let mut is_vwii = false;
match emunand.get_title_tmd([0, 0, 0, 1, 0, 0, 0, 2]) {
Some(tmd) => {
is_vwii = tmd.is_vwii();
println!(" System Menu Version: {}", title::versions::dec_to_standard(tmd.title_version(), "0000000100000002", Some(is_vwii)).unwrap());
},
None => {
println!(" System Menu Version: None");
}
}
let setting_path = emunand.get_emunand_dir("title").unwrap()
.join("00000001")
.join("00000002")
.join("data")
.join("setting.txt");
if setting_path.exists() {
let setting_txt = setting::SettingTxt::from_bytes(&fs::read(setting_path)?)?;
println!(" System Region: {}", setting_txt.area);
} else {
println!(" System Region: N/A");
}
if is_vwii {
println!(" Type: vWii");
} else {
println!(" Type: Wii");
}
let categories = emunand.get_installed_titles();
let mut installed_count = 0;
for category in &categories {
if category.title_type != "00010000" {
for _ in &category.titles {
installed_count += 1;
}
}
}
println!(" Installed Titles: {}", installed_count);
let total_size: u64 = WalkDir::new(emunand.get_emunand_dir("root").unwrap())
.into_iter()
.filter_map(Result::ok)
.filter(|entry| entry.file_type().is_file())
.map(|entry| fs::metadata(entry.path()).map(|m| m.len()).unwrap_or(0))
.sum();
println!(" Space Used: {} blocks ({:.2} MB)", title::bytes_to_blocks(total_size as usize), total_size as f64 / 1048576.0);
println!();
// Build a catalog of all installed titles so that we can display them.
let mut installed_ioses: Vec<String> = Vec::new();
let mut installed_titles: Vec<String> = Vec::new();
let mut disc_titles: Vec<String> = Vec::new();
for category in categories {
if category.title_type == "00000001" {
let mut ioses: Vec<u32> = Vec::new();
for title in category.titles {
if title != "00000002" {
ioses.push(u32::from_str_radix(&title, 16)?);
}
}
ioses.sort();
ioses.iter().for_each(|x| installed_ioses.push(format!("00000001{:08X}", x)));
} else if category.title_type != "00010000" {
category.titles.iter().for_each(|x| installed_titles.push(format!("{}{}", category.title_type, x).to_ascii_uppercase()));
} else if category.title_type == "00000000" {
category.titles.iter().filter(|x| x.as_str() != "48415A41")
.for_each(|x| disc_titles.push(format!("{}{}", category.title_type, x).to_ascii_uppercase()));
}
}
// Print the titles that are installed to the EmuNAND.
if !installed_ioses.is_empty() {
println!("System Titles:");
for ios in &installed_ioses {
if ["00000001", "00000100", "00000101", "00000200", "00000201"].contains(&&ios[8..16]) {
if ios[8..16].eq("00000001") {
println!(" boot2 ({})", ios.to_ascii_uppercase());
} else if ios[8..16].eq("00000100") {
println!(" BC ({})", ios.to_ascii_uppercase());
} else if ios[8..16].eq("00000101") {
println!(" MIOS ({})", ios.to_ascii_uppercase());
} else if ios[8..16].eq("00000200") {
println!(" BC-NAND ({})", ios.to_ascii_uppercase());
} else if ios[8..16].eq("00000201") {
println!(" BC-WFS ({})", ios.to_ascii_uppercase());
}
let tmd = emunand.get_title_tmd(hex::decode(ios)?.try_into().unwrap()).unwrap();
println!(" Version: {}", tmd.title_version());
}
else {
println!(" IOS{} ({})", u32::from_str_radix(&ios[8..16], 16)?, ios.to_ascii_uppercase());
let tmd = emunand.get_title_tmd(hex::decode(ios)?.try_into().unwrap()).unwrap();
println!(" Version: {} ({})", tmd.title_version(), title::versions::dec_to_standard(tmd.title_version(), ios, None).unwrap());
}
}
println!();
}
let mut missing_ioses: Vec<String> = Vec::new();
if !installed_titles.is_empty() {
println!("Installed Titles:");
for title in installed_titles {
let ascii = String::from_utf8_lossy(&hex::decode(&title[8..16])?).to_string();
let ascii_tid = if ascii.len() == 4 {
Some(ascii)
} else {
None
};
if let Some(ascii_tid) = ascii_tid {
println!(" {} ({})", title.to_uppercase(), ascii_tid);
} else {
println!(" {}", title.to_uppercase());
}
let tmd = emunand.get_title_tmd(hex::decode(&title)?.try_into().unwrap()).unwrap();
println!(" Version: {}", tmd.title_version());
let ios_tid = &hex::encode(tmd.ios_tid()).to_ascii_uppercase();
print!(" Required IOS: IOS{} ({})", u32::from_str_radix(&hex::encode(&tmd.ios_tid()[4..8]), 16)?, ios_tid);
if !installed_ioses.contains(ios_tid) {
println!(" *");
if !missing_ioses.contains(ios_tid) {
missing_ioses.push(String::from(ios_tid));
}
}
else {
println!();
}
}
println!();
}
if !disc_titles.is_empty() {
println!("Save data was found for the following disc titles:");
for title in disc_titles {
let ascii = String::from_utf8_lossy(&hex::decode(&title[8..16])?).to_string();
let ascii_tid = if ascii.len() == 4 {
Some(ascii)
} else {
None
};
if let Some(ascii_tid) = ascii_tid {
println!(" {} ({})", title.to_uppercase(), ascii_tid);
} else {
println!(" {}", title.to_uppercase());
}
}
println!();
}
// Finally, list IOSes that are required by an installed title but are not currently installed.
// This message is sponsored by `rustii emunand install-missing`.
if !missing_ioses.is_empty() {
println!("Some titles installed are missing their required IOS. These missing IOSes are \
marked with \"*\" in the title list above. If these IOSes are not installed, the titles \
requiring them will not launch. The IOSes required but not installed are:");
for missing in missing_ioses {
println!(" IOS{} ({})", u32::from_str_radix(&missing[8..16], 16)?, missing);
}
println!("Missing IOSes can be automatically installed using the install-missing command.");
}
Ok(())
}
pub fn install_missing(emunand: &str, vwii: &bool) -> Result<()> {
let emunand_path = Path::new(emunand);
if !emunand_path.exists() {
bail!("Target EmuNAND directory \"{}\" could not be found.", emunand_path.display());
}
let emunand = emunand::EmuNAND::open(emunand_path.to_path_buf())?;
// Determine Wii vs vWii EmuNAND.
let vwii = if *vwii {
true
} else {
match emunand.get_title_tmd([0, 0, 0, 1, 0, 0, 0, 2]) {
Some(tmd) => {
tmd.is_vwii()
},
None => {
false
}
}
};
// Build a list of IOSes that are required by at least one installed title but are not
// installed themselves. Then from there we can call the NUS download_title() function to
// download and trigger an EmuNAND install for each of them.
let categories = emunand.get_installed_titles();
let mut installed_ioses: Vec<String> = Vec::new();
let mut installed_titles: Vec<String> = Vec::new();
for category in categories {
if category.title_type == "00000001" {
let mut ioses: Vec<u32> = Vec::new();
for title in category.titles {
if title == "00000002" {
installed_titles.push(format!("{}{}", category.title_type, title));
} else if title != "00000001" {
ioses.push(u32::from_str_radix(&title, 16)?);
}
}
ioses.sort();
ioses.iter().for_each(|x| installed_ioses.push(format!("00000001{:08X}", x)));
} else if category.title_type != "00010000" {
category.titles.iter().for_each(|x| installed_titles.push(format!("{}{}", category.title_type, x)));
}
}
let title_tmds: Vec<tmd::TMD> = installed_titles.iter().map(|x| emunand.get_title_tmd(hex::decode(x).unwrap().try_into().unwrap()).unwrap()).collect();
let mut missing_ioses: Vec<u32> = title_tmds.iter()
.filter(|x| !installed_ioses.contains(&hex::encode(x.ios_tid()).to_ascii_uppercase()))
.map(|x| u32::from_str_radix(&hex::encode(&x.ios_tid()[4..8]), 16).unwrap()).collect();
if missing_ioses.is_empty() {
bail!("All required IOSes are already installed!");
}
missing_ioses.sort();
// Because we don't need to install the same IOS for every single title that requires it.
missing_ioses.dedup();
let missing_tids: Vec<[u8; 8]> = {
if vwii {
missing_ioses.iter().map(|x| {
let mut tid = [0u8; 8];
tid[3] = 7;
tid[4..8].copy_from_slice(&x.to_be_bytes());
tid
}).collect()
} else {
missing_ioses.iter().map(|x| {
let mut tid = [0u8; 8];
tid[3] = 1;
tid[4..8].copy_from_slice(&x.to_be_bytes());
tid
}).collect()
}
};
println!("Missing IOSes:");
for ios in &missing_tids {
println!(" IOS{} ({})", u32::from_str_radix(&hex::encode(&ios[4..8]), 16)?, hex::encode(ios).to_ascii_uppercase());
}
println!();
for ios in missing_tids {
println!("Downloading IOS{} ({})...", u32::from_str_radix(&hex::encode(&ios[4..8]), 16)?, hex::encode(ios).to_ascii_uppercase());
let title = nus::download_title(ios, None, true)?;
let version = title.tmd().title_version();
println!(" Installing IOS{} ({}) v{}...", u32::from_str_radix(&hex::encode(&ios[4..8]), 16)?, hex::encode(ios).to_ascii_uppercase(), version);
emunand.install_title(title, false)?;
println!(" Installed IOS{} ({}) v{}!", u32::from_str_radix(&hex::encode(&ios[4..8]), 16)?, hex::encode(ios).to_ascii_uppercase(), version);
}
println!("\nAll missing IOSes have been installed!");
Ok(())
}
pub fn install_title(wad: &str, emunand: &str, override_meta: &bool) -> Result<()> {
let wad_path = Path::new(wad);
if !wad_path.exists() {
bail!("Source WAD \"{}\" could not be found.", wad_path.display());
}
let emunand_path = Path::new(emunand);
if !emunand_path.exists() {
bail!("Target EmuNAND directory \"{}\" could not be found.", emunand_path.display());
}
let wad_file = fs::read(wad_path).with_context(|| format!("Failed to open WAD file \"{}\" for reading.", wad_path.display()))?;
let title = title::Title::from_bytes(&wad_file).with_context(|| format!("The provided WAD file \"{}\" appears to be invalid.", wad_path.display()))?;
let emunand = emunand::EmuNAND::open(emunand_path.to_path_buf())?;
emunand.install_title(title, *override_meta)?;
println!("Successfully installed WAD \"{}\" to EmuNAND at \"{}\"!", wad_path.display(), emunand_path.display());
Ok(())
}
pub fn uninstall_title(tid: &str, emunand: &str, remove_ticket: &bool) -> Result<()> {
let emunand_path = Path::new(emunand);
if !emunand_path.exists() {
bail!("Target EmuNAND directory \"{}\" could not be found.", emunand_path.display());
}
let tid_as_path = Path::new(&tid);
let tid_bin: [u8; 8] = if tid_as_path.exists() {
let wad_file = fs::read(tid_as_path).with_context(|| format!("Failed to open WAD file \"{}\" for reading.", tid_as_path.display()))?;
let title = title::Title::from_bytes(&wad_file).with_context(|| format!("The provided WAD file \"{}\" appears to be invalid.", tid_as_path.display()))?;
title.tmd().title_id()
} else {
hex::decode(tid).with_context(|| "The specified Title ID is not valid! The Title ID must be in hex format.")?.try_into().unwrap()
};
let emunand = emunand::EmuNAND::open(emunand_path.to_path_buf())?;
emunand.uninstall_title(tid_bin, *remove_ticket)?;
println!("Successfully uninstalled title with Title ID \"{}\" from EmuNAND at \"{}\"!", hex::encode(tid_bin).to_ascii_uppercase(), emunand_path.display());
Ok(())
}

View File

@@ -0,0 +1,5 @@
// nand/mod.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
pub mod emunand;
pub mod setting;

View File

@@ -0,0 +1,140 @@
// nand/setting.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Code for setting.txt-related commands in the rustwii CLI.
use std::{str, fs};
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use clap::Subcommand;
use regex::RegexBuilder;
use rustwii::nand::setting;
use rustwii::nand::setting::SettingTxt;
#[derive(Subcommand)]
#[command(arg_required_else_help = true)]
pub enum Commands {
/// Decrypt setting.txt
Decrypt {
/// The path to the setting.txt file to decrypt
input: String,
/// An optional output path; defaults to setting_dec.txt
#[arg(short, long)]
output: Option<String>,
},
/// Encrypt setting.txt
Encrypt {
/// The path to the setting.txt to encrypt
input: String,
/// An optional output path; defaults to setting_enc.txt
#[arg(short, long)]
output: Option<String>,
},
/// Generate a new setting.txt from the provided values
Gen {
/// The serial number of the console this file is for
serno: String,
/// Region of the console this file is for (USA, EUR, JPN, or KOR)
region: String
}
}
pub fn decrypt_setting(input: &str, output: &Option<String>) -> Result<()> {
let in_path = Path::new(input);
if !in_path.exists() {
bail!("Source file \"{}\" could not be found.", in_path.display());
}
let out_path = if output.is_some() {
PathBuf::from(output.clone().unwrap()).with_extension("txt")
} else {
PathBuf::from("setting_dec.txt")
};
let setting = setting::SettingTxt::from_bytes(&fs::read(in_path)?).with_context(|| "The provided setting.txt could not be parsed, and is likely invalid.")?;
fs::write(out_path, setting.to_string()?)?;
println!("Successfully decrypted setting.txt!");
Ok(())
}
pub fn encrypt_setting(input: &str, output: &Option<String>) -> Result<()> {
let in_path = Path::new(input);
if !in_path.exists() {
bail!("Source file \"{}\" could not be found.", in_path.display());
}
let out_path = if output.is_some() {
PathBuf::from(output.clone().unwrap()).with_extension("txt")
} else {
PathBuf::from("setting_enc.txt")
};
let setting = setting::SettingTxt::from_string(String::from_utf8(fs::read(in_path)?).with_context(|| "Invalid characters found in input file!")?)?;
fs::write(out_path, setting.to_bytes()?)?;
println!("Successfully encrypted setting.txt!");
Ok(())
}
pub fn generate_setting(serno: &str, region: &str) -> Result<()> {
// Validate the provided SN. It should be 2 or 3 letters followed by 9 numbers.
if serno.len() != 11 && serno.len() != 12 {
bail!("The provided Serial Number is not valid!")
}
let re = RegexBuilder::new(r"[0-9]+").case_insensitive(true).build()?;
if !re.is_match(&serno[serno.len() - 9..]) {
bail!("The provided Serial Number is not valid!")
}
let prefix = &serno[..serno.len() - 9];
// Detect the console revision based on the SN.
let revision = match prefix.chars().next().unwrap() {
'L' => "RVL-001",
'K' => "RVL-101",
'H' => "RVL-201",
_ => "RVL-001"
};
// Validate the region, and then validate the SN based on the region. USA has a two-letter
// prefix for a total length of 11 characters, while other regions have a three-letter prefix
// for a total length of 12 characters.
let valid_regions = ["USA", "EUR", "JPN", "KOR"];
if !valid_regions.contains(&region) {
bail!("The provided region \"{region}\" is not valid!")
}
if (prefix.len() == 2 && region != "USA") || (prefix.len() == 3 && region == "USA") {
bail!("The provided region \"{region}\" does not match the provided Serial Number {serno}!")
}
// Find the values of VIDEO and GAME.
let (video, game) = match region {
"USA" => ("NTSC", "US"),
"EUR" => ("PAL", "EU"),
"JPN" => ("NTSC", "JP"),
"KOR" => ("NTSC", "KR"),
_ => bail!("The provided region \"{region}\" is not valid!")
};
let model = format!("{revision}({region})");
let serial_number = &serno[serno.len() - 9..];
let setting_str = format!("\
AREA={}\r\n\
MODEL={}\r\n\
DVD=0\r\n\
MPCH=0x7FFE\r\n\
CODE={}\r\n\
SERNO={}\r\n\
VIDEO={}\r\n\
GAME={}\r\n", region, model, prefix, serial_number, video, game
);
let setting_txt = SettingTxt::from_string(setting_str)?;
fs::write("setting.txt", setting_txt.to_bytes()?)
.with_context(|| "Failed to write setting.txt!")?;
println!("Successfully created setting.txt for console with serial number {serno} and \
region {region}!");
Ok(())
}

View File

@@ -1,12 +1,12 @@
// title/fakesign.rs from rustii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustii
// title/fakesign.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Code for the fakesign command in the rustii CLI.
// Code for the fakesign command in the rustwii CLI.
use std::{str, fs};
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use rustii::{title, title::tmd, title::ticket};
use rustwii::{title, title::tmd, title::ticket};
use crate::filetypes::{WiiFileType, identify_file_type};
pub fn fakesign(input: &str, output: &Option<String>) -> Result<()> {
@@ -57,7 +57,7 @@ pub fn fakesign(input: &str, output: &Option<String>) -> Result<()> {
fs::write(out_path, ticket.to_bytes()?).with_context(|| "Could not open output file for writing.")?;
println!("Ticket fakesigned!");
},
None => {
_ => {
bail!("You can only fakesign TMDs, Tickets, and WADs!");
}
}

View File

@@ -0,0 +1,150 @@
// title/iospatcher.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Code for the iospatcher command in the rustwii CLI.
use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use clap::Args;
use rustwii::title;
use rustwii::title::iospatcher;
use rustwii::title::tmd::ContentType;
#[derive(Args)]
#[clap(next_help_heading = "Patches")]
#[group(multiple = true, required = true)]
/// Modifications that can be made to a title, shared between the WAD and TMD commands.
pub struct EnabledPatches {
/// Patch out signature checks
#[arg(long, action)]
sig_checks: bool,
/// Patch in access to ES_Identify
#[arg(long, action)]
es_identify: bool,
/// Patch in access to /dev/flash
#[arg(long, action)]
dev_flash: bool,
/// Patch out anti-downgrade checks
#[arg(long, action)]
allow_downgrade: bool,
/// Patch out drive inquiries (EXPERIMENTAL)
#[arg(long, action)]
drive_inquiry: bool,
}
pub fn patch_ios(
input: &str,
output: &Option<String>,
version: &Option<u16>,
slot: &Option<u8>,
no_shared: &bool,
enabled_patches: &EnabledPatches,
) -> Result<()> {
let in_path = Path::new(input);
if !in_path.exists() {
bail!("Source WAD \"{}\" 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 ios = title::Title::from_bytes(&fs::read(in_path)?).with_context(|| "The provided WAD file could not be parsed, and is likely invalid.")?;
let tid = hex::encode(ios.tmd().title_id());
// If the TID is not a valid IOS TID, then bail.
if !tid[..8].eq("00000001") || tid[8..].eq("00000001") || tid[8..].eq("00000002") {
bail!("The provided WAD does not appear to contain an IOS! No patches can be applied.")
}
let mut patches_applied = 0;
if let Some(version) = version {
ios.set_title_version(*version);
println!("Set new IOS version: {version}")
}
if let Some(slot) = slot && *slot >= 3 {
let tid = hex::decode(format!("00000001{slot:08X}"))?;
ios.set_title_id(tid.try_into().unwrap()).expect("Failed to set IOS slot!");
println!("Set new IOS slot: {slot}");
}
if enabled_patches.sig_checks ||
enabled_patches.es_identify ||
enabled_patches.dev_flash ||
enabled_patches.allow_downgrade
{
let es_index = iospatcher::ios_find_module(String::from("ES:"), &ios)
.with_context(|| "The ES module could not be found. This WAD is not a valid IOS.")?;
if enabled_patches.sig_checks {
print!("Applying signature check patch... ");
let count = iospatcher::ios_patch_sigchecks(&mut ios, es_index)?;
println!("{} patch(es) applied", count);
patches_applied += count;
}
if enabled_patches.es_identify {
print!("Applying ES_Identify access patch... ");
let count = iospatcher::ios_patch_es_identify(&mut ios, es_index)?;
println!("{} patch(es) applied", count);
patches_applied += count;
}
if enabled_patches.dev_flash {
print!("Applying /dev/flash access patch... ");
let count = iospatcher::ios_patch_dev_flash(&mut ios, es_index)?;
println!("{} patch(es) applied", count);
patches_applied += count;
}
if enabled_patches.allow_downgrade {
print!("Applying allow downgrading patch... ");
let count = iospatcher::ios_patch_allow_downgrade(&mut ios, es_index)?;
println!("{} patch(es) applied", count);
patches_applied += count;
}
// Set the type of the content containing ES to "Normal" to avoid it getting installed to
// /shared1 on NAND.
if *no_shared {
set_type_normal(&mut ios, es_index)?;
}
}
if enabled_patches.drive_inquiry {
let dip_index = iospatcher::ios_find_module(String::from("DIP:"), &ios)
.with_context(|| "The DIP module could not be found. This WAD is not a valid IOS, \
or this IOS version does not use the DIP module.")?;
print!("Applying (EXPERIMENTAL) drive inquiry patch... ");
let count = iospatcher::ios_patch_drive_inquiry(&mut ios, dip_index)?;
println!("{} patch(es) applied", count);
patches_applied += count;
if *no_shared {
set_type_normal(&mut ios, dip_index)?;
}
}
println!("\nTotal patches applied: {patches_applied}");
if patches_applied == 0 && version.is_none() && slot.is_none() {
bail!("No patchers were applied. Please make sure the specified patches are compatible \
with this IOS.")
}
ios.fakesign()?;
fs::write(out_path, ios.to_wad()?.to_bytes()?)?;
println!("IOS successfully patched!");
Ok(())
}
fn set_type_normal(ios: &mut title::Title, index: usize) -> Result<()> {
let mut content_records = ios.tmd().content_records().clone();
content_records[index].content_type = ContentType::Normal;
let mut tmd = ios.tmd().clone();
tmd.set_content_records(content_records);
ios.set_tmd(tmd);
Ok(())
}

View File

@@ -0,0 +1,9 @@
// title/mod.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
pub mod fakesign;
pub mod nus;
pub mod wad;
pub mod tmd;
mod shared;
pub mod iospatcher;

View File

@@ -1,15 +1,15 @@
// title/nus.rs from rustii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustii
// title/nus.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Code for NUS-related commands in the rustii CLI.
// Code for NUS-related commands in the rustwii CLI.
use std::{str, fs};
use std::path::PathBuf;
use anyhow::{bail, Context, Result};
use clap::{Subcommand, Args};
use sha1::{Sha1, Digest};
use rustii::title::{cert, content, crypto, nus, ticket, tmd};
use rustii::title;
use rustwii::title::{cert, crypto, nus, ticket, tmd};
use rustwii::title;
#[derive(Subcommand)]
#[command(arg_required_else_help = true)]
@@ -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!");
};
@@ -109,11 +109,11 @@ pub fn download_content(tid: &str, cid: &str, version: &Option<String>, output:
Err(_) => bail!("No Ticket is available for this title! The content cannot be decrypted.")
};
println!(" - Decrypting content...");
let (content_hash, content_size, content_index) = tmd.content_records.iter()
let (content_hash, content_size, content_index) = tmd.content_records().iter()
.find(|record| record.content_id == cid)
.map(|record| (record.content_hash, record.content_size, record.index))
.with_context(|| "No matching content record could be found. Please make sure the requested content is from the specified title version.")?;
let mut content_dec = crypto::decrypt_content(&content, tik.dec_title_key(), content_index);
let mut content_dec = crypto::decrypt_content(&content, tik.title_key_dec(), content_index);
content_dec.resize(content_size as usize, 0);
// Verify the content's hash before saving it.
let mut hasher = Sha1::new();
@@ -159,15 +159,15 @@ 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))?;
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))?;
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))?;
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 {
for record in title.tmd().content_records().iter() {
println!(" - Decrypting and saving content with Content ID {}...", record.content_id);
fs::write(out_path.join(format!("{:08X}.app", record.content_id)), title.get_content_by_cid(record.content_id)?)
.with_context(|| format!("Failed to open content file \"{:08X}.app\" for writing.", record.content_id))?;
@@ -176,7 +176,7 @@ fn download_title_dir(title: title::Title, output: String) -> Result<()> {
Ok(())
}
fn download_title_dir_enc(tmd: tmd::TMD, content_region: content::ContentRegion, cert_chain: cert::CertificateChain, output: String) -> Result<()> {
fn download_title_dir_enc(tmd: tmd::TMD, contents: Vec<Vec<u8>>, cert_chain: cert::CertificateChain, output: String) -> Result<()> {
println!(" - Saving downloaded data...");
let out_path = PathBuf::from(output);
if out_path.exists() {
@@ -186,15 +186,16 @@ 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...");
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 {
for record in tmd.content_records().iter() {
println!(" - Saving content with Content ID {}...", record.content_id);
fs::write(out_path.join(format!("{:08X}", record.content_id)), content_region.get_enc_content_by_cid(record.content_id)?)
let idx = tmd.get_index_from_cid(record.content_id)?;
fs::write(out_path.join(format!("{:08X}", record.content_id)), &contents[idx])
.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());
@@ -205,27 +206,22 @@ 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!");
}
if version.is_some() {
println!("Downloading title {} v{}, please wait...", tid, version.clone().unwrap());
println!("Downloading title {} v{}, please wait...", tid, version.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.")?)?;
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 {
@@ -241,18 +237,17 @@ pub fn download_title(tid: &str, version: &Option<String>, output: &TitleOutputT
};
// 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 {
for record in tmd.content_records().iter() {
println!(" - Downloading content {} of {} (Content ID: {}, Size: {} bytes)...",
record.index + 1, &tmd.content_records.len(), record.content_id, record.content_size);
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 let Some(tik) = tik {
// 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)?;
let title = title::Title::from_parts_with_content(cert_chain, None, tik, tmd, contents, None)?;
if output.wad.is_some() {
download_title_wad(title, output.wad.clone().unwrap())?;
} else {
@@ -261,17 +256,12 @@ pub fn download_title(tid: &str, version: &Option<String>, output: &TitleOutputT
} 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())?;
download_title_dir_enc(tmd, contents, 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
};
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

@@ -0,0 +1,73 @@
// title/shared.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Code shared between title commands in the rustwii CLI.
use anyhow::bail;
use clap::Args;
use hex::FromHex;
use regex::RegexBuilder;
use rustwii::title::tmd;
#[derive(Args)]
#[clap(next_help_heading = "Content Identifier")]
#[group(multiple = false, required = true)]
/// Method of identifying individual content in a title, shared between the WAD and TMD commands.
pub struct ContentIdentifier {
/// The index of the target content
#[arg(short, long)]
pub index: Option<usize>,
/// The Content ID of the target content
#[arg(short, long)]
pub cid: Option<String>,
}
#[derive(Args)]
#[clap(next_help_heading = "Possible Modifications")]
#[group(multiple = true, required = true)]
/// Modifications that can be made to a title, shared between the WAD and TMD commands.
pub struct TitleModifications {
/// A new IOS version for this title (formatted as the decimal IOS version, e.g. 58, with a
/// valid range of 3-255)
#[arg(long)]
pub ios: Option<u8>,
/// A new Title ID for this title (formatted as 4 ASCII characters, e.g. HADE)
#[arg(long)]
pub tid: Option<String>,
/// A new type for this title (valid options are "System", "Channel", "SystemChannel",
/// "GameChannel", "DLC", "HiddenChannel")
#[arg(long)]
pub r#type: Option<String>,
}
/// Validates a target IOS number and returns its TID.
pub fn validate_target_ios(new_ios: u8) -> Result<[u8; 8], anyhow::Error> {
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))?;
Ok(new_ios_tid)
}
/// Validates a target Title ID and returns it as a vector.
pub fn validate_target_tid(new_tid_low: &str) -> Result<Vec<u8>, anyhow::Error> {
let re = RegexBuilder::new(r"^[a-z0-9!@#$%^&*]{4}$").case_insensitive(true).build()?;
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 \"!@#$%&*\".");
}
Ok(Vec::from_hex(hex::encode(new_tid_low))?)
}
/// Validates a target title type and returns it.
pub fn validate_target_type(new_type: &str) -> Result<tmd::TitleType, anyhow::Error> {
let new_type = match new_type {
"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.", new_type),
};
Ok(new_type)
}

View File

@@ -0,0 +1,130 @@
// title/tmd.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Code for TMD-related commands in the rustwii CLI.
use std::{str, fs};
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use clap::Subcommand;
use hex::FromHex;
use rustwii::title::tmd;
use crate::title::shared::{validate_target_ios, validate_target_tid, validate_target_type, ContentIdentifier, TitleModifications};
#[derive(Subcommand)]
#[command(arg_required_else_help = true)]
pub enum Commands {
/// Edit the properties of a TMD file
Edit {
/// The path to the TMD to modify
input: String,
/// An optional output path; defaults to overwriting input TMD file
#[arg(short, long)]
output: Option<String>,
#[command(flatten)]
edits: TitleModifications
},
/// Remove content from a TMD file
Remove {
/// The path to the WAD file to modify
input: String,
/// An optional output path; defaults to overwriting input TMD file
#[arg(short, long)]
output: Option<String>,
#[command(flatten)]
identifier: ContentIdentifier,
},
}
pub fn tmd_edit(input: &str, output: &Option<String>, edits: &TitleModifications) -> Result<()> {
let in_path = Path::new(input);
if !in_path.exists() {
bail!("Source TMD \"{}\" does not exist.", in_path.display());
}
let out_path = if output.is_some() {
PathBuf::from(output.clone().unwrap())
} else {
in_path.to_path_buf()
};
let mut tmd = tmd::TMD::from_bytes(&fs::read(in_path)?).with_context(|| "The provided TMD file could not be parsed, and is likely invalid.")?;
// Parse possible edits and perform each one provided.
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 a
// single time.
if edits.tid.is_some() || edits.r#type.is_some() {
let tid_high = if let Some(new_type) = &edits.r#type {
let new_type = validate_target_type(&new_type.to_ascii_lowercase())?;
changes_summary.push(format!("Changed title type from \"{}\" to \"{}\"", tmd.title_type()?, new_type));
Vec::from_hex(format!("{:08X}", new_type as u32))?
} else {
tmd.title_id()[0..4].to_vec()
};
let tid_low = if let Some(new_tid) = &edits.tid {
let new_tid = validate_target_tid(&new_tid.to_ascii_uppercase())?;
changes_summary.push(format!("Changed Title ID from \"{}\" to \"{}\"", hex::encode(&tmd.title_id()[4..8]).to_ascii_uppercase(), hex::encode(&new_tid).to_ascii_uppercase()));
new_tid
} else {
tmd.title_id()[4..8].to_vec()
};
let new_tid: Vec<u8> = tid_high.iter().chain(&tid_low).copied().collect();
tmd.set_title_id(new_tid.try_into().unwrap());
}
// Apply IOS edits.
if let Some(new_ios) = edits.ios {
let new_ios_tid = validate_target_ios(new_ios)?;
changes_summary.push(format!("Changed required IOS from IOS{} to IOS{}", tmd.ios_tid().last().unwrap(), new_ios));
tmd.set_ios_tid(new_ios_tid)?;
}
tmd.fakesign()?;
fs::write(&out_path, tmd.to_bytes()?).with_context(|| format!("Could not open output file \"{}\" for writing.", out_path.display()))?;
println!("Successfully edited TMD file \"{}\"!\nSummary of changes:", out_path.display());
for change in &changes_summary {
println!(" - {}", change);
}
Ok(())
}
pub fn tmd_remove(input: &str, output: &Option<String>, identifier: &ContentIdentifier) -> Result<()> {
let in_path = Path::new(input);
if !in_path.exists() {
bail!("Source TMD \"{}\" could not be found.", in_path.display());
}
let out_path = if output.is_some() {
PathBuf::from(output.clone().unwrap())
} else {
in_path.to_path_buf()
};
let mut tmd = tmd::TMD::from_bytes(&fs::read(in_path)?).with_context(|| "The provided TMD file could not be parsed, and is likely invalid.")?;
// Parse the identifier passed to choose how to find and remove the target.
// ...maybe don't take the above comment out of context
if let Some(index) = identifier.index {
let mut content_records = tmd.content_records().clone();
content_records.remove(index);
tmd.set_content_records(content_records);
tmd.fakesign().with_context(|| "An unknown error occurred while fakesigning the modified TMD.")?;
fs::write(&out_path, tmd.to_bytes()?).with_context(|| "Could not open output file for writing.")?;
println!("Successfully removed content at index {} in TMD file \"{}\".", index, out_path.display());
} else if identifier.cid.is_some() {
let cid = u32::from_str_radix(identifier.cid.clone().unwrap().as_str(), 16).with_context(|| "The specified Content ID is invalid!")?;
let index = match tmd.content_records().iter()
.find(|record| record.content_id == cid)
.map(|record| record.index)
{
Some(index) => index,
None => bail!("The specified Content ID \"{}\" ({}) does not exist in this WAD!", identifier.cid.clone().unwrap(), cid),
};
let mut content_records = tmd.content_records().clone();
content_records.remove(index as usize);
tmd.set_content_records(content_records);
tmd.fakesign().with_context(|| "An unknown error occurred while fakesigning the modified TMD.")?;
fs::write(&out_path, tmd.to_bytes()?).with_context(|| "Could not open output file for writing.")?;
println!("Successfully removed content with Content ID \"{}\" ({}) in WAD file \"{}\".", identifier.cid.clone().unwrap(), cid, out_path.display());
}
Ok(())
}

View File

@@ -0,0 +1,505 @@
// title/wad.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Code for WAD-related commands in the rustwii CLI.
use std::{str, fs, fmt};
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use clap::{Subcommand, Args};
use glob::glob;
use hex::FromHex;
use rand::prelude::*;
use rustwii::title::{cert, crypto, tmd, ticket};
use rustwii::title;
use crate::title::shared::{validate_target_ios, validate_target_tid, validate_target_type, ContentIdentifier, TitleModifications};
#[derive(Subcommand)]
#[command(arg_required_else_help = true)]
pub enum Commands {
/// Add new content to a WAD file
Add {
/// The path to the WAD file to modify
input: String,
/// The path to the new content to add
content: String,
/// An optional output path; defaults to overwriting input WAD file
#[arg(short, long)]
output: Option<String>,
/// An optional Content ID for the new content; defaults to being randomly assigned
#[arg(short, long)]
cid: Option<String>,
/// An optional type for the new content, can be "Normal", "Shared", or "DLC"; defaults to
/// "Normal"
#[arg(short, long)]
r#type: Option<String>,
},
/// Re-encrypt a WAD file with a different key
Convert {
/// The path to the WAD to convert
input: String,
/// An optional WAD name; defaults to <input name>_<new type>.wad
#[arg(short, long)]
output: Option<String>,
#[command(flatten)]
target: ConvertTargets,
},
/// Edit the properties of a WAD file
Edit {
/// The path to the WAD to modify
input: String,
/// An optional output path; defaults to overwriting input WAD file
#[arg(short, long)]
output: Option<String>,
#[command(flatten)]
edits: TitleModifications
},
/// Pack a directory into a WAD file
Pack {
/// The directory to pack into a WAD
input: String,
/// The name of the packed WAD file
output: String
},
/// Remove content from a WAD file
Remove {
/// The path to the WAD file to modify
input: String,
/// An optional output path; defaults to overwriting input WAD file
#[arg(short, long)]
output: Option<String>,
#[command(flatten)]
identifier: ContentIdentifier,
},
/// Replace existing content in a WAD file with new data
Set {
/// The path to the WAD file to modify
input: String,
/// The path to the new content to set
content: String,
/// An optional output path; defaults to overwriting input WAD file
#[arg(short, long)]
output: Option<String>,
/// An optional new type for the content, can be "Normal", "Shared", or "DLC"
#[arg(short, long)]
r#type: Option<String>,
#[command(flatten)]
identifier: ContentIdentifier,
},
/// Unpack a WAD file into a directory
Unpack {
/// The path to the WAD to unpack
input: String,
/// The directory to extract the WAD to
output: String
},
}
#[derive(Args)]
#[clap(next_help_heading = "Encryption Targets")]
#[group(multiple = false, required = true)]
pub struct ConvertTargets {
/// Use the retail common key, allowing this WAD to be installed on retail consoles and Dolphin
#[arg(long)]
retail: bool,
/// Use the development common key, allowing this WAD to be installed on development consoles
#[arg(long)]
dev: bool,
/// Use the vWii key, allowing this WAD to theoretically be installed from Wii U mode if a Wii U mode WAD installer is created
#[arg(long)]
vwii: bool,
}
enum Target {
Retail,
Dev,
Vwii,
}
impl fmt::Display for Target {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match self {
Target::Retail => write!(f, "retail"),
Target::Dev => write!(f, "development"),
Target::Vwii => write!(f, "vWii"),
}
}
}
pub fn wad_add(input: &str, content: &str, output: &Option<String>, cid: &Option<String>, ctype: &Option<String>) -> Result<()> {
let in_path = Path::new(input);
if !in_path.exists() {
bail!("Source WAD \"{}\" could not be found.", in_path.display());
}
let content_path = Path::new(content);
if !content_path.exists() {
bail!("New content \"{}\" could not be found.", content_path.display());
}
let out_path = if output.is_some() {
PathBuf::from(output.clone().unwrap()).with_extension("wad")
} else {
in_path.to_path_buf()
};
// Load the WAD and parse the target type and Content ID.
let mut title = title::Title::from_bytes(&fs::read(in_path)?).with_context(|| "The provided WAD file could not be parsed, and is likely invalid.")?;
let new_content = fs::read(content_path)?;
let target_type = if ctype.is_some() {
match ctype.clone().unwrap().to_ascii_lowercase().as_str() {
"normal" => tmd::ContentType::Normal,
"shared" => tmd::ContentType::Shared,
"dlc" => tmd::ContentType::DLC,
_ => bail!("The specified content type \"{}\" is invalid! Try --help to see valid types.", ctype.clone().unwrap()),
}
} else {
println!("Using default type \"Normal\" because no content type was specified.");
tmd::ContentType::Normal
};
let target_cid = if cid.is_some() {
let cid = u32::from_str_radix(cid.clone().unwrap().as_str(), 16).with_context(|| "The specified Content ID is invalid!")?;
if title.tmd().content_records().iter().any(|record| record.content_id == cid) {
bail!("The specified Content ID \"{:08X}\" is already being used in this WAD!", cid);
}
cid
} else {
// Generate a random CID if one wasn't specified, and ensure that it isn't already in use.
let mut rng = rand::rng();
let mut cid: u32;
loop {
cid = rng.random_range(0..=0xFF);
if !title.tmd().content_records().iter().any(|record| record.content_id == cid) {
break;
}
}
println!("Generated new random Content ID \"{:08X}\" ({}) because no Content ID was specified.", cid, cid);
cid
};
title.add_content(&new_content, target_cid, target_type.clone()).with_context(|| "An unknown error occurred while setting the new content.")?;
title.fakesign().with_context(|| "An unknown error occurred while fakesigning the modified WAD.")?;
fs::write(&out_path, title.to_wad()?.to_bytes()?).with_context(|| "Could not open output file for writing.")?;
println!("Successfully added new content with Content ID \"{:08X}\" ({}) and type \"{}\" to WAD file \"{}\"!", target_cid, target_cid, target_type, out_path.display());
Ok(())
}
pub fn wad_convert(input: &str, target: &ConvertTargets, output: &Option<String>) -> Result<()> {
let in_path = Path::new(input);
if !in_path.exists() {
bail!("Source WAD \"{}\" could not be found.", in_path.display());
}
// Parse the target passed to identify the encryption target.
let target = if target.dev {
Target::Dev
} else if target.vwii {
Target::Vwii
} else {
Target::Retail
};
// Get the output name now that we know the target, if one wasn't passed.
let out_path = if output.is_some() {
PathBuf::from(output.clone().unwrap()).with_extension("wad")
} else {
match target {
Target::Retail => PathBuf::from(format!("{}_retail.wad", in_path.file_stem().unwrap().to_str().unwrap())),
Target::Dev => PathBuf::from(format!("{}_dev.wad", in_path.file_stem().unwrap().to_str().unwrap())),
Target::Vwii => PathBuf::from(format!("{}_vWii.wad", in_path.file_stem().unwrap().to_str().unwrap())),
}
};
let mut title = title::Title::from_bytes(&fs::read(in_path)?).with_context(|| "The provided WAD file could not be parsed, and is likely invalid.")?;
// Bail if the WAD is already using the selected encryption.
if matches!(target, Target::Dev) && title.ticket().is_dev() {
bail!("This is already a development WAD!");
} else if matches!(target, Target::Retail) && !title.ticket().is_dev() && !title.tmd().is_vwii() {
bail!("This is already a retail WAD!");
} else if matches!(target, Target::Vwii) && !title.ticket().is_dev() && title.tmd().is_vwii() {
bail!("This is already a vWii WAD!");
}
// Save the current encryption to display at the end.
let source = if title.ticket().is_dev() {
"development"
} else if title.tmd().is_vwii() {
"vWii"
} else {
"retail"
};
let title_key = title.ticket().title_key_dec();
let title_key_new: [u8; 16];
let mut tmd = title.tmd().clone();
let mut ticket = title.ticket().clone();
match target {
Target::Dev => {
tmd.set_signature_issuer(String::from("Root-CA00000002-CP00000007"))?;
ticket.set_signature_issuer(String::from("Root-CA00000002-XS00000006"))?;
title_key_new = crypto::encrypt_title_key(title_key, 0, title.ticket().title_id(), true);
ticket.set_common_key_index(0);
tmd.set_is_vwii(false);
},
Target::Retail => {
tmd.set_signature_issuer(String::from("Root-CA00000001-CP00000004"))?;
ticket.set_signature_issuer(String::from("Root-CA00000001-XS00000003"))?;
title_key_new = crypto::encrypt_title_key(title_key, 0, title.ticket().title_id(), false);
ticket.set_common_key_index(0);
tmd.set_is_vwii(false);
},
Target::Vwii => {
tmd.set_signature_issuer(String::from("Root-CA00000001-CP00000004"))?;
ticket.set_signature_issuer(String::from("Root-CA00000001-XS00000003"))?;
title_key_new = crypto::encrypt_title_key(title_key, 2, title.ticket().title_id(), false);
ticket.set_common_key_index(2);
tmd.set_is_vwii(true);
}
}
ticket.set_title_key(title_key_new);
title.set_tmd(tmd);
title.set_ticket(ticket);
title.fakesign()?;
fs::write(&out_path, title.to_wad()?.to_bytes()?)?;
println!("Successfully converted {} WAD to {} WAD \"{}\"!", source, target, out_path.file_name().unwrap().to_str().unwrap());
Ok(())
}
pub fn wad_edit(input: &str, output: &Option<String>, edits: &TitleModifications) -> Result<()> {
let in_path = Path::new(input);
if !in_path.exists() {
bail!("Source WAD \"{}\" 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 let Some(new_type) = &edits.r#type {
let new_type = validate_target_type(&new_type.to_ascii_lowercase())?;
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 let Some(new_tid) = &edits.tid {
let new_tid = validate_target_tid(&new_tid.to_ascii_uppercase())?;
changes_summary.push(format!("Changed Title ID from \"{}\" to \"{}\"", hex::encode(&title.tmd().title_id()[4..8]).to_ascii_uppercase(), hex::encode(&new_tid).to_ascii_uppercase()));
new_tid
} 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 let Some(new_ios) = edits.ios {
let new_ios_tid = validate_target_ios(new_ios)?;
changes_summary.push(format!("Changed required IOS from IOS{} to IOS{}", title.tmd().ios_tid().last().unwrap(), new_ios));
let mut tmd = title.tmd().clone();
tmd.set_ios_tid(new_ios_tid)?;
title.set_tmd(tmd);
}
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 wad_pack(input: &str, output: &str) -> Result<()> {
let in_path = Path::new(input);
if !in_path.exists() {
bail!("Source directory \"{}\" does not exist.", in_path.display());
}
// Read TMD file (only accept one file).
let tmd_files: Vec<PathBuf> = glob(&format!("{}/*.tmd", in_path.display()))?
.filter_map(|f| f.ok()).collect();
if tmd_files.is_empty() {
bail!("No TMD file found in the source directory.");
} else if tmd_files.len() > 1 {
bail!("More than one TMD file found in the source directory.");
}
let tmd = tmd::TMD::from_bytes(&fs::read(&tmd_files[0]).with_context(|| "Could not open TMD file for reading.")?)
.with_context(|| "The provided TMD file appears to be invalid.")?;
// Read Ticket file (only accept one file).
let ticket_files: Vec<PathBuf> = glob(&format!("{}/*.tik", in_path.display()))?
.filter_map(|f| f.ok()).collect();
if ticket_files.is_empty() {
bail!("No Ticket file found in the source directory.");
} else if ticket_files.len() > 1 {
bail!("More than one Ticket file found in the source directory.");
}
let tik = ticket::Ticket::from_bytes(&fs::read(&ticket_files[0]).with_context(|| "Could not open Ticket file for reading.")?)
.with_context(|| "The provided Ticket file appears to be invalid.")?;
// Read cert chain (only accept one file).
let cert_files: Vec<PathBuf> = glob(&format!("{}/*.cert", in_path.display()))?
.filter_map(|f| f.ok()).collect();
if cert_files.is_empty() {
bail!("No cert file found in the source directory.");
} else if cert_files.len() > 1 {
bail!("More than one Cert file found in the source directory.");
}
let cert_chain = cert::CertificateChain::from_bytes(&fs::read(&cert_files[0]).with_context(|| "Could not open cert chain file for reading.")?)
.with_context(|| "The provided certificate chain appears to be invalid.")?;
// Read footer, if one exists (only accept one file).
let footer_files: Vec<PathBuf> = glob(&format!("{}/*.footer", in_path.display()))?
.filter_map(|f| f.ok()).collect();
let mut footer: Vec<u8> = Vec::new();
if footer_files.len() == 1 {
footer = fs::read(&footer_files[0]).with_context(|| "Could not open footer file for reading.")?;
}
// Create a title to use for content loading.
let mut title = title::Title::from_parts(
cert_chain,
None,
tik,
tmd,
Some(&footer)
)?;
// Iterate over expected content and load the content into the title.
let content_indexes: Vec<u16> = title.tmd().content_records().iter().map(|record| record.index).collect();
for index in content_indexes {
let data = fs::read(format!("{}/{:08X}.app", in_path.display(), index))
.with_context(|| format!("Could not open content file \"{:08X}.app\" for reading.", index))?;
title.set_content(&data, index as usize, None, None)
.with_context(|| "Failed to load content into the ContentRegion.")?;
}
let wad = title.to_wad()?;
// Write out WAD file.
let mut out_path = PathBuf::from(output);
match out_path.extension() {
Some(ext) => {
if ext != "wad" {
out_path.set_extension("wad");
}
},
None => {
out_path.set_extension("wad");
}
}
fs::write(&out_path, wad.to_bytes()?).with_context(|| format!("Could not open output file \"{}\" for writing.", out_path.display()))?;
println!("Successfully packed WAD file to \"{}\"!", out_path.display());
Ok(())
}
pub fn wad_remove(input: &str, output: &Option<String>, identifier: &ContentIdentifier) -> Result<()> {
let in_path = Path::new(input);
if !in_path.exists() {
bail!("Source WAD \"{}\" could not be found.", in_path.display());
}
let out_path = if output.is_some() {
PathBuf::from(output.clone().unwrap()).with_extension("wad")
} else {
in_path.to_path_buf()
};
let mut title = title::Title::from_bytes(&fs::read(in_path)?).with_context(|| "The provided WAD file could not be parsed, and is likely invalid.")?;
// Parse the identifier passed to choose how to find and remove the target.
// ...maybe don't take the above comment out of context
if let Some(index) = identifier.index {
title.remove_content(index).with_context(|| "The specified index does not exist in the provided WAD!")?;
title.fakesign().with_context(|| "An unknown error occurred while fakesigning the modified WAD.")?;
fs::write(&out_path, title.to_wad()?.to_bytes()?).with_context(|| "Could not open output file for writing.")?;
println!("Successfully removed content at index {} in WAD file \"{}\".", index, out_path.display());
} else if identifier.cid.is_some() {
let cid = u32::from_str_radix(identifier.cid.clone().unwrap().as_str(), 16).with_context(|| "The specified Content ID is invalid!")?;
let index = match title.tmd().get_index_from_cid(cid) {
Ok(index) => index,
Err(_) => bail!("The specified Content ID \"{}\" ({}) does not exist in this WAD!", identifier.cid.clone().unwrap(), cid),
};
title.remove_content(index).with_context(|| "An unknown error occurred while removing content from the WAD.")?;
title.fakesign().with_context(|| "An unknown error occurred while fakesigning the modified WAD.")?;
fs::write(&out_path, title.to_wad()?.to_bytes()?).with_context(|| "Could not open output file for writing.")?;
println!("Successfully removed content with Content ID \"{}\" ({}) in WAD file \"{}\".", identifier.cid.clone().unwrap(), cid, out_path.display());
}
Ok(())
}
pub fn wad_set(input: &str, content: &str, output: &Option<String>, identifier: &ContentIdentifier, ctype: &Option<String>) -> Result<()> {
let in_path = Path::new(input);
if !in_path.exists() {
bail!("Source WAD \"{}\" could not be found.", in_path.display());
}
let content_path = Path::new(content);
if !content_path.exists() {
bail!("New content \"{}\" could not be found.", content_path.display());
}
let out_path = if output.is_some() {
PathBuf::from(output.clone().unwrap()).with_extension("wad")
} else {
in_path.to_path_buf()
};
// Load the WAD and parse the new type, if one was specified.
let mut title = title::Title::from_bytes(&fs::read(in_path)?).with_context(|| "The provided WAD file could not be parsed, and is likely invalid.")?;
let new_content = fs::read(content_path)?;
let mut target_type: Option<tmd::ContentType> = None;
if ctype.is_some() {
target_type = match ctype.clone().unwrap().to_ascii_lowercase().as_str() {
"normal" => Some(tmd::ContentType::Normal),
"shared" => Some(tmd::ContentType::Shared),
"dlc" => Some(tmd::ContentType::DLC),
_ => bail!("The specified content type \"{}\" is invalid!", ctype.clone().unwrap()),
};
}
// Parse the identifier passed to choose how to do the find and replace.
if let Some(index) = identifier.index {
match title.set_content(&new_content, index, None, target_type) {
Err(title::TitleError::IndexOutOfRange { index, max }) => {
bail!("The specified index {} does not exist in this WAD! The maximum index is {}.", index, max)
},
Err(e) => bail!("An unknown error occurred while setting the new content: {e}"),
Ok(_) => (),
}
title.fakesign().with_context(|| "An unknown error occurred while fakesigning the modified WAD.")?;
fs::write(&out_path, title.to_wad()?.to_bytes()?).with_context(|| "Could not open output file for writing.")?;
println!("Successfully replaced content at index {} in WAD file \"{}\".", identifier.index.unwrap(), out_path.display());
} else if identifier.cid.is_some() {
let cid = u32::from_str_radix(identifier.cid.clone().unwrap().as_str(), 16).with_context(|| "The specified Content ID is invalid!")?;
let index = match title.tmd().get_index_from_cid(cid) {
Ok(index) => index,
Err(_) => bail!("The specified Content ID \"{}\" ({}) does not exist in this WAD!", identifier.cid.clone().unwrap(), cid),
};
title.set_content(&new_content, index, None, target_type).with_context(|| "An unknown error occurred while setting the new content.")?;
title.fakesign().with_context(|| "An unknown error occurred while fakesigning the modified WAD.")?;
fs::write(&out_path, title.to_wad()?.to_bytes()?).with_context(|| "Could not open output file for writing.")?;
println!("Successfully replaced content with Content ID \"{}\" ({}) in WAD file \"{}\".", identifier.cid.clone().unwrap(), cid, out_path.display());
}
Ok(())
}
pub fn wad_unpack(input: &str, output: &str) -> Result<()> {
let in_path = Path::new(input);
if !in_path.exists() {
bail!("Source WAD \"{}\" could not be found.", input);
}
let wad_file = fs::read(in_path).with_context(|| format!("Failed to open WAD file \"{}\" for reading.", in_path.display()))?;
let title = title::Title::from_bytes(&wad_file).with_context(|| format!("The provided WAD file \"{}\" appears to be invalid.", in_path.display()))?;
let tid = hex::encode(title.tmd().title_id());
// Create output directory if it doesn't exist.
let out_path = Path::new(output);
if !out_path.exists() {
fs::create_dir(out_path).with_context(|| format!("The output directory \"{}\" could not be created.", out_path.display()))?;
}
// Write out all WAD components.
let tmd_file_name = format!("{}.tmd", tid);
fs::write(Path::join(out_path, tmd_file_name.clone()), title.tmd().to_bytes()?).with_context(|| format!("Failed to open TMD file \"{}\" for writing.", tmd_file_name))?;
let ticket_file_name = format!("{}.tik", tid);
fs::write(Path::join(out_path, ticket_file_name.clone()), title.ticket().to_bytes()?).with_context(|| format!("Failed to open Ticket file \"{}\" for writing.", ticket_file_name))?;
let cert_file_name = format!("{}.cert", tid);
fs::write(Path::join(out_path, cert_file_name.clone()), title.cert_chain().to_bytes()?).with_context(|| format!("Failed to open certificate chain file \"{}\" for writing.", cert_file_name))?;
let meta_file_name = format!("{}.footer", tid);
fs::write(Path::join(out_path, meta_file_name.clone()), title.meta()).with_context(|| format!("Failed to open footer file \"{}\" for writing.", meta_file_name))?;
// Iterate over contents, decrypt them, and write them out.
for i in 0..title.tmd().content_records().len() {
let content_file_name = format!("{:08X}.app", title.tmd().content_records()[i].index);
let dec_content = title.get_content_by_index(i).with_context(|| format!("Failed to unpack content with Content ID {:08X}.", title.tmd().content_records()[i].content_id))?;
fs::write(Path::join(out_path, content_file_name), dec_content).with_context(|| format!("Failed to open content file \"{:08X}.app\" for writing.", title.tmd().content_records()[i].content_id))?;
}
println!("Successfully unpacked WAD file to \"{}\"!", out_path.display());
Ok(())
}

View File

@@ -1,7 +1,8 @@
// lib.rs from rustii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustii
// lib.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Root level module that imports the feature modules.
pub mod archive;
pub mod nand;
pub mod title;

264
src/nand/emunand.rs Normal file
View File

@@ -0,0 +1,264 @@
// nand/emunand.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Implements the structures and methods required for handling Wii EmuNANDs.
use std::fs;
use std::collections::HashMap;
use std::path::PathBuf;
use glob::glob;
use thiserror::Error;
use crate::nand::{sharedcontentmap, sys};
use crate::title;
use crate::title::{cert, ticket, tmd};
#[derive(Debug, Error)]
pub enum EmuNANDError {
#[error("the specified title is not installed to the EmuNAND")]
TitleNotInstalled,
#[error("EmuNAND requires the directory `{0}`, but a file with that name already exists")]
DirectoryNameConflict(String),
#[error("specified EmuNAND root does not exist")]
RootNotFound,
#[error("uid.sys processing error")]
UidSys(#[from] sys::UidSysError),
#[error("certificate processing error")]
CertificateError(#[from] cert::CertificateError),
#[error("TMD processing error")]
TMD(#[from] tmd::TMDError),
#[error("Ticket processing error")]
Ticket(#[from] ticket::TicketError),
#[error("Title content processing error")]
TitleContent(#[from] title::TitleError),
#[error("content.map processing error")]
SharedContent(#[from] sharedcontentmap::SharedContentError),
#[error("io error occurred during EmuNAND operation")]
IO(#[from] std::io::Error),
}
#[derive(Debug)]
/// A structure that represents titles installed to an EmuNAND. The title_type is the Title ID high,
/// which is the type of the titles the structure represents, and titles contains a Vec of Title ID
/// lows that represent each title installed in the given type.
pub struct InstalledTitles {
pub title_type: String,
pub titles: Vec<String>,
}
fn safe_create_dir(dir: &PathBuf) -> Result<(), EmuNANDError> {
if !dir.exists() {
fs::create_dir(dir)?;
} else if !dir.is_dir() {
return Err(EmuNANDError::DirectoryNameConflict(dir.to_str().unwrap().to_string()));
}
Ok(())
}
/// An EmuNAND object that allows for creating and modifying Wii EmuNANDs.
pub struct EmuNAND {
emunand_dirs: HashMap<String, PathBuf>,
}
impl EmuNAND {
/// Open an existing EmuNAND in an EmuNAND instance that can be used to interact with it. This
/// will initialize the basic directory structure if it doesn't already exist, but will not do
/// anything beyond that.
pub fn open(emunand_root: PathBuf) -> Result<Self, EmuNANDError> {
if !emunand_root.exists() {
return Err(EmuNANDError::RootNotFound);
}
let mut emunand_dirs: HashMap<String, PathBuf> = HashMap::new();
emunand_dirs.insert(String::from("root"), emunand_root.clone());
emunand_dirs.insert(String::from("import"), emunand_root.join("import"));
emunand_dirs.insert(String::from("meta"), emunand_root.join("meta"));
emunand_dirs.insert(String::from("shared1"), emunand_root.join("shared1"));
emunand_dirs.insert(String::from("shared2"), emunand_root.join("shared2"));
emunand_dirs.insert(String::from("sys"), emunand_root.join("sys"));
emunand_dirs.insert(String::from("ticket"), emunand_root.join("ticket"));
emunand_dirs.insert(String::from("title"), emunand_root.join("title"));
emunand_dirs.insert(String::from("tmp"), emunand_root.join("tmp"));
emunand_dirs.insert(String::from("wfs"), emunand_root.join("wfs"));
for dir in emunand_dirs.keys() {
if !emunand_dirs[dir].exists() {
fs::create_dir(&emunand_dirs[dir])?;
} else if !emunand_dirs[dir].is_dir() {
return Err(EmuNANDError::DirectoryNameConflict(emunand_dirs[dir].to_str().unwrap().to_string()));
}
}
Ok(EmuNAND {
emunand_dirs,
})
}
/// Gets the path to a directory in the root of an EmuNAND, if it's a valid directory.
pub fn get_emunand_dir(&self, dir: &str) -> Option<&PathBuf> {
self.emunand_dirs.get(dir)
}
/// Scans titles installed to an EmuNAND and returns a Vec of InstalledTitles instances.
pub fn get_installed_titles(&self) -> Vec<InstalledTitles> {
// Scan TID highs in /title/ first.
let tid_highs: Vec<PathBuf> = glob(&format!("{}/*", self.emunand_dirs["title"].display()))
.unwrap().filter_map(|f| f.ok()).collect();
// Iterate over the TID lows in each TID high, and save every title where
// /title/<tid_high>/<tid_low>/title.tmd exists.
let mut installed_titles: Vec<InstalledTitles> = Vec::new();
for high in tid_highs {
if high.is_dir() {
let tid_lows: Vec<PathBuf> = glob(&format!("{}/*", high.display()))
.unwrap().filter_map(|f| f.ok()).collect();
let mut valid_lows: Vec<String> = Vec::new();
for low in tid_lows {
if low.join("content").join("title.tmd").exists() {
valid_lows.push(low.file_name().unwrap().to_str().unwrap().to_string().to_ascii_uppercase());
}
}
installed_titles.push(InstalledTitles {
title_type: high.file_name().unwrap().to_str().unwrap().to_string().to_ascii_uppercase(),
titles: valid_lows,
})
}
}
installed_titles
}
/// Get the Ticket for a title installed to an EmuNAND. Returns a Ticket instance if a Ticket
/// with the specified Title ID can be found, or None if not.
pub fn get_title_ticket(&self, tid: [u8; 8]) -> Option<ticket::Ticket> {
let ticket_path = self.emunand_dirs["title"]
.join(hex::encode(&tid[0..4]))
.join(format!("{}.tik", hex::encode(&tid[4..8])));
if ticket_path.exists() {
match fs::read(&ticket_path) {
Ok(content) => {
ticket::Ticket::from_bytes(&content).ok()
},
Err(_) => None,
}
} else {
None
}
}
/// Get the TMD for a title installed to an EmuNAND. Returns a Ticket instance if a TMD with the
/// specified Title ID can be found, or None if not.
pub fn get_title_tmd(&self, tid: [u8; 8]) -> Option<tmd::TMD> {
let tmd_path = self.emunand_dirs["title"]
.join(hex::encode(&tid[0..4]))
.join(hex::encode(&tid[4..8]).to_ascii_lowercase())
.join("content")
.join("title.tmd");
if tmd_path.exists() {
match fs::read(&tmd_path) {
Ok(content) => {
tmd::TMD::from_bytes(&content).ok()
},
Err(_) => None,
}
} else {
None
}
}
/// Install the provided title to an EmuNAND, mimicking a WAD installation performed by ES. The
/// "override meta" option will install the content at index 0 as title.met, instead of any
/// actual meta/footer data contained in the title.
pub fn install_title(&self, title: title::Title, override_meta: bool) -> Result<(), EmuNANDError> {
// Save the two halves of the TID, since those are part of the installation path.
let tid_high = hex::encode(&title.tmd().title_id()[0..4]);
let tid_low = hex::encode(&title.tmd().title_id()[4..8]);
// Tickets are installed to /ticket/<tid_high>/<tid_low>.tik.
let ticket_dir = self.emunand_dirs["ticket"].join(&tid_high);
safe_create_dir(&ticket_dir)?;
fs::write(ticket_dir.join(format!("{}.tik", &tid_low)), title.ticket().to_bytes()?)?;
// TMDs and normal content (non-shared) are installed to
// /title/<tid_high>/<tid_low>/content/, as title.tmd and <cid>.app.
let mut title_dir = self.emunand_dirs["title"].join(&tid_high);
safe_create_dir(&title_dir)?;
title_dir = title_dir.join(&tid_low);
safe_create_dir(&title_dir)?;
// Create an empty "data" dir if it doesn't exist.
safe_create_dir(&title_dir.join("data"))?;
title_dir = title_dir.join("content");
// Delete any existing installed content/the current TMD.
if title_dir.exists() {
fs::remove_dir_all(&title_dir)?;
}
fs::create_dir(&title_dir)?;
fs::write(title_dir.join("title.tmd"), title.tmd().to_bytes()?)?;
for i in 0..title.tmd().content_records().len() {
if matches!(title.tmd().content_records()[i].content_type, tmd::ContentType::Normal) {
let content_path = title_dir.join(format!("{:08X}.app", title.tmd().content_records()[i].content_id).to_ascii_lowercase());
fs::write(content_path, title.get_content_by_index(i)?)?;
}
}
// Shared content needs to be installed to /shared1/, with incremental names decided by
// the records in /shared1/content.map.
// Start by checking for a map and loading it if it exists, so that we know what shared
// content is already installed.
let content_map_path = self.emunand_dirs["shared1"].join("content.map");
let mut content_map = if content_map_path.exists() {
sharedcontentmap::SharedContentMap::from_bytes(&fs::read(&content_map_path)?)?
} else {
sharedcontentmap::SharedContentMap::new()
};
for i in 0..title.tmd().content_records().len() {
if matches!(title.tmd().content_records()[i].content_type, tmd::ContentType::Shared) {
if let Some(file_name) = content_map.add(&title.tmd().content_records()[i].content_hash)? {
let content_path = self.emunand_dirs["shared1"].join(format!("{}.app", file_name.to_ascii_lowercase()));
fs::write(content_path, title.get_content_by_index(i)?)?;
}
}
}
fs::write(&content_map_path, content_map.to_bytes()?)?;
// The "footer" (officially "meta") is installed to /meta/<tid_high>/<tid_low>/title.met.
// The "override meta" option installs the content at index 0 to title.met instead, as that
// content contains the banner, and that's what title.met is meant to hold.
let meta_data = if override_meta {
title.get_content_by_index(0)?
} else {
title.meta()
};
if !meta_data.is_empty() {
let mut meta_dir = self.emunand_dirs["meta"].join(&tid_high);
safe_create_dir(&meta_dir)?;
meta_dir = meta_dir.join(&tid_low);
safe_create_dir(&meta_dir)?;
fs::write(meta_dir.join("title.met"), meta_data)?;
}
// Finally, we need to update uid.sys (or create it if it doesn't exist) so that the newly
// installed title will actually show up (at least for channels).
let uid_sys_path = self.emunand_dirs["sys"].join("uid.sys");
let mut uid_sys = if uid_sys_path.exists() {
sys::UidSys::from_bytes(&fs::read(&uid_sys_path)?)?
} else {
sys::UidSys::new()
};
uid_sys.add(&title.tmd().title_id())?;
fs::write(&uid_sys_path, &uid_sys.to_bytes()?)?;
Ok(())
}
/// Uninstall a title with the provided Title ID from an EmuNAND. By default, the Ticket will be
/// left intact unlesss "remove ticket" is set to true.
pub fn uninstall_title(&self, tid: [u8; 8], remove_ticket: bool) -> Result<(), EmuNANDError> {
// Save the two halves of the TID, since those are part of the installation path.
let tid_high = hex::encode(&tid[0..4]);
let tid_low = hex::encode(&tid[4..8]);
// Ensure that a title directory actually exists for the specified title. If it does, then
// delete it.
let title_dir = self.emunand_dirs["title"].join(&tid_high).join(&tid_low);
if !title_dir.exists() {
return Err(EmuNANDError::TitleNotInstalled);
}
fs::remove_dir_all(&title_dir)?;
// If we've been told to delete the Ticket, check if it exists and then do so.
if remove_ticket {
let ticket_path = self.emunand_dirs["ticket"].join(&tid_high).join(format!("{}.tik", &tid_low));
if ticket_path.exists() {
fs::remove_file(&ticket_path)?;
}
}
Ok(())
}
}

9
src/nand/mod.rs Normal file
View File

@@ -0,0 +1,9 @@
// nand/mod.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Root for all NAND-related modules.
pub mod emunand;
pub mod setting;
pub mod sys;
pub mod sharedcontentmap;

100
src/nand/setting.rs Normal file
View File

@@ -0,0 +1,100 @@
// nand/setting.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Implements the structures and methods required for parsing and editing setting.txt in the Wii
// Menu's data.
use std::collections::HashMap;
use std::io::Cursor;
use byteorder::ReadBytesExt;
const SETTINGS_KEY: u32 = 0x73B5DBFA;
/// A structure that allows for encrypting, decrypting, parsing, and editing a setting.txt file.
pub struct SettingTxt {
pub area: String,
pub model: String,
pub dvd: u8,
pub mpch: String,
pub code: String,
pub serial_number: String,
pub video: String,
pub game: String,
}
impl SettingTxt {
/// Creates a new SettingTxt instance from the binary data of an encrypted setting.txt file.
pub fn from_bytes(data: &[u8]) -> Result<Self, std::io::Error> {
// Unlike most files we have to deal with, setting.txt is encrypted. This means we need to
// decrypt it first, and *then* we can parse it.
let mut buf = Cursor::new(data);
let mut key: u32 = SETTINGS_KEY;
let mut dec_data: Vec<u8> = Vec::new();
for _ in 0..256 {
dec_data.push(buf.read_u8()? ^ (key & 0xFF) as u8);
key = key.rotate_left(1); // Automatic bit rotation!? Thanks for the tip clippy!
}
let setting_str = String::from_utf8_lossy(&dec_data);
let setting_str = setting_str[0..setting_str.clone().rfind('\n').unwrap_or(setting_str.len() - 2) + 1].to_string();
let setting_txt = SettingTxt::from_string(setting_str)?;
Ok(setting_txt)
}
/// Creates a new SettingTxt instance from the decrypted text of a setting.txt file.
pub fn from_string(data: String) -> Result<Self, std::io::Error> {
let mut setting_keys: HashMap<String, String> = HashMap::new();
for line in data.lines() {
let (key, value) = line.split_once("=").unwrap();
setting_keys.insert(key.to_owned(), value.to_owned());
}
let area = setting_keys["AREA"].to_string();
let model = setting_keys["MODEL"].to_string();
let dvd = setting_keys["DVD"].as_str().parse::<u8>().unwrap();
let mpch = setting_keys["MPCH"].to_string();
let code = setting_keys["CODE"].to_string();
let serial_number = setting_keys["SERNO"].to_string();
let video = setting_keys["VIDEO"].to_string();
let game = setting_keys["GAME"].to_string();
Ok(SettingTxt {
area,
model,
dvd,
mpch,
code,
serial_number,
video,
game,
})
}
/// Encrypts and then dumps the data in a SettingTxt instance back into binary data that can be
/// written to a file.
pub fn to_bytes(&self) -> Result<Vec<u8>, std::io::Error> {
let setting_str = self.to_string()?;
let setting_bytes = setting_str.as_bytes();
let mut buf = Cursor::new(setting_bytes);
let mut key: u32 = SETTINGS_KEY;
let mut enc_data: Vec<u8> = Vec::new();
for _ in 0..setting_str.len() {
enc_data.push(buf.read_u8()? ^ (key & 0xFF) as u8);
key = key.rotate_left(1);
}
enc_data.resize(256, 0);
Ok(enc_data)
}
/// Dumps the decrypted data in a SettingTxt instance into a string that can be written to a
/// file.
pub fn to_string(&self) -> Result<String, std::io::Error> {
let mut setting_str = String::new();
setting_str += &format!("AREA={}\r\n", self.area);
setting_str += &format!("MODEL={}\r\n", self.model);
setting_str += &format!("DVD={}\r\n", self.dvd);
setting_str += &format!("MPCH={}\r\n", self.mpch);
setting_str += &format!("CODE={}\r\n", self.code);
setting_str += &format!("SERNO={}\r\n", self.serial_number);
setting_str += &format!("VIDEO={}\r\n", self.video);
setting_str += &format!("GAME={}\r\n", self.game);
Ok(setting_str)
}
}

View File

@@ -0,0 +1,104 @@
// nand/sharedcontentmap.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Implements shared content map parsing and editing to update the records of what content is
// installed at /shared1/ on NAND.
use std::io::{Cursor, Read, Write};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum SharedContentError {
#[error("content.map is an invalid length and cannot be parsed")]
InvalidSharedContentMapLength,
#[error("found invalid shared content name `{0}`")]
InvalidSharedContentName(String),
#[error("shared content map is not in a valid format")]
IO(#[from] std::io::Error),
}
#[derive(Debug)]
/// A structure that represents a shared Content ID/content hash pairing in a content.map file.
pub struct ContentMapEntry {
pub shared_id: u32,
pub hash: [u8; 20],
}
/// A structure that allows for parsing and editing a /shared1/content.map file.
pub struct SharedContentMap {
pub records: Vec<ContentMapEntry>,
}
impl Default for SharedContentMap {
fn default() -> Self {
Self::new()
}
}
impl SharedContentMap {
/// Creates a new SharedContentMap instance from the binary data of a content.map file.
pub fn from_bytes(data: &[u8]) -> Result<SharedContentMap, SharedContentError> {
// The uid.sys file must be divisible by a multiple of 28, or something is wrong, since each
// entry is 28 bytes long.
if !data.len().is_multiple_of(28) {
return Err(SharedContentError::InvalidSharedContentMapLength);
}
let record_count = data.len() / 28;
let mut buf = Cursor::new(data);
let mut records: Vec<ContentMapEntry> = Vec::new();
for _ in 0..record_count {
// This requires some convoluted parsing, because Nintendo represents the file names as
// actual chars and not numbers, despite the fact that the names are always numbers and
// using numbers would make incrementing easier. Read the names in as a string, and then
// parse that hex string into a u32.
let mut shared_id_bytes = [0u8; 8];
buf.read_exact(&mut shared_id_bytes)?;
let shared_id_str = String::from_utf8_lossy(&shared_id_bytes);
let shared_id = match u32::from_str_radix(&shared_id_str, 16) {
Ok(id) => id,
Err(_) => return Err(SharedContentError::InvalidSharedContentName(shared_id_str.to_string())),
};
let mut hash = [0u8; 20];
buf.read_exact(&mut hash)?;
records.push(ContentMapEntry { shared_id, hash });
}
Ok(SharedContentMap { records })
}
/// Creates a new, empty SharedContentMap instance that can then be populated.
pub fn new() -> Self {
SharedContentMap { records: Vec::new() }
}
/// Dumps the data in a SharedContentMap back into binary data that can be written to a file.
pub fn to_bytes(&self) -> Result<Vec<u8>, std::io::Error> {
let mut buf: Vec<u8> = Vec::new();
for record in self.records.iter() {
let shared_id = format!("{:08X}", record.shared_id).to_ascii_lowercase();
buf.write_all(shared_id.as_bytes())?;
buf.write_all(&record.hash)?;
}
Ok(buf)
}
/// Adds new shared content to content.map, and assigns it a new file name. The new content
/// will only be added if its hash is not already present in the file. Returns None if the
/// content hash was already present, or the assigned file name if the hash was just added.
pub fn add(&mut self, hash: &[u8; 20]) -> Result<Option<String>, SharedContentError> {
// Return None if the hash is already accounted for.
if self.records.iter().any(|entry| entry.hash == *hash) {
return Ok(None);
}
// Find the highest index (represented by the file name) and increment it to choose the
// name for the new shared content.
let max_index = self.records.iter()
.max_by_key(|record| record.shared_id)
.map(|record| record.shared_id + 1)
.unwrap_or(0);
self.records.push(ContentMapEntry {
shared_id: max_index,
hash: *hash,
});
Ok(Some(format!("{:08X}", max_index)))
}
}

93
src/nand/sys.rs Normal file
View File

@@ -0,0 +1,93 @@
// nand/sys.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Implements the structures and methods required for parsing and editing files in /sys/ on the
// Wii's NAND.
use std::io::{Cursor, Read, Write};
use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum UidSysError {
#[error("uid.sys is an invalid length and cannot be parsed")]
InvalidUidSysLength,
#[error("uid.sys data is not in a valid format")]
IO(#[from] std::io::Error),
}
/// A structure that represents a Title ID/UID pairing in a uid.sys file.
pub struct UidSysEntry {
pub title_id: [u8; 8],
pub uid: u32,
}
/// A structure that allows for creating, parsing, and editing a /sys/uid.sys file.
pub struct UidSys {
entries: Vec<UidSysEntry>,
}
impl Default for UidSys {
fn default() -> Self {
Self::new()
}
}
impl UidSys {
/// Creates a new UidSys instance from the binary data of a uid.sys file.
pub fn from_bytes(data: &[u8]) -> Result<Self, UidSysError> {
// The uid.sys file must be divisible by a multiple of 12, or something is wrong, since each
// entry is 12 bytes long.
if !data.len().is_multiple_of(12) {
return Err(UidSysError::InvalidUidSysLength);
}
let entry_count = data.len() / 12;
let mut buf = Cursor::new(data);
let mut entries: Vec<UidSysEntry> = Vec::new();
for _ in 0..entry_count {
let mut title_id = [0u8; 8];
buf.read_exact(&mut title_id)?;
let uid = buf.read_u32::<BigEndian>()?;
entries.push(UidSysEntry { title_id, uid });
}
Ok(UidSys { entries })
}
/// Creates a new UidSys instance and initializes it with the default entry of the Wii Menu
/// (0000000100000002) with UID 0x1000.
pub fn new() -> Self {
let mut uid_sys = UidSys { entries: Vec::new() };
uid_sys.add(&[0x0, 0x0, 0x0, 0x1, 0x0, 0x0, 0x0, 0x2]).unwrap();
uid_sys
}
/// Dumps the data in a UidSys back into binary data that can be written to a file.
pub fn to_bytes(&self) -> Result<Vec<u8>, UidSysError> {
let mut buf: Vec<u8> = Vec::new();
for entry in self.entries.iter() {
buf.write_all(&entry.title_id)?;
buf.write_u32::<BigEndian>(entry.uid)?;
}
Ok(buf)
}
/// Adds a new Title ID to uid.sys, and assigns it a new UID. The new Title ID will only be
/// added if it is not already present in the file. Returns None if the Title ID was already
/// present, or the newly assigned UID if the Title ID was just added.
pub fn add(&mut self, title_id: &[u8; 8]) -> Result<Option<u32>, UidSysError> {
// Return None if the Title ID is already accounted for.
if self.entries.iter().any(|entry| entry.title_id == *title_id) {
return Ok(None);
}
// Find the highest UID and increment it to choose the UID for the new Title ID.
let max_uid = self.entries.iter()
.max_by_key(|entry| entry.uid)
.map(|entry| entry.uid)
.unwrap_or(4095);
self.entries.push(UidSysEntry {
title_id: *title_id,
uid: max_uid + 1,
});
Ok(Some(max_uid + 1))
}
}

View File

@@ -1,5 +1,5 @@
// title/cert.rs from rustii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustii
// title/cert.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Implements the structures and methods required for validating the signatures of Wii titles.
@@ -37,8 +37,8 @@ pub enum CertificateKeyType {
ECC
}
#[derive(Debug, Clone)]
/// A structure that represents the components of a Wii signing certificate.
#[derive(Debug, Clone)]
pub struct Certificate {
signer_key_type: CertificateKeyType,
signature: Vec<u8>,
@@ -165,8 +165,8 @@ impl Certificate {
}
}
#[derive(Debug)]
/// A structure that represents the components of the Wii's signing certificate chain.
#[derive(Debug)]
pub struct CertificateChain {
ca_cert: Certificate,
tmd_cert: Certificate,
@@ -346,7 +346,7 @@ pub fn verify_tmd(tmd_cert: &Certificate, tmd: &tmd::TMD) -> Result<bool, Certif
let public_key_modulus = BigUint::from_bytes_be(&tmd_cert.pub_key_modulus());
let public_key_exponent = BigUint::from(tmd_cert.pub_key_exponent());
let root_key = RsaPublicKey::new(public_key_modulus, public_key_exponent).unwrap();
match root_key.verify(Pkcs1v15Sign::new::<Sha1>(), &tmd_hash, tmd.signature.as_slice()) {
match root_key.verify(Pkcs1v15Sign::new::<Sha1>(), &tmd_hash, tmd.signature().as_slice()) {
Ok(_) => Ok(true),
Err(_) => Ok(false),
}
@@ -368,7 +368,7 @@ pub fn verify_ticket(ticket_cert: &Certificate, ticket: &ticket::Ticket) -> Resu
let public_key_modulus = BigUint::from_bytes_be(&ticket_cert.pub_key_modulus());
let public_key_exponent = BigUint::from(ticket_cert.pub_key_exponent());
let root_key = RsaPublicKey::new(public_key_modulus, public_key_exponent).unwrap();
match root_key.verify(Pkcs1v15Sign::new::<Sha1>(), &ticket_hash, ticket.signature.as_slice()) {
match root_key.verify(Pkcs1v15Sign::new::<Sha1>(), &ticket_hash, ticket.signature().as_slice()) {
Ok(_) => Ok(true),
Err(_) => Ok(false),
}

View File

@@ -1,5 +1,5 @@
// title/commonkeys.rs from rustii-lib (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustii
// title/commonkeys.rs from rustwii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
const COMMON_KEY: &str = "ebe42a225e8593e448d9c5457381aaf7";
const KOREAN_KEY: &str = "63b82bb4f4614e2e13f2fefbba4c9b7e";

View File

@@ -1,191 +0,0 @@
// title/content.rs from rustii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustii
//
// Implements content parsing and editing.
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;
#[derive(Debug, Error)]
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})")]
BadHash { hash: String, expected: String },
#[error("content data is not in a valid format")]
IO(#[from] std::io::Error),
}
#[derive(Debug)]
/// A structure that represents the block of data containing the content of a digital Wii title.
pub struct ContentRegion {
pub content_records: Vec<ContentRecord>,
pub content_region_size: u32,
pub num_contents: u16,
pub content_start_offsets: Vec<u64>,
pub contents: Vec<Vec<u8>>,
}
impl ContentRegion {
/// Creates a ContentRegion instance that can be used to parse and edit content stored in a
/// digital Wii title from the content area of a WAD and the ContentRecords from a TMD.
pub fn from_bytes(data: &[u8], content_records: Vec<ContentRecord>) -> Result<Self, ContentError> {
let content_region_size = data.len() as u32;
let num_contents = content_records.len() as u16;
// Calculate the starting offsets of each content.
let content_start_offsets: Vec<u64> = std::iter::once(0)
.chain(content_records.iter().scan(0, |offset, record| {
*offset += record.content_size;
if record.content_size % 64 != 0 {
*offset += 64 - (record.content_size % 64);
}
Some(*offset)
})).take(content_records.len()).collect(); // Trims the extra final entry.
let total_content_size: u64 = content_records.iter().map(|x| (x.content_size + 63) & !63).sum();
// Parse the content blob and create a vector of vectors from it.
// Check that the content blob matches the total size of all the contents in the records.
if content_region_size != total_content_size as u32 {
println!("Content region size mismatch.");
//return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "Invalid content blob for content records"));
}
let mut contents: Vec<Vec<u8>> = Vec::with_capacity(num_contents as usize);
let mut buf = Cursor::new(data);
for i in 0..num_contents {
buf.seek(SeekFrom::Start(content_start_offsets[i as usize]))?;
let size = (content_records[i as usize].content_size + 15) & !15;
let mut content = vec![0u8; size as usize];
buf.read_exact(&mut content)?;
contents.push(content);
}
Ok(ContentRegion {
content_records,
content_region_size,
num_contents,
content_start_offsets,
contents,
})
}
/// 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![0; num_contents as usize];
let contents: Vec<Vec<u8>> = vec![Vec::new(); num_contents as usize];
Ok(ContentRegion {
content_records,
content_region_size,
num_contents,
content_start_offsets,
contents,
})
}
/// Dumps the entire ContentRegion back into binary data that can be written to a file.
pub fn to_bytes(&self) -> Result<Vec<u8>, std::io::Error> {
let mut buf: Vec<u8> = Vec::new();
for i in 0..self.num_contents {
let mut content = self.contents[i as usize].clone();
// Round up size to nearest 64 to add appropriate padding.
content.resize((content.len() + 63) & !63, 0);
buf.write_all(&content)?;
}
Ok(buf)
}
/// Gets the encrypted content file from the ContentRegion at the specified index.
pub fn get_enc_content_by_index(&self, index: usize) -> Result<Vec<u8>, ContentError> {
let content = self.contents.get(index).ok_or(ContentError::IndexOutOfRange { index, max: self.content_records.len() - 1 })?;
Ok(content.clone())
}
/// Gets the decrypted content file from the ContentRegion at the specified index.
pub fn get_content_by_index(&self, index: usize, title_key: [u8; 16]) -> Result<Vec<u8>, ContentError> {
let content = self.get_enc_content_by_index(index)?;
// Verify the hash of the decrypted content against its record.
let mut content_dec = crypto::decrypt_content(&content, title_key, self.content_records[index].index);
content_dec.resize(self.content_records[index].content_size as usize, 0);
let mut hasher = Sha1::new();
hasher.update(content_dec.clone());
let result = hasher.finalize();
if result[..] != self.content_records[index].content_hash {
return Err(ContentError::BadHash { hash: hex::encode(result), expected: hex::encode(self.content_records[index].content_hash) });
}
Ok(content_dec)
}
/// Gets the encrypted content file from the ContentRegion with the specified Content ID.
pub fn get_enc_content_by_cid(&self, cid: u32) -> Result<Vec<u8>, ContentError> {
let index = self.content_records.iter().position(|x| x.content_id == cid);
if let Some(index) = index {
let content = self.get_enc_content_by_index(index).map_err(|_| ContentError::CIDNotFound(cid))?;
Ok(content)
} else {
Err(ContentError::CIDNotFound(cid))
}
}
/// Gets the decrypted content file from the ContentRegion with the specified Content ID.
pub fn get_content_by_cid(&self, cid: u32, title_key: [u8; 16]) -> Result<Vec<u8>, ContentError> {
let index = self.content_records.iter().position(|x| x.content_id == cid);
if let Some(index) = index {
let content_dec = self.get_content_by_index(index, title_key)?;
Ok(content_dec)
} else {
Err(ContentError::CIDNotFound(cid))
}
}
/// 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.
pub fn load_content(&mut self, content: &[u8], index: usize, title_key: [u8; 16]) -> Result<(), ContentError> {
if index >= self.content_records.len() {
return Err(ContentError::IndexOutOfRange { index, max: self.content_records.len() - 1 });
}
// Hash the content we're trying to load to ensure it matches the hash expected in the
// matching record.
let mut hasher = Sha1::new();
hasher.update(content);
let result = hasher.finalize();
if result[..] != self.content_records[index].content_hash {
return Err(ContentError::BadHash { hash: hex::encode(result), expected: hex::encode(self.content_records[index].content_hash) });
}
let content_enc = crypto::encrypt_content(content, title_key, self.content_records[index].index, self.content_records[index].content_size);
self.contents[index] = content_enc;
Ok(())
}
}

View File

@@ -1,5 +1,5 @@
// title/crypto.rs from rustii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustii
// title/crypto.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Implements the common crypto functions required to handle Wii content encryption.

133
src/title/iospatcher.rs Normal file
View File

@@ -0,0 +1,133 @@
// title/iospatcher.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Module for applying patches to IOSes using a Title.
use std::io::{Cursor, Seek, SeekFrom, Write};
use thiserror::Error;
use crate::title;
#[derive(Debug, Error)]
pub enum IOSPatcherError {
#[error("this title is not an IOS")]
NotIOS,
#[error("the required module \"{0}\" could not be found, this may not be a valid IOS")]
ModuleNotFound(String),
#[error("failed to set content in Title")]
Title(#[from] title::TitleError),
#[error("IOS content is invalid")]
IO(#[from] std::io::Error),
}
pub fn ios_find_module(module_keyword: String, ios: &title::Title) -> Result<usize, IOSPatcherError> {
let content_records = ios.tmd.content_records();
let tid = hex::encode(ios.tmd.title_id());
// If the TID is not a valid IOS TID, then return NotIOS. It's possible that this could catch
// some modified IOSes that currently have a non-IOS TID but that's weird and if you're doing
// that please stop.
if !tid[..8].eq("00000001") || tid[8..].eq("00000001") || tid[8..].eq("00000002") {
return Err(IOSPatcherError::NotIOS);
}
// Find the module's keyword in the content, and return the (true) index of the content that
// it was found in.
let keyword = module_keyword.as_bytes();
for record in content_records {
let content_decrypted = ios.get_content_by_index(record.index as usize)?;
let offset = content_decrypted
.windows(keyword.len())
.position(|window| window == keyword);
if offset.is_some() {
return Ok(record.index as usize);
}
}
// If we didn't return early by finding the offset, then return a ModuleNotFound error.
Err(IOSPatcherError::ModuleNotFound(module_keyword))
}
fn ios_apply_patches(
target_content: &mut Cursor<Vec<u8>>,
find_seq: Vec<Vec<u8>>,
replace_seq: Vec<Vec<u8>>
) -> Result<i32, IOSPatcherError> {
let mut patch_count = 0;
for idx in 0..find_seq.len() {
let offset = target_content.get_ref()
.windows(find_seq[idx].len())
.position(|window| window == find_seq[idx]);
if let Some(offset) = offset {
target_content.seek(SeekFrom::Start(offset as u64))?;
target_content.write_all(&replace_seq[idx])?;
patch_count += 1;
}
}
Ok(patch_count)
}
pub fn ios_patch_sigchecks(ios: &mut title::Title, es_index: usize) -> Result<i32, IOSPatcherError> {
let target_content = ios.get_content_by_index(es_index)?;
let mut buf = Cursor::new(target_content);
let find_seq = vec![vec![0x20, 0x07, 0x23, 0xa2], vec![0x20, 0x07, 0x4b, 0x0b]];
let replace_seq: Vec<Vec<u8>> = vec![vec![0x20, 0x00, 0x23, 0xa2], vec![0x20, 0x00, 0x4b, 0x0b]];
let patch_count = ios_apply_patches(&mut buf, find_seq, replace_seq)?;
ios.set_content(buf.get_ref(), es_index, None, None)?;
Ok(patch_count)
}
pub fn ios_patch_es_identify(ios: &mut title::Title, es_index: usize) -> Result<i32, IOSPatcherError> {
let target_content = ios.get_content_by_index(es_index)?;
let mut buf = Cursor::new(target_content);
let find_seq = vec![vec![0x28, 0x03, 0xd1, 0x23]];
let replace_seq = vec![vec![0x28, 0x03, 0x00, 0x00]];
let patch_count = ios_apply_patches(&mut buf, find_seq, replace_seq)?;
ios.set_content(buf.get_ref(), es_index, None, None)?;
Ok(patch_count)
}
pub fn ios_patch_dev_flash(ios: &mut title::Title, es_index: usize) -> Result<i32, IOSPatcherError> {
let target_content = ios.get_content_by_index(es_index)?;
let mut buf = Cursor::new(target_content);
let find_seq = vec![vec![0x42, 0x8b, 0xd0, 0x01, 0x25, 0x66]];
let replace_seq = vec![vec![0x42, 0x8b, 0xe0, 0x01, 0x25, 0x66]];
let patch_count = ios_apply_patches(&mut buf, find_seq, replace_seq)?;
ios.set_content(buf.get_ref(), es_index, None, None)?;
Ok(patch_count)
}
pub fn ios_patch_allow_downgrade(ios: &mut title::Title, es_index: usize) -> Result<i32, IOSPatcherError> {
let target_content = ios.get_content_by_index(es_index)?;
let mut buf = Cursor::new(target_content);
let find_seq = vec![vec![0xd2, 0x01, 0x4e, 0x56]];
let replace_seq = vec![vec![0xe0, 0x01, 0x4e, 0x56]];
let patch_count = ios_apply_patches(&mut buf, find_seq, replace_seq)?;
ios.set_content(buf.get_ref(), es_index, None, None)?;
Ok(patch_count)
}
pub fn ios_patch_drive_inquiry(ios: &mut title::Title, dip_index: usize) -> Result<i32, IOSPatcherError> {
let target_content = ios.get_content_by_index(dip_index)?;
let mut buf = Cursor::new(target_content);
let find_seq = vec![vec![0x49, 0x4c, 0x23, 0x90, 0x68, 0x0a]];
let replace_seq = vec![vec![0x20, 0x00, 0xe5, 0x38, 0x68, 0x0a]];
let patch_count = ios_apply_patches(&mut buf, find_seq, replace_seq)?;
ios.set_content(buf.get_ref(), dip_index, None, None)?;
Ok(patch_count)
}

View File

@@ -1,18 +1,20 @@
// title/mod.rs from rustii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustii
// title/mod.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Root for all title-related modules and implementation of the high-level Title object.
pub mod cert;
pub mod commonkeys;
pub mod content;
pub mod crypto;
pub mod iospatcher;
pub mod nus;
pub mod ticket;
pub mod tmd;
pub mod versions;
pub mod wad;
use std::io::{Cursor, Read, Seek, SeekFrom, Write};
use sha1::{Sha1, Digest};
use thiserror::Error;
#[derive(Debug, Error)]
@@ -27,22 +29,33 @@ pub enum TitleError {
TMD(#[from] tmd::TMDError),
#[error("Ticket processing error")]
Ticket(#[from] ticket::TicketError),
#[error("content processing error")]
Content(#[from] content::ContentError),
#[error("WAD processing error")]
WAD(#[from] wad::WADError),
#[error("WAD data is not in a valid format")]
IO(#[from] std::io::Error),
// Content-specific (not generic or inherited from another struct's errors).
#[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("the specified index {0} already exists in the content records")]
IndexAlreadyExists(u16),
#[error("the specified Content ID {0} already exists in the content records")]
CIDAlreadyExists(u32),
#[error("content's hash did not match the expected value (was {hash}, expected {expected})")]
BadHash { hash: String, expected: String },
}
#[derive(Debug)]
/// A structure that represents the components of a digital Wii title.
pub struct Title {
pub cert_chain: cert::CertificateChain,
cert_chain: cert::CertificateChain,
crl: Vec<u8>,
pub ticket: ticket::Ticket,
pub tmd: tmd::TMD,
pub content: content::ContentRegion,
ticket: ticket::Ticket,
tmd: tmd::TMD,
content: Vec<Vec<u8>>,
meta: Vec<u8>
}
@@ -52,7 +65,7 @@ impl Title {
let cert_chain = cert::CertificateChain::from_bytes(&wad.cert_chain()).map_err(TitleError::CertificateError)?;
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 content = Self::parse_content_region(wad.content(), tmd.content_records())?;
Ok(Title {
cert_chain,
crl: wad.crl(),
@@ -64,8 +77,18 @@ impl Title {
}
/// 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> {
pub fn from_parts_with_content(
cert_chain: cert::CertificateChain,
crl: Option<&[u8]>,
ticket: ticket::Ticket,
tmd: tmd::TMD,
content: Vec<Vec<u8>>,
meta: Option<&[u8]>
) -> Result<Title, TitleError> {
// Validate the provided content.
if content.len() != tmd.content_records().len() {
return Err(TitleError::MissingContents { required: tmd.content_records().len(), found: content.len()});
}
// 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 {
@@ -85,16 +108,68 @@ impl Title {
meta
})
}
/// Creates a new Title instance from all of its individual components. Content is expected to
/// be added to the title once created.
pub fn from_parts(
cert_chain: cert::CertificateChain,
crl: Option<&[u8]>,
ticket: ticket::Ticket,
tmd: tmd::TMD,
meta: Option<&[u8]>
) -> Result<Title, TitleError> {
let content: Vec<Vec<u8>> = vec![vec![]; tmd.content_records().len()];
Self::from_parts_with_content(
cert_chain,
crl,
ticket,
tmd,
content,
meta
)
}
fn parse_content_region(content_data: Vec<u8>, content_records: &[tmd::ContentRecord]) -> Result<Vec<Vec<u8>>, TitleError> {
let num_contents = content_records.len();
// Calculate the starting offsets of each content.
let content_start_offsets: Vec<u64> = std::iter::once(0)
.chain(content_records.iter().scan(0, |offset, record| {
*offset += record.content_size;
if record.content_size % 64 != 0 {
*offset += 64 - (record.content_size % 64);
}
Some(*offset)
})).take(content_records.len()).collect(); // Trims the extra final entry.
// Parse the content blob and create a vector of vectors from it.
let mut contents: Vec<Vec<u8>> = Vec::with_capacity(num_contents);
let mut buf = Cursor::new(content_data);
for i in 0..num_contents {
buf.seek(SeekFrom::Start(content_start_offsets[i]))?;
let size = (content_records[i].content_size + 15) & !15;
let mut content = vec![0u8; size as usize];
buf.read_exact(&mut content)?;
contents.push(content);
}
Ok(contents)
}
/// Converts a Title instance into a WAD, which can be used to export the Title back to a file.
pub fn to_wad(&self) -> Result<wad::WAD, TitleError> {
let mut content: Vec<u8> = Vec::new();
for i in 0..self.tmd.content_records().len() {
let mut content_cur = self.content[i].clone();
// Round up size to nearest 64 to add appropriate padding.
content_cur.resize((content_cur.len() + 63) & !63, 0);
content.write_all(&content_cur)?;
}
// Create a new WAD from the data in the Title.
let wad = wad::WAD::from_parts(
&self.cert_chain,
&self.crl,
&self.ticket,
&self.tmd,
&self.content,
&content,
&self.meta
).map_err(TitleError::WAD)?;
Ok(wad)
@@ -106,6 +181,18 @@ impl Title {
let title = Title::from_wad(&wad)?;
Ok(title)
}
pub fn cert_chain(&self) -> &cert::CertificateChain {
&self.cert_chain
}
pub fn ticket(&self) -> &ticket::Ticket {
&self.ticket
}
pub fn tmd(&self) -> &tmd::TMD {
&self.tmd
}
/// Gets whether the TMD and Ticket of a Title are both fakesigned.
pub fn is_fakesigned(&self) -> bool {
@@ -119,17 +206,196 @@ impl Title {
self.ticket.fakesign().map_err(TitleError::Ticket)?;
Ok(())
}
/// Gets the encrypted content file from the ContentRegion at the specified index.
pub fn get_enc_content_by_index(&self, index: usize) -> Result<Vec<u8>, TitleError> {
let content = self.content.get(index).ok_or(
TitleError::IndexOutOfRange { index, max: self.tmd.content_records().len() - 1 }
)?;
Ok(content.clone())
}
/// Gets the decrypted content file from the Title at the specified index.
pub fn get_content_by_index(&self, index: usize) -> Result<Vec<u8>, content::ContentError> {
let content = self.content.get_content_by_index(index, self.ticket.dec_title_key())?;
Ok(content)
pub fn get_content_by_index(&self, index: usize) -> Result<Vec<u8>, TitleError> {
let content = self.get_enc_content_by_index(index)?;
// Verify the hash of the decrypted content against its record.
let mut content_dec = crypto::decrypt_content(&content, self.ticket.title_key_dec(), self.tmd.content_records()[index].index);
content_dec.resize(self.tmd.content_records()[index].content_size as usize, 0);
let mut hasher = Sha1::new();
hasher.update(content_dec.clone());
let result = hasher.finalize();
if result[..] != self.tmd.content_records()[index].content_hash {
return Err(TitleError::BadHash {
hash: hex::encode(result), expected: hex::encode(self.tmd.content_records()[index].content_hash)
});
}
Ok(content_dec)
}
/// Gets the encrypted content file from the ContentRegion with the specified Content ID.
pub fn get_enc_content_by_cid(&self, cid: u32) -> Result<Vec<u8>, TitleError> {
let index = self.tmd.content_records().iter().position(|x| x.content_id == cid);
if let Some(index) = index {
let content = self.get_enc_content_by_index(index).map_err(|_| TitleError::CIDNotFound(cid))?;
Ok(content)
} else {
Err(TitleError::CIDNotFound(cid))
}
}
/// Gets the decrypted content file from the Title with the specified Content ID.
pub fn get_content_by_cid(&self, cid: u32) -> Result<Vec<u8>, content::ContentError> {
let content = self.content.get_content_by_cid(cid, self.ticket.dec_title_key())?;
Ok(content)
pub fn get_content_by_cid(&self, cid: u32) -> Result<Vec<u8>, TitleError> {
let index = self.tmd.content_records().iter().position(|x| x.content_id == cid);
if let Some(index) = index {
let content_dec = self.get_content_by_index(index)?;
Ok(content_dec)
} else {
Err(TitleError::CIDNotFound(cid))
}
}
/// 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<(), TitleError> {
if index >= self.tmd.content_records().len() {
return Err(TitleError::IndexOutOfRange { index, max: self.tmd.content_records().len() - 1 });
}
self.content[index] = content.to_vec();
Ok(())
}
/// Sets the content at the specified index to the provided encrypted content. This requires
/// the size and hash of the original decrypted content to be known so that the appropriate
/// values can be set in the corresponding content record. Optionally, a new Content ID or
/// content type can be provided, with the existing values being preserved by default.
pub fn set_enc_content(
&mut self, content: &[u8],
index: usize, content_size: u64,
content_hash: [u8; 20],
cid: Option<u32>,
content_type: Option<tmd::ContentType>
) -> Result<(), TitleError> {
if index >= self.tmd.content_records().len() {
return Err(TitleError::IndexOutOfRange { index, max: self.tmd.content_records().len() - 1 });
}
let mut content_records = self.tmd.content_records().clone();
content_records[index].content_size = content_size;
content_records[index].content_hash = content_hash;
if let Some(cid) = cid {
// Make sure that the new CID isn't already in use.
if content_records.iter().any(|record| record.content_id == cid) {
return Err(TitleError::CIDAlreadyExists(cid));
}
content_records[index].content_id = cid;
}
if let Some(content_type) = content_type {
content_records[index].content_type = content_type;
}
self.tmd.set_content_records(content_records);
self.content[index] = content.to_vec();
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.
pub fn load_content(&mut self, content: &[u8], index: usize) -> Result<(), TitleError> {
if index >= self.tmd.content_records().len() {
return Err(TitleError::IndexOutOfRange { index, max: self.tmd.content_records().len() - 1 });
}
// Hash the content we're trying to load to ensure it matches the hash expected in the
// matching record.
let mut hasher = Sha1::new();
hasher.update(content);
let result = hasher.finalize();
if result[..] != self.tmd.content_records()[index].content_hash {
return Err(TitleError::BadHash {
hash: hex::encode(result), expected: hex::encode(self.tmd.content_records()[index].content_hash)
});
}
let content_enc = crypto::encrypt_content(
content,
self.ticket.title_key_dec(),
self.tmd.content_records()[index].index,
self.tmd.content_records()[index].content_size
);
self.content[index] = content_enc;
Ok(())
}
/// Sets the content at the specified index to the provided decrypted content. This content will
/// have its size and hash saved into the matching record. Optionally, a new Content ID or
/// content type can be provided, with the existing values being preserved by default.
pub fn set_content(&mut self, content: &[u8], index: usize, cid: Option<u32>, content_type: Option<tmd::ContentType>) -> Result<(), TitleError> {
let content_size = content.len() as u64;
let mut hasher = Sha1::new();
hasher.update(content);
let content_hash: [u8; 20] = hasher.finalize().into();
let content_enc = crypto::encrypt_content(
content,
self.ticket.title_key_dec(),
index as u16,
content_size
);
self.set_enc_content(&content_enc, index, content_size, content_hash, cid, content_type)?;
Ok(())
}
/// Removes the content at the specified index from the content list and content records. This
/// may leave a gap in the indexes recorded in the content records, but this should not cause
/// issues on the Wii or with correctly implemented WAD parsers.
pub fn remove_content(&mut self, index: usize) -> Result<(), TitleError> {
if self.content.get(index).is_none() || self.tmd.content_records().get(index).is_none() {
return Err(TitleError::IndexOutOfRange { index, max: self.tmd.content_records().len() - 1 });
}
self.content.remove(index);
let mut content_records = self.tmd.content_records().clone();
content_records.remove(index);
self.tmd.set_content_records(content_records);
Ok(())
}
/// Adds new encrypted content to the end of the content list and content records. The provided
/// Content ID, type, index, and decrypted hash will be added to the record.
pub fn add_enc_content(
&mut self, content:
&[u8], index: u16,
cid: u32,
content_type: tmd::ContentType,
content_size: u64,
content_hash: [u8; 20]
) -> Result<(), TitleError> {
// Return an error if the specified index or CID already exist in the records.
if self.tmd.content_records().iter().any(|record| record.index == index) {
return Err(TitleError::IndexAlreadyExists(index));
}
if self.tmd.content_records().iter().any(|record| record.content_id == cid) {
return Err(TitleError::CIDAlreadyExists(cid));
}
self.content.push(content.to_vec());
let mut content_records = self.tmd.content_records().clone();
content_records.push(tmd::ContentRecord { content_id: cid, index, content_type, content_size, content_hash });
self.tmd.set_content_records(content_records);
Ok(())
}
/// Adds new decrypted content to the end of the content list and content records. The provided
/// Content ID and type will be added to the record alongside a hash of the decrypted data. An
/// index will be automatically assigned based on the highest index currently recorded in the
/// content records.
pub fn add_content(&mut self, content: &[u8], cid: u32, content_type: tmd::ContentType) -> Result<(), TitleError> {
let max_index = self.tmd.content_records().iter()
.max_by_key(|record| record.index)
.map(|record| record.index)
.unwrap_or(0); // This should be impossible, but I guess 0 is a safe value just in case?
let new_index = max_index + 1;
let content_size = content.len() as u64;
let mut hasher = Sha1::new();
hasher.update(content);
let content_hash: [u8; 20] = hasher.finalize().into();
let content_enc = crypto::encrypt_content(content, self.ticket.title_key_dec(), new_index, content_size);
self.add_enc_content(&content_enc, new_index, cid, content_type, content_size, content_hash)?;
Ok(())
}
/// Gets the installed size of the title, in bytes. Use the optional parameter "absolute" to set
@@ -140,7 +406,7 @@ impl Title {
// accurate results.
title_size += self.tmd.to_bytes().map_err(|x| TitleError::TMD(tmd::TMDError::IO(x)))?.len();
title_size += self.ticket.to_bytes().map_err(|x| TitleError::Ticket(ticket::TicketError::IO(x)))?.len();
for record in &self.tmd.content_records {
for record in self.tmd.content_records().iter() {
if matches!(record.content_type, tmd::ContentType::Shared) {
if absolute == Some(true) {
title_size += record.content_size as usize;
@@ -153,13 +419,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> {
@@ -176,6 +435,19 @@ 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_title_version(&mut self, version: u16) {
self.tmd.set_title_version(version);
self.ticket.set_title_version(version);
}
pub fn set_cert_chain(&mut self, cert_chain: cert::CertificateChain) {
self.cert_chain = cert_chain;
@@ -197,7 +469,7 @@ impl Title {
self.tmd = tmd;
}
pub fn set_content(&mut self, content: content::ContentRegion) {
pub fn set_contents(&mut self, content: Vec<Vec<u8>>) {
self.content = content;
}
@@ -209,3 +481,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

@@ -1,5 +1,5 @@
// title/nus.rs from rustii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustii
// title/nus.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Implements the functions required for downloading data from the NUS.
@@ -7,7 +7,7 @@ use std::str;
use std::io::Write;
use reqwest;
use thiserror::Error;
use crate::title::{cert, tmd, ticket, content};
use crate::title::{cert, tmd, ticket};
use crate::title;
const WII_NUS_ENDPOINT: &str = "http://nus.cdn.shop.wii.com/ccs/download/";
@@ -25,8 +25,6 @@ pub enum NUSError {
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")]
@@ -80,10 +78,10 @@ pub fn download_content(title_id: [u8; 8], content_id: u32, wiiu_endpoint: bool)
/// 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 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)?);
contents.push(download_content(tmd.title_id(), id, wiiu_endpoint)?);
}
Ok(contents)
}
@@ -112,8 +110,8 @@ pub fn download_title(title_id: [u8; 8], title_version: Option<u16>, wiiu_endpoi
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)?;
let contents = download_contents(&tmd, wiiu_endpoint)?;
let title = title::Title::from_parts_with_content(cert_chain, None, tik, tmd, contents, None)?;
Ok(title)
}
@@ -126,8 +124,8 @@ pub fn download_tmd(title_id: [u8; 8], title_version: Option<u16>, wiiu_endpoint
} 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())
let tmd_url = if let Some(title_version) = title_version {
format!("{}{}/tmd.{}", endpoint_url, &hex::encode(title_id), title_version)
} else {
format!("{}{}/tmd", endpoint_url, &hex::encode(title_id))
};

View File

@@ -1,5 +1,5 @@
// title/tik.rs from rustii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustii
// title/tik.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Implements the structures and methods required for Ticket parsing and editing.
@@ -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)]
@@ -29,31 +30,31 @@ pub struct TitleLimit {
pub limit_max: u32,
}
#[derive(Debug)]
#[derive(Debug, Clone)]
/// A structure that represents a Wii Ticket file.
pub struct Ticket {
pub signature_type: u32,
pub signature: [u8; 256],
signature_type: u32,
signature: [u8; 256],
padding1: [u8; 60],
pub signature_issuer: [u8; 64],
pub ecdh_data: [u8; 60],
pub ticket_version: u8,
signature_issuer: [u8; 64],
ecdh_data: [u8; 60],
ticket_version: u8,
reserved1: [u8; 2],
pub title_key: [u8; 16],
title_key: [u8; 16],
unknown1: [u8; 1],
pub ticket_id: [u8; 8],
pub console_id: [u8; 4],
pub title_id: [u8; 8],
ticket_id: [u8; 8],
console_id: [u8; 4],
title_id: [u8; 8],
unknown2: [u8; 2],
pub title_version: u16,
pub permitted_titles_mask: [u8; 4],
pub permit_mask: [u8; 4],
pub title_export_allowed: u8,
pub common_key_index: u8,
title_version: u16,
permitted_titles_mask: [u8; 4],
permit_mask: [u8; 4],
title_export_allowed: u8,
common_key_index: u8,
unknown3: [u8; 48],
pub content_access_permission: [u8; 64],
content_access_permission: [u8; 64],
padding2: [u8; 2],
pub title_limits: [TitleLimit; 8],
title_limits: [TitleLimit; 8],
}
impl Ticket {
@@ -168,8 +169,91 @@ impl Ticket {
Ok(buf)
}
/// Gets the type of the signature on the Ticket.
pub fn signature_type(&self) -> u32 {
self.signature_type
}
/// Gets the signature of the Ticket.
pub fn signature(&self) -> [u8; 256] {
self.signature
}
/// Gets the ECDH data listed in the Ticket.
pub fn ecdh_data(&self) -> [u8; 60] {
self.ecdh_data
}
/// Gets the version of the Ticket file.
pub fn ticket_version(&self) -> u8 {
self.ticket_version
}
/// Gets the raw encrypted Title Key from the Ticket.
pub fn title_key(&self) -> [u8; 16] {
self.title_key
}
pub fn set_title_key(&mut self, title_key: [u8; 16]) {
self.title_key = title_key;
}
/// Gets the Ticket ID listed in the Ticket.
pub fn ticket_id(&self) -> [u8; 8] {
self.ticket_id
}
/// Gets the console ID listed in the Ticket.
pub fn console_id(&self) -> [u8; 4] {
self.console_id
}
/// Gets the version of the title listed in the Ticket.
pub fn title_version(&self) -> u16 {
self.title_version
}
pub fn set_title_version(&mut self, version: u16) {
self.title_version = version;
}
/// Gets the permitted titles mask listed in the Ticket.
pub fn permitted_titles_mask(&self) -> [u8; 4] {
self.permitted_titles_mask
}
/// Gets the permit mask listed in the Ticket.
pub fn permit_mask(&self) -> [u8; 4] {
self.permit_mask
}
/// Gets whether title export is allowed by the Ticket.
pub fn title_export_allowed(&self) -> bool {
self.title_export_allowed == 1
}
/// Gets the index of the common key used by the Ticket.
pub fn common_key_index(&self) -> u8 {
self.common_key_index
}
/// Sets the index of the common key used by the Ticket.
pub fn set_common_key_index(&mut self, index: u8) {
self.common_key_index = index;
}
/// Gets the content access permissions listed in the Ticket.
pub fn content_access_permission(&self) -> [u8; 64] {
self.content_access_permission
}
/// Gets the title usage limits listed in the Ticket.
pub fn title_limits(&self) -> [TitleLimit; 8] {
self.title_limits
}
/// Gets the decrypted version of the Title Key stored in a Ticket.
pub fn dec_title_key(&self) -> [u8; 16] {
pub fn title_key_dec(&self) -> [u8; 16] {
// Get the dev status of this Ticket so decrypt_title_key knows the right common key.
let is_dev = self.is_dev();
decrypt_title_key(self.title_key, self.common_key_index, self.title_id, is_dev)
@@ -232,4 +316,17 @@ 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]) {
let new_enc_title_key = crypto::encrypt_title_key(self.title_key_dec(), self.common_key_index, title_id, self.is_dev());
self.title_key = new_enc_title_key;
self.title_id = title_id;
}
}

View File

@@ -1,5 +1,5 @@
// title/tmd.rs from rustii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustii
// title/tmd.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Implements the structures and methods required for TMD parsing and editing.
@@ -16,21 +16,29 @@ 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("content with requested Content ID {0} could not be found")]
CIDNotFound(u32),
#[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 {
@@ -43,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"),
}
}
}
@@ -74,8 +81,8 @@ pub enum AccessRight {
DVDVideo = 1,
}
#[derive(Debug, Clone)]
/// A structure that represents the metadata of a content file in a digital Wii title.
#[derive(Debug, Clone)]
pub struct ContentRecord {
pub content_id: u32,
pub index: u16,
@@ -84,83 +91,83 @@ pub struct ContentRecord {
pub content_hash: [u8; 20],
}
#[derive(Debug)]
/// A structure that represents a Wii TMD (Title Metadata) file.
#[derive(Debug, Clone)]
pub struct TMD {
pub signature_type: u32,
pub signature: [u8; 256],
signature_type: u32,
signature: [u8; 256],
padding1: [u8; 60],
pub signature_issuer: [u8; 64],
pub tmd_version: u8,
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],
pub group_id: u16,
signature_issuer: [u8; 64],
tmd_version: u8,
ca_crl_version: u8,
signer_crl_version: u8,
is_vwii: u8,
ios_tid: [u8; 8],
title_id: [u8; 8],
title_type: [u8; 4],
group_id: u16,
padding2: [u8; 2],
region: u16,
pub ratings: [u8; 16],
ratings: [u8; 16],
reserved1: [u8; 12],
pub ipc_mask: [u8; 12],
ipc_mask: [u8; 12],
reserved2: [u8; 18],
pub access_rights: u32,
pub title_version: u16,
pub num_contents: u16,
pub boot_index: u16,
pub minor_version: u16, // Normally unused, but good for fakesigning!
pub content_records: Vec<ContentRecord>,
access_rights: u32,
title_version: u16,
num_contents: u16,
boot_index: u16,
minor_version: u16, // Normally unused, but useful when fakesigning.
content_records: Vec<ContentRecord>,
}
impl TMD {
/// Creates a new TMD instance from the binary data of a TMD file.
pub fn from_bytes(data: &[u8]) -> Result<Self, TMDError> {
let mut buf = Cursor::new(data);
let signature_type = buf.read_u32::<BigEndian>().map_err(TMDError::IO)?;
let signature_type = buf.read_u32::<BigEndian>()?;
let mut signature = [0u8; 256];
buf.read_exact(&mut signature).map_err(TMDError::IO)?;
buf.read_exact(&mut signature)?;
// Maybe this can be read differently?
let mut padding1 = [0u8; 60];
buf.read_exact(&mut padding1).map_err(TMDError::IO)?;
buf.read_exact(&mut padding1)?;
let mut signature_issuer = [0u8; 64];
buf.read_exact(&mut signature_issuer).map_err(TMDError::IO)?;
let tmd_version = buf.read_u8().map_err(TMDError::IO)?;
let ca_crl_version = buf.read_u8().map_err(TMDError::IO)?;
let signer_crl_version = buf.read_u8().map_err(TMDError::IO)?;
let is_vwii = buf.read_u8().map_err(TMDError::IO)?;
buf.read_exact(&mut signature_issuer)?;
let tmd_version = buf.read_u8()?;
let ca_crl_version = buf.read_u8()?;
let signer_crl_version = buf.read_u8()?;
let is_vwii = buf.read_u8()?;
let mut ios_tid = [0u8; 8];
buf.read_exact(&mut ios_tid).map_err(TMDError::IO)?;
buf.read_exact(&mut ios_tid)?;
let mut title_id = [0u8; 8];
buf.read_exact(&mut title_id).map_err(TMDError::IO)?;
buf.read_exact(&mut title_id)?;
let mut title_type = [0u8; 4];
buf.read_exact(&mut title_type).map_err(TMDError::IO)?;
let group_id = buf.read_u16::<BigEndian>().map_err(TMDError::IO)?;
buf.read_exact(&mut title_type)?;
let group_id = buf.read_u16::<BigEndian>()?;
// Same here...
let mut padding2 = [0u8; 2];
buf.read_exact(&mut padding2).map_err(TMDError::IO)?;
let region = buf.read_u16::<BigEndian>().map_err(TMDError::IO)?;
buf.read_exact(&mut padding2)?;
let region = buf.read_u16::<BigEndian>()?;
let mut ratings = [0u8; 16];
buf.read_exact(&mut ratings).map_err(TMDError::IO)?;
buf.read_exact(&mut ratings)?;
// ...and here...
let mut reserved1 = [0u8; 12];
buf.read_exact(&mut reserved1).map_err(TMDError::IO)?;
buf.read_exact(&mut reserved1)?;
let mut ipc_mask = [0u8; 12];
buf.read_exact(&mut ipc_mask).map_err(TMDError::IO)?;
buf.read_exact(&mut ipc_mask)?;
// ...and here.
let mut reserved2 = [0u8; 18];
buf.read_exact(&mut reserved2).map_err(TMDError::IO)?;
let access_rights = buf.read_u32::<BigEndian>().map_err(TMDError::IO)?;
let title_version = buf.read_u16::<BigEndian>().map_err(TMDError::IO)?;
let num_contents = buf.read_u16::<BigEndian>().map_err(TMDError::IO)?;
let boot_index = buf.read_u16::<BigEndian>().map_err(TMDError::IO)?;
let minor_version = buf.read_u16::<BigEndian>().map_err(TMDError::IO)?;
buf.read_exact(&mut reserved2)?;
let access_rights = buf.read_u32::<BigEndian>()?;
let title_version = buf.read_u16::<BigEndian>()?;
let num_contents = buf.read_u16::<BigEndian>()?;
let boot_index = buf.read_u16::<BigEndian>()?;
let minor_version = buf.read_u16::<BigEndian>()?;
// Build content records by iterating over the rest of the data num_contents times.
let mut content_records = Vec::with_capacity(num_contents as usize);
for _ in 0..num_contents {
let content_id = buf.read_u32::<BigEndian>().map_err(TMDError::IO)?;
let index = buf.read_u16::<BigEndian>().map_err(TMDError::IO)?;
let type_int = buf.read_u16::<BigEndian>().map_err(TMDError::IO)?;
let content_id = buf.read_u32::<BigEndian>()?;
let index = buf.read_u16::<BigEndian>()?;
let type_int = buf.read_u16::<BigEndian>()?;
let content_type = match type_int {
1 => ContentType::Normal,
2 => ContentType::Development,
@@ -169,9 +176,9 @@ impl TMD {
32769 => ContentType::Shared,
_ => return Err(TMDError::InvalidContentType(type_int))
};
let content_size = buf.read_u64::<BigEndian>().map_err(TMDError::IO)?;
let content_size = buf.read_u64::<BigEndian>()?;
let mut content_hash = [0u8; 20];
buf.read_exact(&mut content_hash).map_err(TMDError::IO)?;
buf.read_exact(&mut content_hash)?;
content_records.push(ContentRecord {
content_id,
index,
@@ -231,11 +238,11 @@ impl TMD {
buf.write_all(&self.reserved2)?;
buf.write_u32::<BigEndian>(self.access_rights)?;
buf.write_u16::<BigEndian>(self.title_version)?;
buf.write_u16::<BigEndian>(self.num_contents)?;
buf.write_u16::<BigEndian>(self.content_records.len() as u16)?;
buf.write_u16::<BigEndian>(self.boot_index)?;
buf.write_u16::<BigEndian>(self.minor_version)?;
// Iterate over content records and write out content record data.
for content in &self.content_records {
for content in self.content_records.iter() {
buf.write_u32::<BigEndian>(content.content_id)?;
buf.write_u16::<BigEndian>(content.index)?;
match content.content_type {
@@ -251,6 +258,76 @@ impl TMD {
Ok(buf)
}
/// Gets the type of the signature on the TMD.
pub fn signature_type(&self) -> u32 {
self.signature_type
}
/// Gets the signature of the TMD.
pub fn signature(&self) -> [u8; 256] {
self.signature
}
/// Gets the version of the TMD file.
pub fn tmd_version(&self) -> u8 {
self.tmd_version
}
/// Gets the version of CA CRL listed in the TMD.
pub fn ca_crl_version(&self) -> u8 {
self.ca_crl_version
}
/// Gets the version of the signer CRL listed in the TMD.
pub fn signer_crl_version(&self) -> u8 {
self.signer_crl_version
}
/// Gets the group ID listed in the TMD.
pub fn group_id(&self) -> u16 {
self.group_id
}
/// Gets the age ratings listed in the TMD.
pub fn ratings(&self) -> [u8; 16] {
self.ratings
}
/// Gets the ipc mask listed in the TMD.
pub fn ipc_mask(&self) -> [u8; 12] {
self.ipc_mask
}
/// Gets the version of title listed in the TMD.
pub fn title_version(&self) -> u16 {
self.title_version
}
/// Gets the number of contents listed in the TMD.
pub fn num_contents(&self) -> u16 {
self.num_contents
}
/// Gets the index of the title's boot content.
pub fn boot_index(&self) -> u16 {
self.boot_index
}
/// Gets the minor version listed in the TMD. This field is typically unused.
pub fn minor_version(&self) -> u16 {
self.minor_version
}
/// Gets a reference to the content records from the TMD.
pub fn content_records(&self) -> &Vec<ContentRecord> {
&self.content_records
}
/// Sets the content records in the TMD.
pub fn set_content_records(&mut self, content_records: Vec<ContentRecord>) {
self.content_records = content_records;
}
/// Gets whether a TMD is fakesigned using the strncmp (trucha) bug or not.
pub fn is_fakesigned(&self) -> bool {
// Can't be fakesigned without a null signature.
@@ -299,25 +376,36 @@ 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(())
}
pub fn set_title_version(&mut self, version: u16) {
self.title_version = version;
}
/// 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
// are out of order, which could cause problems finding the content.
let mut content_indices = Vec::new();
for record in &self.content_records {
for record in self.content_records.iter() {
content_indices.push(record.index);
}
let target_index = content_indices.index(index);
@@ -351,8 +439,57 @@ impl TMD {
Ok(())
}
/// Gets whether this TMD describes a vWii title or not.
/// Gets whether a TMD describes a vWii title.
pub fn is_vwii(&self) -> bool {
self.is_vwii == 1
}
/// Sets whether a TMD describes a vWii title.
pub fn set_is_vwii(&mut self, value: bool) {
self.is_vwii = value as u8;
}
/// 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]) {
self.title_id = title_id;
}
/// 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(())
}
/// Gets the index of content using its Content ID.
pub fn get_index_from_cid(&self, cid: u32) -> Result<usize, TMDError> {
// Use fancy Rust find and map methods to find the index matching the provided CID. Take
// that libWiiPy!
let content_index = self.content_records().iter()
.find(|record| record.content_id == cid)
.map(|record| record.index);
if let Some(index) = content_index {
Ok(index as usize)
} else {
Err(TMDError::CIDNotFound(cid))
}
}
}

View File

@@ -1,5 +1,5 @@
// title/versions.rs from rustii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustii
// title/versions.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Handles converting Title version formats, and provides Wii Menu version constants.
@@ -78,8 +78,6 @@ pub fn dec_to_standard(version: u16, title_id: &str, vwii: Option<bool>) -> Opti
let map = wii_menu_versions_map(vwii);
map.get(&version).cloned()
} else {
let version_upper = (version as f64 / 256.0).floor() as u16;
let version_lower = version % 256;
Some(format!("{}.{}", version_upper, version_lower))
Some(format!("{}.{}", version >> 8, version & 0xF))
}
}

View File

@@ -1,5 +1,5 @@
// title/wad.rs from rustii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustii
// title/wad.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustwii
//
// Implements the structures and methods required for WAD parsing and editing.
@@ -7,7 +7,7 @@ use std::str;
use std::io::{Cursor, Read, Seek, SeekFrom, Write};
use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt};
use thiserror::Error;
use crate::title::{cert, tmd, ticket, content};
use crate::title::{cert, tmd, ticket};
use crate::title::ticket::TicketError;
use crate::title::tmd::TMDError;
@@ -32,16 +32,16 @@ pub enum WADType {
#[derive(Debug)]
/// A structure that represents an entire WAD file as a separate header and body.
pub struct WAD {
pub header: WADHeader,
pub body: WADBody,
header: WADHeader,
body: WADBody,
}
#[derive(Debug)]
/// A structure that represents the header of a WAD file.
pub struct WADHeader {
pub header_size: u32,
pub wad_type: WADType,
pub wad_version: u16,
header_size: u32,
wad_type: WADType,
wad_version: u16,
cert_chain_size: u32,
crl_size: u32,
ticket_size: u32,
@@ -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,
};
@@ -93,18 +93,63 @@ impl WADHeader {
};
Ok(header)
}
/// Gets the size of the header data.
pub fn header_size(&self) -> u32 {
self.header_size
}
/// Gets the type of WAD described by the header.
pub fn wad_type(&self) -> &WADType {
&self.wad_type
}
/// Gets the version of the WAD described by the header.
pub fn wad_version(&self) -> u16 {
self.wad_version
}
/// Gets the size of the certificate chain defined in the header.
pub fn cert_chain_size(&self) -> u32 {
self.cert_chain_size
}
/// Gets the size of the CRL defined in the header.
pub fn crl_size(&self) -> u32 {
self.crl_size
}
/// Gets the size of the Ticket defined in the header.
pub fn ticket_size(&self) -> u32 {
self.ticket_size
}
/// Gets the size of the TMD defined in the header.
pub fn tmd_size(&self) -> u32 {
self.tmd_size
}
/// Gets the size of the content defined in the header.
pub fn content_size(&self) -> u32 {
self.content_size
}
/// Gets the size of the metadata defined in the header.
pub fn meta_size(&self) -> u32 {
self.meta_size
}
}
impl WADBody {
/// Creates a new WADBody instance from instances of the components stored in a WAD file.
pub fn from_parts(cert_chain: &cert::CertificateChain, crl: &[u8], ticket: &ticket::Ticket, tmd: &tmd::TMD,
content: &content::ContentRegion, meta: &[u8]) -> Result<WADBody, WADError> {
content: &[u8], meta: &[u8]) -> Result<WADBody, WADError> {
let body = WADBody {
cert_chain: cert_chain.to_bytes().map_err(WADError::IO)?,
crl: crl.to_vec(),
ticket: ticket.to_bytes().map_err(WADError::IO)?,
tmd: tmd.to_bytes().map_err(WADError::IO)?,
content: content.to_bytes().map_err(WADError::IO)?,
content: content.to_vec(),
meta: meta.to_vec(),
};
Ok(body)
@@ -194,7 +239,7 @@ impl WAD {
/// Creates a new WAD instance from instances of the components stored in a WAD file. This
/// first creates a WADBody from the components, then generates a new WADHeader from them.
pub fn from_parts(cert_chain: &cert::CertificateChain, crl: &[u8], ticket: &ticket::Ticket, tmd: &tmd::TMD,
content: &content::ContentRegion, meta: &[u8]) -> Result<WAD, WADError> {
content: &[u8], meta: &[u8]) -> Result<WAD, WADError> {
let body = WADBody::from_parts(cert_chain, crl, ticket, tmd, content, meta)?;
let header = WADHeader::from_body(&body)?;
let wad = WAD {
@@ -236,6 +281,11 @@ impl WAD {
buf.resize((buf.len() + 63) & !63, 0);
Ok(buf)
}
/// Gets the type of the WAD.
pub fn wad_type(&self) -> &WADType {
self.header.wad_type()
}
pub fn cert_chain_size(&self) -> u32 { self.header.cert_chain_size }