mirror of
https://github.com/NinjaCheetah/rustii.git
synced 2026-03-05 04:05:29 -05:00
Compare commits
44 Commits
5731b8d4d8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 449097967c | |||
|
326bb56ece
|
|||
|
0d34fbc383
|
|||
|
02db260138
|
|||
|
23699a518d
|
|||
|
5cc6c1c8ff
|
|||
|
7c8484edaa
|
|||
|
836d5e912a
|
|||
|
94e0be0eef
|
|||
|
26138c02be
|
|||
| 15947ceff3 | |||
|
a30a0f2c5b
|
|||
|
481594345d
|
|||
|
277c5d6439
|
|||
|
577d5a0efa
|
|||
|
96ace71546
|
|||
|
66476e2c98
|
|||
|
ea2e31756c
|
|||
|
52e11795d3
|
|||
|
7cef25d8f0
|
|||
|
884657268b
|
|||
|
5f578fbfd8
|
|||
|
be9148fcfa
|
|||
|
e55edc10fd
|
|||
|
42fd523843
|
|||
|
e1190e1e58
|
|||
|
c2169f84c4
|
|||
|
0bda6dabf3
|
|||
|
405df67e49
|
|||
|
74584b1ffd
|
|||
|
d9e8465f0c
|
|||
|
85f3f028d4
|
|||
|
3fd701cac6
|
|||
|
97fe838b8c
|
|||
|
e147a953a5
|
|||
|
edf3af0f7c
|
|||
|
444c3def54
|
|||
|
ac1368053b
|
|||
|
3178063a07
|
|||
|
8c7cd48dff
|
|||
|
839e33b911
|
|||
|
1bcc004af7
|
|||
|
561ada3d92
|
|||
| 6b21f03a54 |
52
.github/workflows/rust.yml
vendored
52
.github/workflows/rust.yml
vendored
@@ -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
1
.gitignore
vendored
@@ -23,3 +23,4 @@ target/
|
||||
*.cert
|
||||
*.footer
|
||||
*.app
|
||||
*.arc
|
||||
|
||||
2663
Cargo.lock
generated
2663
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
26
Cargo.toml
26
Cargo.toml
@@ -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"
|
||||
@@ -27,7 +27,17 @@ doc = true
|
||||
byteorder = "1"
|
||||
cbc = "0"
|
||||
aes = "0"
|
||||
rsa = { version = "0", features = ["sha2"] }
|
||||
hex = "0"
|
||||
sha1 = "0"
|
||||
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"
|
||||
|
||||
2
LICENSE
2
LICENSE
@@ -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
|
||||
|
||||
50
README.md
50
README.md
@@ -1,17 +1,43 @@
|
||||
# rustii
|
||||
*Like rusty but it's rustii because the Wii? Get it?*
|
||||

|
||||
# rustwii
|
||||
|
||||
A very WIP and experimental port of [libWiiPy](https://github.com/NinjaCheetah/libWiiPy) to Rust.
|
||||
*Like rusty but it's rustwii because the Wii? Get it?*
|
||||
|
||||
### What's Included
|
||||
- Structs for TMDs and Tickets that can be created from binary data
|
||||
- Simple Title Key encryption/decryption
|
||||
- Content encryption/decryption
|
||||
- WAD parsing (allowing for packing/unpacking)
|
||||
- A very basic test binary that makes sure these things work as expected
|
||||
[](https://github.com/NinjaCheetah/rustwii/actions/workflows/rust.yml)
|
||||
|
||||
### What's Not Included
|
||||
- Basically anything else. This library doesn't do a whole lot yet, and the included `rustii` binary isn't even remotely similar to WiiPy yet. Eventually it aims to be a more optimized replacement for it.
|
||||
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.
|
||||
|
||||
Please note that my Rust code isn't great, I'm still very new to it.
|
||||
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.
|
||||
|
||||
### What's Included (Library-Side)
|
||||
- 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
|
||||
|
||||
### What's Included (CLI-Side)
|
||||
- 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/U8 archives
|
||||
- U8 archive packing/unpacking
|
||||
|
||||
To see specific usage information, check `rustwii --help` and `rustwii <command> --help`.
|
||||
|
||||
## Building
|
||||
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 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).
|
||||
|
||||
217
src/archive/ash.rs
Normal file
217
src/archive/ash.rs
Normal file
@@ -0,0 +1,217 @@
|
||||
// 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.
|
||||
//
|
||||
// This code is MESSY. It's a weird combination of Garhoogin's C implementation and my Python
|
||||
// implementation of his C implementation. It should definitely be rewritten someday.
|
||||
|
||||
use std::io::{Cursor, Read};
|
||||
use byteorder::{ByteOrder, BigEndian};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum ASHError {
|
||||
#[error("this does not appear to be ASH-compressed data (missing magic number)")]
|
||||
NotASHData,
|
||||
#[error("ASH data is invalid")]
|
||||
InvalidData,
|
||||
#[error("LZ77 data is not in a valid format")]
|
||||
IO(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
const TREE_RIGHT: u32 = 0x80000000;
|
||||
const TREE_LEFT: u32 = 0x40000000;
|
||||
const TREE_VAL_MASK: u32 = 0x3FFFFFFF;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ASHBitReader<'a> {
|
||||
src: &'a [u8],
|
||||
size: u32,
|
||||
src_pos: u32,
|
||||
word: u32,
|
||||
bit_capacity: u32,
|
||||
}
|
||||
|
||||
fn ash_bit_reader_feed_word(reader: &mut ASHBitReader) -> Result<(), ASHError> {
|
||||
// Ensure that there's enough data to read en entire word, then if there is, read one.
|
||||
if reader.src_pos + 4 > reader.size {
|
||||
return Err(ASHError::InvalidData);
|
||||
}
|
||||
reader.word = BigEndian::read_u32(&reader.src[reader.src_pos as usize..reader.src_pos as usize + 4]);
|
||||
reader.bit_capacity = 0;
|
||||
reader.src_pos += 4;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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,
|
||||
size,
|
||||
src_pos: startpos,
|
||||
word: 0,
|
||||
bit_capacity: 0,
|
||||
};
|
||||
ash_bit_reader_feed_word(&mut reader)?;
|
||||
Ok(reader)
|
||||
}
|
||||
|
||||
fn ash_bit_reader_read_bit(reader: &mut ASHBitReader) -> Result<u32, ASHError> {
|
||||
// Reads the starting bit of the current word in the provided bit reader. If the capacity is at
|
||||
// 31, then we've shifted through the entire word, so a new one should be fed. If not, increase
|
||||
// the capacity by one and shift the current word left.
|
||||
let bit: u32 = reader.word >> 31;
|
||||
if reader.bit_capacity == 31 {
|
||||
ash_bit_reader_feed_word(reader)?;
|
||||
} else {
|
||||
reader.bit_capacity += 1;
|
||||
reader.word <<= 1;
|
||||
}
|
||||
Ok(bit)
|
||||
}
|
||||
|
||||
fn ash_bit_reader_read_bits(reader: &mut ASHBitReader, num_bits: u32) -> Result<u32, ASHError> {
|
||||
// Reads a series of bytes from the current word in the supplied bit reader.
|
||||
let mut bits: u32;
|
||||
let next_bit = reader.bit_capacity + num_bits;
|
||||
if next_bit <= 32 {
|
||||
bits = reader.word >> (32 - num_bits);
|
||||
if next_bit != 32 {
|
||||
reader.word <<= num_bits;
|
||||
reader.bit_capacity += num_bits;
|
||||
} else {
|
||||
ash_bit_reader_feed_word(reader)?;
|
||||
}
|
||||
} else {
|
||||
bits = reader.word >> (32 - num_bits);
|
||||
ash_bit_reader_feed_word(reader)?;
|
||||
bits |= reader.word >> (64 - next_bit);
|
||||
reader.word <<= next_bit - 32;
|
||||
reader.bit_capacity = next_bit - 32;
|
||||
}
|
||||
Ok(bits)
|
||||
}
|
||||
|
||||
fn ash_read_tree(reader: &mut ASHBitReader, width: u32, left_tree: &mut [u32], right_tree: &mut [u32]) -> Result<u32, ASHError> {
|
||||
// Read either the symbol or distance tree from the ASH file, and return the root of that tree.
|
||||
let mut work = vec![0; 2 * (1 << width)];
|
||||
let mut work_pos = 0;
|
||||
|
||||
let mut r23: u32 = 1 << width;
|
||||
let mut tree_root: u32 = 0;
|
||||
let mut num_nodes: u32 = 0;
|
||||
loop {
|
||||
if ash_bit_reader_read_bit(reader)? != 0 {
|
||||
work[work_pos] = r23 | TREE_RIGHT;
|
||||
work_pos += 1;
|
||||
work[work_pos] = r23 | TREE_LEFT;
|
||||
work_pos += 1;
|
||||
num_nodes += 2;
|
||||
r23 += 1;
|
||||
} else {
|
||||
tree_root = ash_bit_reader_read_bits(reader, width)?;
|
||||
loop {
|
||||
work_pos -= 1;
|
||||
let node_value: u32 = work[work_pos];
|
||||
let idx = node_value & TREE_VAL_MASK;
|
||||
num_nodes -= 1;
|
||||
if (node_value & TREE_RIGHT) != 0 {
|
||||
right_tree[idx as usize] = tree_root;
|
||||
tree_root = idx;
|
||||
} else {
|
||||
left_tree[idx as usize] = tree_root;
|
||||
break;
|
||||
}
|
||||
if num_nodes == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if num_nodes == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(tree_root)
|
||||
}
|
||||
|
||||
fn ash_decompress_main(data: &[u8], size: u32, sym_bits: u32, dist_bits: u32) -> Result<Vec<u8>, ASHError> {
|
||||
let mut decompressed_size: u32 = BigEndian::read_u32(&data[0x4..0x8]) & 0x00FFFFFF;
|
||||
|
||||
let mut buf = vec![0u8; decompressed_size as usize];
|
||||
let mut buf_pos: usize = 0;
|
||||
|
||||
let mut reader1 = ash_bit_reader_init(data, size, BigEndian::read_u32(&data[0x8..0xC]))?;
|
||||
let mut reader2 = ash_bit_reader_init(data, size, 0xC)?;
|
||||
|
||||
let sym_max: u32 = 1 << sym_bits;
|
||||
let dist_max: u32 = 1 << dist_bits;
|
||||
|
||||
let mut sym_left_tree = vec![0u32; (2 * sym_max - 1) as usize];
|
||||
let mut sym_right_tree = vec![0u32; (2 * sym_max - 1) as usize];
|
||||
let mut dist_left_tree = vec![0u32; (2 * dist_max - 1) as usize];
|
||||
let mut dist_right_tree = vec![0u32; (2 * dist_max - 1) as usize];
|
||||
|
||||
let sym_root = ash_read_tree(&mut reader2, sym_bits, &mut sym_left_tree, &mut sym_right_tree)?;
|
||||
let dist_root = ash_read_tree(&mut reader1, dist_bits, &mut dist_left_tree, &mut dist_right_tree)?;
|
||||
|
||||
// Main decompression loop.
|
||||
loop {
|
||||
let mut sym = sym_root;
|
||||
while sym >= sym_max {
|
||||
if ash_bit_reader_read_bit(&mut reader2)? != 0 {
|
||||
sym = sym_right_tree[sym as usize];
|
||||
} else {
|
||||
sym = sym_left_tree[sym as usize];
|
||||
}
|
||||
}
|
||||
if sym < 0x100 {
|
||||
buf[buf_pos] = sym as u8;
|
||||
buf_pos += 1;
|
||||
decompressed_size -= 1;
|
||||
} else {
|
||||
let mut dist_sym = dist_root;
|
||||
while dist_sym >= dist_max {
|
||||
if ash_bit_reader_read_bit(&mut reader1)? != 0 {
|
||||
dist_sym = dist_right_tree[dist_sym as usize];
|
||||
} else {
|
||||
dist_sym = dist_left_tree[dist_sym as usize];
|
||||
}
|
||||
}
|
||||
let mut copy_len = (sym - 0x100) + 3;
|
||||
let mut src_pos = buf_pos - dist_sym as usize - 1;
|
||||
if copy_len > decompressed_size {
|
||||
return Err(ASHError::InvalidData);
|
||||
}
|
||||
|
||||
decompressed_size -= copy_len;
|
||||
while copy_len > 0 {
|
||||
buf[buf_pos] = buf[src_pos];
|
||||
buf_pos += 1;
|
||||
src_pos += 1;
|
||||
copy_len -= 1;
|
||||
}
|
||||
}
|
||||
if decompressed_size == 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Decompresses ASH-compressed data and returns the decompressed result.
|
||||
pub fn decompress_ash(data: &[u8], sym_tree_bits: Option<u8>, dist_tree_bits: Option<u8>) -> Result<Vec<u8>, ASHError> {
|
||||
let mut buf = Cursor::new(data);
|
||||
// Check for magic "ASH0" to make sure that this is actually ASH data.
|
||||
let mut magic = [0u8; 4];
|
||||
buf.read_exact(&mut magic)?;
|
||||
if &magic != b"ASH0" {
|
||||
return Err(ASHError::NotASHData);
|
||||
}
|
||||
// Unwrap passed bit lengths or use defaults.
|
||||
let sym_tree_bits = sym_tree_bits.unwrap_or(9) as u32;
|
||||
let dist_tree_bits = dist_tree_bits.unwrap_or(11) as u32;
|
||||
let decompressed_data = ash_decompress_main(data, buf.get_ref().len() as u32, sym_tree_bits, dist_tree_bits)?;
|
||||
Ok(decompressed_data)
|
||||
}
|
||||
218
src/archive/lz77.rs
Normal file
218
src/archive/lz77.rs
Normal file
@@ -0,0 +1,218 @@
|
||||
// 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.
|
||||
|
||||
use std::cmp::min;
|
||||
use std::io::{Cursor, Read, Write, Seek, SeekFrom};
|
||||
use byteorder::{BigEndian, LittleEndian, ReadBytesExt, WriteBytesExt};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum LZ77Error {
|
||||
#[error("compression is type `{0}` but only 0x10 is supported")]
|
||||
InvalidCompressionType(u8),
|
||||
#[error("LZ77 data is not in a valid format")]
|
||||
IO(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
const LZ_MIN_DISTANCE: usize = 0x01; // Minimum distance for each reference.
|
||||
const LZ_MAX_DISTANCE: usize = 0x1000; // Maximum distance for each reference.
|
||||
const LZ_MIN_LENGTH: usize = 0x03; // Minimum length for each reference.
|
||||
const LZ_MAX_LENGTH: usize = 0x12; // Maximum length for each reference.
|
||||
|
||||
#[derive(Clone)]
|
||||
struct LZNode {
|
||||
dist: usize,
|
||||
len: usize,
|
||||
weight: usize,
|
||||
}
|
||||
|
||||
fn compress_compare_bytes(buf: &[u8], offset1: usize, offset2: usize, abs_len_max: usize) -> usize {
|
||||
// Compare bytes up to the maximum length we can match. Start by comparing the first 3 bytes,
|
||||
// since that's the minimum match length and this allows for a more optimized early exit.
|
||||
let mut num_matched: usize = 0;
|
||||
while num_matched < abs_len_max {
|
||||
if buf[offset1 + num_matched] != buf[offset2 + num_matched] {
|
||||
break
|
||||
}
|
||||
num_matched += 1
|
||||
}
|
||||
num_matched
|
||||
}
|
||||
|
||||
fn compress_search_matches(buf: &[u8], pos: usize) -> (usize, usize) {
|
||||
let bytes_left = buf.len() - pos;
|
||||
// Default to only looking back 4096 bytes, unless we've moved fewer than 4096 bytes, in which
|
||||
// case we should only look as far back as we've gone.
|
||||
let max_dist = min(LZ_MAX_DISTANCE, pos);
|
||||
// Default to only matching up to 18 bytes, unless fewer than 18 bytes remain, in which case
|
||||
// we can only match up to that many bytes.
|
||||
let max_len = min(LZ_MAX_LENGTH, bytes_left);
|
||||
// Log the longest match we found and its offset.
|
||||
let (mut biggest_match, mut biggest_match_pos) = (0, 0);
|
||||
// Search for matches.
|
||||
for i in LZ_MIN_DISTANCE..(max_dist + 1) {
|
||||
let num_matched = compress_compare_bytes(buf, pos - i, pos, max_len);
|
||||
if num_matched > biggest_match {
|
||||
biggest_match = num_matched;
|
||||
biggest_match_pos = i;
|
||||
if biggest_match == max_len {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
(biggest_match, biggest_match_pos)
|
||||
}
|
||||
|
||||
fn compress_node_is_ref(node: LZNode) -> bool {
|
||||
node.len >= LZ_MIN_LENGTH
|
||||
}
|
||||
|
||||
fn compress_get_node_cost(length: usize) -> usize {
|
||||
let num_bytes = if length >= LZ_MIN_LENGTH {
|
||||
2
|
||||
} else {
|
||||
1
|
||||
};
|
||||
1 + (num_bytes * 8)
|
||||
}
|
||||
|
||||
/// Compresses data using the Wii's LZ77 compression algorithm and returns the compressed result.
|
||||
pub fn compress_lz77(data: &[u8]) -> Result<Vec<u8>, LZ77Error> {
|
||||
// Optimized compressor based around a node graph that finds optimal string matches.
|
||||
let mut nodes = vec![LZNode { dist: 0, len: 0, weight: 0 }; data.len()];
|
||||
// Iterate over the uncompressed data, starting from the end.
|
||||
let mut pos = data.len();
|
||||
while pos > 0 {
|
||||
pos -= 1;
|
||||
// Limit the maximum search length when we're near the end of the file.
|
||||
let mut max_search_len = min(LZ_MAX_LENGTH, data.len() - pos);
|
||||
if max_search_len < LZ_MIN_DISTANCE {
|
||||
max_search_len = 1;
|
||||
}
|
||||
// Initialize as 1 for each, since that's all we could use if we weren't compressing.
|
||||
let (mut length, mut dist) = (1, 1);
|
||||
if max_search_len >= LZ_MIN_LENGTH {
|
||||
(length, dist) = compress_search_matches(data, pos);
|
||||
}
|
||||
// Treat as direct bytes if it's too short to copy.
|
||||
if length == 0 || length < LZ_MIN_LENGTH {
|
||||
length = 1;
|
||||
}
|
||||
// If the node goes to the end of the file, the weight is the cost of the node.
|
||||
if (pos + length) == data.len() {
|
||||
nodes[pos].len = length;
|
||||
nodes[pos].dist = dist;
|
||||
nodes[pos].weight = compress_get_node_cost(length);
|
||||
}
|
||||
// Otherwise, search for possible matches and determine the one with the best cost.
|
||||
else {
|
||||
let mut weight_best = u32::MAX as usize;
|
||||
let mut len_best = 1;
|
||||
while length > 0 {
|
||||
let weight_next = nodes[pos + length].weight;
|
||||
let weight = compress_get_node_cost(length) + weight_next;
|
||||
if weight < weight_best {
|
||||
len_best = length;
|
||||
weight_best = weight;
|
||||
}
|
||||
length -= 1;
|
||||
if length != 0 && length < LZ_MIN_LENGTH {
|
||||
length = 1;
|
||||
}
|
||||
}
|
||||
nodes[pos].len = len_best;
|
||||
nodes[pos].dist = dist;
|
||||
nodes[pos].weight = weight_best;
|
||||
}
|
||||
}
|
||||
// Write out compressed data now that we've done our calculations.
|
||||
let mut buf = Cursor::new(Vec::new());
|
||||
buf.write_all(b"LZ77\x10")?;
|
||||
buf.write_u24::<LittleEndian>(data.len() as u32)?;
|
||||
let mut src_pos = 0;
|
||||
while src_pos < data.len() {
|
||||
let mut flag = 0;
|
||||
let flag_pos = buf.position();
|
||||
buf.write_u8(b'\x00')?; // Reserve a byte for the flag.
|
||||
let mut i = 0;
|
||||
while i < 8 && src_pos < data.len() {
|
||||
let current_node = nodes[src_pos].clone();
|
||||
let length = current_node.len;
|
||||
let dist = current_node.dist;
|
||||
// This is a reference node.
|
||||
if compress_node_is_ref(current_node) {
|
||||
let encoded = ((((length - LZ_MIN_LENGTH) & 0xF) << 12) | ((dist - LZ_MIN_DISTANCE) & 0xFFF)) as u16;
|
||||
buf.write_u16::<BigEndian>(encoded)?;
|
||||
flag |= 1 << (7 - i);
|
||||
}
|
||||
// This is a direct copy node.
|
||||
else {
|
||||
buf.write_all(&data[src_pos..src_pos + 1])?;
|
||||
}
|
||||
src_pos += length;
|
||||
i += 1
|
||||
}
|
||||
pos = buf.position() as usize;
|
||||
buf.seek(SeekFrom::Start(flag_pos))?;
|
||||
buf.write_u8(flag)?;
|
||||
buf.seek(SeekFrom::Start(pos as u64))?;
|
||||
}
|
||||
Ok(buf.into_inner())
|
||||
}
|
||||
|
||||
/// Decompresses LZ77-compressed data and returns the decompressed result.
|
||||
pub fn decompress_lz77(data: &[u8]) -> Result<Vec<u8>, LZ77Error> {
|
||||
let mut buf = Cursor::new(data);
|
||||
// Check for magic so that we know where to start. If the compressed data was sourced from
|
||||
// inside of something, it may not have the magic and instead starts immediately at 0.
|
||||
let mut magic = [0u8; 4];
|
||||
buf.read_exact(&mut magic)?;
|
||||
if &magic != b"LZ77" {
|
||||
buf.seek(SeekFrom::Start(0))?;
|
||||
}
|
||||
// Read one byte to ensure this is compression type 0x10. Nintendo used other types, but only
|
||||
// 0x10 was supported on the Wii.
|
||||
let compression_type = buf.read_u8()?;
|
||||
if compression_type != 0x10 {
|
||||
return Err(LZ77Error::InvalidCompressionType(compression_type));
|
||||
}
|
||||
// Read the decompressed size, which is stored as 3 LE bytes for some reason.
|
||||
let decompressed_size = buf.read_u24::<LittleEndian>()? as usize;
|
||||
let mut out_buf = vec![0u8; decompressed_size];
|
||||
let mut pos = 0;
|
||||
while pos < decompressed_size {
|
||||
let flag = buf.read_u8()?;
|
||||
// Read bits in flag from most to least significant.
|
||||
let mut x = 7;
|
||||
while x >= 0 {
|
||||
// Prevents buffer overrun if the final flag is only partially used.
|
||||
if pos >= decompressed_size {
|
||||
break;
|
||||
}
|
||||
// Bit is 1, which is a reference to previous data in the file.
|
||||
if flag & (1 << x) != 0 {
|
||||
let reference = buf.read_u16::<BigEndian>()?;
|
||||
let length = 3 + ((reference >> 12) & 0xF);
|
||||
let mut offset = pos - (reference & 0xFFF) as usize - 1;
|
||||
for _ in 0..length {
|
||||
out_buf[pos] = out_buf[offset];
|
||||
pos += 1;
|
||||
offset += 1;
|
||||
// Avoids a buffer overrun if the copy length would extend past the end of the file.
|
||||
if pos >= decompressed_size {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Bit is 0, which is a direct byte copy.
|
||||
else {
|
||||
out_buf[pos] = buf.read_u8()?;
|
||||
pos += 1;
|
||||
}
|
||||
x -= 1;
|
||||
}
|
||||
}
|
||||
Ok(out_buf)
|
||||
}
|
||||
8
src/archive/mod.rs
Normal file
8
src/archive/mod.rs
Normal file
@@ -0,0 +1,8 @@
|
||||
// archive/mod.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
|
||||
// https://github.com/NinjaCheetah/rustwii
|
||||
//
|
||||
// Root for all archive-related modules.
|
||||
|
||||
pub mod ash;
|
||||
pub mod lz77;
|
||||
pub mod u8;
|
||||
335
src/archive/u8.rs
Normal file
335
src/archive/u8.rs
Normal file
@@ -0,0 +1,335 @@
|
||||
// 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 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)")]
|
||||
NotU8Data,
|
||||
#[error("U8 data is not in a valid format")]
|
||||
IO(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
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(Clone, Debug)]
|
||||
struct U8Reader {
|
||||
buf: Cursor<Box<[u8]>>,
|
||||
u8_nodes: Vec<U8Node>,
|
||||
index: usize,
|
||||
base_name_offset: u64
|
||||
}
|
||||
|
||||
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)?;
|
||||
// Check for an IMET header if the magic number isn't the correct value before throwing an
|
||||
// error.
|
||||
if &magic != b"\x55\xAA\x38\x2D" {
|
||||
// Check for an IMET header immediately at the start of the file.
|
||||
buf.seek(SeekFrom::Start(0x40))?;
|
||||
buf.read_exact(&mut magic)?;
|
||||
if &magic == b"\x49\x4D\x45\x54" {
|
||||
// IMET with no build tag means the U8 archive should start at 0x600.
|
||||
buf.seek(SeekFrom::Start(0x600))?;
|
||||
buf.read_exact(&mut magic)?;
|
||||
if &magic != b"\x55\xAA\x38\x2D" {
|
||||
return Err(U8Error::NotU8Data);
|
||||
}
|
||||
println!("ignoring IMET header at 0x40");
|
||||
}
|
||||
// Check for an IMET header that comes after a build tag.
|
||||
else {
|
||||
buf.seek(SeekFrom::Start(0x80))?;
|
||||
buf.read_exact(&mut magic)?;
|
||||
if &magic == b"\x49\x4D\x45\x54" {
|
||||
// IMET with a build tag means the U8 archive should start at 0x600.
|
||||
buf.seek(SeekFrom::Start(0x640))?;
|
||||
buf.read_exact(&mut magic)?;
|
||||
if &magic != b"\x55\xAA\x38\x2D" {
|
||||
return Err(U8Error::NotU8Data);
|
||||
}
|
||||
println!("ignoring IMET header at 0x80");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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()?;
|
||||
let root_node_name_offset = buf.read_u24::<BigEndian>()?;
|
||||
let root_node_data_offset = buf.read_u32::<BigEndian>()?;
|
||||
let root_node_size = buf.read_u32::<BigEndian>()?;
|
||||
let root_node = U8Node {
|
||||
node_type: root_node_type,
|
||||
name_offset: root_node_name_offset,
|
||||
data_offset: root_node_data_offset,
|
||||
size: root_node_size,
|
||||
};
|
||||
|
||||
// Create a vec of nodes, push the root node, and then iterate over the remaining number
|
||||
// of nodes in the file and push them to the vec.
|
||||
let mut u8_nodes: Vec<U8Node> = Vec::new();
|
||||
u8_nodes.push(root_node);
|
||||
for _ in 1..root_node_size {
|
||||
let node_type = buf.read_u8()?;
|
||||
let name_offset = buf.read_u24::<BigEndian>()?;
|
||||
let data_offset = buf.read_u32::<BigEndian>()?;
|
||||
let size = buf.read_u32::<BigEndian>()?;
|
||||
u8_nodes.push(U8Node { node_type, name_offset, data_offset, size })
|
||||
}
|
||||
// Save the base name offset for later.
|
||||
let base_name_offset = buf.position();
|
||||
|
||||
Ok(Self {
|
||||
buf,
|
||||
u8_nodes,
|
||||
index: 0,
|
||||
base_name_offset
|
||||
})
|
||||
}
|
||||
|
||||
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()
|
||||
)
|
||||
}
|
||||
|
||||
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..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 &file_names {
|
||||
header_size += file_name.len() as u32 + 1
|
||||
}
|
||||
// The initial data offset is equal to the file header (32 bytes) + node data aligned to
|
||||
// 64 bytes.
|
||||
let data_offset: u32 = (header_size + 32 + 63) & !63;
|
||||
// Adjust all nodes to place file data in the same order as the nodes. For some reason
|
||||
// Nintendo-made U8 archives don't necessarily do this?
|
||||
let mut current_data_offset = data_offset;
|
||||
let mut current_name_offset: u32 = 0;
|
||||
for i in 0..u8_nodes.len() {
|
||||
if u8_nodes[i].node_type == 0 {
|
||||
u8_nodes[i].data_offset = (current_data_offset + 31) & !31;
|
||||
current_data_offset += (u8_nodes[i].size + 31) & !31;
|
||||
}
|
||||
// Calculate the name offsets, including the extra 1 for the NULL byte.
|
||||
u8_nodes[i].name_offset = current_name_offset;
|
||||
current_name_offset += 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(&[0; 16])?;
|
||||
// Iterate over nodes and write them out.
|
||||
for node in &u8_nodes {
|
||||
buf.write_u8(node.node_type)?;
|
||||
buf.write_u24::<BigEndian>(node.name_offset)?;
|
||||
buf.write_u32::<BigEndian>(node.data_offset)?;
|
||||
buf.write_u32::<BigEndian>(node.size)?;
|
||||
}
|
||||
// Iterate over file names with a null byte at the end.
|
||||
for file_name in &file_names {
|
||||
buf.write_all(file_name.as_bytes())?;
|
||||
buf.write_u8(b'\0')?;
|
||||
}
|
||||
// Pad to the nearest multiple of 64 bytes.
|
||||
buf.resize((buf.len() + 63) & !63, 0);
|
||||
// Iterate over the file data and dump it. The file needs to be aligned to 32 bytes after
|
||||
// each write.
|
||||
for data in &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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +1,58 @@
|
||||
// Sample file for testing rustii library stuff.
|
||||
|
||||
use std::fs;
|
||||
use rustii::title::{tmd, ticket, content, crypto, wad};
|
||||
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());
|
||||
let tmd = tmd::TMD::from_bytes(&wad.tmd()).unwrap();
|
||||
println!("num content records: {:?}", tmd.content_records.len());
|
||||
println!("first record data: {:?}", tmd.content_records.first().unwrap());
|
||||
assert_eq!(wad.tmd(), tmd.to_bytes().unwrap());
|
||||
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());
|
||||
|
||||
let tik = ticket::Ticket::from_bytes(&wad.ticket()).unwrap();
|
||||
println!("title version from ticket is: {:?}", tik.title_version);
|
||||
println!("title key (enc): {:?}", tik.title_key);
|
||||
println!("title key (dec): {:?}", tik.dec_title_key());
|
||||
assert_eq!(wad.ticket(), tik.to_bytes().unwrap());
|
||||
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());
|
||||
|
||||
let content_region = content::ContentRegion::from_bytes(&wad.content(), tmd.content_records).unwrap();
|
||||
assert_eq!(wad.content(), content_region.to_bytes().unwrap());
|
||||
println!("content OK");
|
||||
println!("title is fakesigned: {:?}", title.is_fakesigned());
|
||||
|
||||
let content_dec = content_region.get_content_by_index(0, tik.dec_title_key()).unwrap();
|
||||
println!("content dec from index: {:?}", content_dec);
|
||||
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 content = content_region.get_enc_content_by_index(0).unwrap();
|
||||
assert_eq!(content, crypto::encrypt_content(&content_dec, tik.dec_title_key(), 0, content_region.content_records[0].content_size));
|
||||
println!("content re-encrypted OK");
|
||||
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();
|
||||
println!("TMD verified successfully: {}", result);
|
||||
|
||||
println!("wad header: {:?}", wad.header);
|
||||
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();
|
||||
println!("Ticket verified successfully: {}", result);
|
||||
|
||||
let repacked = wad.to_bytes().unwrap();
|
||||
assert_eq!(repacked, data);
|
||||
println!("wad packed OK");
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -1,45 +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 title;
|
||||
use clap::{Subcommand, Parser};
|
||||
use title::wad;
|
||||
|
||||
|
||||
#[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 {
|
||||
/// Pack/unpack/edit a WAD file
|
||||
Wad {
|
||||
#[command(subcommand)]
|
||||
command: Option<wad::Commands>,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let cli = Cli::parse();
|
||||
|
||||
match &cli.command {
|
||||
Some(Commands::Wad { command }) => {
|
||||
match command {
|
||||
Some(wad::Commands::Pack { input, output}) => {
|
||||
wad::pack_wad(input, output)
|
||||
},
|
||||
Some(wad::Commands::Unpack { input, output }) => {
|
||||
wad::unpack_wad(input, output)
|
||||
},
|
||||
&None => { /* This is handled by clap */}
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
// title/mod.rs from rustii (c) 2025 NinjaCheetah & Contributors
|
||||
// https://github.com/NinjaCheetah/rustii
|
||||
|
||||
pub mod wad;
|
||||
@@ -1,119 +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};
|
||||
use std::path::{Path, PathBuf};
|
||||
use clap::Subcommand;
|
||||
use glob::glob;
|
||||
use rustii::title::{tmd, ticket, content, wad};
|
||||
use rustii::title;
|
||||
|
||||
#[derive(Subcommand)]
|
||||
#[command(arg_required_else_help = true)]
|
||||
pub enum Commands {
|
||||
/// Pack a directory into a WAD file
|
||||
Pack {
|
||||
input: String,
|
||||
output: String
|
||||
},
|
||||
/// Unpack a WAD file into a directory
|
||||
Unpack {
|
||||
input: String,
|
||||
output: String
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pack_wad(input: &str, output: &str) {
|
||||
let in_path = Path::new(input);
|
||||
if !in_path.exists() {
|
||||
panic!("Error: Source directory does not exist.");
|
||||
}
|
||||
// Read TMD file (only accept one file).
|
||||
let tmd_files: Vec<PathBuf> = glob(&format!("{}/*.tmd", in_path.display()))
|
||||
.expect("failed to read glob pattern")
|
||||
.filter_map(|f| f.ok()).collect();
|
||||
if tmd_files.is_empty() {
|
||||
panic!("Error: No TMD file found in the source directory.");
|
||||
} else if tmd_files.len() > 1 {
|
||||
panic!("Error: More than one TMD file found in the source directory.")
|
||||
}
|
||||
let tmd = tmd::TMD::from_bytes(&fs::read(&tmd_files[0]).expect("could not read TMD file")).unwrap();
|
||||
// Read Ticket file (only accept one file).
|
||||
let ticket_files: Vec<PathBuf> = glob(&format!("{}/*.tik", in_path.display()))
|
||||
.expect("failed to read glob pattern")
|
||||
.filter_map(|f| f.ok()).collect();
|
||||
if ticket_files.is_empty() {
|
||||
panic!("Error: No Ticket file found in the source directory.");
|
||||
} else if ticket_files.len() > 1 {
|
||||
panic!("Error: More than one Ticket file found in the source directory.")
|
||||
}
|
||||
let tik = ticket::Ticket::from_bytes(&fs::read(&ticket_files[0]).expect("could not read Ticket file")).unwrap();
|
||||
// Read cert chain (only accept one file).
|
||||
let cert_files: Vec<PathBuf> = glob(&format!("{}/*.cert", in_path.display()))
|
||||
.expect("failed to read glob pattern")
|
||||
.filter_map(|f| f.ok()).collect();
|
||||
if cert_files.is_empty() {
|
||||
panic!("Error: No cert file found in the source directory.");
|
||||
} else if cert_files.len() > 1 {
|
||||
panic!("Error: More than one Cert file found in the source directory.")
|
||||
}
|
||||
let cert_chain = fs::read(&cert_files[0]).expect("could not read cert chain file");
|
||||
// Read footer, if one exists (only accept one file).
|
||||
let footer_files: Vec<PathBuf> = glob(&format!("{}/*.footer", in_path.display()))
|
||||
.expect("failed to read glob pattern")
|
||||
.filter_map(|f| f.ok()).collect();
|
||||
let mut footer: Vec<u8> = Vec::new();
|
||||
if footer_files.len() == 1 {
|
||||
footer = fs::read(&footer_files[0]).unwrap();
|
||||
}
|
||||
// Iterate over expected content and read it into a content region.
|
||||
let mut content_region = content::ContentRegion::new(tmd.content_records.clone()).expect("could not create content region");
|
||||
for content in tmd.content_records.clone() {
|
||||
let data = fs::read(format!("{}/{:08X}.app", in_path.display(), content.index)).expect("could not read required content");
|
||||
content_region.load_content(&data, content.index as usize, tik.dec_title_key()).expect("failed to load content into ContentRegion");
|
||||
}
|
||||
let wad = wad::WAD::from_parts(&cert_chain, &[], &tik, &tmd, &content_region, &footer).expect("failed to create 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().unwrap()).expect("could not write to wad file");
|
||||
println!("WAD file packed!");
|
||||
}
|
||||
|
||||
pub fn unpack_wad(input: &str, output: &str) {
|
||||
let wad_file = fs::read(input).expect("could not read WAD");
|
||||
let title = title::Title::from_bytes(&wad_file).unwrap();
|
||||
let tid = hex::encode(title.tmd.title_id);
|
||||
// Create output directory if it doesn't exist.
|
||||
if !Path::new(output).exists() {
|
||||
fs::create_dir(output).expect("could not create output directory");
|
||||
}
|
||||
let out_path = Path::new(output);
|
||||
// Write out all WAD components.
|
||||
let tmd_file_name = format!("{}.tmd", tid);
|
||||
fs::write(Path::join(out_path, tmd_file_name), title.tmd.to_bytes().unwrap()).expect("could not write TMD file");
|
||||
let ticket_file_name = format!("{}.tik", tid);
|
||||
fs::write(Path::join(out_path, ticket_file_name), title.ticket.to_bytes().unwrap()).expect("could not write Ticket file");
|
||||
let cert_file_name = format!("{}.cert", tid);
|
||||
fs::write(Path::join(out_path, cert_file_name), title.cert_chain()).expect("could not write Cert file");
|
||||
let meta_file_name = format!("{}.footer", tid);
|
||||
fs::write(Path::join(out_path, meta_file_name), title.meta()).expect("could not write footer file");
|
||||
// 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).unwrap();
|
||||
fs::write(Path::join(out_path, content_file_name), dec_content).unwrap();
|
||||
}
|
||||
println!("WAD file unpacked!");
|
||||
}
|
||||
53
src/bin/rustwii/archive/ash.rs
Normal file
53
src/bin/rustwii/archive/ash.rs
Normal file
@@ -0,0 +1,53 @@
|
||||
// archive/ash.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
|
||||
// https://github.com/NinjaCheetah/rustwii
|
||||
//
|
||||
// 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 rustwii::archive::ash;
|
||||
|
||||
#[derive(Subcommand)]
|
||||
#[command(arg_required_else_help = true)]
|
||||
pub enum Commands {
|
||||
/// Compress a file with ASH compression (NOT IMPLEMENTED)
|
||||
Compress {
|
||||
/// The path to the file to compress
|
||||
input: String,
|
||||
/// An optional output name; defaults to <input name>.ash
|
||||
#[arg(short, long)]
|
||||
output: Option<String>,
|
||||
},
|
||||
/// Decompress an ASH-compressed file
|
||||
Decompress {
|
||||
/// The path to the file to decompress
|
||||
input: String,
|
||||
/// An optional output name; defaults to <input name>.out
|
||||
#[arg(short, long)]
|
||||
output: Option<String>,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compress_ash(_input: &str, _output: &Option<String>) -> Result<()> {
|
||||
todo!();
|
||||
}
|
||||
|
||||
pub fn decompress_ash(input: &str, output: &Option<String>) -> Result<()> {
|
||||
let in_path = Path::new(input);
|
||||
if !in_path.exists() {
|
||||
bail!("Compressed file \"{}\" could not be found.", in_path.display());
|
||||
}
|
||||
let compressed = fs::read(in_path)?;
|
||||
let decompressed = ash::decompress_ash(&compressed, None, None).with_context(|| "An unknown error occurred while decompressing the data.")?;
|
||||
let out_path = if output.is_some() {
|
||||
PathBuf::from(output.clone().unwrap())
|
||||
} else {
|
||||
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());
|
||||
Ok(())
|
||||
}
|
||||
65
src/bin/rustwii/archive/lz77.rs
Normal file
65
src/bin/rustwii/archive/lz77.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
// archive/lz77.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
|
||||
// https://github.com/NinjaCheetah/rustwii
|
||||
//
|
||||
// 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 rustwii::archive::lz77;
|
||||
|
||||
#[derive(Subcommand)]
|
||||
#[command(arg_required_else_help = true)]
|
||||
pub enum Commands {
|
||||
/// Compress a file with LZ77 compression
|
||||
Compress {
|
||||
/// The path to the file to compress
|
||||
input: String,
|
||||
/// An optional output name; defaults to <input name>.lz77
|
||||
#[arg(short, long)]
|
||||
output: Option<String>,
|
||||
},
|
||||
/// Decompress an LZ77-compressed file
|
||||
Decompress {
|
||||
/// The path to the file to decompress
|
||||
input: String,
|
||||
/// An optional output name; defaults to <input name>.out
|
||||
#[arg(short, long)]
|
||||
output: Option<String>,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn compress_lz77(input: &str, output: &Option<String>) -> Result<()> {
|
||||
let in_path = Path::new(input);
|
||||
if !in_path.exists() {
|
||||
bail!("Input file \"{}\" could not be found.", in_path.display());
|
||||
}
|
||||
let decompressed = fs::read(in_path)?;
|
||||
let compressed = lz77::compress_lz77(&decompressed).with_context(|| "An unknown error occurred while compressing the data.")?;
|
||||
let out_path = if output.is_some() {
|
||||
PathBuf::from(output.clone().unwrap())
|
||||
} else {
|
||||
PathBuf::from(in_path).with_extension(format!("{}.lz77", in_path.extension().unwrap_or("".as_ref()).to_str().unwrap()))
|
||||
};
|
||||
fs::write(out_path.clone(), compressed)?;
|
||||
println!("Successfully compressed file to \"{}\"!", out_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn decompress_lz77(input: &str, output: &Option<String>) -> Result<()> {
|
||||
let in_path = Path::new(input);
|
||||
if !in_path.exists() {
|
||||
bail!("Compressed file \"{}\" could not be found.", in_path.display());
|
||||
}
|
||||
let compressed = fs::read(in_path)?;
|
||||
let decompressed = lz77::decompress_lz77(&compressed).with_context(|| "An unknown error occurred while decompressing the data.")?;
|
||||
let out_path = if output.is_some() {
|
||||
PathBuf::from(output.clone().unwrap())
|
||||
} else {
|
||||
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());
|
||||
Ok(())
|
||||
}
|
||||
7
src/bin/rustwii/archive/mod.rs
Normal file
7
src/bin/rustwii/archive/mod.rs
Normal 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;
|
||||
173
src/bin/rustwii/archive/theme.rs
Normal file
173
src/bin/rustwii/archive/theme.rs
Normal 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(())
|
||||
}
|
||||
100
src/bin/rustwii/archive/u8.rs
Normal file
100
src/bin/rustwii/archive/u8.rs
Normal 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(())
|
||||
}
|
||||
104
src/bin/rustwii/filetypes.rs
Normal file
104
src/bin/rustwii/filetypes.rs
Normal file
@@ -0,0 +1,104 @@
|
||||
// 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, Seek, SeekFrom};
|
||||
use std::path::Path;
|
||||
use regex::RegexBuilder;
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(PartialEq)]
|
||||
pub enum WiiFileType {
|
||||
Wad,
|
||||
Tmd,
|
||||
Ticket,
|
||||
U8,
|
||||
}
|
||||
|
||||
pub fn identify_file_type(input: &str) -> Option<WiiFileType> {
|
||||
let input = Path::new(input);
|
||||
let re = RegexBuilder::new(r"tmd\.?[0-9]*").case_insensitive(true).build().unwrap();
|
||||
// == TMD ==
|
||||
if re.is_match(input.to_str()?) ||
|
||||
input.file_name().is_some_and(|f| f.eq_ignore_ascii_case("tmd.bin")) ||
|
||||
input.extension().is_some_and(|f| f.eq_ignore_ascii_case("tmd")) {
|
||||
return Some(WiiFileType::Tmd);
|
||||
}
|
||||
// == Ticket ==
|
||||
if input.extension().is_some_and(|f| f.eq_ignore_ascii_case("tik")) ||
|
||||
input.file_name().is_some_and(|f| f.eq_ignore_ascii_case("ticket.bin")) ||
|
||||
input.file_name().is_some_and(|f| f.eq_ignore_ascii_case("cetk")) {
|
||||
return Some(WiiFileType::Ticket);
|
||||
}
|
||||
// == WAD ==
|
||||
if input.extension().is_some_and(|f| f.eq_ignore_ascii_case("wad")) {
|
||||
return Some(WiiFileType::Wad);
|
||||
}
|
||||
// == 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! ==
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_tmd() {
|
||||
assert_eq!(identify_file_type("tmd"), Some(WiiFileType::Tmd));
|
||||
assert_eq!(identify_file_type("TMD"), Some(WiiFileType::Tmd));
|
||||
assert_eq!(identify_file_type("tmd.bin"), Some(WiiFileType::Tmd));
|
||||
assert_eq!(identify_file_type("TMD.BIN"), Some(WiiFileType::Tmd));
|
||||
assert_eq!(identify_file_type("tmd.513"), Some(WiiFileType::Tmd));
|
||||
assert_eq!(identify_file_type("0000000100000002.tmd"), Some(WiiFileType::Tmd));
|
||||
assert_eq!(identify_file_type("0000000100000002.TMD"), Some(WiiFileType::Tmd));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_tik() {
|
||||
assert_eq!(identify_file_type("ticket.bin"), Some(WiiFileType::Ticket));
|
||||
assert_eq!(identify_file_type("TICKET.BIN"), Some(WiiFileType::Ticket));
|
||||
assert_eq!(identify_file_type("cetk"), Some(WiiFileType::Ticket));
|
||||
assert_eq!(identify_file_type("CETK"), Some(WiiFileType::Ticket));
|
||||
assert_eq!(identify_file_type("0000000100000002.tik"), Some(WiiFileType::Ticket));
|
||||
assert_eq!(identify_file_type("0000000100000002.TIK"), Some(WiiFileType::Ticket));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_wad() {
|
||||
assert_eq!(identify_file_type("0000000100000002.wad"), Some(WiiFileType::Wad));
|
||||
assert_eq!(identify_file_type("0000000100000002.WAD"), Some(WiiFileType::Wad));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_no_match() {
|
||||
assert_eq!(identify_file_type("somefile.txt"), None);
|
||||
}
|
||||
}
|
||||
300
src/bin/rustwii/info.rs
Normal file
300
src/bin/rustwii/info.rs
Normal file
@@ -0,0 +1,300 @@
|
||||
// info.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
|
||||
// https://github.com/NinjaCheetah/rustwii
|
||||
//
|
||||
// Code for the info command in the rustwii CLI.
|
||||
|
||||
use std::{str, fs};
|
||||
use std::path::Path;
|
||||
use anyhow::{bail, Context, Result};
|
||||
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.
|
||||
fn print_tid(title_id: [u8; 8]) -> Result<()> {
|
||||
let ascii = String::from_utf8_lossy(&title_id[4..]).trim_end_matches('\0').trim_start_matches('\0').to_owned();
|
||||
let ascii_tid = if ascii.len() == 4 {
|
||||
Some(ascii)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
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());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Same as above, both the TMD and Ticket info print the title version in the same way.
|
||||
fn print_title_version(title_version: u16, title_id: [u8; 8], is_vwii: bool) -> Result<()> {
|
||||
let converted_ver = versions::dec_to_standard(title_version, &hex::encode(title_id), Some(is_vwii));
|
||||
if hex::encode(title_id).eq("0000000100000001") {
|
||||
println!(" Title Version: {} (boot2v{})", title_version, title_version);
|
||||
} else if hex::encode(title_id)[..8].eq("00000001") && converted_ver.is_some() {
|
||||
println!(" Title Version: {} ({})", title_version, converted_ver.unwrap());
|
||||
} else {
|
||||
println!(" Title Version: {}", title_version);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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") {
|
||||
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());
|
||||
}
|
||||
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)");
|
||||
}
|
||||
else if signature_issuer.contains("CP00000007") {
|
||||
println!(" Certificate: CP00000007 (Development)");
|
||||
println!(" Certificate Issuer: Root-CA00000002 (Development)");
|
||||
}
|
||||
else if signature_issuer.contains("CP00000005") {
|
||||
println!(" Certificate: CP00000005 (Development/Unknown)");
|
||||
println!(" Certificate Issuer: Root-CA00000002 (Development)");
|
||||
}
|
||||
else if signature_issuer.contains("CP10000000") {
|
||||
println!(" Certificate: CP10000000 (Arcade)");
|
||||
println!(" Certificate Issuer: Root-CA10000000 (Arcade)");
|
||||
}
|
||||
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()))
|
||||
.unwrap_or_default().chars().last() {
|
||||
Some('U') => "USA",
|
||||
Some('E') => "EUR",
|
||||
Some('J') => "JPN",
|
||||
Some('K') => "KOR",
|
||||
_ => "None"
|
||||
}
|
||||
} 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());
|
||||
println!(" DVD Video Access: {}", tmd.check_access_right(tmd::AccessRight::DVDVideo));
|
||||
println!(" AHB Access: {}", tmd.check_access_right(tmd::AccessRight::AHB));
|
||||
if let Some(cert) = cert {
|
||||
let signing_str = match cert::verify_tmd(&cert, tmd) {
|
||||
Ok(result) => match result {
|
||||
true => "Valid (Unmodified TMD)",
|
||||
false => {
|
||||
if tmd.is_fakesigned() {
|
||||
"Fakesigned"
|
||||
} else {
|
||||
"Invalid (Modified TMD)"
|
||||
}
|
||||
},
|
||||
},
|
||||
Err(_) => {
|
||||
if tmd.is_fakesigned() {
|
||||
"Fakesigned"
|
||||
} else {
|
||||
"Invalid (Modified TMD)"
|
||||
}
|
||||
}
|
||||
};
|
||||
println!(" Signature: {}", signing_str);
|
||||
} else {
|
||||
println!(" Fakesigned: {}", tmd.is_fakesigned());
|
||||
}
|
||||
println!("\nContent Info");
|
||||
println!(" Total Contents: {}", tmd.content_records().len());
|
||||
println!(" Boot Content Index: {}", tmd.boot_index());
|
||||
println!(" 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 ({} 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<()> {
|
||||
// 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();
|
||||
if signature_issuer.contains("XS00000003") {
|
||||
println!(" Certificate: XS00000003 (Retail)");
|
||||
println!(" Certificate Issuer: Root-CA00000001 (Retail)");
|
||||
} else if signature_issuer.contains("XS00000006") {
|
||||
println!(" Certificate: XS00000006 (Development)");
|
||||
println!(" Certificate Issuer: Root-CA00000002 (Development)");
|
||||
} else if signature_issuer.contains("XS00000004") {
|
||||
println!(" Certificate: XS00000004 (Development/Unknown)");
|
||||
println!(" Certificate Issuer: Root-CA00000002 (Development)");
|
||||
} else {
|
||||
println!(" Certificate Info: {} (Unknown)", signature_issuer);
|
||||
}
|
||||
let key = match ticket.common_key_index() {
|
||||
0 => {
|
||||
if ticket.is_dev() { "Common (Development)" }
|
||||
else { "Common (Retail)" }
|
||||
}
|
||||
1 => "Korean",
|
||||
2 => "vWii",
|
||||
_ => "Unknown (Likely Common)"
|
||||
};
|
||||
println!(" Decryption Key: {}", key);
|
||||
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 => {
|
||||
if ticket.is_fakesigned() {
|
||||
"Fakesigned"
|
||||
} else {
|
||||
"Invalid (Modified Ticket)"
|
||||
}
|
||||
},
|
||||
},
|
||||
Err(_) => {
|
||||
if ticket.is_fakesigned() {
|
||||
"Fakesigned"
|
||||
} else {
|
||||
"Invalid (Modified Ticket)"
|
||||
}
|
||||
}
|
||||
};
|
||||
println!(" Signature: {}", signing_str);
|
||||
} else {
|
||||
println!(" Fakesigned: {}", ticket.is_fakesigned());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_wad_info(wad: wad::WAD) -> Result<()> {
|
||||
println!("WAD Info");
|
||||
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::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 {
|
||||
println!(" Installed Size: {}-{} blocks", min_size_blocks, max_size_blocks);
|
||||
}
|
||||
let min_size = title.title_size(None)? as f64 / 1048576.0;
|
||||
let max_size = title.title_size(Some(true))? as f64 / 1048576.0;
|
||||
if min_size == max_size {
|
||||
println!(" Installed Size (MB): {:.2} MB", min_size);
|
||||
} else {
|
||||
println!(" Installed Size (MB): {:.2}-{:.2} MB", min_size, max_size);
|
||||
}
|
||||
println!(" Has Meta/Footer: {}", wad.meta_size() != 0);
|
||||
println!(" Has CRL: {}", wad.crl_size() != 0);
|
||||
let signing_str = match title.verify() {
|
||||
Ok(result) => match result {
|
||||
true => "Legitimate (Unmodified TMD + Ticket)",
|
||||
false => {
|
||||
if title.is_fakesigned() {
|
||||
"Fakesigned"
|
||||
} 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())? {
|
||||
"Edited (Modified TMD, Unmodified Ticket)"
|
||||
} else {
|
||||
"Illegitimate (Modified TMD + Ticket)"
|
||||
}
|
||||
},
|
||||
},
|
||||
Err(_) => {
|
||||
if title.is_fakesigned() {
|
||||
"Fakesigned"
|
||||
} else {
|
||||
"Illegitimate (Modified TMD + Ticket)"
|
||||
}
|
||||
}
|
||||
};
|
||||
println!(" Signing Status: {}", signing_str);
|
||||
println!();
|
||||
print_ticket_info(title.ticket(), Some(title.cert_chain().ticket_cert()))?;
|
||||
println!();
|
||||
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(())
|
||||
}
|
||||
|
||||
pub fn info(input: &str) -> Result<()> {
|
||||
let in_path = Path::new(input);
|
||||
if !in_path.exists() {
|
||||
bail!("Input file \"{}\" does not exist.", in_path.display());
|
||||
}
|
||||
match identify_file_type(input) {
|
||||
Some(WiiFileType::Tmd) => {
|
||||
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)?).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)?).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.");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
252
src/bin/rustwii/main.rs
Normal file
252
src/bin/rustwii/main.rs
Normal 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(())
|
||||
}
|
||||
336
src/bin/rustwii/nand/emunand.rs
Normal file
336
src/bin/rustwii/nand/emunand.rs
Normal 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(())
|
||||
}
|
||||
5
src/bin/rustwii/nand/mod.rs
Normal file
5
src/bin/rustwii/nand/mod.rs
Normal 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;
|
||||
140
src/bin/rustwii/nand/setting.rs
Normal file
140
src/bin/rustwii/nand/setting.rs
Normal 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(®ion) {
|
||||
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(())
|
||||
}
|
||||
65
src/bin/rustwii/title/fakesign.rs
Normal file
65
src/bin/rustwii/title/fakesign.rs
Normal file
@@ -0,0 +1,65 @@
|
||||
// title/fakesign.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
|
||||
// https://github.com/NinjaCheetah/rustwii
|
||||
//
|
||||
// Code for the fakesign command in the rustwii CLI.
|
||||
|
||||
use std::{str, fs};
|
||||
use std::path::{Path, PathBuf};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use rustwii::{title, title::tmd, title::ticket};
|
||||
use crate::filetypes::{WiiFileType, identify_file_type};
|
||||
|
||||
pub fn fakesign(input: &str, output: &Option<String>) -> Result<()> {
|
||||
let in_path = Path::new(input);
|
||||
if !in_path.exists() {
|
||||
bail!("Input file \"{}\" does not exist.", in_path.display());
|
||||
}
|
||||
match identify_file_type(input) {
|
||||
Some(WiiFileType::Wad) => {
|
||||
let out_path = if output.is_some() {
|
||||
PathBuf::from(output.clone().unwrap().as_str()).with_extension("wad")
|
||||
} else {
|
||||
PathBuf::from(input)
|
||||
};
|
||||
// Load WAD into a Title instance, then fakesign it.
|
||||
let mut title = title::Title::from_bytes(fs::read(in_path).with_context(|| "Could not open WAD file for reading.")?.as_slice())
|
||||
.with_context(|| "The provided WAD file could not be parsed, and is likely invalid.")?;
|
||||
title.fakesign().with_context(|| "An unknown error occurred while fakesigning the provided WAD.")?;
|
||||
// Write output file.
|
||||
fs::write(out_path, title.to_wad()?.to_bytes()?).with_context(|| "Could not open output file for writing.")?;
|
||||
println!("WAD fakesigned!");
|
||||
},
|
||||
Some(WiiFileType::Tmd) => {
|
||||
let out_path = if output.is_some() {
|
||||
PathBuf::from(output.clone().unwrap().as_str()).with_extension("tmd")
|
||||
} else {
|
||||
PathBuf::from(input)
|
||||
};
|
||||
// Load TMD into a TMD instance, then fakesign it.
|
||||
let mut tmd = tmd::TMD::from_bytes(fs::read(in_path).with_context(|| "Could not open TMD file for reading.")?.as_slice())
|
||||
.with_context(|| "The provided TMD file could not be parsed, and is likely invalid.")?;
|
||||
tmd.fakesign().with_context(|| "An unknown error occurred while fakesigning the provided TMD.")?;
|
||||
// Write output file.
|
||||
fs::write(out_path, tmd.to_bytes()?).with_context(|| "Could not open output file for writing.")?;
|
||||
println!("TMD fakesigned!");
|
||||
},
|
||||
Some(WiiFileType::Ticket) => {
|
||||
let out_path = if output.is_some() {
|
||||
PathBuf::from(output.clone().unwrap().as_str()).with_extension("tik")
|
||||
} else {
|
||||
PathBuf::from(input)
|
||||
};
|
||||
// Load Ticket into a Ticket instance, then fakesign it.
|
||||
let mut ticket = ticket::Ticket::from_bytes(fs::read(in_path).with_context(|| "Could not open Ticket file for reading.")?.as_slice())
|
||||
.with_context(|| "The provided Ticket file could not be parsed, and is likely invalid.")?;
|
||||
ticket.fakesign().with_context(|| "An unknown error occurred while fakesigning the provided Ticket.")?;
|
||||
// Write output file.
|
||||
fs::write(out_path, ticket.to_bytes()?).with_context(|| "Could not open output file for writing.")?;
|
||||
println!("Ticket fakesigned!");
|
||||
},
|
||||
_ => {
|
||||
bail!("You can only fakesign TMDs, Tickets, and WADs!");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
150
src/bin/rustwii/title/iospatcher.rs
Normal file
150
src/bin/rustwii/title/iospatcher.rs
Normal 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(())
|
||||
}
|
||||
9
src/bin/rustwii/title/mod.rs
Normal file
9
src/bin/rustwii/title/mod.rs
Normal 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;
|
||||
281
src/bin/rustwii/title/nus.rs
Normal file
281
src/bin/rustwii/title/nus.rs
Normal file
@@ -0,0 +1,281 @@
|
||||
// title/nus.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
|
||||
// https://github.com/NinjaCheetah/rustwii
|
||||
//
|
||||
// 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 rustwii::title::{cert, crypto, nus, ticket, tmd};
|
||||
use rustwii::title;
|
||||
|
||||
#[derive(Subcommand)]
|
||||
#[command(arg_required_else_help = true)]
|
||||
pub enum Commands {
|
||||
/// Download specific content from the NUS
|
||||
Content {
|
||||
/// The Title ID that the content belongs to
|
||||
tid: String,
|
||||
/// The Content ID of the content (in hex format, like 000000xx)
|
||||
cid: String,
|
||||
/// The title version that the content belongs to (only required for decryption)
|
||||
#[arg(short, long)]
|
||||
version: Option<u16>,
|
||||
/// An optional content file name; defaults to <cid>(.app)
|
||||
#[arg(short, long)]
|
||||
output: Option<String>,
|
||||
/// Decrypt the content
|
||||
#[arg(short, long)]
|
||||
decrypt: bool,
|
||||
},
|
||||
/// Download a Ticket from the NUS
|
||||
Ticket {
|
||||
/// The Title ID that the Ticket is for
|
||||
tid: String,
|
||||
/// An optional Ticket name; defaults to <tid>.tik
|
||||
#[arg(short, long)]
|
||||
output: Option<String>,
|
||||
},
|
||||
/// Download a title from the NUS
|
||||
Title {
|
||||
/// The Title ID of the Title to download
|
||||
tid: String,
|
||||
/// The version of the Title to download
|
||||
#[arg(short, long)]
|
||||
version: Option<u16>,
|
||||
#[command(flatten)]
|
||||
output: TitleOutputType,
|
||||
},
|
||||
/// Download a TMD from the NUS
|
||||
Tmd {
|
||||
/// The Title ID that the TMD is for
|
||||
tid: String,
|
||||
/// The version of the TMD to download
|
||||
#[arg(short, long)]
|
||||
version: Option<u16>,
|
||||
/// An optional TMD name; defaults to <tid>.tmd
|
||||
#[arg(short, long)]
|
||||
output: Option<String>,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Args)]
|
||||
#[clap(next_help_heading = "Output Format")]
|
||||
#[group(multiple = false, required = true)]
|
||||
pub struct TitleOutputType {
|
||||
/// Download the Title data to the specified output directory
|
||||
#[arg(short, long)]
|
||||
output: Option<String>,
|
||||
/// Download the Title to a WAD file
|
||||
#[arg(short, long)]
|
||||
wad: Option<String>,
|
||||
}
|
||||
|
||||
pub fn download_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!");
|
||||
}
|
||||
let cid = u32::from_str_radix(cid, 16).with_context(|| "The specified Content ID is invalid!")?;
|
||||
let tid: [u8; 8] = hex::decode(tid)?.try_into().unwrap();
|
||||
let content = nus::download_content(tid, cid, true).with_context(|| "Content data could not be downloaded.")?;
|
||||
let out_path = if output.is_some() {
|
||||
PathBuf::from(output.clone().unwrap())
|
||||
} else if *decrypt {
|
||||
PathBuf::from(format!("{:08X}.app", cid))
|
||||
} else {
|
||||
PathBuf::from(format!("{:08X}", cid))
|
||||
};
|
||||
if *decrypt {
|
||||
// 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.unwrap()
|
||||
} else {
|
||||
bail!("You must specify the title version that the requested content belongs to for decryption!");
|
||||
};
|
||||
let tmd_res = &nus::download_tmd(tid, Some(version), true);
|
||||
println!(" - Downloading TMD...");
|
||||
let tmd = match tmd_res {
|
||||
Ok(tmd) => tmd::TMD::from_bytes(tmd)?,
|
||||
Err(_) => bail!("No TMD could be found for the specified version! Check the version and try again.")
|
||||
};
|
||||
println!(" - Downloading Ticket...");
|
||||
let tik_res = &nus::download_ticket(tid, true);
|
||||
let tik = match tik_res {
|
||||
Ok(tik) => ticket::Ticket::from_bytes(tik)?,
|
||||
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()
|
||||
.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.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();
|
||||
hasher.update(&content_dec);
|
||||
let result = hasher.finalize();
|
||||
if result[..] != content_hash {
|
||||
bail!("The content's hash did not match the expected value. (Hash was {}, but the expected hash is {}.)",
|
||||
hex::encode(result), hex::encode(content_hash));
|
||||
}
|
||||
fs::write(&out_path, content_dec).with_context(|| format!("Failed to open content file \"{}\" for writing.", out_path.display()))?;
|
||||
} else {
|
||||
// If we're not decrypting, just write the file out and call it a day.
|
||||
fs::write(&out_path, content).with_context(|| format!("Failed to open content file \"{}\" for writing.", out_path.display()))?
|
||||
}
|
||||
println!("Successfully downloaded content with Content ID {:08X} to file \"{}\"!", cid, out_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn download_ticket(tid: &str, output: &Option<String>) -> Result<()> {
|
||||
println!("Downloading Ticket for title {tid}...");
|
||||
if tid.len() != 16 {
|
||||
bail!("The specified Title ID is invalid!");
|
||||
}
|
||||
let out_path = if output.is_some() {
|
||||
PathBuf::from(output.clone().unwrap())
|
||||
} else {
|
||||
PathBuf::from(format!("{}.tik", tid))
|
||||
};
|
||||
let tid: [u8; 8] = hex::decode(tid)?.try_into().unwrap();
|
||||
let tik_data = nus::download_ticket(tid, true).with_context(|| "Ticket data could not be downloaded.")?;
|
||||
fs::write(&out_path, tik_data)?;
|
||||
println!("Successfully downloaded Ticket to \"{}\"!", out_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn download_title_dir(title: title::Title, output: String) -> Result<()> {
|
||||
println!(" - Saving downloaded data...");
|
||||
let out_path = PathBuf::from(output);
|
||||
if out_path.exists() {
|
||||
if !out_path.is_dir() {
|
||||
bail!("A file already exists with the specified directory name!");
|
||||
}
|
||||
} else {
|
||||
fs::create_dir(&out_path).with_context(|| format!("The output directory \"{}\" could not be created.", out_path.display()))?;
|
||||
}
|
||||
let tid = hex::encode(title.tmd().title_id());
|
||||
println!(" - Saving TMD...");
|
||||
fs::write(out_path.join(format!("{}.tmd", &tid)), title.tmd().to_bytes()?).with_context(|| format!("Failed to open TMD file \"{}.tmd\" for writing.", tid))?;
|
||||
println!(" - Saving Ticket...");
|
||||
fs::write(out_path.join(format!("{}.tik", &tid)), title.ticket().to_bytes()?).with_context(|| format!("Failed to open Ticket file \"{}.tmd\" for writing.", tid))?;
|
||||
println!(" - Saving certificate chain...");
|
||||
fs::write(out_path.join(format!("{}.cert", &tid)), title.cert_chain().to_bytes()?).with_context(|| format!("Failed to open certificate chain file \"{}.cert\" for writing.", tid))?;
|
||||
// Iterate over the content files and write them out in encrypted form.
|
||||
for record in title.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))?;
|
||||
}
|
||||
println!("Successfully downloaded title with Title ID {} to directory \"{}\"!", tid, out_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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() {
|
||||
if !out_path.is_dir() {
|
||||
bail!("A file already exists with the specified directory name!");
|
||||
}
|
||||
} else {
|
||||
fs::create_dir(&out_path).with_context(|| format!("The output directory \"{}\" could not be created.", out_path.display()))?;
|
||||
}
|
||||
let tid = hex::encode(tmd.title_id());
|
||||
println!(" - Saving TMD...");
|
||||
fs::write(out_path.join(format!("{}.tmd", &tid)), tmd.to_bytes()?).with_context(|| format!("Failed to open TMD file \"{}.tmd\" for writing.", tid))?;
|
||||
println!(" - Saving certificate chain...");
|
||||
fs::write(out_path.join(format!("{}.cert", &tid)), cert_chain.to_bytes()?).with_context(|| format!("Failed to open certificate chain file \"{}.cert\" for writing.", tid))?;
|
||||
// Iterate over the content files and write them out in encrypted form.
|
||||
for record in tmd.content_records().iter() {
|
||||
println!(" - Saving content with Content ID {}...", 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());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn download_title_wad(title: title::Title, output: String) -> Result<()> {
|
||||
println!(" - Packing WAD...");
|
||||
let out_path = PathBuf::from(output).with_extension("wad");
|
||||
fs::write(&out_path, title.to_wad().with_context(|| "A WAD could not be packed.")?.to_bytes()?).with_context(|| format!("Could not open WAD file \"{}\" for writing.", out_path.display()))?;
|
||||
println!("Successfully downloaded title with Title ID {} to WAD file \"{}\"!", hex::encode(title.tmd().title_id()), out_path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn download_title(tid: &str, version: &Option<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.unwrap());
|
||||
} else {
|
||||
println!("Downloading title {} vLatest, please wait...", tid);
|
||||
}
|
||||
let tid: [u8; 8] = hex::decode(tid)?.try_into().unwrap();
|
||||
println!(" - Downloading and parsing TMD...");
|
||||
let tmd = tmd::TMD::from_bytes(&nus::download_tmd(tid, *version, true).with_context(|| "TMD data could not be downloaded.")?)?;
|
||||
println!(" - Downloading and parsing Ticket...");
|
||||
let tik_res = &nus::download_ticket(tid, true);
|
||||
let tik = match tik_res {
|
||||
Ok(tik) => Some(ticket::Ticket::from_bytes(tik)?),
|
||||
Err(_) => {
|
||||
if output.wad.is_some() {
|
||||
bail!("--wad was specified, but this Title has no common Ticket and cannot be packed into a WAD!");
|
||||
} else {
|
||||
println!(" - No Ticket is available!");
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
// Build a vec of contents by iterating over the content records and downloading each one.
|
||||
let mut contents: Vec<Vec<u8>> = Vec::new();
|
||||
for record in tmd.content_records().iter() {
|
||||
println!(" - Downloading content {} of {} (Content ID: {}, Size: {} bytes)...",
|
||||
record.index + 1, &tmd.content_records().len(), record.content_id, record.content_size);
|
||||
contents.push(nus::download_content(tid, record.content_id, true).with_context(|| format!("Content with Content ID {} could not be downloaded.", record.content_id))?);
|
||||
println!(" - Done!");
|
||||
}
|
||||
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 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_with_content(cert_chain, None, tik, tmd, contents, None)?;
|
||||
if output.wad.is_some() {
|
||||
download_title_wad(title, output.wad.clone().unwrap())?;
|
||||
} else {
|
||||
download_title_dir(title, output.output.clone().unwrap())?;
|
||||
}
|
||||
} else {
|
||||
// If we're downloading to a directory and have no Ticket, save the TMD and encrypted
|
||||
// contents to the directory only.
|
||||
download_title_dir_enc(tmd, contents, cert_chain, output.output.clone().unwrap())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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!");
|
||||
}
|
||||
let out_path = if output.is_some() {
|
||||
PathBuf::from(output.clone().unwrap())
|
||||
} else if version.is_some() {
|
||||
PathBuf::from(format!("{}.tmd.{}", tid, version.unwrap()))
|
||||
} else {
|
||||
PathBuf::from(format!("{}.tmd", tid))
|
||||
};
|
||||
let tid: [u8; 8] = hex::decode(tid)?.try_into().unwrap();
|
||||
let tmd_data = nus::download_tmd(tid, *version, true).with_context(|| "TMD data could not be downloaded.")?;
|
||||
fs::write(&out_path, tmd_data)?;
|
||||
println!("Successfully downloaded TMD to \"{}\"!", out_path.display());
|
||||
Ok(())
|
||||
}
|
||||
73
src/bin/rustwii/title/shared.rs
Normal file
73
src/bin/rustwii/title/shared.rs
Normal 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)
|
||||
}
|
||||
130
src/bin/rustwii/title/tmd.rs
Normal file
130
src/bin/rustwii/title/tmd.rs
Normal 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(())
|
||||
}
|
||||
505
src/bin/rustwii/title/wad.rs
Normal file
505
src/bin/rustwii/title/wad.rs
Normal 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(())
|
||||
}
|
||||
@@ -1,5 +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
264
src/nand/emunand.rs
Normal 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
9
src/nand/mod.rs
Normal 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
100
src/nand/setting.rs
Normal 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)
|
||||
}
|
||||
}
|
||||
104
src/nand/sharedcontentmap.rs
Normal file
104
src/nand/sharedcontentmap.rs
Normal 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
93
src/nand/sys.rs
Normal 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))
|
||||
}
|
||||
}
|
||||
375
src/title/cert.rs
Normal file
375
src/title/cert.rs
Normal file
@@ -0,0 +1,375 @@
|
||||
// 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.
|
||||
|
||||
use std::io::{Cursor, Read, Write, SeekFrom, Seek};
|
||||
use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt};
|
||||
use rsa::pkcs8::DecodePublicKey;
|
||||
use rsa::pkcs1v15::Pkcs1v15Sign;
|
||||
use rsa::{RsaPublicKey, BigUint};
|
||||
use sha1::{Digest, Sha1};
|
||||
use thiserror::Error;
|
||||
use crate::title::{tmd, ticket};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CertificateError {
|
||||
#[error("certificate appears to be signed with invalid key type `{0}`")]
|
||||
InvalidSignatureKeyType(u32),
|
||||
#[error("certificate appears to contain key with invalid type `{0}`")]
|
||||
InvalidContainedKeyType(u32),
|
||||
#[error("certificate chain contains an unknown certificate")]
|
||||
UnknownCertificate,
|
||||
#[error("certificate chain is missing required certificate `{0}`")]
|
||||
MissingCertificate(String),
|
||||
#[error("attempted to load incorrect certificate `{0}`")]
|
||||
IncorrectCertificate(String),
|
||||
#[error("the data you are attempting to verify was not signed with the provided certificate")]
|
||||
NonMatchingCertificates,
|
||||
#[error("certificate data is not in a valid format")]
|
||||
IO(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum CertificateKeyType {
|
||||
Rsa4096,
|
||||
Rsa2048,
|
||||
ECC
|
||||
}
|
||||
|
||||
/// A structure that represents the components of a Wii signing certificate.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Certificate {
|
||||
signer_key_type: CertificateKeyType,
|
||||
signature: Vec<u8>,
|
||||
signature_issuer: [u8; 64],
|
||||
pub_key_type: CertificateKeyType,
|
||||
child_cert_identity: [u8; 64],
|
||||
pub_key_id: u32,
|
||||
pub_key_modulus: Vec<u8>,
|
||||
pub_key_exponent: u32
|
||||
}
|
||||
|
||||
impl Certificate {
|
||||
/// Creates a new Certificate instance from the binary data of a certificate file.
|
||||
pub fn from_bytes(data: &[u8]) -> Result<Self, CertificateError> {
|
||||
let mut buf = Cursor::new(data);
|
||||
let signer_key_type_int = buf.read_u32::<BigEndian>().map_err(CertificateError::IO)?;
|
||||
let signer_key_type = match signer_key_type_int {
|
||||
0x00010000 => CertificateKeyType::Rsa4096,
|
||||
0x00010001 => CertificateKeyType::Rsa2048,
|
||||
0x00010002 => CertificateKeyType::ECC,
|
||||
_ => return Err(CertificateError::InvalidSignatureKeyType(signer_key_type_int))
|
||||
};
|
||||
let signature_len = match signer_key_type {
|
||||
CertificateKeyType::Rsa4096 => 512,
|
||||
CertificateKeyType::Rsa2048 => 256,
|
||||
CertificateKeyType::ECC => 60,
|
||||
};
|
||||
let mut signature = vec![0u8; signature_len];
|
||||
buf.read_exact(&mut signature).map_err(CertificateError::IO)?;
|
||||
// Skip past padding at the end of the signature.
|
||||
buf.seek(SeekFrom::Start(0x40 + signature_len as u64)).map_err(CertificateError::IO)?;
|
||||
let mut signature_issuer = [0u8; 64];
|
||||
buf.read_exact(&mut signature_issuer).map_err(CertificateError::IO)?;
|
||||
let pub_key_type_int = buf.read_u32::<BigEndian>().map_err(CertificateError::IO)?;
|
||||
let pub_key_type = match pub_key_type_int {
|
||||
0x00000000 => CertificateKeyType::Rsa4096,
|
||||
0x00000001 => CertificateKeyType::Rsa2048,
|
||||
0x00000002 => CertificateKeyType::ECC,
|
||||
_ => return Err(CertificateError::InvalidContainedKeyType(pub_key_type_int))
|
||||
};
|
||||
let mut child_cert_identity = [0u8; 64];
|
||||
buf.read_exact(&mut child_cert_identity).map_err(CertificateError::IO)?;
|
||||
let pub_key_id = buf.read_u32::<BigEndian>().map_err(CertificateError::IO)?;
|
||||
let mut pub_key_modulus: Vec<u8>;
|
||||
let mut pub_key_exponent: u32 = 0;
|
||||
// The key size and exponent are different based on the key type. ECC has no exponent.
|
||||
match pub_key_type {
|
||||
CertificateKeyType::Rsa4096 => {
|
||||
pub_key_modulus = vec![0u8; 512];
|
||||
buf.read_exact(&mut pub_key_modulus).map_err(CertificateError::IO)?;
|
||||
pub_key_exponent = buf.read_u32::<BigEndian>().map_err(CertificateError::IO)?;
|
||||
},
|
||||
CertificateKeyType::Rsa2048 => {
|
||||
pub_key_modulus = vec![0u8; 256];
|
||||
buf.read_exact(&mut pub_key_modulus).map_err(CertificateError::IO)?;
|
||||
pub_key_exponent = buf.read_u32::<BigEndian>().map_err(CertificateError::IO)?;
|
||||
},
|
||||
CertificateKeyType::ECC => {
|
||||
pub_key_modulus = vec![0u8; 60];
|
||||
buf.read_exact(&mut pub_key_modulus).map_err(CertificateError::IO)?;
|
||||
}
|
||||
}
|
||||
Ok(Certificate {
|
||||
signer_key_type,
|
||||
signature,
|
||||
signature_issuer,
|
||||
pub_key_type,
|
||||
child_cert_identity,
|
||||
pub_key_id,
|
||||
pub_key_modulus,
|
||||
pub_key_exponent
|
||||
})
|
||||
}
|
||||
|
||||
/// Dumps the data in a Certificate instance 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();
|
||||
match self.signer_key_type {
|
||||
CertificateKeyType::Rsa4096 => { buf.write_u32::<BigEndian>(0x00010000)? },
|
||||
CertificateKeyType::Rsa2048 => { buf.write_u32::<BigEndian>(0x00010001)? },
|
||||
CertificateKeyType::ECC => { buf.write_u32::<BigEndian>(0x00010002)? },
|
||||
}
|
||||
buf.write_all(&self.signature)?;
|
||||
// Pad to nearest 64 bytes after the signature.
|
||||
buf.resize(0x40 + self.signature.len(), 0);
|
||||
buf.write_all(&self.signature_issuer)?;
|
||||
match self.pub_key_type {
|
||||
CertificateKeyType::Rsa4096 => { buf.write_u32::<BigEndian>(0x0000000)? },
|
||||
CertificateKeyType::Rsa2048 => { buf.write_u32::<BigEndian>(0x00000001)? },
|
||||
CertificateKeyType::ECC => { buf.write_u32::<BigEndian>(0x00000002)? },
|
||||
}
|
||||
buf.write_all(&self.child_cert_identity)?;
|
||||
buf.write_u32::<BigEndian>(self.pub_key_id)?;
|
||||
buf.write_all(&self.pub_key_modulus)?;
|
||||
// The key exponent is only used for the RSA keys and not ECC keys, so only write it out
|
||||
// if this is one of those two key types.
|
||||
if matches!(self.pub_key_type, CertificateKeyType::Rsa4096) ||
|
||||
matches!(self.pub_key_type, CertificateKeyType::Rsa2048) {
|
||||
buf.write_u32::<BigEndian>(self.pub_key_exponent)?;
|
||||
}
|
||||
// Pad the certificate data out to the nearest multiple of 64.
|
||||
buf.resize((buf.len() + 63) & !63, 0);
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
/// Gets the name of the certificate used to sign a certificate as a string.
|
||||
pub fn signature_issuer(&self) -> String {
|
||||
String::from_utf8_lossy(&self.signature_issuer).trim_end_matches('\0').to_owned()
|
||||
}
|
||||
|
||||
/// Gets the name of a certificate's child certificate as a string.
|
||||
pub fn child_cert_identity(&self) -> String {
|
||||
String::from_utf8_lossy(&self.child_cert_identity).trim_end_matches('\0').to_owned()
|
||||
}
|
||||
|
||||
/// Gets the modulus of the public key contained in a certificate.
|
||||
pub fn pub_key_modulus(&self) -> Vec<u8> {
|
||||
self.pub_key_modulus.clone()
|
||||
}
|
||||
|
||||
/// Gets the exponent of the public key contained in a certificate.
|
||||
pub fn pub_key_exponent(&self) -> u32 {
|
||||
self.pub_key_exponent
|
||||
}
|
||||
}
|
||||
|
||||
/// A structure that represents the components of the Wii's signing certificate chain.
|
||||
#[derive(Debug)]
|
||||
pub struct CertificateChain {
|
||||
ca_cert: Certificate,
|
||||
tmd_cert: Certificate,
|
||||
ticket_cert: Certificate,
|
||||
}
|
||||
|
||||
impl CertificateChain {
|
||||
/// Creates a new CertificateChain instance from the binary data of an entire certificate chain.
|
||||
/// This chain must contain a CA certificate, a TMD certificate, and a Ticket certificate or
|
||||
/// else this method will return an error.
|
||||
pub fn from_bytes(data: &[u8]) -> Result<CertificateChain, CertificateError> {
|
||||
let mut buf = Cursor::new(data);
|
||||
let mut offset: u64 = 0;
|
||||
let mut ca_cert: Option<Certificate> = None;
|
||||
let mut tmd_cert: Option<Certificate> = None;
|
||||
let mut ticket_cert: Option<Certificate> = None;
|
||||
// Iterate 3 times, because the chain should contain 3 certs.
|
||||
for _ in 0..3 {
|
||||
buf.seek(SeekFrom::Start(offset)).map_err(CertificateError::IO)?;
|
||||
let signer_key_type = buf.read_u32::<BigEndian>().map_err(CertificateError::IO)?;
|
||||
let signature_len = match signer_key_type {
|
||||
0x00010000 => 512, // 0x200
|
||||
0x00010001 => 256, // 0x100
|
||||
0x00010002 => 60,
|
||||
_ => return Err(CertificateError::InvalidSignatureKeyType(signer_key_type))
|
||||
};
|
||||
buf.seek(SeekFrom::Start(offset + 0x80 + signature_len)).map_err(CertificateError::IO)?;
|
||||
let pub_key_type = buf.read_u32::<BigEndian>().map_err(CertificateError::IO)?;
|
||||
let pub_key_len = match pub_key_type {
|
||||
0x00000000 => 568, // 0x238
|
||||
0x00000001 => 312, // 0x138
|
||||
0x00000002 => 120,
|
||||
_ => return Err(CertificateError::InvalidContainedKeyType(pub_key_type))
|
||||
};
|
||||
// Cert size is the base length (0xC8) + the signature length + the public key length.
|
||||
// Like a lot of values, it needs to be rounded to the nearest multiple of 64.
|
||||
let cert_size = (0xC8 + signature_len + pub_key_len + 63) & !63;
|
||||
buf.seek(SeekFrom::End(0)).map_err(CertificateError::IO)?;
|
||||
buf.seek(SeekFrom::Start(offset)).map_err(CertificateError::IO)?;
|
||||
let mut cert_buf = vec![0u8; cert_size as usize];
|
||||
buf.read_exact(&mut cert_buf).map_err(CertificateError::IO)?;
|
||||
let cert = Certificate::from_bytes(&cert_buf)?;
|
||||
let issuer_name = String::from_utf8_lossy(&cert.signature_issuer).trim_end_matches('\0').to_owned();
|
||||
if issuer_name.eq("Root") {
|
||||
ca_cert = Some(cert.clone());
|
||||
} else if issuer_name.contains("Root-CA") {
|
||||
let child_name = String::from_utf8_lossy(&cert.child_cert_identity).trim_end_matches('\0').to_owned();
|
||||
if child_name.contains("CP") {
|
||||
tmd_cert = Some(cert.clone());
|
||||
} else if child_name.contains("XS") {
|
||||
ticket_cert = Some(cert.clone());
|
||||
} else {
|
||||
return Err(CertificateError::UnknownCertificate);
|
||||
}
|
||||
} else {
|
||||
return Err(CertificateError::UnknownCertificate);
|
||||
}
|
||||
offset += cert_size;
|
||||
}
|
||||
if ca_cert.is_none() { return Err(CertificateError::MissingCertificate("CA".to_owned())) }
|
||||
if tmd_cert.is_none() { return Err(CertificateError::MissingCertificate("TMD".to_owned())) }
|
||||
if ticket_cert.is_none() { return Err(CertificateError::MissingCertificate("Ticket".to_owned())) }
|
||||
Ok(CertificateChain {
|
||||
ca_cert: ca_cert.unwrap(),
|
||||
tmd_cert: tmd_cert.unwrap(),
|
||||
ticket_cert: ticket_cert.unwrap(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Creates a new CertificateChain instance from three separate Certificate instances each
|
||||
/// containing one of the three certificates stored in the chain. You must provide a CA
|
||||
/// certificate, a TMD certificate, and a Ticket certificate, or this method will return an
|
||||
/// error.
|
||||
pub fn from_certs(ca_cert: Certificate, tmd_cert: Certificate, ticket_cert: Certificate) -> Result<Self, CertificateError> {
|
||||
if String::from_utf8_lossy(&ca_cert.signature_issuer).trim_end_matches('\0').ne("Root") {
|
||||
return Err(CertificateError::IncorrectCertificate("CA".to_owned()));
|
||||
}
|
||||
if !String::from_utf8_lossy(&tmd_cert.child_cert_identity).trim_end_matches('\0').contains("CP") {
|
||||
return Err(CertificateError::IncorrectCertificate("TMD".to_owned()));
|
||||
}
|
||||
if !String::from_utf8_lossy(&ticket_cert.child_cert_identity).contains("XS") {
|
||||
return Err(CertificateError::IncorrectCertificate("Ticket".to_owned()));
|
||||
}
|
||||
Ok(CertificateChain {
|
||||
ca_cert,
|
||||
tmd_cert,
|
||||
ticket_cert,
|
||||
})
|
||||
}
|
||||
|
||||
/// Dumps the entire CertificateChain 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();
|
||||
buf.write_all(&self.ca_cert().to_bytes()?)?;
|
||||
buf.write_all(&self.tmd_cert().to_bytes()?)?;
|
||||
buf.write_all(&self.ticket_cert().to_bytes()?)?;
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
pub fn ca_cert(&self) -> Certificate {
|
||||
self.ca_cert.clone()
|
||||
}
|
||||
|
||||
pub fn tmd_cert(&self) -> Certificate {
|
||||
self.tmd_cert.clone()
|
||||
}
|
||||
|
||||
pub fn ticket_cert(&self) -> Certificate {
|
||||
self.ticket_cert.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies a Wii CA certificate (either CA00000001 for retail or CA00000002 for development) using
|
||||
/// the root keys.
|
||||
pub fn verify_ca_cert(ca_cert: &Certificate) -> Result<bool, CertificateError> {
|
||||
// Reject if the issuer isn't "Root" and this isn't one of the CA certs.
|
||||
if String::from_utf8_lossy(&ca_cert.signature_issuer).trim_end_matches('\0').ne("Root") ||
|
||||
!String::from_utf8_lossy(&ca_cert.child_cert_identity).contains("CA") {
|
||||
return Err(CertificateError::IncorrectCertificate("CA".to_owned()));
|
||||
}
|
||||
let root_key = if String::from_utf8_lossy(&ca_cert.child_cert_identity).trim_end_matches('\0').eq("CA00000001") {
|
||||
// Include key str from local file.
|
||||
let retail_pem = include_str!("keys/retail-pub.pem");
|
||||
RsaPublicKey::from_public_key_pem(retail_pem).unwrap()
|
||||
} else if String::from_utf8_lossy(&ca_cert.child_cert_identity).trim_end_matches('\0').eq("CA00000002") {
|
||||
// Include key str from local file.
|
||||
let dev_pem = include_str!("keys/dev-pub.pem");
|
||||
RsaPublicKey::from_public_key_pem(dev_pem).unwrap()
|
||||
} else {
|
||||
return Err(CertificateError::UnknownCertificate);
|
||||
};
|
||||
let mut hasher = Sha1::new();
|
||||
let cert_body = ca_cert.to_bytes()?;
|
||||
hasher.update(&cert_body[576..]);
|
||||
let cert_hash = hasher.finalize().as_slice().to_owned();
|
||||
match root_key.verify(Pkcs1v15Sign::new::<Sha1>(), &cert_hash, ca_cert.signature.as_slice()) {
|
||||
Ok(_) => Ok(true),
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies a TMD or Ticket signing certificate using a CA certificate. The CA certificate and
|
||||
/// child certificate being verified must match, or this function will return an error without
|
||||
/// attempting signature verification.
|
||||
pub fn verify_child_cert(ca_cert: &Certificate, child_cert: &Certificate) -> Result<bool, CertificateError> {
|
||||
if ca_cert.signature_issuer().ne("Root") || !ca_cert.child_cert_identity().contains("CA") {
|
||||
return Err(CertificateError::IncorrectCertificate("CA".to_owned()));
|
||||
}
|
||||
if format!("Root-{}", ca_cert.child_cert_identity()).ne(&child_cert.signature_issuer()) {
|
||||
return Err(CertificateError::NonMatchingCertificates)
|
||||
}
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(&child_cert.to_bytes().map_err(CertificateError::IO)?[320..]);
|
||||
let cert_hash = hasher.finalize().as_slice().to_owned();
|
||||
let public_key_modulus = BigUint::from_bytes_be(&ca_cert.pub_key_modulus());
|
||||
let public_key_exponent = BigUint::from(ca_cert.pub_key_exponent());
|
||||
let root_key = RsaPublicKey::new(public_key_modulus, public_key_exponent).unwrap();
|
||||
match root_key.verify(Pkcs1v15Sign::new::<Sha1>(), &cert_hash, child_cert.signature.as_slice()) {
|
||||
Ok(_) => Ok(true),
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies the signature of a TMD using a TMD signing certificate. The TMD certificate must match
|
||||
/// the certificate used to sign the TMD, or this function will return an error without attempting
|
||||
/// signature verification.
|
||||
pub fn verify_tmd(tmd_cert: &Certificate, tmd: &tmd::TMD) -> Result<bool, CertificateError> {
|
||||
if !tmd_cert.signature_issuer().contains("Root-CA") || !tmd_cert.child_cert_identity().contains("CP") {
|
||||
return Err(CertificateError::IncorrectCertificate("TMD".to_owned()));
|
||||
}
|
||||
if format!("{}-{}", tmd_cert.signature_issuer(), tmd_cert.child_cert_identity()).ne(&tmd.signature_issuer()) {
|
||||
return Err(CertificateError::NonMatchingCertificates)
|
||||
}
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(&tmd.to_bytes().map_err(CertificateError::IO)?[320..]);
|
||||
let tmd_hash = hasher.finalize().as_slice().to_owned();
|
||||
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()) {
|
||||
Ok(_) => Ok(true),
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies the signature of a Ticket using a Ticket signing certificate. The Ticket certificate
|
||||
/// must match the certificate used to sign the Ticket, or this function will return an error
|
||||
/// without attempting signature verification.
|
||||
pub fn verify_ticket(ticket_cert: &Certificate, ticket: &ticket::Ticket) -> Result<bool, CertificateError> {
|
||||
if !ticket_cert.signature_issuer().contains("Root-CA") || !ticket_cert.child_cert_identity().contains("XS") {
|
||||
return Err(CertificateError::IncorrectCertificate("Ticket".to_owned()));
|
||||
}
|
||||
if format!("{}-{}", ticket_cert.signature_issuer(), ticket_cert.child_cert_identity()).ne(&ticket.signature_issuer()) {
|
||||
return Err(CertificateError::NonMatchingCertificates)
|
||||
}
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(&ticket.to_bytes().map_err(CertificateError::IO)?[320..]);
|
||||
let ticket_hash = hasher.finalize().as_slice().to_owned();
|
||||
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()) {
|
||||
Ok(_) => Ok(true),
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
// 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";
|
||||
const VWII_KEY: &str = "30bfc76e7c19afbb23163330ced7c28d";
|
||||
const DEV_COMMON_KEY: &str = "a1604a6a7123b529ae8bec32c816fcaa";
|
||||
|
||||
pub fn get_common_key(index: u8, is_dev: Option<bool>) -> [u8; 16] {
|
||||
/// Returns the common key for the specified index. Providing Some(true) for the optional argument
|
||||
/// is_dev will make index 0 return the development common key instead of the retail common key.
|
||||
pub fn get_common_key(index: u8, is_dev: bool) -> [u8; 16] {
|
||||
// Match the Korean and vWii keys, and if they don't match then fall back on the common key.
|
||||
// The is_dev argument is an option, and if it's set to false or None, then the regular
|
||||
// common key will be used.
|
||||
@@ -16,8 +18,8 @@ pub fn get_common_key(index: u8, is_dev: Option<bool>) -> [u8; 16] {
|
||||
2 => selected_key = VWII_KEY,
|
||||
_ => {
|
||||
match is_dev {
|
||||
Some(true) => selected_key = DEV_COMMON_KEY,
|
||||
_ => selected_key = COMMON_KEY,
|
||||
true => selected_key = DEV_COMMON_KEY,
|
||||
false => selected_key = COMMON_KEY,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,22 +32,22 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_get_common_key() {
|
||||
assert_eq!(get_common_key(0, None), [0xeb, 0xe4, 0x2a, 0x22, 0x5e, 0x85, 0x93, 0xe4, 0x48, 0xd9, 0xc5, 0x45, 0x73, 0x81, 0xaa, 0xf7]);
|
||||
assert_eq!(get_common_key(0, false), [0xeb, 0xe4, 0x2a, 0x22, 0x5e, 0x85, 0x93, 0xe4, 0x48, 0xd9, 0xc5, 0x45, 0x73, 0x81, 0xaa, 0xf7]);
|
||||
}
|
||||
#[test]
|
||||
fn test_get_invalid_index() {
|
||||
assert_eq!(get_common_key(57, None), [0xeb, 0xe4, 0x2a, 0x22, 0x5e, 0x85, 0x93, 0xe4, 0x48, 0xd9, 0xc5, 0x45, 0x73, 0x81, 0xaa, 0xf7]);
|
||||
assert_eq!(get_common_key(57, false), [0xeb, 0xe4, 0x2a, 0x22, 0x5e, 0x85, 0x93, 0xe4, 0x48, 0xd9, 0xc5, 0x45, 0x73, 0x81, 0xaa, 0xf7]);
|
||||
}
|
||||
#[test]
|
||||
fn test_get_korean_key() {
|
||||
assert_eq!(get_common_key(1, None), [0x63, 0xb8, 0x2b, 0xb4, 0xf4, 0x61, 0x4e, 0x2e, 0x13, 0xf2, 0xfe, 0xfb, 0xba, 0x4c, 0x9b, 0x7e]);
|
||||
assert_eq!(get_common_key(1, false), [0x63, 0xb8, 0x2b, 0xb4, 0xf4, 0x61, 0x4e, 0x2e, 0x13, 0xf2, 0xfe, 0xfb, 0xba, 0x4c, 0x9b, 0x7e]);
|
||||
}
|
||||
#[test]
|
||||
fn test_get_vwii_key() {
|
||||
assert_eq!(get_common_key(2, None), [0x30, 0xbf, 0xc7, 0x6e, 0x7c, 0x19, 0xaf, 0xbb, 0x23, 0x16, 0x33, 0x30, 0xce, 0xd7, 0xc2, 0x8d]);
|
||||
assert_eq!(get_common_key(2, false), [0x30, 0xbf, 0xc7, 0x6e, 0x7c, 0x19, 0xaf, 0xbb, 0x23, 0x16, 0x33, 0x30, 0xce, 0xd7, 0xc2, 0x8d]);
|
||||
}
|
||||
#[test]
|
||||
fn test_get_dev_key() {
|
||||
assert_eq!(get_common_key(0, Some(true)), [0xa1, 0x60, 0x4a, 0x6a, 0x71, 0x23, 0xb5, 0x29, 0xae, 0x8b, 0xec, 0x32, 0xc8, 0x16, 0xfc, 0xaa]);
|
||||
assert_eq!(get_common_key(0, true), [0xa1, 0x60, 0x4a, 0x6a, 0x71, 0x23, 0xb5, 0x29, 0xae, 0x8b, 0xec, 0x32, 0xc8, 0x16, 0xfc, 0xaa]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
// title/content.rs from rustii (c) 2025 NinjaCheetah & Contributors
|
||||
// https://github.com/NinjaCheetah/rustii
|
||||
//
|
||||
// Implements content parsing and editing.
|
||||
|
||||
use std::error::Error;
|
||||
use std::fmt;
|
||||
use std::io::{Cursor, Read, Seek, SeekFrom, Write};
|
||||
use sha1::{Sha1, Digest};
|
||||
use crate::title::tmd::ContentRecord;
|
||||
use crate::title::crypto;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ContentError {
|
||||
IndexNotFound,
|
||||
CIDNotFound,
|
||||
BadHash,
|
||||
}
|
||||
|
||||
impl fmt::Display for ContentError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
let description = match *self {
|
||||
ContentError::IndexNotFound => "The specified content index does not exist.",
|
||||
ContentError::CIDNotFound => "The specified Content ID does not exist.",
|
||||
ContentError::BadHash => "The content hash does not match the expected hash.",
|
||||
};
|
||||
f.write_str(description)
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for ContentError {}
|
||||
|
||||
#[derive(Debug)]
|
||||
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, std::io::Error> {
|
||||
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 {
|
||||
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 from the ContentRecords of a TMD that contains no actual
|
||||
/// content. This can be used to load existing content from files.
|
||||
pub fn new(content_records: Vec<ContentRecord>) -> Result<Self, ContentError> {
|
||||
let content_region_size: u64 = content_records.iter().map(|x| (x.content_size + 63) & !63).sum();
|
||||
let content_region_size = content_region_size as u32;
|
||||
let num_contents = content_records.len() as u16;
|
||||
let content_start_offsets: Vec<u64> = Vec::new();
|
||||
let mut contents: Vec<Vec<u8>> = Vec::new();
|
||||
contents.resize(num_contents as usize, Vec::new());
|
||||
Ok(ContentRegion {
|
||||
content_records,
|
||||
content_region_size,
|
||||
num_contents,
|
||||
content_start_offsets,
|
||||
contents,
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
pub fn get_enc_content_by_index(&self, index: usize) -> Result<Vec<u8>, ContentError> {
|
||||
let content = self.contents.get(index).ok_or(ContentError::IndexNotFound)?;
|
||||
Ok(content.clone())
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
Ok(content_dec)
|
||||
}
|
||||
|
||||
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)?;
|
||||
Ok(content)
|
||||
} else {
|
||||
Err(ContentError::CIDNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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::IndexNotFound);
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
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(())
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -14,27 +14,27 @@ fn title_id_to_iv(title_id: [u8; 8]) -> [u8; 16] {
|
||||
iv.as_slice().try_into().unwrap()
|
||||
}
|
||||
|
||||
// Decrypt a Title Key using the specified common key.
|
||||
pub fn decrypt_title_key(title_key_enc: [u8; 16], common_key_index: u8, title_id: [u8; 8]) -> [u8; 16] {
|
||||
/// Decrypts a Title Key using the specified common key and the corresponding Title ID.
|
||||
pub fn decrypt_title_key(title_key_enc: [u8; 16], common_key_index: u8, title_id: [u8; 8], is_dev: bool) -> [u8; 16] {
|
||||
let iv = title_id_to_iv(title_id);
|
||||
type Aes128CbcDec = cbc::Decryptor<aes::Aes128>;
|
||||
let decryptor = Aes128CbcDec::new(&get_common_key(common_key_index, None).into(), &iv.into());
|
||||
let decryptor = Aes128CbcDec::new(&get_common_key(common_key_index, is_dev).into(), &iv.into());
|
||||
let mut title_key = title_key_enc;
|
||||
decryptor.decrypt_padded_mut::<ZeroPadding>(&mut title_key).unwrap();
|
||||
title_key
|
||||
}
|
||||
|
||||
// Encrypt a Title Key using the specified common key.
|
||||
pub fn encrypt_title_key(title_key_dec: [u8; 16], common_key_index: u8, title_id: [u8; 8]) -> [u8; 16] {
|
||||
/// Encrypts a Title Key using the specified common key and the corresponding Title ID.
|
||||
pub fn encrypt_title_key(title_key_dec: [u8; 16], common_key_index: u8, title_id: [u8; 8], is_dev: bool) -> [u8; 16] {
|
||||
let iv = title_id_to_iv(title_id);
|
||||
type Aes128CbcEnc = cbc::Encryptor<aes::Aes128>;
|
||||
let encryptor = Aes128CbcEnc::new(&get_common_key(common_key_index, None).into(), &iv.into());
|
||||
let encryptor = Aes128CbcEnc::new(&get_common_key(common_key_index, is_dev).into(), &iv.into());
|
||||
let mut title_key = title_key_dec;
|
||||
encryptor.encrypt_padded_mut::<ZeroPadding>(&mut title_key, 16).unwrap();
|
||||
title_key
|
||||
}
|
||||
|
||||
// Decrypt content using a Title Key.
|
||||
/// Decrypt content using the corresponding Title Key and content index.
|
||||
pub fn decrypt_content(data: &[u8], title_key: [u8; 16], index: u16) -> Vec<u8> {
|
||||
let mut iv = Vec::from(index.to_be_bytes());
|
||||
iv.resize(16, 0);
|
||||
@@ -45,7 +45,7 @@ pub fn decrypt_content(data: &[u8], title_key: [u8; 16], index: u16) -> Vec<u8>
|
||||
buf
|
||||
}
|
||||
|
||||
// Encrypt content using a Title Key.
|
||||
/// Encrypt content using the corresponding Title Key and content index.
|
||||
pub fn encrypt_content(data: &[u8], title_key: [u8; 16], index: u16, size: u64) -> Vec<u8> {
|
||||
let mut iv = Vec::from(index.to_be_bytes());
|
||||
iv.resize(16, 0);
|
||||
|
||||
133
src/title/iospatcher.rs
Normal file
133
src/title/iospatcher.rs
Normal 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)
|
||||
}
|
||||
14
src/title/keys/dev-pub.pem
Normal file
14
src/title/keys/dev-pub.pem
Normal file
@@ -0,0 +1,14 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA0B/hANQ1VrJLVtrpcbWl
|
||||
04S5MAO+G78oojBbBgZFRn1bAlHSVhonT56fnOxkYVCrPSrjNmhmrKS66Brj15qm
|
||||
sEqLy6fm+2SJRevf24W6CR/X0RS1o6eA46Iubs2HtaTG+RDkAyIIgUsM7qGhffc5
|
||||
aV9hfvY1KNuUljegVgN/ezJBOJXAqPGYLhVl447twi5ZDuJne4YJ9IwuMD+8QFys
|
||||
GAQvgiCE5JNoA9p/QTSSSFYrjuEvePgDJGMwvHvn7nJK9FikcuerRqGnwQwvGPoH
|
||||
w93YmAahHJzBMLJHozyNR95n8p5Vd7EcQ0k9W7p2NKfk5xUxt99Zgf4koRRVTL2P
|
||||
AFzh2zUIXM/HeAa23iVAaKJstUktRYBDj+Hlqe11xe1FHc54lDnMw7ooojEqG4cZ
|
||||
7w9ztxOVDAJZGnRipgfzfAqnoY+pQ6NtdSpfQZLwE2EAqpy0G74UvrH5/Gkv36CU
|
||||
Rt5and4spfaMHAwhQpKHyy2qo9JjdS9z4J+vRHnSgXQp9pgAr95rWS3BmIK99YHM
|
||||
q/LLkQKe81xM/bv/ScH6Gy/jHeelYOy0frz+MkJblW+BtpkXSH47eJFR2y54sf0u
|
||||
vn5iaz6hZbT7AMy3Ua9QcynEo5Oept2cUKDnOGsBRXlrQa9h94VVlE87wi3DvQ0A
|
||||
+HmKQrGqoIMgZZrHOVq08ykCAwEAAQ==
|
||||
-----END PUBLIC KEY-----
|
||||
14
src/title/keys/retail-pub.pem
Normal file
14
src/title/keys/retail-pub.pem
Normal file
@@ -0,0 +1,14 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA+CRsWLrnUAMB+7fC6+AB
|
||||
BXHakiN48FFOwAMd0NIe09B+/IUgabXem7lRqLyQokSSbTeSla6UNqqmowJRDHsd
|
||||
7dX7IIadfzAW9r5l04OhbbMyG5U1GJCxcAKTfuGT9X6ZokdOnTgkx67jhUH1Z+dR
|
||||
jHoOOOfrr0EZG8/xe0KmtO3mzo3nMY9/UgSzmQ4iZ0Wv1IWyRJMAiwjH9rflawKz
|
||||
6P4MnYWcuLaCI7irJ+5fZTgHiy25HioVPoWBgHKiO23ZMoEFT2+w9vWtKD7KC3rz
|
||||
VFXgPae2gybz7INK8xQEisbfINKFCGc8q2Kix7wTGlM+C2aAaxwwZks3IzG9xLDK
|
||||
2NEe57vZKFVIquwfZughs8igR2kAxeaI6AzOPGHWnLuhN8ZgT3py3Yx7Pj1RKQ2q
|
||||
all7CB+dNjOjRno1YQmsp919Li+ywa644g9Ikti5+LRvTjwR9PR9i3V9/v6jiZwz
|
||||
WVxe/evLq+hBPjqagDxpNW6ysq1cxMhYRV7197MGRLR8ZAaM34CfdgJaLbRG4D18
|
||||
9i805wJFewKkz12d1TylOnymKXiMZ8oIv+zKQ6lXrRbJThzYdcoQfc5+ARjw32v+
|
||||
5R3b2ZHCbmDNSFiqWSyCAHXyn1JskXxv5UA+p9SlDOw7c4TeiG6C0utNTkK18rFJ
|
||||
qB6nznFE3CmUz8ROH5HL1JUCAwEAAQ==
|
||||
-----END PUBLIC KEY-----
|
||||
458
src/title/mod.rs
458
src/title/mod.rs
@@ -1,105 +1,456 @@
|
||||
// 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::error::Error;
|
||||
use std::fmt;
|
||||
use std::io::{Cursor, Read, Seek, SeekFrom, Write};
|
||||
use sha1::{Sha1, Digest};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TitleError {
|
||||
BadTicket,
|
||||
BadTMD,
|
||||
BadContent,
|
||||
#[error("the data for required Title component `{0}` was invalid")]
|
||||
InvalidData(String),
|
||||
#[error("WAD data is not in a valid format")]
|
||||
InvalidWAD,
|
||||
WADError(wad::WADError),
|
||||
IOError(std::io::Error),
|
||||
#[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("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 },
|
||||
}
|
||||
|
||||
impl fmt::Display for TitleError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
let description = match *self {
|
||||
TitleError::BadTicket => "The provided Ticket data was invalid.",
|
||||
TitleError::BadTMD => "The provided TMD data was invalid.",
|
||||
TitleError::BadContent => "The provided content data was invalid.",
|
||||
TitleError::InvalidWAD => "The provided WAD data was invalid.",
|
||||
TitleError::WADError(_) => "A WAD could not be built from the provided data.",
|
||||
TitleError::IOError(_) => "The provided Title data was invalid.",
|
||||
};
|
||||
f.write_str(description)
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for TitleError {}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// A structure that represents the components of a digital Wii title.
|
||||
pub struct Title {
|
||||
cert_chain: Vec<u8>,
|
||||
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>
|
||||
}
|
||||
|
||||
impl Title {
|
||||
/// Creates a new Title instance from an existing WAD instance.
|
||||
pub fn from_wad(wad: &wad::WAD) -> Result<Title, TitleError> {
|
||||
let ticket = ticket::Ticket::from_bytes(&wad.ticket()).map_err(|_| TitleError::BadTicket)?;
|
||||
let tmd = tmd::TMD::from_bytes(&wad.tmd()).map_err(|_| TitleError::BadTMD)?;
|
||||
let content = content::ContentRegion::from_bytes(&wad.content(), tmd.content_records.clone()).map_err(|_| TitleError::BadContent)?;
|
||||
let title = Title {
|
||||
cert_chain: wad.cert_chain(),
|
||||
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 = Self::parse_content_region(wad.content(), tmd.content_records())?;
|
||||
Ok(Title {
|
||||
cert_chain,
|
||||
crl: wad.crl(),
|
||||
ticket,
|
||||
tmd,
|
||||
content,
|
||||
meta: wad.meta(),
|
||||
};
|
||||
Ok(title)
|
||||
})
|
||||
}
|
||||
|
||||
/// Creates a new Title instance from all of its individual components.
|
||||
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 {
|
||||
Some(crl) => crl.to_vec(),
|
||||
None => Vec::new()
|
||||
};
|
||||
let meta = match meta {
|
||||
Some(meta) => meta.to_vec(),
|
||||
None => Vec::new()
|
||||
};
|
||||
Ok(Title {
|
||||
cert_chain,
|
||||
crl,
|
||||
ticket,
|
||||
tmd,
|
||||
content,
|
||||
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::WADError)?;
|
||||
).map_err(TitleError::WAD)?;
|
||||
Ok(wad)
|
||||
}
|
||||
|
||||
/// Creates a new Title instance from the binary data of a WAD file.
|
||||
pub fn from_bytes(bytes: &[u8]) -> Result<Title, TitleError> {
|
||||
let wad = wad::WAD::from_bytes(bytes).map_err(|_| TitleError::InvalidWAD)?;
|
||||
let title = Title::from_wad(&wad)?;
|
||||
Ok(title)
|
||||
}
|
||||
|
||||
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 cert_chain(&self) -> &cert::CertificateChain {
|
||||
&self.cert_chain
|
||||
}
|
||||
|
||||
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 ticket(&self) -> &ticket::Ticket {
|
||||
&self.ticket
|
||||
}
|
||||
|
||||
pub fn cert_chain(&self) -> Vec<u8> {
|
||||
self.cert_chain.clone()
|
||||
pub fn tmd(&self) -> &tmd::TMD {
|
||||
&self.tmd
|
||||
}
|
||||
|
||||
pub fn set_cert_chain(&mut self, cert_chain: &[u8]) {
|
||||
self.cert_chain = cert_chain.to_vec();
|
||||
/// Gets whether the TMD and Ticket of a Title are both fakesigned.
|
||||
pub fn is_fakesigned(&self) -> bool {
|
||||
self.tmd.is_fakesigned() && self.ticket.is_fakesigned()
|
||||
}
|
||||
|
||||
/// Fakesigns the TMD and Ticket of a Title.
|
||||
pub fn fakesign(&mut self) -> Result<(), TitleError> {
|
||||
// Run the fakesign methods on the TMD and Ticket.
|
||||
self.tmd.fakesign().map_err(TitleError::TMD)?;
|
||||
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>, 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>, 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
|
||||
/// whether shared content should be included in this total or not.
|
||||
pub fn title_size(&self, absolute: Option<bool>) -> Result<usize, TitleError> {
|
||||
let mut title_size: usize = 0;
|
||||
// Get the TMD and Ticket size by dumping them and measuring their length for the most
|
||||
// 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().iter() {
|
||||
if matches!(record.content_type, tmd::ContentType::Shared) {
|
||||
if absolute == Some(true) {
|
||||
title_size += record.content_size as usize;
|
||||
}
|
||||
}
|
||||
else {
|
||||
title_size += record.content_size as usize;
|
||||
}
|
||||
}
|
||||
Ok(title_size)
|
||||
}
|
||||
|
||||
/// 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> {
|
||||
if !cert::verify_ca_cert(&self.cert_chain.ca_cert()).map_err(TitleError::CertificateError)? {
|
||||
return Ok(false)
|
||||
}
|
||||
if !cert::verify_child_cert(&self.cert_chain.ca_cert(), &self.cert_chain.tmd_cert()).map_err(TitleError::CertificateError)? ||
|
||||
!cert::verify_child_cert(&self.cert_chain.ca_cert(), &self.cert_chain.ticket_cert()).map_err(TitleError::CertificateError)? {
|
||||
return Ok(false)
|
||||
}
|
||||
if !cert::verify_tmd(&self.cert_chain.tmd_cert(), &self.tmd).map_err(TitleError::CertificateError)? ||
|
||||
!cert::verify_ticket(&self.cert_chain.ticket_cert(), &self.ticket).map_err(TitleError::CertificateError)? {
|
||||
return Ok(false)
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
pub fn crl(&self) -> Vec<u8> {
|
||||
@@ -118,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;
|
||||
}
|
||||
|
||||
@@ -130,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
|
||||
}
|
||||
|
||||
139
src/title/nus.rs
Normal file
139
src/title/nus.rs
Normal file
@@ -0,0 +1,139 @@
|
||||
// title/nus.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
|
||||
// https://github.com/NinjaCheetah/rustwii
|
||||
//
|
||||
// Implements the functions required for downloading data from the NUS.
|
||||
|
||||
use std::str;
|
||||
use std::io::Write;
|
||||
use reqwest;
|
||||
use thiserror::Error;
|
||||
use crate::title::{cert, tmd, ticket};
|
||||
use crate::title;
|
||||
|
||||
const WII_NUS_ENDPOINT: &str = "http://nus.cdn.shop.wii.com/ccs/download/";
|
||||
const WII_U_NUS_ENDPOINT: &str = "http://ccs.cdn.wup.shop.nintendo.net/ccs/download/";
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum NUSError {
|
||||
#[error("the data returned by the NUS is not valid")]
|
||||
InvalidData,
|
||||
#[error("the requested Title ID or version could not be found on the NUS")]
|
||||
NotFound,
|
||||
#[error("Certificate processing error")]
|
||||
Certificate(#[from] cert::CertificateError),
|
||||
#[error("TMD processing error")]
|
||||
TMD(#[from] tmd::TMDError),
|
||||
#[error("Ticket processing error")]
|
||||
Ticket(#[from] ticket::TicketError),
|
||||
#[error("an error occurred while assembling a Title from the downloaded data")]
|
||||
Title(#[from] title::TitleError),
|
||||
#[error("data could not be downloaded from the NUS")]
|
||||
Request(#[from] reqwest::Error),
|
||||
#[error("an error occurred writing NUS data")]
|
||||
IO(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
/// Downloads the retail certificate chain from the NUS.
|
||||
pub fn download_cert_chain(wiiu_endpoint: bool) -> Result<Vec<u8>, NUSError> {
|
||||
// To build the certificate chain, we need to download both the TMD and Ticket of a title. For
|
||||
// the sake of simplicity, we'll use the Wii Menu 4.3U because I already found the required TMD
|
||||
// and Ticket offsets for it.
|
||||
let endpoint_url = if wiiu_endpoint {
|
||||
WII_U_NUS_ENDPOINT.to_owned()
|
||||
} else {
|
||||
WII_NUS_ENDPOINT.to_owned()
|
||||
};
|
||||
let tmd_url = format!("{}0000000100000002/tmd.513", endpoint_url);
|
||||
let tik_url = format!("{}0000000100000002/cetk", endpoint_url);
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let tmd = client.get(tmd_url).header(reqwest::header::USER_AGENT, "wii libnup/1.0").send()?.bytes()?;
|
||||
let tik = client.get(tik_url).header(reqwest::header::USER_AGENT, "wii libnup/1.0").send()?.bytes()?;
|
||||
// Assemble the certificate chain.
|
||||
let mut cert_chain: Vec<u8> = Vec::new();
|
||||
// Certificate Authority data.
|
||||
cert_chain.write_all(&tik[0x2A4 + 768..])?;
|
||||
// Certificate Policy (TMD certificate) data.
|
||||
cert_chain.write_all(&tmd[0x328..0x328 + 768])?;
|
||||
// XS (Ticket certificate) data.
|
||||
cert_chain.write_all(&tik[0x2A4..0x2A4 + 768])?;
|
||||
Ok(cert_chain)
|
||||
}
|
||||
|
||||
/// Downloads a specified content file from the specified title from the NUS.
|
||||
pub fn download_content(title_id: [u8; 8], content_id: u32, wiiu_endpoint: bool) -> Result<Vec<u8>, NUSError> {
|
||||
// Build the download URL. The structure is download/<TID>/<CID>
|
||||
let endpoint_url = if wiiu_endpoint {
|
||||
WII_U_NUS_ENDPOINT.to_owned()
|
||||
} else {
|
||||
WII_NUS_ENDPOINT.to_owned()
|
||||
};
|
||||
let content_url = format!("{}{}/{:08X}", endpoint_url, &hex::encode(title_id), content_id);
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let response = client.get(content_url).header(reqwest::header::USER_AGENT, "wii libnup/1.0").send()?;
|
||||
if !response.status().is_success() {
|
||||
return Err(NUSError::NotFound);
|
||||
}
|
||||
Ok(response.bytes()?.to_vec())
|
||||
}
|
||||
|
||||
/// Downloads all contents from the specified title from the NUS.
|
||||
pub fn download_contents(tmd: &tmd::TMD, wiiu_endpoint: bool) -> Result<Vec<Vec<u8>>, NUSError> {
|
||||
let content_ids: Vec<u32> = tmd.content_records().iter().map(|record| { record.content_id }).collect();
|
||||
let mut contents: Vec<Vec<u8>> = Vec::new();
|
||||
for id in content_ids {
|
||||
contents.push(download_content(tmd.title_id(), id, wiiu_endpoint)?);
|
||||
}
|
||||
Ok(contents)
|
||||
}
|
||||
|
||||
/// Downloads the Ticket for a specified Title ID from the NUS, if it's available.
|
||||
pub fn download_ticket(title_id: [u8; 8], wiiu_endpoint: bool) -> Result<Vec<u8>, NUSError> {
|
||||
// Build the download URL. The structure is download/<TID>/cetk.
|
||||
let endpoint_url = if wiiu_endpoint {
|
||||
WII_U_NUS_ENDPOINT.to_owned()
|
||||
} else {
|
||||
WII_NUS_ENDPOINT.to_owned()
|
||||
};
|
||||
let tik_url = format!("{}{}/cetk", endpoint_url, &hex::encode(title_id));
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let response = client.get(tik_url).header(reqwest::header::USER_AGENT, "wii libnup/1.0").send()?;
|
||||
if !response.status().is_success() {
|
||||
return Err(NUSError::NotFound);
|
||||
}
|
||||
let tik = ticket::Ticket::from_bytes(&response.bytes()?).map_err(|_| NUSError::InvalidData)?;
|
||||
tik.to_bytes().map_err(|_| NUSError::InvalidData)
|
||||
}
|
||||
|
||||
/// Downloads an entire title with all of its content from the NUS and returns a Title instance.
|
||||
pub fn download_title(title_id: [u8; 8], title_version: Option<u16>, wiiu_endpoint: bool) -> Result<title::Title, NUSError> {
|
||||
// Download the individual components of a title and then build a title from them.
|
||||
let cert_chain = cert::CertificateChain::from_bytes(&download_cert_chain(wiiu_endpoint)?)?;
|
||||
let tmd = tmd::TMD::from_bytes(&download_tmd(title_id, title_version, wiiu_endpoint)?)?;
|
||||
let tik = ticket::Ticket::from_bytes(&download_ticket(title_id, wiiu_endpoint)?)?;
|
||||
let contents = download_contents(&tmd, wiiu_endpoint)?;
|
||||
let title = title::Title::from_parts_with_content(cert_chain, None, tik, tmd, contents, None)?;
|
||||
Ok(title)
|
||||
}
|
||||
|
||||
/// Downloads the TMD for a specified Title ID from the NUS.
|
||||
pub fn download_tmd(title_id: [u8; 8], title_version: Option<u16>, wiiu_endpoint: bool) -> Result<Vec<u8>, NUSError> {
|
||||
// Build the download URL. The structure is download/<TID>/tmd for latest and
|
||||
// download/<TID>/tmd.<version> for when a specific version is requested.
|
||||
let endpoint_url = if wiiu_endpoint {
|
||||
WII_U_NUS_ENDPOINT.to_owned()
|
||||
} else {
|
||||
WII_NUS_ENDPOINT.to_owned()
|
||||
};
|
||||
let tmd_url = if 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))
|
||||
};
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let response = client.get(tmd_url).header(reqwest::header::USER_AGENT, "wii libnup/1.0").send()?;
|
||||
if !response.status().is_success() {
|
||||
return Err(NUSError::NotFound);
|
||||
}
|
||||
let tmd = tmd::TMD::from_bytes(&response.bytes()?).map_err(|_| NUSError::InvalidData)?;
|
||||
tmd.to_bytes().map_err(|_| NUSError::InvalidData)
|
||||
}
|
||||
@@ -1,15 +1,28 @@
|
||||
// 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.
|
||||
|
||||
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)]
|
||||
#[derive(Copy)]
|
||||
#[derive(Clone)]
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TicketError {
|
||||
#[error("Ticket is version `{0}` but only v0 is supported")]
|
||||
UnsupportedVersion(u8),
|
||||
#[error("Ticket data could not be fakesigned")]
|
||||
CannotFakesign,
|
||||
#[error("signature issuer string must not exceed 64 characters (was {0})")]
|
||||
IssuerTooLong(usize),
|
||||
#[error("Ticket data is not in a valid format")]
|
||||
IO(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Copy, Clone)]
|
||||
pub struct TitleLimit {
|
||||
// The type of limit being applied (time, launch count, etc.)
|
||||
pub limit_type: u32,
|
||||
@@ -17,78 +30,84 @@ 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 {
|
||||
pub fn from_bytes(data: &[u8]) -> Result<Self, std::io::Error> {
|
||||
/// Creates a new Ticket instance from the binary data of a Ticket file.
|
||||
pub fn from_bytes(data: &[u8]) -> Result<Self, TicketError> {
|
||||
let mut buf = Cursor::new(data);
|
||||
let signature_type = buf.read_u32::<BigEndian>()?;
|
||||
let signature_type = buf.read_u32::<BigEndian>().map_err(TicketError::IO)?;
|
||||
let mut signature = [0u8; 256];
|
||||
buf.read_exact(&mut signature)?;
|
||||
buf.read_exact(&mut signature).map_err(TicketError::IO)?;
|
||||
// Maybe this can be read differently?
|
||||
let mut padding1 = [0u8; 60];
|
||||
buf.read_exact(&mut padding1)?;
|
||||
buf.read_exact(&mut padding1).map_err(TicketError::IO)?;
|
||||
let mut signature_issuer = [0u8; 64];
|
||||
buf.read_exact(&mut signature_issuer)?;
|
||||
buf.read_exact(&mut signature_issuer).map_err(TicketError::IO)?;
|
||||
let mut ecdh_data = [0u8; 60];
|
||||
buf.read_exact(&mut ecdh_data)?;
|
||||
let ticket_version = buf.read_u8()?;
|
||||
buf.read_exact(&mut ecdh_data).map_err(TicketError::IO)?;
|
||||
let ticket_version = buf.read_u8().map_err(TicketError::IO)?;
|
||||
// v1 Tickets are NOT supported (just like in libWiiPy).
|
||||
if ticket_version != 0 {
|
||||
return Err(TicketError::UnsupportedVersion(ticket_version));
|
||||
}
|
||||
let mut reserved1 = [0u8; 2];
|
||||
buf.read_exact(&mut reserved1)?;
|
||||
buf.read_exact(&mut reserved1).map_err(TicketError::IO)?;
|
||||
let mut title_key = [0u8; 16];
|
||||
buf.read_exact(&mut title_key)?;
|
||||
buf.read_exact(&mut title_key).map_err(TicketError::IO)?;
|
||||
let mut unknown1 = [0u8; 1];
|
||||
buf.read_exact(&mut unknown1)?;
|
||||
buf.read_exact(&mut unknown1).map_err(TicketError::IO)?;
|
||||
let mut ticket_id = [0u8; 8];
|
||||
buf.read_exact(&mut ticket_id)?;
|
||||
buf.read_exact(&mut ticket_id).map_err(TicketError::IO)?;
|
||||
let mut console_id = [0u8; 4];
|
||||
buf.read_exact(&mut console_id)?;
|
||||
buf.read_exact(&mut console_id).map_err(TicketError::IO)?;
|
||||
let mut title_id = [0u8; 8];
|
||||
buf.read_exact(&mut title_id)?;
|
||||
buf.read_exact(&mut title_id).map_err(TicketError::IO)?;
|
||||
let mut unknown2 = [0u8; 2];
|
||||
buf.read_exact(&mut unknown2)?;
|
||||
let title_version = buf.read_u16::<BigEndian>()?;
|
||||
buf.read_exact(&mut unknown2).map_err(TicketError::IO)?;
|
||||
let title_version = buf.read_u16::<BigEndian>().map_err(TicketError::IO)?;
|
||||
let mut permitted_titles_mask = [0u8; 4];
|
||||
buf.read_exact(&mut permitted_titles_mask)?;
|
||||
buf.read_exact(&mut permitted_titles_mask).map_err(TicketError::IO)?;
|
||||
let mut permit_mask = [0u8; 4];
|
||||
buf.read_exact(&mut permit_mask)?;
|
||||
let title_export_allowed = buf.read_u8()?;
|
||||
let common_key_index = buf.read_u8()?;
|
||||
buf.read_exact(&mut permit_mask).map_err(TicketError::IO)?;
|
||||
let title_export_allowed = buf.read_u8().map_err(TicketError::IO)?;
|
||||
let common_key_index = buf.read_u8().map_err(TicketError::IO)?;
|
||||
let mut unknown3 = [0u8; 48];
|
||||
buf.read_exact(&mut unknown3)?;
|
||||
buf.read_exact(&mut unknown3).map_err(TicketError::IO)?;
|
||||
let mut content_access_permission = [0u8; 64];
|
||||
buf.read_exact(&mut content_access_permission)?;
|
||||
buf.read_exact(&mut content_access_permission).map_err(TicketError::IO)?;
|
||||
let mut padding2 = [0u8; 2];
|
||||
buf.read_exact(&mut padding2)?;
|
||||
buf.read_exact(&mut padding2).map_err(TicketError::IO)?;
|
||||
// Build the array of title limits.
|
||||
let mut title_limits: Vec<TitleLimit> = Vec::new();
|
||||
for _ in 0..8 {
|
||||
let limit_type = buf.read_u32::<BigEndian>()?;
|
||||
let limit_max = buf.read_u32::<BigEndian>()?;
|
||||
let limit_type = buf.read_u32::<BigEndian>().map_err(TicketError::IO)?;
|
||||
let limit_max = buf.read_u32::<BigEndian>().map_err(TicketError::IO)?;
|
||||
title_limits.push(TitleLimit { limit_type, limit_max });
|
||||
}
|
||||
let title_limits = title_limits.try_into().unwrap();
|
||||
@@ -118,6 +137,7 @@ impl Ticket {
|
||||
})
|
||||
}
|
||||
|
||||
/// Dumps the data in a Ticket instance 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();
|
||||
buf.write_u32::<BigEndian>(self.signature_type)?;
|
||||
@@ -149,7 +169,164 @@ impl Ticket {
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
pub fn dec_title_key(&self) -> [u8; 16] {
|
||||
decrypt_title_key(self.title_key, self.common_key_index, self.title_id)
|
||||
/// 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 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)
|
||||
}
|
||||
|
||||
/// Gets whether a Ticket was signed for development (true) or retail (false).
|
||||
pub fn is_dev(&self) -> bool {
|
||||
// Parse the signature issuer to determine if this is a dev Ticket or not.
|
||||
let issuer_str = String::from_utf8(Vec::from(&self.signature_issuer)).unwrap_or_default();
|
||||
issuer_str.contains("Root-CA00000002-XS00000004") || issuer_str.contains("Root-CA00000002-XS00000006")
|
||||
}
|
||||
|
||||
/// Gets whether a Ticket is fakesigned using the strncmp (trucha) bug or not.
|
||||
pub fn is_fakesigned(&self) -> bool {
|
||||
// Can't be fakesigned without a null signature.
|
||||
if self.signature != [0; 256] {
|
||||
return false;
|
||||
}
|
||||
// Test the hash of the Ticket body to make sure it starts with 00.
|
||||
let mut hasher = Sha1::new();
|
||||
let ticket_body = self.to_bytes().unwrap();
|
||||
hasher.update(&ticket_body[320..]);
|
||||
let result = hasher.finalize();
|
||||
if result[0] != 0 {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Fakesigns a Ticket for use with the strncmp (trucha) bug.
|
||||
pub fn fakesign(&mut self) -> Result<(), TicketError> {
|
||||
// Erase the signature.
|
||||
self.signature = [0; 256];
|
||||
let mut current_int: u16 = 0;
|
||||
let mut test_hash: [u8; 20] = [255; 20];
|
||||
while test_hash[0] != 0 {
|
||||
if current_int == 65535 { return Err(TicketError::CannotFakesign); }
|
||||
current_int += 1;
|
||||
self.unknown2 = current_int.to_be_bytes();
|
||||
let mut hasher = Sha1::new();
|
||||
let ticket_body = self.to_bytes()?;
|
||||
hasher.update(&ticket_body[320..]);
|
||||
test_hash = <[u8; 20]>::from(hasher.finalize());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gets the name of the certificate used to sign a Ticket as a string.
|
||||
pub fn signature_issuer(&self) -> String {
|
||||
String::from_utf8_lossy(&self.signature_issuer).trim_end_matches('\0').to_owned()
|
||||
}
|
||||
|
||||
/// Sets a new name for the certificate used to sign a Ticket.
|
||||
pub fn set_signature_issuer(&mut self, signature_issuer: String) -> Result<(), TicketError> {
|
||||
if signature_issuer.len() > 64 {
|
||||
return Err(TicketError::IssuerTooLong(signature_issuer.len()));
|
||||
}
|
||||
let mut issuer = signature_issuer.into_bytes();
|
||||
issuer.resize(64, 0);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
387
src/title/tmd.rs
387
src/title/tmd.rs
@@ -1,52 +1,128 @@
|
||||
// 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.
|
||||
|
||||
use std::fmt;
|
||||
use std::io::{Cursor, Read, Write};
|
||||
use std::ops::Index;
|
||||
use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt};
|
||||
use sha1::{Sha1, Digest};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Clone)]
|
||||
#[derive(Debug, Error)]
|
||||
pub enum TMDError {
|
||||
#[error("TMD data could not be fakesigned")]
|
||||
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 = 0x00000001,
|
||||
Game = 0x00010000,
|
||||
Channel = 0x00010001,
|
||||
SystemChannel = 0x00010002,
|
||||
GameChannel = 0x00010004,
|
||||
DLC = 0x00010005,
|
||||
HiddenChannel = 0x00010008,
|
||||
}
|
||||
|
||||
impl fmt::Display for TitleType {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
TitleType::System => write!(f, "System"),
|
||||
TitleType::Game => write!(f, "Game"),
|
||||
TitleType::Channel => write!(f, "Channel"),
|
||||
TitleType::SystemChannel => write!(f, "SystemChannel"),
|
||||
TitleType::GameChannel => write!(f, "GameChannel"),
|
||||
TitleType::DLC => write!(f, "DLC"),
|
||||
TitleType::HiddenChannel => write!(f, "HiddenChannel"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ContentType {
|
||||
Normal = 1,
|
||||
Development = 2,
|
||||
HashTree = 3,
|
||||
DLC = 16385,
|
||||
Shared = 32769,
|
||||
}
|
||||
|
||||
impl fmt::Display for ContentType {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
match self {
|
||||
ContentType::Normal => write!(f, "Normal"),
|
||||
ContentType::Development => write!(f, "Development/Unknown"),
|
||||
ContentType::HashTree => write!(f, "Hash Tree"),
|
||||
ContentType::DLC => write!(f, "DLC"),
|
||||
ContentType::Shared => write!(f, "Shared"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub enum AccessRight {
|
||||
AHB = 0,
|
||||
DVDVideo = 1,
|
||||
}
|
||||
|
||||
/// 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,
|
||||
pub content_type: u16,
|
||||
pub content_type: ContentType,
|
||||
pub content_size: u64,
|
||||
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],
|
||||
pub region: u16,
|
||||
pub ratings: [u8; 16],
|
||||
region: u16,
|
||||
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, std::io::Error> {
|
||||
pub fn from_bytes(data: &[u8]) -> Result<Self, TMDError> {
|
||||
let mut buf = Cursor::new(data);
|
||||
let signature_type = buf.read_u32::<BigEndian>()?;
|
||||
let mut signature = [0u8; 256];
|
||||
@@ -91,7 +167,15 @@ impl TMD {
|
||||
for _ in 0..num_contents {
|
||||
let content_id = buf.read_u32::<BigEndian>()?;
|
||||
let index = buf.read_u16::<BigEndian>()?;
|
||||
let content_type = buf.read_u16::<BigEndian>()?;
|
||||
let type_int = buf.read_u16::<BigEndian>()?;
|
||||
let content_type = match type_int {
|
||||
1 => ContentType::Normal,
|
||||
2 => ContentType::Development,
|
||||
3 => ContentType::HashTree,
|
||||
16385 => ContentType::DLC,
|
||||
32769 => ContentType::Shared,
|
||||
_ => return Err(TMDError::InvalidContentType(type_int))
|
||||
};
|
||||
let content_size = buf.read_u64::<BigEndian>()?;
|
||||
let mut content_hash = [0u8; 20];
|
||||
buf.read_exact(&mut content_hash)?;
|
||||
@@ -154,17 +238,258 @@ 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)?;
|
||||
buf.write_u16::<BigEndian>(content.content_type)?;
|
||||
match content.content_type {
|
||||
ContentType::Normal => { buf.write_u16::<BigEndian>(1)?; },
|
||||
ContentType::Development => { buf.write_u16::<BigEndian>(2)?; },
|
||||
ContentType::HashTree => { buf.write_u16::<BigEndian>(3)?; },
|
||||
ContentType::DLC => { buf.write_u16::<BigEndian>(16385)?; },
|
||||
ContentType::Shared => { buf.write_u16::<BigEndian>(32769)?; }
|
||||
}
|
||||
buf.write_u64::<BigEndian>(content.content_size)?;
|
||||
buf.write_all(&content.content_hash)?;
|
||||
}
|
||||
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.
|
||||
if self.signature != [0; 256] {
|
||||
return false;
|
||||
}
|
||||
// Test the hash of the TMD body to make sure it starts with 00.
|
||||
let mut hasher = Sha1::new();
|
||||
let tmd_body = self.to_bytes().unwrap();
|
||||
hasher.update(&tmd_body[320..]);
|
||||
let result = hasher.finalize();
|
||||
if result[0] != 0 {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Fakesigns a TMD for use with the strncmp (trucha) bug.
|
||||
pub fn fakesign(&mut self) -> Result<(), TMDError> {
|
||||
// Erase the signature.
|
||||
self.signature = [0; 256];
|
||||
let mut current_int: u16 = 0;
|
||||
let mut test_hash: [u8; 20] = [255; 20];
|
||||
while test_hash[0] != 0 {
|
||||
if current_int == 65535 { return Err(TMDError::CannotFakesign); }
|
||||
current_int += 1;
|
||||
self.minor_version = current_int;
|
||||
let mut hasher = Sha1::new();
|
||||
let ticket_body = self.to_bytes()?;
|
||||
hasher.update(&ticket_body[320..]);
|
||||
test_hash = <[u8; 20]>::from(hasher.finalize());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Gets the 3-letter code of the region a TMD was created for.
|
||||
pub fn region(&self) -> &str {
|
||||
match self.region {
|
||||
0 => "JPN",
|
||||
1 => "USA",
|
||||
2 => "EUR",
|
||||
3 => "None",
|
||||
4 => "KOR",
|
||||
_ => "Unknown",
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the type of title described by a TMD.
|
||||
pub fn title_type(&self) -> Result<TitleType, TMDError> {
|
||||
match hex::encode(self.title_id)[..8].to_string().as_str() {
|
||||
"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.iter() {
|
||||
content_indices.push(record.index);
|
||||
}
|
||||
let target_index = content_indices.index(index);
|
||||
match self.content_records[*target_index as usize].content_type {
|
||||
ContentType::Normal => ContentType::Normal,
|
||||
ContentType::Development => ContentType::Development,
|
||||
ContentType::HashTree => ContentType::HashTree,
|
||||
ContentType::DLC => ContentType::DLC,
|
||||
ContentType::Shared => ContentType::Shared,
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets whether a specified access right is enabled in a TMD.
|
||||
pub fn check_access_right(&self, right: AccessRight) -> bool {
|
||||
self.access_rights & (1 << right as u8) != 0
|
||||
}
|
||||
|
||||
/// Gets the name of the certificate used to sign a TMD as a string.
|
||||
pub fn signature_issuer(&self) -> String {
|
||||
String::from_utf8_lossy(&self.signature_issuer).trim_end_matches('\0').to_owned()
|
||||
}
|
||||
|
||||
/// Sets a new name for the certificate used to sign a TMD.
|
||||
pub fn set_signature_issuer(&mut self, signature_issuer: String) -> Result<(), TMDError> {
|
||||
if signature_issuer.len() > 64 {
|
||||
return Err(TMDError::IssuerTooLong(signature_issuer.len()));
|
||||
}
|
||||
let mut issuer = signature_issuer.into_bytes();
|
||||
issuer.resize(64, 0);
|
||||
self.signature_issuer = issuer.try_into().unwrap();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
83
src/title/versions.rs
Normal file
83
src/title/versions.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
// 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.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn wii_menu_versions_map(vwii: Option<bool>) -> HashMap<u16, String> {
|
||||
let mut menu_versions: HashMap<u16, String> = HashMap::new();
|
||||
if vwii == Some(true) {
|
||||
menu_versions.insert(512, "vWii-1.0.0J".to_string());
|
||||
menu_versions.insert(513, "vWii-1.0.0U".to_string());
|
||||
menu_versions.insert(514, "vWii-1.0.0E".to_string());
|
||||
menu_versions.insert(544, "vWii-4.0.0J".to_string());
|
||||
menu_versions.insert(545, "vWii-4.0.0U".to_string());
|
||||
menu_versions.insert(546, "vWii-4.0.0E".to_string());
|
||||
menu_versions.insert(608, "vWii-5.2.0J".to_string());
|
||||
menu_versions.insert(609, "vWii-5.2.0U".to_string());
|
||||
menu_versions.insert(610, "vWii-5.2.0E".to_string());
|
||||
} else {
|
||||
menu_versions.insert( 0, "Prelaunch".to_string());
|
||||
menu_versions.insert( 1, "Prelaunch".to_string());
|
||||
menu_versions.insert( 2, "Prelaunch".to_string());
|
||||
menu_versions.insert( 64, "1.0J".to_string());
|
||||
menu_versions.insert( 33, "1.0U".to_string());
|
||||
menu_versions.insert( 34, "1.0E".to_string());
|
||||
menu_versions.insert( 128, "2.0J".to_string());
|
||||
menu_versions.insert( 97, "2.0U".to_string());
|
||||
menu_versions.insert( 130, "2.0E".to_string());
|
||||
menu_versions.insert( 162, "2.1E".to_string());
|
||||
menu_versions.insert( 192, "2.2J".to_string());
|
||||
menu_versions.insert( 193, "2.2U".to_string());
|
||||
menu_versions.insert( 194, "2.2E".to_string());
|
||||
menu_versions.insert( 224, "3.0J".to_string());
|
||||
menu_versions.insert( 225, "3.0U".to_string());
|
||||
menu_versions.insert( 226, "3.0E".to_string());
|
||||
menu_versions.insert( 256, "3.1J".to_string());
|
||||
menu_versions.insert( 257, "3.1U".to_string());
|
||||
menu_versions.insert( 258, "3.1E".to_string());
|
||||
menu_versions.insert( 288, "3.2J".to_string());
|
||||
menu_versions.insert( 289, "3.2U".to_string());
|
||||
menu_versions.insert( 290, "3.2E".to_string());
|
||||
menu_versions.insert( 352, "3.3J".to_string());
|
||||
menu_versions.insert( 353, "3.3U".to_string());
|
||||
menu_versions.insert( 354, "3.3E".to_string());
|
||||
menu_versions.insert( 326, "3.3K".to_string());
|
||||
menu_versions.insert( 384, "3.4J".to_string());
|
||||
menu_versions.insert( 385, "3.4U".to_string());
|
||||
menu_versions.insert( 386, "3.4E".to_string());
|
||||
menu_versions.insert( 390, "3.5K".to_string());
|
||||
menu_versions.insert( 416, "4.0J".to_string());
|
||||
menu_versions.insert( 417, "4.0U".to_string());
|
||||
menu_versions.insert( 418, "4.0E".to_string());
|
||||
menu_versions.insert( 448, "4.1J".to_string());
|
||||
menu_versions.insert( 449, "4.1U".to_string());
|
||||
menu_versions.insert( 450, "4.1E".to_string());
|
||||
menu_versions.insert( 454, "4.1K".to_string());
|
||||
menu_versions.insert( 480, "4.2J".to_string());
|
||||
menu_versions.insert( 481, "4.2U".to_string());
|
||||
menu_versions.insert( 482, "4.2E".to_string());
|
||||
menu_versions.insert( 486, "4.2K".to_string());
|
||||
menu_versions.insert( 512, "4.3J".to_string());
|
||||
menu_versions.insert( 513, "4.3U".to_string());
|
||||
menu_versions.insert( 514, "4.3E".to_string());
|
||||
menu_versions.insert( 518, "4.3K".to_string());
|
||||
menu_versions.insert( 4609, "4.3U-Mini".to_string());
|
||||
menu_versions.insert( 4610, "4.3E-Mini".to_string());
|
||||
}
|
||||
menu_versions
|
||||
}
|
||||
|
||||
/// Converts the decimal version of a title (vXXX) into a more standard format for applicable
|
||||
/// titles. For the Wii Menu, this uses the optional vwii argument and a hash table to determine
|
||||
/// the user-friendly version number, as there is no way to directly derive it from the decimal
|
||||
/// format.
|
||||
pub fn dec_to_standard(version: u16, title_id: &str, vwii: Option<bool>) -> Option<String> {
|
||||
if title_id == "0000000100000002" {
|
||||
let map = wii_menu_versions_map(vwii);
|
||||
map.get(&version).cloned()
|
||||
} else {
|
||||
Some(format!("{}.{}", version >> 8, version & 0xF))
|
||||
}
|
||||
}
|
||||
218
src/title/wad.rs
218
src/title/wad.rs
@@ -1,33 +1,28 @@
|
||||
// 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.
|
||||
|
||||
use std::error::Error;
|
||||
use std::fmt;
|
||||
use std::str;
|
||||
use std::io::{Cursor, Read, Seek, SeekFrom, Write};
|
||||
use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt};
|
||||
use crate::title::{tmd, ticket, content};
|
||||
use thiserror::Error;
|
||||
use crate::title::{cert, tmd, ticket};
|
||||
use crate::title::ticket::TicketError;
|
||||
use crate::title::tmd::TMDError;
|
||||
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Error)]
|
||||
pub enum WADError {
|
||||
BadType,
|
||||
IOError(std::io::Error),
|
||||
#[error("WAD is invalid type `{0}`")]
|
||||
BadType(String),
|
||||
#[error("TMD processing error")]
|
||||
TMD(#[from] TMDError),
|
||||
#[error("Ticket processing error")]
|
||||
Ticket(#[from] TicketError),
|
||||
#[error("WAD data is not in a valid format")]
|
||||
IO(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
impl fmt::Display for WADError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||
let description = match *self {
|
||||
WADError::BadType => "An invalid WAD type was specified.",
|
||||
WADError::IOError(_) => "The provided WAD data was invalid.",
|
||||
};
|
||||
f.write_str(description)
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for WADError {}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum WADType {
|
||||
Installable,
|
||||
@@ -35,16 +30,18 @@ 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,
|
||||
@@ -55,6 +52,7 @@ pub struct WADHeader {
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// A structure that represent the data contained in the body of a WAD file.
|
||||
pub struct WADBody {
|
||||
cert_chain: Vec<u8>,
|
||||
crl: Vec<u8>,
|
||||
@@ -65,11 +63,12 @@ pub struct WADBody {
|
||||
}
|
||||
|
||||
impl WADHeader {
|
||||
/// Creates a new WADHeader instance from the binary data of a WAD file's header.
|
||||
pub fn from_body(body: &WADBody) -> Result<WADHeader, WADError> {
|
||||
// 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::IOError)?;
|
||||
let wad_type = match hex::encode(tmd.title_id).as_str() {
|
||||
let tmd = tmd::TMD::from_bytes(&body.tmd).map_err(WADError::TMD)?;
|
||||
let wad_type = match hex::encode(tmd.title_id()).as_str() {
|
||||
"0000000100000001" => WADType::ImportBoot,
|
||||
_ => WADType::Installable,
|
||||
};
|
||||
@@ -94,17 +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 {
|
||||
pub fn from_parts(cert_chain: &[u8], crl: &[u8], ticket: &ticket::Ticket, tmd: &tmd::TMD,
|
||||
content: &content::ContentRegion, meta: &[u8]) -> Result<WADBody, WADError> {
|
||||
/// 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: &[u8], meta: &[u8]) -> Result<WADBody, WADError> {
|
||||
let body = WADBody {
|
||||
cert_chain: cert_chain.to_vec(),
|
||||
cert_chain: cert_chain.to_bytes().map_err(WADError::IO)?,
|
||||
crl: crl.to_vec(),
|
||||
ticket: ticket.to_bytes().map_err(WADError::IOError)?,
|
||||
tmd: tmd.to_bytes().map_err(WADError::IOError)?,
|
||||
content: content.to_bytes().map_err(WADError::IOError)?,
|
||||
ticket: ticket.to_bytes().map_err(WADError::IO)?,
|
||||
tmd: tmd.to_bytes().map_err(WADError::IO)?,
|
||||
content: content.to_vec(),
|
||||
meta: meta.to_vec(),
|
||||
};
|
||||
Ok(body)
|
||||
@@ -112,29 +157,30 @@ impl WADBody {
|
||||
}
|
||||
|
||||
impl WAD {
|
||||
/// Creates a new WAD instance from the binary data of a WAD file.
|
||||
pub fn from_bytes(data: &[u8]) -> Result<WAD, WADError> {
|
||||
let mut buf = Cursor::new(data);
|
||||
let header_size = buf.read_u32::<BigEndian>().map_err(WADError::IOError)?;
|
||||
let header_size = buf.read_u32::<BigEndian>().map_err(WADError::IO)?;
|
||||
let mut wad_type = [0u8; 2];
|
||||
buf.read_exact(&mut wad_type).map_err(WADError::IOError)?;
|
||||
buf.read_exact(&mut wad_type).map_err(WADError::IO)?;
|
||||
let wad_type = match str::from_utf8(&wad_type) {
|
||||
Ok(wad_type) => match wad_type {
|
||||
"Is" => WADType::Installable,
|
||||
"ib" => WADType::ImportBoot,
|
||||
_ => return Err(WADError::BadType),
|
||||
_ => return Err(WADError::BadType(wad_type.to_string())),
|
||||
},
|
||||
Err(_) => return Err(WADError::BadType),
|
||||
Err(_) => return Err(WADError::BadType(String::new())),
|
||||
};
|
||||
let wad_version = buf.read_u16::<BigEndian>().map_err(WADError::IOError)?;
|
||||
let cert_chain_size = buf.read_u32::<BigEndian>().map_err(WADError::IOError)?;
|
||||
let crl_size = buf.read_u32::<BigEndian>().map_err(WADError::IOError)?;
|
||||
let ticket_size = buf.read_u32::<BigEndian>().map_err(WADError::IOError)?;
|
||||
let tmd_size = buf.read_u32::<BigEndian>().map_err(WADError::IOError)?;
|
||||
let wad_version = buf.read_u16::<BigEndian>().map_err(WADError::IO)?;
|
||||
let cert_chain_size = buf.read_u32::<BigEndian>().map_err(WADError::IO)?;
|
||||
let crl_size = buf.read_u32::<BigEndian>().map_err(WADError::IO)?;
|
||||
let ticket_size = buf.read_u32::<BigEndian>().map_err(WADError::IO)?;
|
||||
let tmd_size = buf.read_u32::<BigEndian>().map_err(WADError::IO)?;
|
||||
// Round the content size to the nearest 16.
|
||||
let content_size = (buf.read_u32::<BigEndian>().map_err(WADError::IOError)? + 15) & !15;
|
||||
let meta_size = buf.read_u32::<BigEndian>().map_err(WADError::IOError)?;
|
||||
let content_size = (buf.read_u32::<BigEndian>().map_err(WADError::IO)? + 15) & !15;
|
||||
let meta_size = buf.read_u32::<BigEndian>().map_err(WADError::IO)?;
|
||||
let mut padding = [0u8; 32];
|
||||
buf.read_exact(&mut padding).map_err(WADError::IOError)?;
|
||||
buf.read_exact(&mut padding).map_err(WADError::IO)?;
|
||||
// Build header so we can use that data to read the WAD data.
|
||||
let header = WADHeader {
|
||||
header_size,
|
||||
@@ -156,24 +202,24 @@ impl WAD {
|
||||
let content_offset = (tmd_offset + header.tmd_size + 63) & !63;
|
||||
let meta_offset = (content_offset + header.content_size + 63) & !63;
|
||||
// Read cert chain data.
|
||||
buf.seek(SeekFrom::Start(cert_chain_offset as u64)).map_err(WADError::IOError)?;
|
||||
buf.seek(SeekFrom::Start(cert_chain_offset as u64)).map_err(WADError::IO)?;
|
||||
let mut cert_chain = vec![0u8; header.cert_chain_size as usize];
|
||||
buf.read_exact(&mut cert_chain).map_err(WADError::IOError)?;
|
||||
buf.seek(SeekFrom::Start(crl_offset as u64)).map_err(WADError::IOError)?;
|
||||
buf.read_exact(&mut cert_chain).map_err(WADError::IO)?;
|
||||
buf.seek(SeekFrom::Start(crl_offset as u64)).map_err(WADError::IO)?;
|
||||
let mut crl = vec![0u8; header.crl_size as usize];
|
||||
buf.read_exact(&mut crl).map_err(WADError::IOError)?;
|
||||
buf.seek(SeekFrom::Start(ticket_offset as u64)).map_err(WADError::IOError)?;
|
||||
buf.read_exact(&mut crl).map_err(WADError::IO)?;
|
||||
buf.seek(SeekFrom::Start(ticket_offset as u64)).map_err(WADError::IO)?;
|
||||
let mut ticket = vec![0u8; header.ticket_size as usize];
|
||||
buf.read_exact(&mut ticket).map_err(WADError::IOError)?;
|
||||
buf.seek(SeekFrom::Start(tmd_offset as u64)).map_err(WADError::IOError)?;
|
||||
buf.read_exact(&mut ticket).map_err(WADError::IO)?;
|
||||
buf.seek(SeekFrom::Start(tmd_offset as u64)).map_err(WADError::IO)?;
|
||||
let mut tmd = vec![0u8; header.tmd_size as usize];
|
||||
buf.read_exact(&mut tmd).map_err(WADError::IOError)?;
|
||||
buf.seek(SeekFrom::Start(content_offset as u64)).map_err(WADError::IOError)?;
|
||||
buf.read_exact(&mut tmd).map_err(WADError::IO)?;
|
||||
buf.seek(SeekFrom::Start(content_offset as u64)).map_err(WADError::IO)?;
|
||||
let mut content = vec![0u8; header.content_size as usize];
|
||||
buf.read_exact(&mut content).map_err(WADError::IOError)?;
|
||||
buf.seek(SeekFrom::Start(meta_offset as u64)).map_err(WADError::IOError)?;
|
||||
buf.read_exact(&mut content).map_err(WADError::IO)?;
|
||||
buf.seek(SeekFrom::Start(meta_offset as u64)).map_err(WADError::IO)?;
|
||||
let mut meta = vec![0u8; header.meta_size as usize];
|
||||
buf.read_exact(&mut meta).map_err(WADError::IOError)?;
|
||||
buf.read_exact(&mut meta).map_err(WADError::IO)?;
|
||||
let body = WADBody {
|
||||
cert_chain,
|
||||
crl,
|
||||
@@ -190,8 +236,10 @@ impl WAD {
|
||||
Ok(wad)
|
||||
}
|
||||
|
||||
pub fn from_parts(cert_chain: &[u8], crl: &[u8], ticket: &ticket::Ticket, tmd: &tmd::TMD,
|
||||
content: &content::ContentRegion, meta: &[u8]) -> Result<WAD, WADError> {
|
||||
/// 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: &[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 {
|
||||
@@ -201,38 +249,46 @@ impl WAD {
|
||||
Ok(wad)
|
||||
}
|
||||
|
||||
/// Dumps the data in a WAD instance back into binary data that can be written to a file.
|
||||
pub fn to_bytes(&self) -> Result<Vec<u8>, WADError> {
|
||||
let mut buf = Vec::new();
|
||||
buf.write_u32::<BigEndian>(self.header.header_size).map_err(WADError::IOError)?;
|
||||
buf.write_u32::<BigEndian>(self.header.header_size).map_err(WADError::IO)?;
|
||||
match self.header.wad_type {
|
||||
WADType::Installable => { buf.write("Is".as_bytes()).map_err(WADError::IOError)?; },
|
||||
WADType::ImportBoot => { buf.write("ib".as_bytes()).map_err(WADError::IOError)?; },
|
||||
WADType::Installable => { buf.write("Is".as_bytes()).map_err(WADError::IO)?; },
|
||||
WADType::ImportBoot => { buf.write("ib".as_bytes()).map_err(WADError::IO)?; },
|
||||
}
|
||||
buf.write_u16::<BigEndian>(self.header.wad_version).map_err(WADError::IOError)?;
|
||||
buf.write_u32::<BigEndian>(self.header.cert_chain_size).map_err(WADError::IOError)?;
|
||||
buf.write_u32::<BigEndian>(self.header.crl_size).map_err(WADError::IOError)?;
|
||||
buf.write_u32::<BigEndian>(self.header.ticket_size).map_err(WADError::IOError)?;
|
||||
buf.write_u32::<BigEndian>(self.header.tmd_size).map_err(WADError::IOError)?;
|
||||
buf.write_u32::<BigEndian>(self.header.content_size).map_err(WADError::IOError)?;
|
||||
buf.write_u32::<BigEndian>(self.header.meta_size).map_err(WADError::IOError)?;
|
||||
buf.write_all(&self.header.padding).map_err(WADError::IOError)?;
|
||||
buf.write_u16::<BigEndian>(self.header.wad_version).map_err(WADError::IO)?;
|
||||
buf.write_u32::<BigEndian>(self.header.cert_chain_size).map_err(WADError::IO)?;
|
||||
buf.write_u32::<BigEndian>(self.header.crl_size).map_err(WADError::IO)?;
|
||||
buf.write_u32::<BigEndian>(self.header.ticket_size).map_err(WADError::IO)?;
|
||||
buf.write_u32::<BigEndian>(self.header.tmd_size).map_err(WADError::IO)?;
|
||||
buf.write_u32::<BigEndian>(self.header.content_size).map_err(WADError::IO)?;
|
||||
buf.write_u32::<BigEndian>(self.header.meta_size).map_err(WADError::IO)?;
|
||||
buf.write_all(&self.header.padding).map_err(WADError::IO)?;
|
||||
// Pad up to nearest multiple of 64. This also needs to happen after each section of data.
|
||||
buf.resize((buf.len() + 63) & !63, 0);
|
||||
buf.write_all(&self.body.cert_chain).map_err(WADError::IOError)?;
|
||||
buf.write_all(&self.body.cert_chain).map_err(WADError::IO)?;
|
||||
buf.resize((buf.len() + 63) & !63, 0);
|
||||
buf.write_all(&self.body.crl).map_err(WADError::IOError)?;
|
||||
buf.write_all(&self.body.crl).map_err(WADError::IO)?;
|
||||
buf.resize((buf.len() + 63) & !63, 0);
|
||||
buf.write_all(&self.body.ticket).map_err(WADError::IOError)?;
|
||||
buf.write_all(&self.body.ticket).map_err(WADError::IO)?;
|
||||
buf.resize((buf.len() + 63) & !63, 0);
|
||||
buf.write_all(&self.body.tmd).map_err(WADError::IOError)?;
|
||||
buf.write_all(&self.body.tmd).map_err(WADError::IO)?;
|
||||
buf.resize((buf.len() + 63) & !63, 0);
|
||||
buf.write_all(&self.body.content).map_err(WADError::IOError)?;
|
||||
buf.write_all(&self.body.content).map_err(WADError::IO)?;
|
||||
buf.resize((buf.len() + 63) & !63, 0);
|
||||
buf.write_all(&self.body.meta).map_err(WADError::IOError)?;
|
||||
buf.write_all(&self.body.meta).map_err(WADError::IO)?;
|
||||
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 }
|
||||
|
||||
pub fn cert_chain(&self) -> Vec<u8> {
|
||||
self.body.cert_chain.clone()
|
||||
}
|
||||
@@ -242,6 +298,8 @@ impl WAD {
|
||||
self.header.cert_chain_size = cert_chain.len() as u32;
|
||||
}
|
||||
|
||||
pub fn crl_size(&self) -> u32 { self.header.crl_size }
|
||||
|
||||
pub fn crl(&self) -> Vec<u8> {
|
||||
self.body.crl.clone()
|
||||
}
|
||||
@@ -251,6 +309,8 @@ impl WAD {
|
||||
self.header.crl_size = crl.len() as u32;
|
||||
}
|
||||
|
||||
pub fn ticket_size(&self) -> u32 { self.header.ticket_size }
|
||||
|
||||
pub fn ticket(&self) -> Vec<u8> {
|
||||
self.body.ticket.clone()
|
||||
}
|
||||
@@ -260,6 +320,8 @@ impl WAD {
|
||||
self.header.ticket_size = ticket.len() as u32;
|
||||
}
|
||||
|
||||
pub fn tmd_size(&self) -> u32 { self.header.tmd_size }
|
||||
|
||||
pub fn tmd(&self) -> Vec<u8> {
|
||||
self.body.tmd.clone()
|
||||
}
|
||||
@@ -269,6 +331,8 @@ impl WAD {
|
||||
self.header.tmd_size = tmd.len() as u32;
|
||||
}
|
||||
|
||||
pub fn content_size(&self) -> u32 { self.header.content_size }
|
||||
|
||||
pub fn content(&self) -> Vec<u8> {
|
||||
self.body.content.clone()
|
||||
}
|
||||
@@ -278,6 +342,8 @@ impl WAD {
|
||||
self.header.content_size = content.len() as u32;
|
||||
}
|
||||
|
||||
pub fn meta_size(&self) -> u32 { self.header.meta_size }
|
||||
|
||||
pub fn meta(&self) -> Vec<u8> {
|
||||
self.body.meta.clone()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user