mirror of
https://github.com/NinjaCheetah/rustii.git
synced 2026-03-05 20:15:28 -05:00
Compare commits
10 Commits
15947ceff3
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 449097967c | |||
|
326bb56ece
|
|||
|
0d34fbc383
|
|||
|
02db260138
|
|||
|
23699a518d
|
|||
|
5cc6c1c8ff
|
|||
|
7c8484edaa
|
|||
|
836d5e912a
|
|||
|
94e0be0eef
|
|||
|
26138c02be
|
52
.github/workflows/rust.yml
vendored
52
.github/workflows/rust.yml
vendored
@@ -1,4 +1,4 @@
|
|||||||
name: Build rustii
|
name: Build rustwii
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
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.
|
# Not sure if this is the best choice, but I'm building in release mode to produce more effective nightly binaries.
|
||||||
- name: Update Toolchain
|
- name: Update Toolchain
|
||||||
run: rustup update
|
run: rustup update
|
||||||
- name: Build rustii
|
- name: Build rustwii
|
||||||
run: cargo build --verbose --release
|
run: cargo build --verbose --release
|
||||||
- name: Package rustii for Upload
|
- name: Package rustwii for Upload
|
||||||
run: |
|
run: |
|
||||||
mv target/release/rustii ~/rustii
|
mv target/release/rustwii ~/rustwii
|
||||||
cd ~
|
cd ~
|
||||||
tar cvf rustii.tar rustii
|
tar cvf rustwii.tar rustwii
|
||||||
- name: Upload rustii
|
- name: Upload rustwii
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
path: ~/rustii.tar
|
path: ~/rustwii.tar
|
||||||
name: rustii-Linux-bin
|
name: rustwii-Linux-bin
|
||||||
|
|
||||||
build-macos-arm64:
|
build-macos-arm64:
|
||||||
|
|
||||||
@@ -42,18 +42,18 @@ jobs:
|
|||||||
run: rustup update
|
run: rustup update
|
||||||
- name: Add ARM64 Target
|
- name: Add ARM64 Target
|
||||||
run: rustup target add aarch64-apple-darwin
|
run: rustup target add aarch64-apple-darwin
|
||||||
- name: Build rustii
|
- name: Build rustwii
|
||||||
run: cargo build --verbose --release --target aarch64-apple-darwin
|
run: cargo build --verbose --release --target aarch64-apple-darwin
|
||||||
- name: Package rustii for Upload
|
- name: Package rustwii for Upload
|
||||||
run: |
|
run: |
|
||||||
mv target/aarch64-apple-darwin/release/rustii ~/rustii
|
mv target/aarch64-apple-darwin/release/rustwii ~/rustwii
|
||||||
cd ~
|
cd ~
|
||||||
tar cvf rustii.tar rustii
|
tar cvf rustwii.tar rustwii
|
||||||
- name: Upload rustii
|
- name: Upload rustwii
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
path: ~/rustii.tar
|
path: ~/rustwii.tar
|
||||||
name: rustii-macOS-arm64-bin
|
name: rustwii-macOS-arm64-bin
|
||||||
|
|
||||||
build-macos-x86_64:
|
build-macos-x86_64:
|
||||||
|
|
||||||
@@ -65,18 +65,18 @@ jobs:
|
|||||||
run: rustup update
|
run: rustup update
|
||||||
- name: Add x86_64 Target
|
- name: Add x86_64 Target
|
||||||
run: rustup target add x86_64-apple-darwin
|
run: rustup target add x86_64-apple-darwin
|
||||||
- name: Build rustii
|
- name: Build rustwii
|
||||||
run: cargo build --verbose --release --target x86_64-apple-darwin
|
run: cargo build --verbose --release --target x86_64-apple-darwin
|
||||||
- name: Package rustii for Upload
|
- name: Package rustwii for Upload
|
||||||
run: |
|
run: |
|
||||||
mv target/x86_64-apple-darwin/release/rustii ~/rustii
|
mv target/x86_64-apple-darwin/release/rustwii ~/rustwii
|
||||||
cd ~
|
cd ~
|
||||||
tar cvf rustii.tar rustii
|
tar cvf rustwii.tar rustwii
|
||||||
- name: Upload rustii
|
- name: Upload rustwii
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
path: ~/rustii.tar
|
path: ~/rustwii.tar
|
||||||
name: rustii-macOS-x86_64-bin
|
name: rustwii-macOS-x86_64-bin
|
||||||
|
|
||||||
build-windows-x86_64:
|
build-windows-x86_64:
|
||||||
|
|
||||||
@@ -86,12 +86,12 @@ jobs:
|
|||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Update Toolchain
|
- name: Update Toolchain
|
||||||
run: rustup update
|
run: rustup update
|
||||||
- name: Build rustii
|
- name: Build rustwii
|
||||||
run: cargo build --verbose --release
|
run: cargo build --verbose --release
|
||||||
- name: Upload rustii
|
- name: Upload rustwii
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
path: D:\a\rustii\rustii\target\release\rustii.exe
|
path: D:\a\rustwii\rustwii\target\release\rustwii.exe
|
||||||
name: rustii-Windows-bin
|
name: rustwii-Windows-bin
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
1951
Cargo.lock
generated
1951
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
20
Cargo.toml
20
Cargo.toml
@@ -1,18 +1,18 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rustii"
|
name = "rustwii"
|
||||||
authors = ["NinjaCheetah <ninjacheetah@ncxprogramming.com>"]
|
authors = ["NinjaCheetah <campbell@ninjacheetah.dev>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
description = "A Rust library and CLI for handling files and formats used by the Wii"
|
description = "A Rust library and CLI for handling files and formats used by the Wii"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
homepage = "https://github.com/NinjaCheetah/rustii"
|
homepage = "https://github.com/NinjaCheetah/rustwii"
|
||||||
repository = "https://github.com/NinjaCheetah/rustii"
|
repository = "https://github.com/NinjaCheetah/rustwii"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
default-run = "rustii"
|
default-run = "rustwii"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "rustii"
|
name = "rustwii"
|
||||||
path = "src/bin/rustii/main.rs"
|
path = "src/bin/rustwii/main.rs"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "playground"
|
name = "playground"
|
||||||
@@ -29,7 +29,7 @@ cbc = "0"
|
|||||||
aes = "0"
|
aes = "0"
|
||||||
rsa = { version = "0", features = ["sha2"] }
|
rsa = { version = "0", features = ["sha2"] }
|
||||||
hex = "0"
|
hex = "0"
|
||||||
sha1 = { version = "0", features = ["oid"]}
|
sha1 = { version = "0", features = ["oid"] }
|
||||||
glob = "0"
|
glob = "0"
|
||||||
regex = "1"
|
regex = "1"
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
@@ -37,3 +37,7 @@ anyhow = "1"
|
|||||||
thiserror = "2"
|
thiserror = "2"
|
||||||
reqwest = { version = "0", features = ["blocking"] }
|
reqwest = { version = "0", features = ["blocking"] }
|
||||||
rand = "0"
|
rand = "0"
|
||||||
|
walkdir = "2"
|
||||||
|
tempfile = "3"
|
||||||
|
rust-ini = "0"
|
||||||
|
zip = "8"
|
||||||
|
|||||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2025 NinjaCheetah
|
Copyright (c) 2025-2026 NinjaCheetah
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -1,13 +1,13 @@
|
|||||||

|

|
||||||
# rustii
|
# rustwii
|
||||||
|
|
||||||
*Like rusty but it's rustii because the Wii? Get it?*
|
*Like rusty but it's rustwii because the Wii? Get it?*
|
||||||
|
|
||||||
[](https://github.com/NinjaCheetah/rustii/actions/workflows/rust.yml)
|
[](https://github.com/NinjaCheetah/rustwii/actions/workflows/rust.yml)
|
||||||
|
|
||||||
rustii is a library and command line tool written in Rust for handling the various files and formats found on the Wii. rustii is a port of my other library, [libWiiPy](https://github.com/NinjaCheetah/libWiiPy), which aims to accomplish the same goal in Python. At this point, rustii should not be considered stable, however it offers most of the same core functionality as libWiiPy, and the rustii CLI offers most of the same features as WiiPy. You can check which features are available and ready for use in both the library and the CLI below. The goal is for rustii and libWiiPy to eventually have feature parity, with the rustii CLI acting as a drop-in replacement for the (comparatively much less efficient) [WiiPy](https://github.com/NinjaCheetah/WiiPy) CLI.
|
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 rustii, as I'm putting that off until I reach feature parity with libWiiPy so that the APIs are an equal level of stable. You can, however, reference the doc strings present on many of the structs and functions, and build them into basic documentation yourself (using `cargo doc --no-deps`). The [libWiiPy API docs](https://docs.ninjacheetah.dev) may also be helpful in some cases.
|
There is currently no public documentation for rustwii, as I'm putting that off until I reach feature parity with libWiiPy so that the APIs are an equal level of stable. You can, however, reference the doc strings present on many of the structs and functions, and build them into basic documentation yourself (using `cargo doc --no-deps`). The [libWiiPy API docs](https://docs.ninjacheetah.dev) may also be helpful in some cases.
|
||||||
|
|
||||||
I'm still very new to Rust, so pardon any messy code or confusing API decisions you may find. libWiiPy started off like that, too.
|
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.
|
||||||
|
|
||||||
@@ -31,13 +31,13 @@ I'm still very new to Rust, so pardon any messy code or confusing API decisions
|
|||||||
- Info command for WADs/TMDs/Tickets/U8 archives
|
- Info command for WADs/TMDs/Tickets/U8 archives
|
||||||
- U8 archive packing/unpacking
|
- U8 archive packing/unpacking
|
||||||
|
|
||||||
To see specific usage information, check `rustii --help` and `rustii <command> --help`.
|
To see specific usage information, check `rustwii --help` and `rustwii <command> --help`.
|
||||||
|
|
||||||
## Building
|
## Building
|
||||||
rustii is a standard Rust crate. You'll need to have [Rust installed](https://www.rust-lang.org/learn/get-started), and then you can simply run:
|
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
|
cargo build --release
|
||||||
```
|
```
|
||||||
to compile the rustii library and CLI. The CLI can then be found at `target/release/rustii(.exe)`.
|
to compile the rustwii library and CLI. The CLI can then be found at `target/release/rustwii(.exe)`.
|
||||||
|
|
||||||
You can also download the latest nightly build from [GitHub Actions](https://github.com/NinjaCheetah/rustii/actions).
|
You can also download the latest nightly build from [GitHub Actions](https://github.com/NinjaCheetah/rustwii/actions).
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// archive/ash.rs from rustii (c) 2025 NinjaCheetah & Contributors
|
// archive/ash.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
|
||||||
// https://github.com/NinjaCheetah/rustii
|
// https://github.com/NinjaCheetah/rustwii
|
||||||
//
|
//
|
||||||
// Implements the decompression routines used for the Wii's ASH compression scheme.
|
// 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.
|
// May someday even include the compression routines! If I ever get around to it.
|
||||||
@@ -45,7 +45,7 @@ fn ash_bit_reader_feed_word(reader: &mut ASHBitReader) -> Result<(), ASHError> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn ash_bit_reader_init(src: &[u8], size: u32, startpos: u32) -> Result<ASHBitReader, ASHError> {
|
fn ash_bit_reader_init(src: &'_ [u8], size: u32, startpos: u32) -> Result<ASHBitReader<'_>, ASHError> {
|
||||||
// Load data into a bit reader, then have it read its first word.
|
// Load data into a bit reader, then have it read its first word.
|
||||||
let mut reader = ASHBitReader {
|
let mut reader = ASHBitReader {
|
||||||
src,
|
src,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// archive/lz77.rs from rustii (c) 2025 NinjaCheetah & Contributors
|
// archive/lz77.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
|
||||||
// https://github.com/NinjaCheetah/rustii
|
// https://github.com/NinjaCheetah/rustwii
|
||||||
//
|
//
|
||||||
// Implements the compression and decompression routines used for the Wii's LZ77 compression scheme.
|
// Implements the compression and decompression routines used for the Wii's LZ77 compression scheme.
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// archive/mod.rs from rustii (c) 2025 NinjaCheetah & Contributors
|
// archive/mod.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
|
||||||
// https://github.com/NinjaCheetah/rustii
|
// https://github.com/NinjaCheetah/rustwii
|
||||||
//
|
//
|
||||||
// Root for all archive-related modules.
|
// Root for all archive-related modules.
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
// archive/u8.rs from rustii (c) 2025 NinjaCheetah & Contributors
|
// archive/u8.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
|
||||||
// https://github.com/NinjaCheetah/rustii
|
// https://github.com/NinjaCheetah/rustwii
|
||||||
//
|
//
|
||||||
// Implements the structures and methods required for parsing U8 archives.
|
// Implements the structures and methods required for parsing U8 archives.
|
||||||
|
|
||||||
use std::cell::RefCell;
|
|
||||||
use std::io::{Cursor, Read, Seek, SeekFrom, Write};
|
use std::io::{Cursor, Read, Seek, SeekFrom, Write};
|
||||||
use std::rc::{Rc, Weak};
|
|
||||||
use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt};
|
use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
@@ -26,83 +24,18 @@ pub enum U8Error {
|
|||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct U8Directory {
|
pub struct U8Directory {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub parent: Option<Weak<RefCell<U8Directory>>>,
|
pub dirs: Vec<U8Directory>,
|
||||||
pub dirs: Vec<Rc<RefCell<U8Directory>>>,
|
pub files: Vec<U8File>,
|
||||||
pub files: Vec<Rc<RefCell<U8File>>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct U8File {
|
pub struct U8File {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub data: Vec<u8>,
|
pub data: Vec<u8>,
|
||||||
pub parent: Option<Weak<RefCell<U8Directory>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl U8Directory {
|
|
||||||
pub fn new(name: String) -> Rc<RefCell<Self>> {
|
|
||||||
Rc::new(RefCell::new(Self {
|
|
||||||
name,
|
|
||||||
parent: None,
|
|
||||||
dirs: Vec::new(),
|
|
||||||
files: Vec::new(),
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_dir(parent: &Rc<RefCell<Self>>, child: Rc<RefCell<Self>>) {
|
|
||||||
child.borrow_mut().parent = Some(Rc::downgrade(parent));
|
|
||||||
parent.borrow_mut().dirs.push(child);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn add_file(parent: &Rc<RefCell<Self>>, file: Rc<RefCell<U8File>>) {
|
|
||||||
file.borrow_mut().parent = Some(Rc::downgrade(parent));
|
|
||||||
parent.borrow_mut().files.push(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_parent(&self) -> Option<Rc<RefCell<U8Directory>>> {
|
|
||||||
self.parent.as_ref()?.upgrade()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_child_dir(parent: &Rc<RefCell<U8Directory>>, name: &str) -> Option<Rc<RefCell<U8Directory>>> {
|
|
||||||
parent.borrow().dirs.iter()
|
|
||||||
.find(|dir| dir.borrow().name == name)
|
|
||||||
.map(Rc::clone)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn count_recursive(dir: &Rc<RefCell<U8Directory>>, count: &mut usize) {
|
|
||||||
*count += dir.borrow().files.len();
|
|
||||||
for dir in dir.borrow().dirs.iter() {
|
|
||||||
*count += 1;
|
|
||||||
Self::count_recursive(dir, count);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn count(&self) -> usize {
|
|
||||||
let mut count: usize = 1;
|
|
||||||
count += self.files.len();
|
|
||||||
for dir in &self.dirs {
|
|
||||||
count += 1;
|
|
||||||
Self::count_recursive(dir, &mut count);
|
|
||||||
}
|
|
||||||
count
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl U8File {
|
|
||||||
pub fn new(name: String, data: Vec<u8>) -> Rc<RefCell<Self>> {
|
|
||||||
Rc::new(RefCell::new(Self {
|
|
||||||
name,
|
|
||||||
data,
|
|
||||||
parent: None,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn get_parent(&self) -> Option<Rc<RefCell<U8Directory>>> {
|
|
||||||
self.parent.as_ref()?.upgrade()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct U8Node {
|
struct U8Node {
|
||||||
pub node_type: u8,
|
pub node_type: u8,
|
||||||
pub name_offset: u32, // This is really type u24, so the most significant byte will be ignored.
|
pub name_offset: u32, // This is really type u24, so the most significant byte will be ignored.
|
||||||
pub data_offset: u32,
|
pub data_offset: u32,
|
||||||
@@ -110,13 +43,15 @@ pub struct U8Node {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct U8Archive {
|
struct U8Reader {
|
||||||
pub node_tree: Rc<RefCell<U8Directory>>,
|
buf: Cursor<Box<[u8]>>,
|
||||||
|
u8_nodes: Vec<U8Node>,
|
||||||
|
index: usize,
|
||||||
|
base_name_offset: u64
|
||||||
}
|
}
|
||||||
|
|
||||||
impl U8Archive {
|
impl U8Reader {
|
||||||
/// Creates a new U8 instance from the binary data of a U8 file.
|
fn new(data: Box<[u8]>) -> Result<Self, U8Error> {
|
||||||
pub fn from_bytes(data: &[u8]) -> Result<Self, U8Error> {
|
|
||||||
let mut buf = Cursor::new(data);
|
let mut buf = Cursor::new(data);
|
||||||
let mut magic = [0u8; 4];
|
let mut magic = [0u8; 4];
|
||||||
buf.read_exact(&mut magic)?;
|
buf.read_exact(&mut magic)?;
|
||||||
@@ -135,7 +70,7 @@ impl U8Archive {
|
|||||||
}
|
}
|
||||||
println!("ignoring IMET header at 0x40");
|
println!("ignoring IMET header at 0x40");
|
||||||
}
|
}
|
||||||
// Check for an IMET header that comes after a built tag.
|
// Check for an IMET header that comes after a build tag.
|
||||||
else {
|
else {
|
||||||
buf.seek(SeekFrom::Start(0x80))?;
|
buf.seek(SeekFrom::Start(0x80))?;
|
||||||
buf.read_exact(&mut magic)?;
|
buf.read_exact(&mut magic)?;
|
||||||
@@ -150,6 +85,7 @@ impl U8Archive {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// We're skipping the following values:
|
// We're skipping the following values:
|
||||||
// root_node_offset (u32): constant value, always 0x20
|
// 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
|
// header_size (u32): we don't need this because we already know how long the string table is
|
||||||
@@ -168,6 +104,7 @@ impl U8Archive {
|
|||||||
data_offset: root_node_data_offset,
|
data_offset: root_node_data_offset,
|
||||||
size: root_node_size,
|
size: root_node_size,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create a vec of nodes, push the root node, and then iterate over the remaining number
|
// 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.
|
// of nodes in the file and push them to the vec.
|
||||||
let mut u8_nodes: Vec<U8Node> = Vec::new();
|
let mut u8_nodes: Vec<U8Node> = Vec::new();
|
||||||
@@ -179,103 +116,131 @@ impl U8Archive {
|
|||||||
let size = buf.read_u32::<BigEndian>()?;
|
let size = buf.read_u32::<BigEndian>()?;
|
||||||
u8_nodes.push(U8Node { node_type, name_offset, data_offset, size })
|
u8_nodes.push(U8Node { node_type, name_offset, data_offset, size })
|
||||||
}
|
}
|
||||||
// Iterate over the loaded nodes and load the file names and data associated with them.
|
// Save the base name offset for later.
|
||||||
let base_name_offset = buf.position();
|
let base_name_offset = buf.position();
|
||||||
let mut file_names = Vec::<String>::new();
|
|
||||||
let mut file_data = Vec::<Vec<u8>>::new();
|
Ok(Self {
|
||||||
for node in &u8_nodes {
|
buf,
|
||||||
buf.seek(SeekFrom::Start(base_name_offset + node.name_offset as u64))?;
|
u8_nodes,
|
||||||
let mut name_bin = Vec::<u8>::new();
|
index: 0,
|
||||||
// Read the file name one byte at a time until we find a null byte.
|
base_name_offset
|
||||||
loop {
|
})
|
||||||
let byte = buf.read_u8()?;
|
}
|
||||||
if byte == b'\0' {
|
|
||||||
break;
|
fn file_name(&mut self, name_offset: u64) -> Result<String, U8Error> {
|
||||||
}
|
self.buf.seek(SeekFrom::Start(self.base_name_offset + name_offset))?;
|
||||||
name_bin.push(byte);
|
let mut name_bin = Vec::<u8>::new();
|
||||||
}
|
loop {
|
||||||
file_names.push(String::from_utf8(name_bin).map_err(|_| U8Error::InvalidFileName(base_name_offset + node.name_offset as u64))?.to_owned());
|
let byte = self.buf.read_u8()?;
|
||||||
// If this is a file node, read the data for the file.
|
if byte == b'\0' {
|
||||||
if node.node_type == 0 {
|
break;
|
||||||
buf.seek(SeekFrom::Start(node.data_offset as u64))?;
|
|
||||||
let mut data = vec![0u8; node.size as usize];
|
|
||||||
buf.read_exact(&mut data)?;
|
|
||||||
file_data.push(data);
|
|
||||||
} else {
|
|
||||||
file_data.push(Vec::new());
|
|
||||||
}
|
}
|
||||||
|
name_bin.push(byte);
|
||||||
}
|
}
|
||||||
// Now that we have all the data loaded out of the file, assemble the tree of U8Items that
|
Ok(String::from_utf8(name_bin)
|
||||||
// provides an actual map of the archive's data.
|
.map_err(|_| U8Error::InvalidFileName(self.base_name_offset + name_offset))?.to_owned()
|
||||||
let node_tree = U8Directory::new(String::new());
|
)
|
||||||
let mut focused_node = Rc::clone(&node_tree);
|
}
|
||||||
// This is the order of directory nodes we've traversed down.
|
|
||||||
let mut parent_dirs: Vec<u32> = Vec::from([0]);
|
fn file_data(&mut self, data_offset: u64, size: usize) -> Result<Vec<u8>, U8Error> {
|
||||||
for i in 0..u8_nodes.len() {
|
self.buf.seek(SeekFrom::Start(data_offset))?;
|
||||||
match u8_nodes[i].node_type {
|
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 => {
|
1 => {
|
||||||
// Code for a directory node.
|
// Directory node, recursive over the child dir and then add it to the
|
||||||
if u8_nodes[i].name_offset != 0 {
|
// current one.
|
||||||
// If we're already at the correct level, push a new empty dir item to the
|
let child_dir = self.read_dir_recursive()?;
|
||||||
// item we're currently working on.
|
current_dir.add_dir(child_dir);
|
||||||
if u8_nodes[i].data_offset == *parent_dirs.last().unwrap() {
|
|
||||||
parent_dirs.push(i as u32);
|
|
||||||
U8Directory::add_dir(&focused_node, U8Directory::new(file_names[i].clone()));
|
|
||||||
focused_node = U8Directory::get_child_dir(&focused_node, &file_names[i]).unwrap();
|
|
||||||
}
|
|
||||||
// Otherwise, go back up the path until we're at the correct level.
|
|
||||||
else {
|
|
||||||
while u8_nodes[i].data_offset != *parent_dirs.last().unwrap() {
|
|
||||||
parent_dirs.pop();
|
|
||||||
let parent = focused_node.as_ref().borrow().get_parent().unwrap();
|
|
||||||
focused_node = parent;
|
|
||||||
}
|
|
||||||
parent_dirs.push(i as u32);
|
|
||||||
// Rebuild current working directory, and make sure all directories in the
|
|
||||||
// path exist.
|
|
||||||
U8Directory::add_dir(&focused_node, U8Directory::new(file_names[i].clone()));
|
|
||||||
focused_node = U8Directory::get_child_dir(&focused_node, &file_names[i]).unwrap()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
0 => {
|
0 => {
|
||||||
// Code for a file node.
|
// File node, add
|
||||||
U8Directory::add_file(&focused_node, U8File::new(file_names[i].clone(), file_data[i].clone()));
|
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, i))
|
x => return Err(U8Error::InvalidNodeType(x, self.index))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(U8Archive {
|
|
||||||
node_tree,
|
Ok(current_dir)
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
pub fn from_tree(node_tree: &Rc<RefCell<U8Directory>>) -> Result<Self, U8Error> {
|
|
||||||
Ok(U8Archive {
|
impl U8Directory {
|
||||||
node_tree: node_tree.clone(),
|
pub fn new(name: String) -> Self {
|
||||||
})
|
Self {
|
||||||
|
name,
|
||||||
|
dirs: vec![],
|
||||||
|
files: vec![]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pack_dir_recursive(file_names: &mut Vec<String>, file_data: &mut Vec<Vec<u8>>, u8_nodes: &mut Vec<U8Node>, current_node: &Rc<RefCell<U8Directory>>) {
|
pub fn dirs(&self) -> &Vec<U8Directory> {
|
||||||
// For files, read their data into the file data list, add their name into the file name
|
&self.dirs
|
||||||
// list, then calculate the offset for their file name and create a new U8Node() for them.
|
}
|
||||||
|
|
||||||
|
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.
|
// 0 values for name/data offsets are temporary and are set later.
|
||||||
let parent_node = u8_nodes.len() - 1;
|
let parent_node = u8_nodes.len() - 1;
|
||||||
for file in ¤t_node.borrow().files {
|
for file in &self.files {
|
||||||
file_names.push(file.borrow().name.clone());
|
file_names.push(file.name.clone());
|
||||||
file_data.push(file.borrow().data.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});
|
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
|
// For directories, add their name to the file name list, add empty data to the file data
|
||||||
// the final node included in it, then recursively call this function again on that
|
// 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.
|
// directory to process it.
|
||||||
for dir in ¤t_node.borrow().dirs {
|
for dir in &self.dirs {
|
||||||
file_names.push(dir.borrow().name.clone());
|
file_names.push(dir.name.clone());
|
||||||
file_data.push(Vec::new());
|
file_data.push(Vec::new());
|
||||||
let max_node = u8_nodes.len() + current_node.borrow().count() + 1;
|
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});
|
u8_nodes.push(U8Node { node_type: 1, name_offset: 0, data_offset: parent_node as u32, size: max_node as u32});
|
||||||
U8Archive::pack_dir_recursive(file_names, file_data, u8_nodes, dir)
|
dir.pack_dir_recursive(file_names, file_data, u8_nodes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,9 +250,9 @@ impl U8Archive {
|
|||||||
let mut file_names: Vec<String> = vec![String::new()];
|
let mut file_names: Vec<String> = vec![String::new()];
|
||||||
let mut file_data: Vec<Vec<u8>> = vec![Vec::new()];
|
let mut file_data: Vec<Vec<u8>> = vec![Vec::new()];
|
||||||
let mut u8_nodes: Vec<U8Node> = 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.node_tree.borrow().count() as u32 });
|
u8_nodes.push(U8Node { node_type: 1, name_offset: 0, data_offset: 0, size: self.count() as u32 });
|
||||||
let root_node = Rc::clone(&self.node_tree);
|
self.pack_dir_recursive(&mut file_names, &mut file_data, &mut u8_nodes);
|
||||||
U8Archive::pack_dir_recursive(&mut file_names, &mut file_data, &mut u8_nodes, &root_node);
|
|
||||||
// Header size starts at 0 because the header size starts with the nodes and does not
|
// Header size starts at 0 because the header size starts with the nodes and does not
|
||||||
// include the actual file header.
|
// include the actual file header.
|
||||||
let mut header_size: u32 = 0;
|
let mut header_size: u32 = 0;
|
||||||
@@ -315,6 +280,7 @@ impl U8Archive {
|
|||||||
u8_nodes[i].name_offset = current_name_offset;
|
u8_nodes[i].name_offset = current_name_offset;
|
||||||
current_name_offset += file_names[i].len() as u32 + 1
|
current_name_offset += file_names[i].len() as u32 + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Begin writing file data.
|
// Begin writing file data.
|
||||||
let mut buf: Vec<u8> = Vec::new();
|
let mut buf: Vec<u8> = Vec::new();
|
||||||
buf.write_all(b"\x55\xAA\x38\x2D")?;
|
buf.write_all(b"\x55\xAA\x38\x2D")?;
|
||||||
@@ -344,4 +310,26 @@ impl U8Archive {
|
|||||||
}
|
}
|
||||||
Ok(buf)
|
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,48 +1,58 @@
|
|||||||
// Sample file for testing rustii library stuff.
|
// Sample file for testing rustii library stuff.
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use rustii::title::{wad, cert};
|
use rustwii::title::{wad, cert};
|
||||||
use rustii::title;
|
use rustwii::title;
|
||||||
|
use rustwii::archive::u8;
|
||||||
|
// use rustii::title::content;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let data = fs::read("sm.wad").unwrap();
|
let data = fs::read("ios9.wad").unwrap();
|
||||||
let title = title::Title::from_bytes(&data).unwrap();
|
let mut title = title::Title::from_bytes(&data).unwrap();
|
||||||
println!("Title ID from WAD via Title object: {}", hex::encode(title.tmd.title_id()));
|
|
||||||
|
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();
|
let wad = wad::WAD::from_bytes(&data).unwrap();
|
||||||
println!("size of tmd: {:?}", wad.tmd().len());
|
println!("size of tmd: {:?}", wad.tmd().len());
|
||||||
println!("num content records: {:?}", title.tmd.content_records.borrow().len());
|
println!("num content records: {:?}", title.tmd().content_records().len());
|
||||||
println!("first record data: {:?}", title.tmd.content_records.borrow().first().unwrap());
|
println!("first record data: {:?}", title.tmd().content_records().first().unwrap());
|
||||||
println!("TMD is fakesigned: {:?}",title.tmd.is_fakesigned());
|
println!("TMD is fakesigned: {:?}",title.tmd().is_fakesigned());
|
||||||
|
|
||||||
println!("title version from ticket is: {:?}", title.ticket.title_version);
|
println!("title version from ticket is: {:?}", title.ticket().title_version());
|
||||||
println!("title key (enc): {:?}", title.ticket.title_key);
|
println!("title key (enc): {:?}", title.ticket().title_key());
|
||||||
println!("title key (dec): {:?}", title.ticket.dec_title_key());
|
println!("title key (dec): {:?}", title.ticket().title_key_dec());
|
||||||
println!("ticket is fakesigned: {:?}", title.ticket.is_fakesigned());
|
println!("ticket is fakesigned: {:?}", title.ticket().is_fakesigned());
|
||||||
|
|
||||||
println!("title is fakesigned: {:?}", title.is_fakesigned());
|
println!("title is fakesigned: {:?}", title.is_fakesigned());
|
||||||
|
|
||||||
println!("wad header: {:?}", wad.header);
|
let cert_chain = &title.cert_chain();
|
||||||
|
|
||||||
let cert_chain = &title.cert_chain;
|
|
||||||
println!("cert chain OK");
|
println!("cert chain OK");
|
||||||
let result = cert::verify_ca_cert(&cert_chain.ca_cert()).unwrap();
|
let result = cert::verify_ca_cert(&cert_chain.ca_cert()).unwrap();
|
||||||
println!("CA cert {} verified successfully: {}", cert_chain.ca_cert().child_cert_identity(), result);
|
println!("CA cert {} verified successfully: {}", cert_chain.ca_cert().child_cert_identity(), result);
|
||||||
|
|
||||||
let result = cert::verify_child_cert(&cert_chain.ca_cert(), &cert_chain.tmd_cert()).unwrap();
|
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);
|
println!("TMD cert {} verified successfully: {}", cert_chain.tmd_cert().child_cert_identity(), result);
|
||||||
let result = cert::verify_tmd(&cert_chain.tmd_cert(), &title.tmd).unwrap();
|
let result = cert::verify_tmd(&cert_chain.tmd_cert(), title.tmd()).unwrap();
|
||||||
println!("TMD verified successfully: {}", result);
|
println!("TMD verified successfully: {}", result);
|
||||||
|
|
||||||
let result = cert::verify_child_cert(&cert_chain.ca_cert(), &cert_chain.ticket_cert()).unwrap();
|
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);
|
println!("Ticket cert {} verified successfully: {}", cert_chain.ticket_cert().child_cert_identity(), result);
|
||||||
let result = cert::verify_ticket(&cert_chain.ticket_cert(), &title.ticket).unwrap();
|
let result = cert::verify_ticket(&cert_chain.ticket_cert(), title.ticket()).unwrap();
|
||||||
println!("Ticket verified successfully: {}", result);
|
println!("Ticket verified successfully: {}", result);
|
||||||
|
|
||||||
let result = title.verify().unwrap();
|
let result = title.verify().unwrap();
|
||||||
println!("full title verified successfully: {}", result);
|
println!("full title verified successfully: {}", result);
|
||||||
// let mut u8_archive = u8::U8Archive::from_bytes(&fs::read("00000001.app").unwrap()).unwrap();
|
|
||||||
// println!("files and dirs counted: {}", u8_archive.node_tree.borrow().count());
|
let u8_archive = u8::U8Directory::from_bytes(fs::read("testu8.arc").unwrap().into_boxed_slice()).unwrap();
|
||||||
// fs::write("outfile.arc", u8_archive.to_bytes().unwrap()).unwrap();
|
println!("{:#?}", u8_archive);
|
||||||
// println!("re-written");
|
|
||||||
|
// 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,6 +0,0 @@
|
|||||||
// archive/mod.rs from rustii (c) 2025 NinjaCheetah & Contributors
|
|
||||||
// https://github.com/NinjaCheetah/rustii
|
|
||||||
|
|
||||||
pub mod ash;
|
|
||||||
pub mod lz77;
|
|
||||||
pub mod u8;
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
// title/mod.rs from rustii (c) 2025 NinjaCheetah & Contributors
|
|
||||||
// https://github.com/NinjaCheetah/rustii
|
|
||||||
|
|
||||||
pub mod fakesign;
|
|
||||||
pub mod nus;
|
|
||||||
pub mod wad;
|
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
// archive/ash.rs from rustii (c) 2025 NinjaCheetah & Contributors
|
// archive/ash.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
|
||||||
// https://github.com/NinjaCheetah/rustii
|
// https://github.com/NinjaCheetah/rustwii
|
||||||
//
|
//
|
||||||
// Code for the ASH decompression command in the rustii CLI.
|
// Code for the ASH decompression command in the rustwii CLI.
|
||||||
// Might even have the compression command someday if I ever write the compression code!
|
// Might even have the compression command someday if I ever write the compression code!
|
||||||
|
|
||||||
use std::{str, fs};
|
use std::{str, fs};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
use clap::Subcommand;
|
use clap::Subcommand;
|
||||||
use rustii::archive::ash;
|
use rustwii::archive::ash;
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
#[command(arg_required_else_help = true)]
|
#[command(arg_required_else_help = true)]
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
// archive/lz77.rs from rustii (c) 2025 NinjaCheetah & Contributors
|
// archive/lz77.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
|
||||||
// https://github.com/NinjaCheetah/rustii
|
// https://github.com/NinjaCheetah/rustwii
|
||||||
//
|
//
|
||||||
// Code for the LZ77 compression/decompression commands in the rustii CLI.
|
// Code for the LZ77 compression/decompression commands in the rustwii CLI.
|
||||||
|
|
||||||
use std::{str, fs};
|
use std::{str, fs};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
use clap::Subcommand;
|
use clap::Subcommand;
|
||||||
use rustii::archive::lz77;
|
use rustwii::archive::lz77;
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
#[command(arg_required_else_help = true)]
|
#[command(arg_required_else_help = true)]
|
||||||
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(())
|
||||||
|
}
|
||||||
@@ -1,16 +1,14 @@
|
|||||||
// archive/u8.rs from rustii (c) 2025 NinjaCheetah & Contributors
|
// archive/u8.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
|
||||||
// https://github.com/NinjaCheetah/rustii
|
// https://github.com/NinjaCheetah/rustwii
|
||||||
//
|
//
|
||||||
// Code for the U8 packing/unpacking commands in the rustii CLI.
|
// Code for the U8 packing/unpacking commands in the rustwii CLI.
|
||||||
|
|
||||||
use std::{str, fs};
|
use std::{str, fs};
|
||||||
use std::cell::RefCell;
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::rc::Rc;
|
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
use clap::Subcommand;
|
use clap::Subcommand;
|
||||||
use glob::glob;
|
use glob::glob;
|
||||||
use rustii::archive::u8;
|
use rustwii::archive::u8;
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
#[command(arg_required_else_help = true)]
|
#[command(arg_required_else_help = true)]
|
||||||
@@ -31,7 +29,7 @@ pub enum Commands {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn pack_dir_recursive(dir: &Rc<RefCell<u8::U8Directory>>, in_path: PathBuf) -> Result<()> {
|
pub fn pack_dir_recursive(dir: &mut u8::U8Directory, in_path: PathBuf) -> Result<()> {
|
||||||
let mut files = Vec::new();
|
let mut files = Vec::new();
|
||||||
let mut dirs = Vec::new();
|
let mut dirs = Vec::new();
|
||||||
for entry in glob(&format!("{}/*", in_path.display()))?.flatten() {
|
for entry in glob(&format!("{}/*", in_path.display()))?.flatten() {
|
||||||
@@ -43,13 +41,12 @@ fn pack_dir_recursive(dir: &Rc<RefCell<u8::U8Directory>>, in_path: PathBuf) -> R
|
|||||||
}
|
}
|
||||||
for file in files {
|
for file in files {
|
||||||
let node = u8::U8File::new(file.file_name().unwrap().to_str().unwrap().to_owned(), fs::read(file)?);
|
let node = u8::U8File::new(file.file_name().unwrap().to_str().unwrap().to_owned(), fs::read(file)?);
|
||||||
u8::U8Directory::add_file(dir, node);
|
dir.add_file(node);
|
||||||
}
|
}
|
||||||
for child_dir in dirs {
|
for child_dir in dirs {
|
||||||
let node = u8::U8Directory::new(child_dir.file_name().unwrap().to_str().unwrap().to_owned());
|
let node = u8::U8Directory::new(child_dir.file_name().unwrap().to_str().unwrap().to_owned());
|
||||||
u8::U8Directory::add_dir(dir, node);
|
let dir = dir.add_dir(node);
|
||||||
let dir = u8::U8Directory::get_child_dir(dir, child_dir.file_name().unwrap().to_str().unwrap()).unwrap();
|
pack_dir_recursive(dir, child_dir)?;
|
||||||
pack_dir_recursive(&dir, child_dir)?;
|
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -60,22 +57,21 @@ pub fn pack_u8_archive(input: &str, output: &str) -> Result<()> {
|
|||||||
bail!("Source directory \"{}\" could not be found.", in_path.display());
|
bail!("Source directory \"{}\" could not be found.", in_path.display());
|
||||||
}
|
}
|
||||||
let out_path = PathBuf::from(output);
|
let out_path = PathBuf::from(output);
|
||||||
let node_tree = u8::U8Directory::new(String::new());
|
let mut root_dir = u8::U8Directory::new(String::new());
|
||||||
pack_dir_recursive(&node_tree, in_path.to_path_buf()).with_context(|| "A U8 archive could not be packed.")?;
|
pack_dir_recursive(&mut root_dir, in_path.to_path_buf()).with_context(|| "A U8 archive could not be packed.")?;
|
||||||
let u8_archive = u8::U8Archive::from_tree(&node_tree).with_context(|| "An unknown error occurred while creating a U8 archive from the data.")?;
|
fs::write(&out_path, &root_dir.to_bytes()?).with_context(|| format!("Could not open output file \"{}\" for writing.", out_path.display()))?;
|
||||||
fs::write(&out_path, &u8_archive.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());
|
println!("Successfully packed directory \"{}\" into U8 archive \"{}\"!", in_path.display(), out_path.display());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn unpack_dir_recursive(dir: &Rc<RefCell<u8::U8Directory>>, out_path: PathBuf) -> Result<()> {
|
pub fn unpack_dir_recursive(dir: &u8::U8Directory, out_path: PathBuf) -> Result<()> {
|
||||||
let out_path = out_path.join(&dir.borrow().name);
|
let out_path = out_path.join(&dir.name);
|
||||||
for file in &dir.borrow().files {
|
for file in &dir.files {
|
||||||
fs::write(out_path.join(&file.borrow().name), &file.borrow().data).with_context(|| format!("Failed to write output file \"{}\".", &file.borrow().name))?;
|
fs::write(out_path.join(&file.name), &file.data).with_context(|| format!("Failed to write output file \"{}\".", &file.name))?;
|
||||||
}
|
}
|
||||||
for dir in &dir.borrow().dirs {
|
for dir in &dir.dirs {
|
||||||
if !out_path.join(&dir.borrow().name).exists() {
|
if !out_path.join(&dir.name).exists() {
|
||||||
fs::create_dir(out_path.join(&dir.borrow().name)).with_context(|| format!("The output directory \"{}\" could not be created.", out_path.display()))?;
|
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())?;
|
unpack_dir_recursive(dir, out_path.clone())?;
|
||||||
}
|
}
|
||||||
@@ -97,8 +93,8 @@ pub fn unpack_u8_archive(input: &str, output: &str) -> Result<()> {
|
|||||||
}
|
}
|
||||||
// Extract the files and directories in the root, and then recurse over each directory to
|
// Extract the files and directories in the root, and then recurse over each directory to
|
||||||
// extract the files and directories they contain.
|
// extract the files and directories they contain.
|
||||||
let u8_archive = u8::U8Archive::from_bytes(&fs::read(in_path).with_context(|| format!("Input file \"{}\" could not be read.", in_path.display()))?)?;
|
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(&u8_archive.node_tree, out_path.clone())?;
|
unpack_dir_recursive(&root_dir, out_path.clone())?;
|
||||||
println!("Successfully unpacked U8 archive to directory \"{}\"!", out_path.display());
|
println!("Successfully unpacked U8 archive to directory \"{}\"!", out_path.display());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// filetypes.rs from rustii (c) 2025 NinjaCheetah & Contributors
|
// filetypes.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
|
||||||
// https://github.com/NinjaCheetah/rustii
|
// https://github.com/NinjaCheetah/rustwii
|
||||||
//
|
//
|
||||||
// Common code for identifying Wii file types.
|
// Common code for identifying Wii file types.
|
||||||
|
|
||||||
@@ -1,15 +1,13 @@
|
|||||||
// info.rs from rustii (c) 2025 NinjaCheetah & Contributors
|
// info.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
|
||||||
// https://github.com/NinjaCheetah/rustii
|
// https://github.com/NinjaCheetah/rustwii
|
||||||
//
|
//
|
||||||
// Code for the info command in the rustii CLI.
|
// Code for the info command in the rustwii CLI.
|
||||||
|
|
||||||
use std::{str, fs};
|
use std::{str, fs};
|
||||||
use std::cell::RefCell;
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::rc::Rc;
|
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
use rustii::archive::u8;
|
use rustwii::archive::u8;
|
||||||
use rustii::{title, title::cert, title::tmd, title::ticket, title::wad, title::versions};
|
use rustwii::{title, title::cert, title::tmd, title::ticket, title::wad, title::versions};
|
||||||
use crate::filetypes::{WiiFileType, identify_file_type};
|
use crate::filetypes::{WiiFileType, identify_file_type};
|
||||||
|
|
||||||
// Avoids duplicated code, since both TMD and Ticket info print the TID in the same way.
|
// Avoids duplicated code, since both TMD and Ticket info print the TID in the same way.
|
||||||
@@ -20,8 +18,8 @@ fn print_tid(title_id: [u8; 8]) -> Result<()> {
|
|||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
if ascii_tid.is_some() {
|
if let Some(ascii_tid) = ascii_tid {
|
||||||
println!(" Title ID: {} ({})", hex::encode(title_id).to_uppercase(), ascii_tid.unwrap());
|
println!(" Title ID: {} ({})", hex::encode(title_id).to_uppercase(), ascii_tid);
|
||||||
} else {
|
} else {
|
||||||
println!(" Title ID: {}", hex::encode(title_id).to_uppercase());
|
println!(" Title ID: {}", hex::encode(title_id).to_uppercase());
|
||||||
}
|
}
|
||||||
@@ -41,19 +39,19 @@ fn print_title_version(title_version: u16, title_id: [u8; 8], is_vwii: bool) ->
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_tmd_info(tmd: tmd::TMD, cert: Option<cert::Certificate>) -> Result<()> {
|
fn print_tmd_info(tmd: &tmd::TMD, cert: Option<cert::Certificate>) -> Result<()> {
|
||||||
// Print all important keys from the TMD.
|
// Print all important keys from the TMD.
|
||||||
println!("Title Info");
|
println!("Title Info");
|
||||||
print_tid(tmd.title_id())?;
|
print_tid(tmd.title_id())?;
|
||||||
print_title_version(tmd.title_version, tmd.title_id(), tmd.is_vwii())?;
|
print_title_version(tmd.title_version(), tmd.title_id(), tmd.is_vwii())?;
|
||||||
println!(" TMD Version: {}", tmd.tmd_version);
|
println!(" TMD Version: {}", tmd.tmd_version());
|
||||||
if hex::encode(tmd.ios_tid()).eq("0000000000000000") {
|
if hex::encode(tmd.ios_tid()).eq("0000000000000000") {
|
||||||
println!(" Required IOS: N/A");
|
println!(" Required IOS: N/A");
|
||||||
}
|
}
|
||||||
else if hex::encode(tmd.ios_tid()).ne(&format!("{:016X}", tmd.title_version)) {
|
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());
|
println!(" Required IOS: IOS{} ({})", tmd.ios_tid().last().unwrap(), hex::encode(tmd.ios_tid()).to_uppercase());
|
||||||
}
|
}
|
||||||
let signature_issuer = String::from_utf8(Vec::from(tmd.signature_issuer)).unwrap_or_default();
|
let signature_issuer = String::from_utf8(Vec::from(tmd.signature_issuer())).unwrap_or_default();
|
||||||
if signature_issuer.contains("CP00000004") {
|
if signature_issuer.contains("CP00000004") {
|
||||||
println!(" Certificate: CP00000004 (Retail)");
|
println!(" Certificate: CP00000004 (Retail)");
|
||||||
println!(" Certificate Issuer: Root-CA00000001 (Retail)");
|
println!(" Certificate Issuer: Root-CA00000001 (Retail)");
|
||||||
@@ -74,7 +72,7 @@ fn print_tmd_info(tmd: tmd::TMD, cert: Option<cert::Certificate>) -> Result<()>
|
|||||||
println!(" Certificate Info: {} (Unknown)", signature_issuer);
|
println!(" Certificate Info: {} (Unknown)", signature_issuer);
|
||||||
}
|
}
|
||||||
let region = if hex::encode(tmd.title_id()).eq("0000000100000002") {
|
let region = if hex::encode(tmd.title_id()).eq("0000000100000002") {
|
||||||
match versions::dec_to_standard(tmd.title_version, &hex::encode(tmd.title_id()), Some(tmd.is_vwii != 0))
|
match versions::dec_to_standard(tmd.title_version(), &hex::encode(tmd.title_id()), Some(tmd.is_vwii()))
|
||||||
.unwrap_or_default().chars().last() {
|
.unwrap_or_default().chars().last() {
|
||||||
Some('U') => "USA",
|
Some('U') => "USA",
|
||||||
Some('E') => "EUR",
|
Some('E') => "EUR",
|
||||||
@@ -89,11 +87,11 @@ fn print_tmd_info(tmd: tmd::TMD, cert: Option<cert::Certificate>) -> Result<()>
|
|||||||
};
|
};
|
||||||
println!(" Region: {}", region);
|
println!(" Region: {}", region);
|
||||||
println!(" Title Type: {}", tmd.title_type()?);
|
println!(" Title Type: {}", tmd.title_type()?);
|
||||||
println!(" vWii Title: {}", tmd.is_vwii != 0);
|
println!(" vWii Title: {}", tmd.is_vwii());
|
||||||
println!(" DVD Video Access: {}", tmd.check_access_right(tmd::AccessRight::DVDVideo));
|
println!(" DVD Video Access: {}", tmd.check_access_right(tmd::AccessRight::DVDVideo));
|
||||||
println!(" AHB Access: {}", tmd.check_access_right(tmd::AccessRight::AHB));
|
println!(" AHB Access: {}", tmd.check_access_right(tmd::AccessRight::AHB));
|
||||||
if cert.is_some() {
|
if let Some(cert) = cert {
|
||||||
let signing_str = match cert::verify_tmd(&cert.unwrap(), &tmd) {
|
let signing_str = match cert::verify_tmd(&cert, tmd) {
|
||||||
Ok(result) => match result {
|
Ok(result) => match result {
|
||||||
true => "Valid (Unmodified TMD)",
|
true => "Valid (Unmodified TMD)",
|
||||||
false => {
|
false => {
|
||||||
@@ -117,10 +115,10 @@ fn print_tmd_info(tmd: tmd::TMD, cert: Option<cert::Certificate>) -> Result<()>
|
|||||||
println!(" Fakesigned: {}", tmd.is_fakesigned());
|
println!(" Fakesigned: {}", tmd.is_fakesigned());
|
||||||
}
|
}
|
||||||
println!("\nContent Info");
|
println!("\nContent Info");
|
||||||
println!(" Total Contents: {}", tmd.content_records.borrow().len());
|
println!(" Total Contents: {}", tmd.content_records().len());
|
||||||
println!(" Boot Content Index: {}", tmd.boot_index);
|
println!(" Boot Content Index: {}", tmd.boot_index());
|
||||||
println!(" Content Records:");
|
println!(" Content Records:");
|
||||||
for content in tmd.content_records.borrow().iter() {
|
for content in tmd.content_records().iter() {
|
||||||
println!(" Content Index: {}", content.index);
|
println!(" Content Index: {}", content.index);
|
||||||
println!(" Content ID: {:08X}", content.content_id);
|
println!(" Content ID: {:08X}", content.content_id);
|
||||||
println!(" Content Type: {}", content.content_type);
|
println!(" Content Type: {}", content.content_type);
|
||||||
@@ -130,13 +128,13 @@ fn print_tmd_info(tmd: tmd::TMD, cert: Option<cert::Certificate>) -> Result<()>
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_ticket_info(ticket: ticket::Ticket, cert: Option<cert::Certificate>) -> Result<()> {
|
fn print_ticket_info(ticket: &ticket::Ticket, cert: Option<cert::Certificate>) -> Result<()> {
|
||||||
// Print all important keys from the Ticket.
|
// Print all important keys from the Ticket.
|
||||||
println!("Ticket Info");
|
println!("Ticket Info");
|
||||||
print_tid(ticket.title_id())?;
|
print_tid(ticket.title_id())?;
|
||||||
print_title_version(ticket.title_version, ticket.title_id(), ticket.common_key_index == 2)?;
|
print_title_version(ticket.title_version(), ticket.title_id(), ticket.common_key_index() == 2)?;
|
||||||
println!(" Ticket Version: {}", ticket.ticket_version);
|
println!(" Ticket Version: {}", ticket.ticket_version());
|
||||||
let signature_issuer = String::from_utf8(Vec::from(ticket.signature_issuer)).unwrap_or_default();
|
let signature_issuer = String::from_utf8(Vec::from(ticket.signature_issuer())).unwrap_or_default();
|
||||||
if signature_issuer.contains("XS00000003") {
|
if signature_issuer.contains("XS00000003") {
|
||||||
println!(" Certificate: XS00000003 (Retail)");
|
println!(" Certificate: XS00000003 (Retail)");
|
||||||
println!(" Certificate Issuer: Root-CA00000001 (Retail)");
|
println!(" Certificate Issuer: Root-CA00000001 (Retail)");
|
||||||
@@ -149,7 +147,7 @@ fn print_ticket_info(ticket: ticket::Ticket, cert: Option<cert::Certificate>) ->
|
|||||||
} else {
|
} else {
|
||||||
println!(" Certificate Info: {} (Unknown)", signature_issuer);
|
println!(" Certificate Info: {} (Unknown)", signature_issuer);
|
||||||
}
|
}
|
||||||
let key = match ticket.common_key_index {
|
let key = match ticket.common_key_index() {
|
||||||
0 => {
|
0 => {
|
||||||
if ticket.is_dev() { "Common (Development)" }
|
if ticket.is_dev() { "Common (Development)" }
|
||||||
else { "Common (Retail)" }
|
else { "Common (Retail)" }
|
||||||
@@ -159,10 +157,10 @@ fn print_ticket_info(ticket: ticket::Ticket, cert: Option<cert::Certificate>) ->
|
|||||||
_ => "Unknown (Likely Common)"
|
_ => "Unknown (Likely Common)"
|
||||||
};
|
};
|
||||||
println!(" Decryption Key: {}", key);
|
println!(" Decryption Key: {}", key);
|
||||||
println!(" Title Key (Encrypted): {}", hex::encode(ticket.title_key));
|
println!(" Title Key (Encrypted): {}", hex::encode(ticket.title_key()));
|
||||||
println!(" Title Key (Decrypted): {}", hex::encode(ticket.dec_title_key()));
|
println!(" Title Key (Decrypted): {}", hex::encode(ticket.title_key_dec()));
|
||||||
if cert.is_some() {
|
if let Some(cert) = cert {
|
||||||
let signing_str = match cert::verify_ticket(&cert.unwrap(), &ticket) {
|
let signing_str = match cert::verify_ticket(&cert, ticket) {
|
||||||
Ok(result) => match result {
|
Ok(result) => match result {
|
||||||
true => "Valid (Unmodified Ticket)",
|
true => "Valid (Unmodified Ticket)",
|
||||||
false => {
|
false => {
|
||||||
@@ -190,7 +188,7 @@ fn print_ticket_info(ticket: ticket::Ticket, cert: Option<cert::Certificate>) ->
|
|||||||
|
|
||||||
fn print_wad_info(wad: wad::WAD) -> Result<()> {
|
fn print_wad_info(wad: wad::WAD) -> Result<()> {
|
||||||
println!("WAD Info");
|
println!("WAD Info");
|
||||||
match wad.header.wad_type {
|
match wad.wad_type() {
|
||||||
wad::WADType::ImportBoot => { println!(" WAD Type: boot2") },
|
wad::WADType::ImportBoot => { println!(" WAD Type: boot2") },
|
||||||
wad::WADType::Installable => { println!(" WAD Type: Standard Installable") },
|
wad::WADType::Installable => { println!(" WAD Type: Standard Installable") },
|
||||||
}
|
}
|
||||||
@@ -218,9 +216,9 @@ fn print_wad_info(wad: wad::WAD) -> Result<()> {
|
|||||||
false => {
|
false => {
|
||||||
if title.is_fakesigned() {
|
if title.is_fakesigned() {
|
||||||
"Fakesigned"
|
"Fakesigned"
|
||||||
} else if cert::verify_tmd(&title.cert_chain.tmd_cert(), &title.tmd)? {
|
} else if cert::verify_tmd(&title.cert_chain().tmd_cert(), title.tmd())? {
|
||||||
"Piratelegit (Unmodified TMD, Modified Ticket)"
|
"Piratelegit (Unmodified TMD, Modified Ticket)"
|
||||||
} else if cert::verify_ticket(&title.cert_chain.ticket_cert(), &title.ticket)? {
|
} else if cert::verify_ticket(&title.cert_chain().ticket_cert(), title.ticket())? {
|
||||||
"Edited (Modified TMD, Unmodified Ticket)"
|
"Edited (Modified TMD, Unmodified Ticket)"
|
||||||
} else {
|
} else {
|
||||||
"Illegitimate (Modified TMD + Ticket)"
|
"Illegitimate (Modified TMD + Ticket)"
|
||||||
@@ -237,38 +235,38 @@ fn print_wad_info(wad: wad::WAD) -> Result<()> {
|
|||||||
};
|
};
|
||||||
println!(" Signing Status: {}", signing_str);
|
println!(" Signing Status: {}", signing_str);
|
||||||
println!();
|
println!();
|
||||||
print_ticket_info(title.ticket, Some(title.cert_chain.ticket_cert()))?;
|
print_ticket_info(title.ticket(), Some(title.cert_chain().ticket_cert()))?;
|
||||||
println!();
|
println!();
|
||||||
print_tmd_info(title.tmd, Some(title.cert_chain.tmd_cert()))?;
|
print_tmd_info(title.tmd(), Some(title.cert_chain().tmd_cert()))?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_full_tree(dir: &Rc<RefCell<u8::U8Directory>>, indent: usize) {
|
fn print_full_tree(dir: &u8::U8Directory, indent: usize) {
|
||||||
let prefix = " ".repeat(indent);
|
let prefix = " ".repeat(indent);
|
||||||
let dir_name = if !dir.borrow().name.is_empty() {
|
let dir_name = if !dir.name.is_empty() {
|
||||||
&dir.borrow().name
|
&dir.name
|
||||||
} else {
|
} else {
|
||||||
&String::from("root")
|
&String::from("root")
|
||||||
};
|
};
|
||||||
println!("{}D {}", prefix, dir_name);
|
println!("{}D {}", prefix, dir_name);
|
||||||
|
|
||||||
// Print subdirectories
|
// Print subdirectories
|
||||||
for subdir in &dir.borrow().dirs {
|
for subdir in &dir.dirs {
|
||||||
print_full_tree(subdir, indent + 1);
|
print_full_tree(subdir, indent + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Print files
|
// Print files
|
||||||
for file in &dir.borrow().files {
|
for file in &dir.files {
|
||||||
let file_name = &file.borrow().name;
|
let file_name = &file.name;
|
||||||
println!("{} F {}", prefix, file_name);
|
println!("{} F {}", prefix, file_name);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_u8_info(u8_archive: u8::U8Archive) -> Result<()> {
|
fn print_u8_info(root_dir: u8::U8Directory) -> Result<()> {
|
||||||
println!("U8 Archive Info");
|
println!("U8 Archive Info");
|
||||||
println!(" Node Count: {}", u8_archive.node_tree.borrow().count());
|
println!(" Node Count: {}", root_dir.count());
|
||||||
println!(" Archive Data:");
|
println!(" Archive Data:");
|
||||||
print_full_tree(&u8_archive.node_tree, 2);
|
print_full_tree(&root_dir, 2);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,18 +278,18 @@ pub fn info(input: &str) -> Result<()> {
|
|||||||
match identify_file_type(input) {
|
match identify_file_type(input) {
|
||||||
Some(WiiFileType::Tmd) => {
|
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.")?;
|
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)?;
|
print_tmd_info(&tmd, None)?;
|
||||||
},
|
},
|
||||||
Some(WiiFileType::Ticket) => {
|
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.")?;
|
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)?;
|
print_ticket_info(&ticket, None)?;
|
||||||
},
|
},
|
||||||
Some(WiiFileType::Wad) => {
|
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.")?;
|
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)?;
|
print_wad_info(wad)?;
|
||||||
},
|
},
|
||||||
Some(WiiFileType::U8) => {
|
Some(WiiFileType::U8) => {
|
||||||
let u8_archive = u8::U8Archive::from_bytes(&fs::read(in_path)?).with_context(|| "The provided U8 archive could not be parsed, and is likely invalid.")?;
|
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)?;
|
print_u8_info(u8_archive)?;
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
@@ -1,12 +1,13 @@
|
|||||||
// main.rs from rustii (c) 2025 NinjaCheetah & Contributors
|
// main.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
|
||||||
// https://github.com/NinjaCheetah/rustii
|
// https://github.com/NinjaCheetah/rustwii
|
||||||
//
|
//
|
||||||
// Base for the rustii CLI that handles argument parsing and directs execution to the proper module.
|
// Base for the rustwii CLI that handles argument parsing and directs execution to the proper module.
|
||||||
|
|
||||||
mod archive;
|
mod archive;
|
||||||
mod title;
|
mod title;
|
||||||
mod filetypes;
|
mod filetypes;
|
||||||
mod info;
|
mod info;
|
||||||
|
mod nand;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use clap::{Subcommand, Parser};
|
use clap::{Subcommand, Parser};
|
||||||
@@ -26,11 +27,16 @@ enum Commands {
|
|||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: archive::ash::Commands,
|
command: archive::ash::Commands,
|
||||||
},
|
},
|
||||||
|
/// Manage Wii EmuNANDs
|
||||||
|
Emunand {
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: nand::emunand::Commands,
|
||||||
|
},
|
||||||
/// Fakesign a TMD, Ticket, or WAD (trucha bug)
|
/// Fakesign a TMD, Ticket, or WAD (trucha bug)
|
||||||
Fakesign {
|
Fakesign {
|
||||||
/// The path to a TMD, Ticket, or WAD
|
/// The path to a TMD, Ticket, or WAD
|
||||||
input: String,
|
input: String,
|
||||||
/// An (optional) output name; defaults to overwriting input file if not provided
|
/// An optional output path; defaults to overwriting input file if not provided
|
||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
output: Option<String>,
|
output: Option<String>,
|
||||||
},
|
},
|
||||||
@@ -39,6 +45,25 @@ enum Commands {
|
|||||||
/// The path to a TMD, Ticket, or WAD
|
/// The path to a TMD, Ticket, or WAD
|
||||||
input: String,
|
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
|
/// Compress/decompress data using LZ77 compression
|
||||||
Lz77 {
|
Lz77 {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
@@ -49,6 +74,21 @@ enum Commands {
|
|||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: title::nus::Commands
|
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
|
/// Pack/unpack a U8 archive
|
||||||
U8 {
|
U8 {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
@@ -74,6 +114,22 @@ fn main() -> Result<()> {
|
|||||||
archive::ash::decompress_ash(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 }) => {
|
Some(Commands::Fakesign { input, output }) => {
|
||||||
title::fakesign::fakesign(input, output)?
|
title::fakesign::fakesign(input, output)?
|
||||||
@@ -81,6 +137,24 @@ fn main() -> Result<()> {
|
|||||||
Some(Commands::Info { input }) => {
|
Some(Commands::Info { input }) => {
|
||||||
info::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 }) => {
|
Some(Commands::Lz77 { command }) => {
|
||||||
match command {
|
match command {
|
||||||
archive::lz77::Commands::Compress { input, output } => {
|
archive::lz77::Commands::Compress { input, output } => {
|
||||||
@@ -106,7 +180,37 @@ fn main() -> Result<()> {
|
|||||||
title::nus::download_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 }) => {
|
Some(Commands::U8 { command }) => {
|
||||||
match command {
|
match command {
|
||||||
archive::u8::Commands::Pack { input, output } => {
|
archive::u8::Commands::Pack { input, output } => {
|
||||||
@@ -120,25 +224,25 @@ fn main() -> Result<()> {
|
|||||||
Some(Commands::Wad { command }) => {
|
Some(Commands::Wad { command }) => {
|
||||||
match command {
|
match command {
|
||||||
title::wad::Commands::Add { input, content, output, cid, r#type } => {
|
title::wad::Commands::Add { input, content, output, cid, r#type } => {
|
||||||
title::wad::add_wad(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::Commands::Convert { input, target, output } => {
|
||||||
title::wad::convert_wad(input, target, output)?
|
title::wad::wad_convert(input, target, output)?
|
||||||
},
|
},
|
||||||
title::wad::Commands::Edit { input, output, edits } => {
|
title::wad::Commands::Edit { input, output, edits } => {
|
||||||
title::wad::edit_wad(input, output, edits)?
|
title::wad::wad_edit(input, output, edits)?
|
||||||
},
|
},
|
||||||
title::wad::Commands::Pack { input, output} => {
|
title::wad::Commands::Pack { input, output} => {
|
||||||
title::wad::pack_wad(input, output)?
|
title::wad::wad_pack(input, output)?
|
||||||
},
|
},
|
||||||
title::wad::Commands::Remove { input, output, identifier } => {
|
title::wad::Commands::Remove { input, output, identifier } => {
|
||||||
title::wad::remove_wad(input, output, identifier)?
|
title::wad::wad_remove(input, output, identifier)?
|
||||||
},
|
},
|
||||||
title::wad::Commands::Set { input, content, output, identifier, r#type} => {
|
title::wad::Commands::Set { input, content, output, identifier, r#type} => {
|
||||||
title::wad::set_wad(input, content, output, identifier, r#type)?
|
title::wad::wad_set(input, content, output, identifier, r#type)?
|
||||||
},
|
},
|
||||||
title::wad::Commands::Unpack { input, output } => {
|
title::wad::Commands::Unpack { input, output } => {
|
||||||
title::wad::unpack_wad(input, output)?
|
title::wad::wad_unpack(input, output)?
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
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(())
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
// title/fakesign.rs from rustii (c) 2025 NinjaCheetah & Contributors
|
// title/fakesign.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
|
||||||
// https://github.com/NinjaCheetah/rustii
|
// https://github.com/NinjaCheetah/rustwii
|
||||||
//
|
//
|
||||||
// Code for the fakesign command in the rustii CLI.
|
// Code for the fakesign command in the rustwii CLI.
|
||||||
|
|
||||||
use std::{str, fs};
|
use std::{str, fs};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
use rustii::{title, title::tmd, title::ticket};
|
use rustwii::{title, title::tmd, title::ticket};
|
||||||
use crate::filetypes::{WiiFileType, identify_file_type};
|
use crate::filetypes::{WiiFileType, identify_file_type};
|
||||||
|
|
||||||
pub fn fakesign(input: &str, output: &Option<String>) -> Result<()> {
|
pub fn fakesign(input: &str, output: &Option<String>) -> Result<()> {
|
||||||
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;
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
// title/nus.rs from rustii (c) 2025 NinjaCheetah & Contributors
|
// title/nus.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
|
||||||
// https://github.com/NinjaCheetah/rustii
|
// https://github.com/NinjaCheetah/rustwii
|
||||||
//
|
//
|
||||||
// Code for NUS-related commands in the rustii CLI.
|
// Code for NUS-related commands in the rustwii CLI.
|
||||||
|
|
||||||
use std::{str, fs};
|
use std::{str, fs};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
use clap::{Subcommand, Args};
|
use clap::{Subcommand, Args};
|
||||||
use sha1::{Sha1, Digest};
|
use sha1::{Sha1, Digest};
|
||||||
use rustii::title::{cert, content, crypto, nus, ticket, tmd};
|
use rustwii::title::{cert, crypto, nus, ticket, tmd};
|
||||||
use rustii::title;
|
use rustwii::title;
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
#[command(arg_required_else_help = true)]
|
#[command(arg_required_else_help = true)]
|
||||||
@@ -109,11 +109,11 @@ pub fn download_content(tid: &str, cid: &str, version: &Option<u16>, output: &Op
|
|||||||
Err(_) => bail!("No Ticket is available for this title! The content cannot be decrypted.")
|
Err(_) => bail!("No Ticket is available for this title! The content cannot be decrypted.")
|
||||||
};
|
};
|
||||||
println!(" - Decrypting content...");
|
println!(" - Decrypting content...");
|
||||||
let (content_hash, content_size, content_index) = tmd.content_records.borrow().iter()
|
let (content_hash, content_size, content_index) = tmd.content_records().iter()
|
||||||
.find(|record| record.content_id == cid)
|
.find(|record| record.content_id == cid)
|
||||||
.map(|record| (record.content_hash, record.content_size, record.index))
|
.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.")?;
|
.with_context(|| "No matching content record could be found. Please make sure the requested content is from the specified title version.")?;
|
||||||
let mut content_dec = crypto::decrypt_content(&content, tik.dec_title_key(), content_index);
|
let mut content_dec = crypto::decrypt_content(&content, tik.title_key_dec(), content_index);
|
||||||
content_dec.resize(content_size as usize, 0);
|
content_dec.resize(content_size as usize, 0);
|
||||||
// Verify the content's hash before saving it.
|
// Verify the content's hash before saving it.
|
||||||
let mut hasher = Sha1::new();
|
let mut hasher = Sha1::new();
|
||||||
@@ -159,15 +159,15 @@ fn download_title_dir(title: title::Title, output: String) -> Result<()> {
|
|||||||
} else {
|
} else {
|
||||||
fs::create_dir(&out_path).with_context(|| format!("The output directory \"{}\" could not be created.", out_path.display()))?;
|
fs::create_dir(&out_path).with_context(|| format!("The output directory \"{}\" could not be created.", out_path.display()))?;
|
||||||
}
|
}
|
||||||
let tid = hex::encode(title.tmd.title_id());
|
let tid = hex::encode(title.tmd().title_id());
|
||||||
println!(" - Saving TMD...");
|
println!(" - Saving TMD...");
|
||||||
fs::write(out_path.join(format!("{}.tmd", &tid)), title.tmd.to_bytes()?).with_context(|| format!("Failed to open TMD file \"{}.tmd\" for writing.", tid))?;
|
fs::write(out_path.join(format!("{}.tmd", &tid)), title.tmd().to_bytes()?).with_context(|| format!("Failed to open TMD file \"{}.tmd\" for writing.", tid))?;
|
||||||
println!(" - Saving Ticket...");
|
println!(" - Saving Ticket...");
|
||||||
fs::write(out_path.join(format!("{}.tik", &tid)), title.ticket.to_bytes()?).with_context(|| format!("Failed to open Ticket file \"{}.tmd\" for writing.", tid))?;
|
fs::write(out_path.join(format!("{}.tik", &tid)), title.ticket().to_bytes()?).with_context(|| format!("Failed to open Ticket file \"{}.tmd\" for writing.", tid))?;
|
||||||
println!(" - Saving certificate chain...");
|
println!(" - Saving certificate chain...");
|
||||||
fs::write(out_path.join(format!("{}.cert", &tid)), title.cert_chain.to_bytes()?).with_context(|| format!("Failed to open certificate chain file \"{}.cert\" for writing.", tid))?;
|
fs::write(out_path.join(format!("{}.cert", &tid)), title.cert_chain().to_bytes()?).with_context(|| format!("Failed to open certificate chain file \"{}.cert\" for writing.", tid))?;
|
||||||
// Iterate over the content files and write them out in encrypted form.
|
// Iterate over the content files and write them out in encrypted form.
|
||||||
for record in title.content.content_records.borrow().iter() {
|
for record in title.tmd().content_records().iter() {
|
||||||
println!(" - Decrypting and saving content with Content ID {}...", record.content_id);
|
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)?)
|
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))?;
|
.with_context(|| format!("Failed to open content file \"{:08X}.app\" for writing.", record.content_id))?;
|
||||||
@@ -176,7 +176,7 @@ fn download_title_dir(title: title::Title, output: String) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn download_title_dir_enc(tmd: tmd::TMD, content_region: content::ContentRegion, cert_chain: cert::CertificateChain, output: String) -> Result<()> {
|
fn download_title_dir_enc(tmd: tmd::TMD, contents: Vec<Vec<u8>>, cert_chain: cert::CertificateChain, output: String) -> Result<()> {
|
||||||
println!(" - Saving downloaded data...");
|
println!(" - Saving downloaded data...");
|
||||||
let out_path = PathBuf::from(output);
|
let out_path = PathBuf::from(output);
|
||||||
if out_path.exists() {
|
if out_path.exists() {
|
||||||
@@ -192,9 +192,10 @@ fn download_title_dir_enc(tmd: tmd::TMD, content_region: content::ContentRegion,
|
|||||||
println!(" - Saving certificate chain...");
|
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))?;
|
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.
|
// Iterate over the content files and write them out in encrypted form.
|
||||||
for record in content_region.content_records.borrow().iter() {
|
for record in tmd.content_records().iter() {
|
||||||
println!(" - Saving content with Content ID {}...", record.content_id);
|
println!(" - Saving content with Content ID {}...", record.content_id);
|
||||||
fs::write(out_path.join(format!("{:08X}", record.content_id)), content_region.get_enc_content_by_cid(record.content_id)?)
|
let idx = tmd.get_index_from_cid(record.content_id)?;
|
||||||
|
fs::write(out_path.join(format!("{:08X}", record.content_id)), &contents[idx])
|
||||||
.with_context(|| format!("Failed to open content file \"{:08X}\" for writing.", record.content_id))?;
|
.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());
|
println!("Successfully downloaded title with Title ID {} to directory \"{}\"!", tid, out_path.display());
|
||||||
@@ -205,7 +206,7 @@ fn download_title_wad(title: title::Title, output: String) -> Result<()> {
|
|||||||
println!(" - Packing WAD...");
|
println!(" - Packing WAD...");
|
||||||
let out_path = PathBuf::from(output).with_extension("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()))?;
|
fs::write(&out_path, title.to_wad().with_context(|| "A WAD could not be packed.")?.to_bytes()?).with_context(|| format!("Could not open WAD file \"{}\" for writing.", out_path.display()))?;
|
||||||
println!("Successfully downloaded title with Title ID {} to WAD file \"{}\"!", hex::encode(title.tmd.title_id()), out_path.display());
|
println!("Successfully downloaded title with Title ID {} to WAD file \"{}\"!", hex::encode(title.tmd().title_id()), out_path.display());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,7 +215,7 @@ pub fn download_title(tid: &str, version: &Option<u16>, output: &TitleOutputType
|
|||||||
bail!("The specified Title ID is invalid!");
|
bail!("The specified Title ID is invalid!");
|
||||||
}
|
}
|
||||||
if version.is_some() {
|
if version.is_some() {
|
||||||
println!("Downloading title {} v{}, please wait...", tid, version.clone().unwrap());
|
println!("Downloading title {} v{}, please wait...", tid, version.unwrap());
|
||||||
} else {
|
} else {
|
||||||
println!("Downloading title {} vLatest, please wait...", tid);
|
println!("Downloading title {} vLatest, please wait...", tid);
|
||||||
}
|
}
|
||||||
@@ -236,18 +237,17 @@ pub fn download_title(tid: &str, version: &Option<u16>, output: &TitleOutputType
|
|||||||
};
|
};
|
||||||
// Build a vec of contents by iterating over the content records and downloading each one.
|
// Build a vec of contents by iterating over the content records and downloading each one.
|
||||||
let mut contents: Vec<Vec<u8>> = Vec::new();
|
let mut contents: Vec<Vec<u8>> = Vec::new();
|
||||||
for record in tmd.content_records.borrow().iter() {
|
for record in tmd.content_records().iter() {
|
||||||
println!(" - Downloading content {} of {} (Content ID: {}, Size: {} bytes)...",
|
println!(" - Downloading content {} of {} (Content ID: {}, Size: {} bytes)...",
|
||||||
record.index + 1, &tmd.content_records.borrow().len(), record.content_id, record.content_size);
|
record.index + 1, &tmd.content_records().len(), record.content_id, record.content_size);
|
||||||
contents.push(nus::download_content(tid, record.content_id, true).with_context(|| format!("Content with Content ID {} could not be downloaded.", record.content_id))?);
|
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!(" - Done!");
|
||||||
}
|
}
|
||||||
let content_region = content::ContentRegion::from_contents(contents, tmd.content_records.clone())?;
|
|
||||||
println!(" - Building certificate chain...");
|
println!(" - Building certificate chain...");
|
||||||
let cert_chain = cert::CertificateChain::from_bytes(&nus::download_cert_chain(true).with_context(|| "Certificate chain could not be built.")?)?;
|
let cert_chain = cert::CertificateChain::from_bytes(&nus::download_cert_chain(true).with_context(|| "Certificate chain could not be built.")?)?;
|
||||||
if tik.is_some() {
|
if let Some(tik) = tik {
|
||||||
// If we have a Ticket, then build a Title and jump to the output method.
|
// If we have a Ticket, then build a Title and jump to the output method.
|
||||||
let title = title::Title::from_parts(cert_chain, None, tik.unwrap(), tmd, content_region, None)?;
|
let title = title::Title::from_parts_with_content(cert_chain, None, tik, tmd, contents, None)?;
|
||||||
if output.wad.is_some() {
|
if output.wad.is_some() {
|
||||||
download_title_wad(title, output.wad.clone().unwrap())?;
|
download_title_wad(title, output.wad.clone().unwrap())?;
|
||||||
} else {
|
} else {
|
||||||
@@ -256,7 +256,7 @@ pub fn download_title(tid: &str, version: &Option<u16>, output: &TitleOutputType
|
|||||||
} else {
|
} else {
|
||||||
// If we're downloading to a directory and have no Ticket, save the TMD and encrypted
|
// If we're downloading to a directory and have no Ticket, save the TMD and encrypted
|
||||||
// contents to the directory only.
|
// contents to the directory only.
|
||||||
download_title_dir_enc(tmd, content_region, cert_chain, output.output.clone().unwrap())?;
|
download_title_dir_enc(tmd, contents, cert_chain, output.output.clone().unwrap())?;
|
||||||
}
|
}
|
||||||
Ok(())
|
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(())
|
||||||
|
}
|
||||||
@@ -1,19 +1,18 @@
|
|||||||
// title/wad.rs from rustii (c) 2025 NinjaCheetah & Contributors
|
// title/wad.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
|
||||||
// https://github.com/NinjaCheetah/rustii
|
// https://github.com/NinjaCheetah/rustwii
|
||||||
//
|
//
|
||||||
// Code for WAD-related commands in the rustii CLI.
|
// Code for WAD-related commands in the rustwii CLI.
|
||||||
|
|
||||||
use std::{str, fs, fmt};
|
use std::{str, fs, fmt};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::rc::Rc;
|
|
||||||
use anyhow::{bail, Context, Result};
|
use anyhow::{bail, Context, Result};
|
||||||
use clap::{Subcommand, Args};
|
use clap::{Subcommand, Args};
|
||||||
use glob::glob;
|
use glob::glob;
|
||||||
use hex::FromHex;
|
use hex::FromHex;
|
||||||
use rand::prelude::*;
|
use rand::prelude::*;
|
||||||
use regex::RegexBuilder;
|
use rustwii::title::{cert, crypto, tmd, ticket};
|
||||||
use rustii::title::{cert, crypto, tmd, ticket, content, wad};
|
use rustwii::title;
|
||||||
use rustii::title;
|
use crate::title::shared::{validate_target_ios, validate_target_tid, validate_target_type, ContentIdentifier, TitleModifications};
|
||||||
|
|
||||||
#[derive(Subcommand)]
|
#[derive(Subcommand)]
|
||||||
#[command(arg_required_else_help = true)]
|
#[command(arg_required_else_help = true)]
|
||||||
@@ -53,7 +52,7 @@ pub enum Commands {
|
|||||||
#[arg(short, long)]
|
#[arg(short, long)]
|
||||||
output: Option<String>,
|
output: Option<String>,
|
||||||
#[command(flatten)]
|
#[command(flatten)]
|
||||||
edits: WadModifications
|
edits: TitleModifications
|
||||||
},
|
},
|
||||||
/// Pack a directory into a WAD file
|
/// Pack a directory into a WAD file
|
||||||
Pack {
|
Pack {
|
||||||
@@ -111,35 +110,6 @@ pub struct ConvertTargets {
|
|||||||
vwii: bool,
|
vwii: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Args)]
|
|
||||||
#[clap(next_help_heading = "Content Identifier")]
|
|
||||||
#[group(multiple = false, required = true)]
|
|
||||||
pub struct ContentIdentifier {
|
|
||||||
/// The index of the target content
|
|
||||||
#[arg(short, long)]
|
|
||||||
index: Option<usize>,
|
|
||||||
/// The Content ID of the target content
|
|
||||||
#[arg(short, long)]
|
|
||||||
cid: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Args)]
|
|
||||||
#[clap(next_help_heading = "Possible Modifications")]
|
|
||||||
#[group(multiple = true, required = true)]
|
|
||||||
pub struct WadModifications {
|
|
||||||
/// A new IOS version for this WAD (formatted as the decimal IOS version, e.g. 58, with a valid
|
|
||||||
/// range of 3-255)
|
|
||||||
#[arg(long)]
|
|
||||||
ios: Option<u8>,
|
|
||||||
/// A new Title ID for this WAD (formatted as 4 ASCII characters, e.g. HADE)
|
|
||||||
#[arg(long)]
|
|
||||||
tid: Option<String>,
|
|
||||||
/// A new type for this WAD (valid options are "System", "Channel", "SystemChannel",
|
|
||||||
/// "GameChannel", "DLC", "HiddenChannel")
|
|
||||||
#[arg(long)]
|
|
||||||
r#type: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Target {
|
enum Target {
|
||||||
Retail,
|
Retail,
|
||||||
Dev,
|
Dev,
|
||||||
@@ -156,7 +126,7 @@ impl fmt::Display for Target {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_wad(input: &str, content: &str, output: &Option<String>, cid: &Option<String>, ctype: &Option<String>) -> Result<()> {
|
pub fn wad_add(input: &str, content: &str, output: &Option<String>, cid: &Option<String>, ctype: &Option<String>) -> Result<()> {
|
||||||
let in_path = Path::new(input);
|
let in_path = Path::new(input);
|
||||||
if !in_path.exists() {
|
if !in_path.exists() {
|
||||||
bail!("Source WAD \"{}\" could not be found.", in_path.display());
|
bail!("Source WAD \"{}\" could not be found.", in_path.display());
|
||||||
@@ -186,7 +156,7 @@ pub fn add_wad(input: &str, content: &str, output: &Option<String>, cid: &Option
|
|||||||
};
|
};
|
||||||
let target_cid = if cid.is_some() {
|
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!")?;
|
let cid = u32::from_str_radix(cid.clone().unwrap().as_str(), 16).with_context(|| "The specified Content ID is invalid!")?;
|
||||||
if title.content.content_records.borrow().iter().any(|record| record.content_id == cid) {
|
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);
|
bail!("The specified Content ID \"{:08X}\" is already being used in this WAD!", cid);
|
||||||
}
|
}
|
||||||
cid
|
cid
|
||||||
@@ -196,7 +166,7 @@ pub fn add_wad(input: &str, content: &str, output: &Option<String>, cid: &Option
|
|||||||
let mut cid: u32;
|
let mut cid: u32;
|
||||||
loop {
|
loop {
|
||||||
cid = rng.random_range(0..=0xFF);
|
cid = rng.random_range(0..=0xFF);
|
||||||
if !title.content.content_records.borrow().iter().any(|record| record.content_id == cid) {
|
if !title.tmd().content_records().iter().any(|record| record.content_id == cid) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -210,7 +180,7 @@ pub fn add_wad(input: &str, content: &str, output: &Option<String>, cid: &Option
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn convert_wad(input: &str, target: &ConvertTargets, output: &Option<String>) -> Result<()> {
|
pub fn wad_convert(input: &str, target: &ConvertTargets, output: &Option<String>) -> Result<()> {
|
||||||
let in_path = Path::new(input);
|
let in_path = Path::new(input);
|
||||||
if !in_path.exists() {
|
if !in_path.exists() {
|
||||||
bail!("Source WAD \"{}\" could not be found.", in_path.display());
|
bail!("Source WAD \"{}\" could not be found.", in_path.display());
|
||||||
@@ -235,57 +205,61 @@ pub fn convert_wad(input: &str, target: &ConvertTargets, output: &Option<String>
|
|||||||
};
|
};
|
||||||
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 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.
|
// Bail if the WAD is already using the selected encryption.
|
||||||
if matches!(target, Target::Dev) && title.ticket.is_dev() {
|
if matches!(target, Target::Dev) && title.ticket().is_dev() {
|
||||||
bail!("This is already a development WAD!");
|
bail!("This is already a development WAD!");
|
||||||
} else if matches!(target, Target::Retail) && !title.ticket.is_dev() && !title.tmd.is_vwii() {
|
} else if matches!(target, Target::Retail) && !title.ticket().is_dev() && !title.tmd().is_vwii() {
|
||||||
bail!("This is already a retail WAD!");
|
bail!("This is already a retail WAD!");
|
||||||
} else if matches!(target, Target::Vwii) && !title.ticket.is_dev() && title.tmd.is_vwii() {
|
} else if matches!(target, Target::Vwii) && !title.ticket().is_dev() && title.tmd().is_vwii() {
|
||||||
bail!("This is already a vWii WAD!");
|
bail!("This is already a vWii WAD!");
|
||||||
}
|
}
|
||||||
// Save the current encryption to display at the end.
|
// Save the current encryption to display at the end.
|
||||||
let source = if title.ticket.is_dev() {
|
let source = if title.ticket().is_dev() {
|
||||||
"development"
|
"development"
|
||||||
} else if title.tmd.is_vwii() {
|
} else if title.tmd().is_vwii() {
|
||||||
"vWii"
|
"vWii"
|
||||||
} else {
|
} else {
|
||||||
"retail"
|
"retail"
|
||||||
};
|
};
|
||||||
let title_key = title.ticket.dec_title_key();
|
let title_key = title.ticket().title_key_dec();
|
||||||
let title_key_new: [u8; 16];
|
let title_key_new: [u8; 16];
|
||||||
|
let mut tmd = title.tmd().clone();
|
||||||
|
let mut ticket = title.ticket().clone();
|
||||||
match target {
|
match target {
|
||||||
Target::Dev => {
|
Target::Dev => {
|
||||||
title.tmd.set_signature_issuer(String::from("Root-CA00000002-CP00000007"))?;
|
tmd.set_signature_issuer(String::from("Root-CA00000002-CP00000007"))?;
|
||||||
title.ticket.set_signature_issuer(String::from("Root-CA00000002-XS00000006"))?;
|
ticket.set_signature_issuer(String::from("Root-CA00000002-XS00000006"))?;
|
||||||
title_key_new = crypto::encrypt_title_key(title_key, 0, title.ticket.title_id(), true);
|
title_key_new = crypto::encrypt_title_key(title_key, 0, title.ticket().title_id(), true);
|
||||||
title.ticket.common_key_index = 0;
|
ticket.set_common_key_index(0);
|
||||||
title.tmd.is_vwii = 0;
|
tmd.set_is_vwii(false);
|
||||||
},
|
},
|
||||||
Target::Retail => {
|
Target::Retail => {
|
||||||
title.tmd.set_signature_issuer(String::from("Root-CA00000001-CP00000004"))?;
|
tmd.set_signature_issuer(String::from("Root-CA00000001-CP00000004"))?;
|
||||||
title.ticket.set_signature_issuer(String::from("Root-CA00000001-XS00000003"))?;
|
ticket.set_signature_issuer(String::from("Root-CA00000001-XS00000003"))?;
|
||||||
title_key_new = crypto::encrypt_title_key(title_key, 0, title.ticket.title_id(), false);
|
title_key_new = crypto::encrypt_title_key(title_key, 0, title.ticket().title_id(), false);
|
||||||
title.ticket.common_key_index = 0;
|
ticket.set_common_key_index(0);
|
||||||
title.tmd.is_vwii = 0;
|
tmd.set_is_vwii(false);
|
||||||
},
|
},
|
||||||
Target::Vwii => {
|
Target::Vwii => {
|
||||||
title.tmd.set_signature_issuer(String::from("Root-CA00000001-CP00000004"))?;
|
tmd.set_signature_issuer(String::from("Root-CA00000001-CP00000004"))?;
|
||||||
title.ticket.set_signature_issuer(String::from("Root-CA00000001-XS00000003"))?;
|
ticket.set_signature_issuer(String::from("Root-CA00000001-XS00000003"))?;
|
||||||
title_key_new = crypto::encrypt_title_key(title_key, 2, title.ticket.title_id(), false);
|
title_key_new = crypto::encrypt_title_key(title_key, 2, title.ticket().title_id(), false);
|
||||||
title.ticket.common_key_index = 2;
|
ticket.set_common_key_index(2);
|
||||||
title.tmd.is_vwii = 1;
|
tmd.set_is_vwii(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
title.ticket.title_key = title_key_new;
|
ticket.set_title_key(title_key_new);
|
||||||
|
title.set_tmd(tmd);
|
||||||
|
title.set_ticket(ticket);
|
||||||
title.fakesign()?;
|
title.fakesign()?;
|
||||||
fs::write(&out_path, title.to_wad()?.to_bytes()?)?;
|
fs::write(&out_path, title.to_wad()?.to_bytes()?)?;
|
||||||
println!("Successfully converted {} WAD to {} WAD \"{}\"!", source, target, out_path.file_name().unwrap().to_str().unwrap());
|
println!("Successfully converted {} WAD to {} WAD \"{}\"!", source, target, out_path.file_name().unwrap().to_str().unwrap());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn edit_wad(input: &str, output: &Option<String>, edits: &WadModifications) -> Result<()> {
|
pub fn wad_edit(input: &str, output: &Option<String>, edits: &TitleModifications) -> Result<()> {
|
||||||
let in_path = Path::new(input);
|
let in_path = Path::new(input);
|
||||||
if !in_path.exists() {
|
if !in_path.exists() {
|
||||||
bail!("Source directory \"{}\" does not exist.", in_path.display());
|
bail!("Source WAD \"{}\" does not exist.", in_path.display());
|
||||||
}
|
}
|
||||||
let out_path = if output.is_some() {
|
let out_path = if output.is_some() {
|
||||||
PathBuf::from(output.clone().unwrap()).with_extension("wad")
|
PathBuf::from(output.clone().unwrap()).with_extension("wad")
|
||||||
@@ -299,44 +273,34 @@ pub fn edit_wad(input: &str, output: &Option<String>, edits: &WadModifications)
|
|||||||
// These are joined, because that way if both are selected we only need to set the TID (and by
|
// 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.
|
// extension, re-encrypt the Title Key) a single time.
|
||||||
if edits.tid.is_some() || edits.r#type.is_some() {
|
if edits.tid.is_some() || edits.r#type.is_some() {
|
||||||
let tid_high = if edits.r#type.is_some() {
|
let tid_high = if let Some(new_type) = &edits.r#type {
|
||||||
let new_type = match edits.r#type.clone().unwrap().to_ascii_lowercase().as_str() {
|
let new_type = validate_target_type(&new_type.to_ascii_lowercase())?;
|
||||||
"system" => tmd::TitleType::System,
|
changes_summary.push(format!("Changed title type from \"{}\" to \"{}\"", title.tmd().title_type()?, new_type));
|
||||||
"channel" => tmd::TitleType::Channel,
|
|
||||||
"systemchannel" => tmd::TitleType::SystemChannel,
|
|
||||||
"gamechannel" => tmd::TitleType::GameChannel,
|
|
||||||
"dlc" => tmd::TitleType::DLC,
|
|
||||||
"hiddenchannel" => tmd::TitleType::HiddenChannel,
|
|
||||||
_ => bail!("The specified title type \"{}\" is invalid! Try --help to see valid types.", edits.r#type.clone().unwrap()),
|
|
||||||
};
|
|
||||||
changes_summary.push(format!("Changed title type from \"{}\" to \"{}\"", title.tmd.title_type()?, new_type));
|
|
||||||
Vec::from_hex(format!("{:08X}", new_type as u32))?
|
Vec::from_hex(format!("{:08X}", new_type as u32))?
|
||||||
} else {
|
} else {
|
||||||
title.tmd.title_id()[0..4].to_vec()
|
title.tmd().title_id()[0..4].to_vec()
|
||||||
};
|
};
|
||||||
let tid_low = if edits.tid.is_some() {
|
|
||||||
let re = RegexBuilder::new(r"^[a-z0-9!@#$%^&*]{4}$").case_insensitive(true).build()?;
|
let tid_low = if let Some(new_tid) = &edits.tid {
|
||||||
let new_tid_low = edits.tid.clone().unwrap().to_ascii_uppercase();
|
let new_tid = validate_target_tid(&new_tid.to_ascii_uppercase())?;
|
||||||
if !re.is_match(&new_tid_low) {
|
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()));
|
||||||
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 \"!@#$%&*\".");
|
new_tid
|
||||||
}
|
|
||||||
changes_summary.push(format!("Changed Title ID from \"{}\" to \"{}\"", hex::encode(&title.tmd.title_id()[4..8]).to_ascii_uppercase(), hex::encode(&new_tid_low).to_ascii_uppercase()));
|
|
||||||
Vec::from_hex(hex::encode(new_tid_low))?
|
|
||||||
} else {
|
} else {
|
||||||
title.tmd.title_id()[4..8].to_vec()
|
title.tmd().title_id()[4..8].to_vec()
|
||||||
};
|
};
|
||||||
|
|
||||||
let new_tid: Vec<u8> = tid_high.iter().chain(&tid_low).copied().collect();
|
let new_tid: Vec<u8> = tid_high.iter().chain(&tid_low).copied().collect();
|
||||||
title.set_title_id(new_tid.try_into().unwrap())?;
|
title.set_title_id(new_tid.try_into().unwrap())?;
|
||||||
}
|
}
|
||||||
if edits.ios.is_some() {
|
|
||||||
let new_ios = edits.ios.unwrap();
|
if let Some(new_ios) = edits.ios {
|
||||||
if new_ios < 3 {
|
let new_ios_tid = validate_target_ios(new_ios)?;
|
||||||
bail!("The specified IOS version is not valid! The new IOS version must be between 3 and 255.")
|
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();
|
||||||
let new_ios_tid = <[u8; 8]>::from_hex(format!("00000001{:08X}", new_ios))?;
|
tmd.set_ios_tid(new_ios_tid)?;
|
||||||
changes_summary.push(format!("Changed required IOS from IOS{} to IOS{}", title.tmd.ios_tid().last().unwrap(), new_ios));
|
title.set_tmd(tmd);
|
||||||
title.tmd.set_ios_tid(new_ios_tid)?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
title.fakesign()?;
|
title.fakesign()?;
|
||||||
fs::write(&out_path, title.to_wad()?.to_bytes()?).with_context(|| format!("Could not open output file \"{}\" for writing.", out_path.display()))?;
|
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());
|
println!("Successfully edited WAD file \"{}\"!\nSummary of changes:", out_path.display());
|
||||||
@@ -346,7 +310,7 @@ pub fn edit_wad(input: &str, output: &Option<String>, edits: &WadModifications)
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn pack_wad(input: &str, output: &str) -> Result<()> {
|
pub fn wad_pack(input: &str, output: &str) -> Result<()> {
|
||||||
let in_path = Path::new(input);
|
let in_path = Path::new(input);
|
||||||
if !in_path.exists() {
|
if !in_path.exists() {
|
||||||
bail!("Source directory \"{}\" does not exist.", in_path.display());
|
bail!("Source directory \"{}\" does not exist.", in_path.display());
|
||||||
@@ -359,7 +323,7 @@ pub fn pack_wad(input: &str, output: &str) -> Result<()> {
|
|||||||
} else if tmd_files.len() > 1 {
|
} else if tmd_files.len() > 1 {
|
||||||
bail!("More than one TMD file found in the source directory.");
|
bail!("More than one TMD file found in the source directory.");
|
||||||
}
|
}
|
||||||
let mut tmd = tmd::TMD::from_bytes(&fs::read(&tmd_files[0]).with_context(|| "Could not open TMD file for reading.")?)
|
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.")?;
|
.with_context(|| "The provided TMD file appears to be invalid.")?;
|
||||||
// Read Ticket file (only accept one file).
|
// Read Ticket file (only accept one file).
|
||||||
let ticket_files: Vec<PathBuf> = glob(&format!("{}/*.tik", in_path.display()))?
|
let ticket_files: Vec<PathBuf> = glob(&format!("{}/*.tik", in_path.display()))?
|
||||||
@@ -388,17 +352,25 @@ pub fn pack_wad(input: &str, output: &str) -> Result<()> {
|
|||||||
if footer_files.len() == 1 {
|
if footer_files.len() == 1 {
|
||||||
footer = fs::read(&footer_files[0]).with_context(|| "Could not open footer file for reading.")?;
|
footer = fs::read(&footer_files[0]).with_context(|| "Could not open footer file for reading.")?;
|
||||||
}
|
}
|
||||||
// Iterate over expected content and read it into a content region.
|
|
||||||
let mut content_region = content::ContentRegion::new(Rc::clone(&tmd.content_records))?;
|
// Create a title to use for content loading.
|
||||||
let content_indexes: Vec<u16> = tmd.content_records.borrow().iter().map(|record| record.index).collect();
|
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 {
|
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))?;
|
let data = fs::read(format!("{}/{:08X}.app", in_path.display(), index))
|
||||||
content_region.set_content(&data, index as usize, None, None, tik.dec_title_key())
|
.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.")?;
|
.with_context(|| "Failed to load content into the ContentRegion.")?;
|
||||||
}
|
}
|
||||||
// Ensure that the TMD is modified with our potentially updated content records.
|
let wad = title.to_wad()?;
|
||||||
tmd.content_records = content_region.content_records.clone();
|
|
||||||
let wad = wad::WAD::from_parts(&cert_chain, &[], &tik, &tmd, &content_region, &footer).with_context(|| "An unknown error occurred while building a WAD from the input files.")?;
|
|
||||||
// Write out WAD file.
|
// Write out WAD file.
|
||||||
let mut out_path = PathBuf::from(output);
|
let mut out_path = PathBuf::from(output);
|
||||||
match out_path.extension() {
|
match out_path.extension() {
|
||||||
@@ -416,7 +388,7 @@ pub fn pack_wad(input: &str, output: &str) -> Result<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn remove_wad(input: &str, output: &Option<String>, identifier: &ContentIdentifier) -> Result<()> {
|
pub fn wad_remove(input: &str, output: &Option<String>, identifier: &ContentIdentifier) -> Result<()> {
|
||||||
let in_path = Path::new(input);
|
let in_path = Path::new(input);
|
||||||
if !in_path.exists() {
|
if !in_path.exists() {
|
||||||
bail!("Source WAD \"{}\" could not be found.", in_path.display());
|
bail!("Source WAD \"{}\" could not be found.", in_path.display());
|
||||||
@@ -429,20 +401,18 @@ pub fn remove_wad(input: &str, output: &Option<String>, identifier: &ContentIden
|
|||||||
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 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.
|
// Parse the identifier passed to choose how to find and remove the target.
|
||||||
// ...maybe don't take the above comment out of context
|
// ...maybe don't take the above comment out of context
|
||||||
if identifier.index.is_some() {
|
if let Some(index) = identifier.index {
|
||||||
title.content.remove_content(identifier.index.unwrap()).with_context(|| "The specified index does not exist in the provided WAD!")?;
|
title.remove_content(index).with_context(|| "The specified index does not exist in the provided WAD!")?;
|
||||||
println!("{:?}", title.tmd);
|
|
||||||
title.fakesign().with_context(|| "An unknown error occurred while fakesigning the modified 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.")?;
|
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 \"{}\".", identifier.index.unwrap(), out_path.display());
|
println!("Successfully removed content at index {} in WAD file \"{}\".", index, out_path.display());
|
||||||
} else if identifier.cid.is_some() {
|
} 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 cid = u32::from_str_radix(identifier.cid.clone().unwrap().as_str(), 16).with_context(|| "The specified Content ID is invalid!")?;
|
||||||
let index = match title.content.get_index_from_cid(cid) {
|
let index = match title.tmd().get_index_from_cid(cid) {
|
||||||
Ok(index) => index,
|
Ok(index) => index,
|
||||||
Err(_) => bail!("The specified Content ID \"{}\" ({}) does not exist in this WAD!", identifier.cid.clone().unwrap(), cid),
|
Err(_) => bail!("The specified Content ID \"{}\" ({}) does not exist in this WAD!", identifier.cid.clone().unwrap(), cid),
|
||||||
};
|
};
|
||||||
title.content.remove_content(index).with_context(|| "An unknown error occurred while removing content from the WAD.")?;
|
title.remove_content(index).with_context(|| "An unknown error occurred while removing content from the WAD.")?;
|
||||||
println!("{:?}", title.tmd);
|
|
||||||
title.fakesign().with_context(|| "An unknown error occurred while fakesigning the modified 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.")?;
|
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());
|
println!("Successfully removed content with Content ID \"{}\" ({}) in WAD file \"{}\".", identifier.cid.clone().unwrap(), cid, out_path.display());
|
||||||
@@ -450,7 +420,7 @@ pub fn remove_wad(input: &str, output: &Option<String>, identifier: &ContentIden
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_wad(input: &str, content: &str, output: &Option<String>, identifier: &ContentIdentifier, ctype: &Option<String>) -> Result<()> {
|
pub fn wad_set(input: &str, content: &str, output: &Option<String>, identifier: &ContentIdentifier, ctype: &Option<String>) -> Result<()> {
|
||||||
let in_path = Path::new(input);
|
let in_path = Path::new(input);
|
||||||
if !in_path.exists() {
|
if !in_path.exists() {
|
||||||
bail!("Source WAD \"{}\" could not be found.", in_path.display());
|
bail!("Source WAD \"{}\" could not be found.", in_path.display());
|
||||||
@@ -477,9 +447,9 @@ pub fn set_wad(input: &str, content: &str, output: &Option<String>, identifier:
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
// Parse the identifier passed to choose how to do the find and replace.
|
// Parse the identifier passed to choose how to do the find and replace.
|
||||||
if identifier.index.is_some() {
|
if let Some(index) = identifier.index {
|
||||||
match title.set_content(&new_content, identifier.index.unwrap(), None, target_type) {
|
match title.set_content(&new_content, index, None, target_type) {
|
||||||
Err(title::TitleError::Content(content::ContentError::IndexOutOfRange { index, max })) => {
|
Err(title::TitleError::IndexOutOfRange { index, max }) => {
|
||||||
bail!("The specified index {} does not exist in this WAD! The maximum index is {}.", 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}"),
|
Err(e) => bail!("An unknown error occurred while setting the new content: {e}"),
|
||||||
@@ -490,7 +460,7 @@ pub fn set_wad(input: &str, content: &str, output: &Option<String>, identifier:
|
|||||||
println!("Successfully replaced content at index {} in WAD file \"{}\".", identifier.index.unwrap(), out_path.display());
|
println!("Successfully replaced content at index {} in WAD file \"{}\".", identifier.index.unwrap(), out_path.display());
|
||||||
} else if identifier.cid.is_some() {
|
} 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 cid = u32::from_str_radix(identifier.cid.clone().unwrap().as_str(), 16).with_context(|| "The specified Content ID is invalid!")?;
|
||||||
let index = match title.content.get_index_from_cid(cid) {
|
let index = match title.tmd().get_index_from_cid(cid) {
|
||||||
Ok(index) => index,
|
Ok(index) => index,
|
||||||
Err(_) => bail!("The specified Content ID \"{}\" ({}) does not exist in this WAD!", identifier.cid.clone().unwrap(), cid),
|
Err(_) => bail!("The specified Content ID \"{}\" ({}) does not exist in this WAD!", identifier.cid.clone().unwrap(), cid),
|
||||||
};
|
};
|
||||||
@@ -502,14 +472,14 @@ pub fn set_wad(input: &str, content: &str, output: &Option<String>, identifier:
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn unpack_wad(input: &str, output: &str) -> Result<()> {
|
pub fn wad_unpack(input: &str, output: &str) -> Result<()> {
|
||||||
let in_path = Path::new(input);
|
let in_path = Path::new(input);
|
||||||
if !in_path.exists() {
|
if !in_path.exists() {
|
||||||
bail!("Source WAD \"{}\" could not be found.", input);
|
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 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 title = title::Title::from_bytes(&wad_file).with_context(|| format!("The provided WAD file \"{}\" appears to be invalid.", in_path.display()))?;
|
||||||
let tid = hex::encode(title.tmd.title_id());
|
let tid = hex::encode(title.tmd().title_id());
|
||||||
// Create output directory if it doesn't exist.
|
// Create output directory if it doesn't exist.
|
||||||
let out_path = Path::new(output);
|
let out_path = Path::new(output);
|
||||||
if !out_path.exists() {
|
if !out_path.exists() {
|
||||||
@@ -517,18 +487,18 @@ pub fn unpack_wad(input: &str, output: &str) -> Result<()> {
|
|||||||
}
|
}
|
||||||
// Write out all WAD components.
|
// Write out all WAD components.
|
||||||
let tmd_file_name = format!("{}.tmd", tid);
|
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))?;
|
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);
|
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))?;
|
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);
|
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))?;
|
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);
|
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))?;
|
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.
|
// Iterate over contents, decrypt them, and write them out.
|
||||||
for i in 0..title.tmd.content_records.borrow().len() {
|
for i in 0..title.tmd().content_records().len() {
|
||||||
let content_file_name = format!("{:08X}.app", title.content.content_records.borrow()[i].index);
|
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.content.content_records.borrow()[i].content_id))?;
|
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.content.content_records.borrow()[i].content_id))?;
|
fs::write(Path::join(out_path, content_file_name), dec_content).with_context(|| format!("Failed to open content file \"{:08X}.app\" for writing.", title.tmd().content_records()[i].content_id))?;
|
||||||
}
|
}
|
||||||
println!("Successfully unpacked WAD file to \"{}\"!", out_path.display());
|
println!("Successfully unpacked WAD file to \"{}\"!", out_path.display());
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
// lib.rs from rustii (c) 2025 NinjaCheetah & Contributors
|
// lib.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
|
||||||
// https://github.com/NinjaCheetah/rustii
|
// https://github.com/NinjaCheetah/rustwii
|
||||||
//
|
//
|
||||||
// Root level module that imports the feature modules.
|
// Root level module that imports the feature modules.
|
||||||
|
|
||||||
pub mod archive;
|
pub mod archive;
|
||||||
|
pub mod nand;
|
||||||
pub mod title;
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// title/cert.rs from rustii (c) 2025 NinjaCheetah & Contributors
|
// title/cert.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
|
||||||
// https://github.com/NinjaCheetah/rustii
|
// https://github.com/NinjaCheetah/rustwii
|
||||||
//
|
//
|
||||||
// Implements the structures and methods required for validating the signatures of Wii titles.
|
// Implements the structures and methods required for validating the signatures of Wii titles.
|
||||||
|
|
||||||
@@ -37,8 +37,8 @@ pub enum CertificateKeyType {
|
|||||||
ECC
|
ECC
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
/// A structure that represents the components of a Wii signing certificate.
|
/// A structure that represents the components of a Wii signing certificate.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
pub struct Certificate {
|
pub struct Certificate {
|
||||||
signer_key_type: CertificateKeyType,
|
signer_key_type: CertificateKeyType,
|
||||||
signature: Vec<u8>,
|
signature: Vec<u8>,
|
||||||
@@ -165,8 +165,8 @@ impl Certificate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
/// A structure that represents the components of the Wii's signing certificate chain.
|
/// A structure that represents the components of the Wii's signing certificate chain.
|
||||||
|
#[derive(Debug)]
|
||||||
pub struct CertificateChain {
|
pub struct CertificateChain {
|
||||||
ca_cert: Certificate,
|
ca_cert: Certificate,
|
||||||
tmd_cert: Certificate,
|
tmd_cert: Certificate,
|
||||||
@@ -346,7 +346,7 @@ pub fn verify_tmd(tmd_cert: &Certificate, tmd: &tmd::TMD) -> Result<bool, Certif
|
|||||||
let public_key_modulus = BigUint::from_bytes_be(&tmd_cert.pub_key_modulus());
|
let public_key_modulus = BigUint::from_bytes_be(&tmd_cert.pub_key_modulus());
|
||||||
let public_key_exponent = BigUint::from(tmd_cert.pub_key_exponent());
|
let public_key_exponent = BigUint::from(tmd_cert.pub_key_exponent());
|
||||||
let root_key = RsaPublicKey::new(public_key_modulus, public_key_exponent).unwrap();
|
let root_key = RsaPublicKey::new(public_key_modulus, public_key_exponent).unwrap();
|
||||||
match root_key.verify(Pkcs1v15Sign::new::<Sha1>(), &tmd_hash, tmd.signature.as_slice()) {
|
match root_key.verify(Pkcs1v15Sign::new::<Sha1>(), &tmd_hash, tmd.signature().as_slice()) {
|
||||||
Ok(_) => Ok(true),
|
Ok(_) => Ok(true),
|
||||||
Err(_) => Ok(false),
|
Err(_) => Ok(false),
|
||||||
}
|
}
|
||||||
@@ -368,7 +368,7 @@ pub fn verify_ticket(ticket_cert: &Certificate, ticket: &ticket::Ticket) -> Resu
|
|||||||
let public_key_modulus = BigUint::from_bytes_be(&ticket_cert.pub_key_modulus());
|
let public_key_modulus = BigUint::from_bytes_be(&ticket_cert.pub_key_modulus());
|
||||||
let public_key_exponent = BigUint::from(ticket_cert.pub_key_exponent());
|
let public_key_exponent = BigUint::from(ticket_cert.pub_key_exponent());
|
||||||
let root_key = RsaPublicKey::new(public_key_modulus, public_key_exponent).unwrap();
|
let root_key = RsaPublicKey::new(public_key_modulus, public_key_exponent).unwrap();
|
||||||
match root_key.verify(Pkcs1v15Sign::new::<Sha1>(), &ticket_hash, ticket.signature.as_slice()) {
|
match root_key.verify(Pkcs1v15Sign::new::<Sha1>(), &ticket_hash, ticket.signature().as_slice()) {
|
||||||
Ok(_) => Ok(true),
|
Ok(_) => Ok(true),
|
||||||
Err(_) => Ok(false),
|
Err(_) => Ok(false),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// title/commonkeys.rs from rustii-lib (c) 2025 NinjaCheetah & Contributors
|
// title/commonkeys.rs from rustwii (c) 2025 NinjaCheetah & Contributors
|
||||||
// https://github.com/NinjaCheetah/rustii
|
// https://github.com/NinjaCheetah/rustwii
|
||||||
|
|
||||||
const COMMON_KEY: &str = "ebe42a225e8593e448d9c5457381aaf7";
|
const COMMON_KEY: &str = "ebe42a225e8593e448d9c5457381aaf7";
|
||||||
const KOREAN_KEY: &str = "63b82bb4f4614e2e13f2fefbba4c9b7e";
|
const KOREAN_KEY: &str = "63b82bb4f4614e2e13f2fefbba4c9b7e";
|
||||||
|
|||||||
@@ -1,287 +0,0 @@
|
|||||||
// title/content.rs from rustii (c) 2025 NinjaCheetah & Contributors
|
|
||||||
// https://github.com/NinjaCheetah/rustii
|
|
||||||
//
|
|
||||||
// Implements content parsing and editing.
|
|
||||||
|
|
||||||
use std::cell::RefCell;
|
|
||||||
use std::io::{Cursor, Read, Seek, SeekFrom, Write};
|
|
||||||
use std::rc::Rc;
|
|
||||||
use sha1::{Sha1, Digest};
|
|
||||||
use thiserror::Error;
|
|
||||||
use crate::title::tmd::{ContentRecord, ContentType};
|
|
||||||
use crate::title::crypto;
|
|
||||||
use crate::title::crypto::encrypt_content;
|
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
|
||||||
pub enum ContentError {
|
|
||||||
#[error("requested index {index} is out of range (must not exceed {max})")]
|
|
||||||
IndexOutOfRange { index: usize, max: usize },
|
|
||||||
#[error("expected {required} contents based on content records but found {found}")]
|
|
||||||
MissingContents { required: usize, found: usize },
|
|
||||||
#[error("content with requested Content ID {0} could not be found")]
|
|
||||||
CIDNotFound(u32),
|
|
||||||
#[error("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 },
|
|
||||||
#[error("content data is not in a valid format")]
|
|
||||||
IO(#[from] std::io::Error),
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
/// A structure that represents the block of data containing the content of a digital Wii title.
|
|
||||||
pub struct ContentRegion {
|
|
||||||
pub content_records: Rc<RefCell<Vec<ContentRecord>>>,
|
|
||||||
pub content_region_size: u32,
|
|
||||||
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: Rc<RefCell<Vec<ContentRecord>>>) -> Result<Self, ContentError> {
|
|
||||||
let content_region_size = data.len() as u32;
|
|
||||||
let num_contents = content_records.borrow().len() as u16;
|
|
||||||
// Calculate the starting offsets of each content.
|
|
||||||
let content_start_offsets: Vec<u64> = std::iter::once(0)
|
|
||||||
.chain(content_records.borrow().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.borrow().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 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.borrow()[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: Rc::clone(&content_records),
|
|
||||||
content_region_size,
|
|
||||||
content_start_offsets,
|
|
||||||
contents,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates a ContentRegion instance that can be used to parse and edit content stored in a
|
|
||||||
/// digital Wii title from a vector of contents and the ContentRecords from a TMD.
|
|
||||||
pub fn from_contents(contents: Vec<Vec<u8>>, content_records: Rc<RefCell<Vec<ContentRecord>>>) -> Result<Self, ContentError> {
|
|
||||||
if contents.len() != content_records.borrow().len() {
|
|
||||||
return Err(ContentError::MissingContents { required: content_records.borrow().len(), found: contents.len()});
|
|
||||||
}
|
|
||||||
let mut content_region = Self::new(Rc::clone(&content_records))?;
|
|
||||||
for i in 0..contents.len() {
|
|
||||||
let target_index = content_region.content_records.borrow()[i].index;
|
|
||||||
content_region.load_enc_content(&contents[i], target_index as usize)?;
|
|
||||||
}
|
|
||||||
Ok(content_region)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Creates a ContentRegion instance from the ContentRecords of a TMD that contains no actual
|
|
||||||
/// content. This can be used to load existing content from files.
|
|
||||||
pub fn new(content_records: Rc<RefCell<Vec<ContentRecord>>>) -> Result<Self, ContentError> {
|
|
||||||
let content_region_size: u64 = content_records.borrow().iter().map(|x| (x.content_size + 63) & !63).sum();
|
|
||||||
let content_region_size = content_region_size as u32;
|
|
||||||
let num_contents = content_records.borrow().len() as u16;
|
|
||||||
let content_start_offsets: Vec<u64> = vec![0; num_contents as usize];
|
|
||||||
let contents: Vec<Vec<u8>> = vec![Vec::new(); num_contents as usize];
|
|
||||||
Ok(ContentRegion {
|
|
||||||
content_records: Rc::clone(&content_records),
|
|
||||||
content_region_size,
|
|
||||||
content_start_offsets,
|
|
||||||
contents,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Dumps the entire ContentRegion back into binary data that can be written to a file.
|
|
||||||
pub fn to_bytes(&self) -> Result<Vec<u8>, std::io::Error> {
|
|
||||||
let mut buf: Vec<u8> = Vec::new();
|
|
||||||
for i in 0..self.content_records.borrow().len() {
|
|
||||||
let mut content = self.contents[i as usize].clone();
|
|
||||||
// Round up size to nearest 64 to add appropriate padding.
|
|
||||||
content.resize((content.len() + 63) & !63, 0);
|
|
||||||
buf.write_all(&content)?;
|
|
||||||
}
|
|
||||||
Ok(buf)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets the index of content using its Content ID.
|
|
||||||
pub fn get_index_from_cid(&self, cid: u32) -> Result<usize, ContentError> {
|
|
||||||
// Use fancy Rust find and map methods to find the index matching the provided CID. Take
|
|
||||||
// that libWiiPy!
|
|
||||||
let content_index = self.content_records.borrow().iter()
|
|
||||||
.find(|record| record.content_id == cid)
|
|
||||||
.map(|record| record.index);
|
|
||||||
if let Some(index) = content_index {
|
|
||||||
Ok(index as usize)
|
|
||||||
} else {
|
|
||||||
Err(ContentError::CIDNotFound(cid))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets the encrypted content file from the ContentRegion at the specified index.
|
|
||||||
pub fn get_enc_content_by_index(&self, index: usize) -> Result<Vec<u8>, ContentError> {
|
|
||||||
let content = self.contents.get(index).ok_or(ContentError::IndexOutOfRange { index, max: self.content_records.borrow().len() - 1 })?;
|
|
||||||
Ok(content.clone())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets the decrypted content file from the ContentRegion at the specified index.
|
|
||||||
pub fn get_content_by_index(&self, index: usize, title_key: [u8; 16]) -> Result<Vec<u8>, ContentError> {
|
|
||||||
let content = self.get_enc_content_by_index(index)?;
|
|
||||||
// Verify the hash of the decrypted content against its record.
|
|
||||||
let mut content_dec = crypto::decrypt_content(&content, title_key, self.content_records.borrow()[index].index);
|
|
||||||
content_dec.resize(self.content_records.borrow()[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.borrow()[index].content_hash {
|
|
||||||
return Err(ContentError::BadHash { hash: hex::encode(result), expected: hex::encode(self.content_records.borrow()[index].content_hash) });
|
|
||||||
}
|
|
||||||
Ok(content_dec)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets the encrypted content file from the ContentRegion with the specified Content ID.
|
|
||||||
pub fn get_enc_content_by_cid(&self, cid: u32) -> Result<Vec<u8>, ContentError> {
|
|
||||||
let index = self.content_records.borrow().iter().position(|x| x.content_id == cid);
|
|
||||||
if let Some(index) = index {
|
|
||||||
let content = self.get_enc_content_by_index(index).map_err(|_| ContentError::CIDNotFound(cid))?;
|
|
||||||
Ok(content)
|
|
||||||
} else {
|
|
||||||
Err(ContentError::CIDNotFound(cid))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Gets the decrypted content file from the ContentRegion with the specified Content ID.
|
|
||||||
pub fn get_content_by_cid(&self, cid: u32, title_key: [u8; 16]) -> Result<Vec<u8>, ContentError> {
|
|
||||||
let index = self.content_records.borrow().iter().position(|x| x.content_id == cid);
|
|
||||||
if let Some(index) = index {
|
|
||||||
let content_dec = self.get_content_by_index(index, title_key)?;
|
|
||||||
Ok(content_dec)
|
|
||||||
} else {
|
|
||||||
Err(ContentError::CIDNotFound(cid))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Loads existing content into the specified index of a ContentRegion instance. This content
|
|
||||||
/// must be encrypted.
|
|
||||||
pub fn load_enc_content(&mut self, content: &[u8], index: usize) -> Result<(), ContentError> {
|
|
||||||
if index >= self.content_records.borrow().len() {
|
|
||||||
return Err(ContentError::IndexOutOfRange { index, max: self.content_records.borrow().len() - 1 });
|
|
||||||
}
|
|
||||||
self.contents[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<ContentType>) -> Result<(), ContentError> {
|
|
||||||
if index >= self.content_records.borrow().len() {
|
|
||||||
return Err(ContentError::IndexOutOfRange { index, max: self.content_records.borrow().len() - 1 });
|
|
||||||
}
|
|
||||||
self.content_records.borrow_mut()[index].content_size = content_size;
|
|
||||||
self.content_records.borrow_mut()[index].content_hash = content_hash;
|
|
||||||
if cid.is_some() {
|
|
||||||
// Make sure that the new CID isn't already in use.
|
|
||||||
if self.content_records.borrow().iter().any(|record| record.content_id == cid.unwrap()) {
|
|
||||||
return Err(ContentError::CIDAlreadyExists(cid.unwrap()));
|
|
||||||
}
|
|
||||||
self.content_records.borrow_mut()[index].content_id = cid.unwrap();
|
|
||||||
}
|
|
||||||
if content_type.is_some() {
|
|
||||||
self.content_records.borrow_mut()[index].content_type = content_type.unwrap();
|
|
||||||
}
|
|
||||||
self.contents[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, title_key: [u8; 16]) -> Result<(), ContentError> {
|
|
||||||
if index >= self.content_records.borrow().len() {
|
|
||||||
return Err(ContentError::IndexOutOfRange { index, max: self.content_records.borrow().len() - 1 });
|
|
||||||
}
|
|
||||||
// Hash the content we're trying to load to ensure it matches the hash expected in the
|
|
||||||
// matching record.
|
|
||||||
let mut hasher = Sha1::new();
|
|
||||||
hasher.update(content);
|
|
||||||
let result = hasher.finalize();
|
|
||||||
if result[..] != self.content_records.borrow()[index].content_hash {
|
|
||||||
return Err(ContentError::BadHash { hash: hex::encode(result), expected: hex::encode(self.content_records.borrow()[index].content_hash) });
|
|
||||||
}
|
|
||||||
let content_enc = encrypt_content(content, title_key, self.content_records.borrow()[index].index, self.content_records.borrow()[index].content_size);
|
|
||||||
self.contents[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. The
|
|
||||||
/// Title Key will be used to encrypt this content before it is stored.
|
|
||||||
pub fn set_content(&mut self, content: &[u8], index: usize, cid: Option<u32>, content_type: Option<ContentType>, title_key: [u8; 16]) -> Result<(), ContentError> {
|
|
||||||
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 = encrypt_content(content, title_key, 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<(), ContentError> {
|
|
||||||
if self.contents.get(index).is_none() || self.content_records.borrow().get(index).is_none() {
|
|
||||||
return Err(ContentError::IndexOutOfRange { index, max: self.content_records.borrow().len() - 1 });
|
|
||||||
}
|
|
||||||
self.contents.remove(index);
|
|
||||||
self.content_records.borrow_mut().remove(index);
|
|
||||||
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: ContentType, content_size: u64, content_hash: [u8; 20]) -> Result<(), ContentError> {
|
|
||||||
// Return an error if the specified index or CID already exist in the records.
|
|
||||||
if self.content_records.borrow().iter().any(|record| record.index == index) {
|
|
||||||
return Err(ContentError::IndexAlreadyExists(index));
|
|
||||||
}
|
|
||||||
if self.content_records.borrow().iter().any(|record| record.content_id == cid) {
|
|
||||||
return Err(ContentError::CIDAlreadyExists(cid));
|
|
||||||
}
|
|
||||||
self.contents.push(content.to_vec());
|
|
||||||
self.content_records.borrow_mut().push(ContentRecord { content_id: cid, index, content_type, content_size, content_hash });
|
|
||||||
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: ContentType, title_key: [u8; 16]) -> Result<(), ContentError> {
|
|
||||||
let max_index = self.content_records.borrow().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 = encrypt_content(content, title_key, new_index, content_size);
|
|
||||||
self.add_enc_content(&content_enc, new_index, cid, content_type, content_size, content_hash)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// title/crypto.rs from rustii (c) 2025 NinjaCheetah & Contributors
|
// title/crypto.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
|
||||||
// https://github.com/NinjaCheetah/rustii
|
// https://github.com/NinjaCheetah/rustwii
|
||||||
//
|
//
|
||||||
// Implements the common crypto functions required to handle Wii content encryption.
|
// Implements the common crypto functions required to handle Wii content encryption.
|
||||||
|
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
307
src/title/mod.rs
307
src/title/mod.rs
@@ -1,19 +1,20 @@
|
|||||||
// title/mod.rs from rustii (c) 2025 NinjaCheetah & Contributors
|
// title/mod.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
|
||||||
// https://github.com/NinjaCheetah/rustii
|
// https://github.com/NinjaCheetah/rustwii
|
||||||
//
|
//
|
||||||
// Root for all title-related modules and implementation of the high-level Title object.
|
// Root for all title-related modules and implementation of the high-level Title object.
|
||||||
|
|
||||||
pub mod cert;
|
pub mod cert;
|
||||||
pub mod commonkeys;
|
pub mod commonkeys;
|
||||||
pub mod content;
|
|
||||||
pub mod crypto;
|
pub mod crypto;
|
||||||
|
pub mod iospatcher;
|
||||||
pub mod nus;
|
pub mod nus;
|
||||||
pub mod ticket;
|
pub mod ticket;
|
||||||
pub mod tmd;
|
pub mod tmd;
|
||||||
pub mod versions;
|
pub mod versions;
|
||||||
pub mod wad;
|
pub mod wad;
|
||||||
|
|
||||||
use std::rc::Rc;
|
use std::io::{Cursor, Read, Seek, SeekFrom, Write};
|
||||||
|
use sha1::{Sha1, Digest};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
@@ -28,22 +29,33 @@ pub enum TitleError {
|
|||||||
TMD(#[from] tmd::TMDError),
|
TMD(#[from] tmd::TMDError),
|
||||||
#[error("Ticket processing error")]
|
#[error("Ticket processing error")]
|
||||||
Ticket(#[from] ticket::TicketError),
|
Ticket(#[from] ticket::TicketError),
|
||||||
#[error("content processing error")]
|
|
||||||
Content(#[from] content::ContentError),
|
|
||||||
#[error("WAD processing error")]
|
#[error("WAD processing error")]
|
||||||
WAD(#[from] wad::WADError),
|
WAD(#[from] wad::WADError),
|
||||||
#[error("WAD data is not in a valid format")]
|
#[error("WAD data is not in a valid format")]
|
||||||
IO(#[from] std::io::Error),
|
IO(#[from] std::io::Error),
|
||||||
|
// Content-specific (not generic or inherited from another struct's errors).
|
||||||
|
#[error("requested index {index} is out of range (must not exceed {max})")]
|
||||||
|
IndexOutOfRange { index: usize, max: usize },
|
||||||
|
#[error("expected {required} contents based on content records but found {found}")]
|
||||||
|
MissingContents { required: usize, found: usize },
|
||||||
|
#[error("content with requested Content ID {0} could not be found")]
|
||||||
|
CIDNotFound(u32),
|
||||||
|
#[error("the specified index {0} already exists in the content records")]
|
||||||
|
IndexAlreadyExists(u16),
|
||||||
|
#[error("the specified Content ID {0} already exists in the content records")]
|
||||||
|
CIDAlreadyExists(u32),
|
||||||
|
#[error("content's hash did not match the expected value (was {hash}, expected {expected})")]
|
||||||
|
BadHash { hash: String, expected: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
/// A structure that represents the components of a digital Wii title.
|
/// A structure that represents the components of a digital Wii title.
|
||||||
pub struct Title {
|
pub struct Title {
|
||||||
pub cert_chain: cert::CertificateChain,
|
cert_chain: cert::CertificateChain,
|
||||||
crl: Vec<u8>,
|
crl: Vec<u8>,
|
||||||
pub ticket: ticket::Ticket,
|
ticket: ticket::Ticket,
|
||||||
pub tmd: tmd::TMD,
|
tmd: tmd::TMD,
|
||||||
pub content: content::ContentRegion,
|
content: Vec<Vec<u8>>,
|
||||||
meta: Vec<u8>
|
meta: Vec<u8>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +65,7 @@ impl Title {
|
|||||||
let cert_chain = cert::CertificateChain::from_bytes(&wad.cert_chain()).map_err(TitleError::CertificateError)?;
|
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 ticket = ticket::Ticket::from_bytes(&wad.ticket()).map_err(TitleError::Ticket)?;
|
||||||
let tmd = tmd::TMD::from_bytes(&wad.tmd()).map_err(TitleError::TMD)?;
|
let tmd = tmd::TMD::from_bytes(&wad.tmd()).map_err(TitleError::TMD)?;
|
||||||
let content = content::ContentRegion::from_bytes(&wad.content(), Rc::clone(&tmd.content_records)).map_err(TitleError::Content)?;
|
let content = Self::parse_content_region(wad.content(), tmd.content_records())?;
|
||||||
Ok(Title {
|
Ok(Title {
|
||||||
cert_chain,
|
cert_chain,
|
||||||
crl: wad.crl(),
|
crl: wad.crl(),
|
||||||
@@ -65,8 +77,18 @@ impl Title {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Creates a new Title instance from all of its individual components.
|
/// Creates a new Title instance from all of its individual components.
|
||||||
pub fn from_parts(cert_chain: cert::CertificateChain, crl: Option<&[u8]>, ticket: ticket::Ticket, tmd: tmd::TMD,
|
pub fn from_parts_with_content(
|
||||||
content: content::ContentRegion, meta: Option<&[u8]>) -> Result<Title, TitleError> {
|
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
|
// Create empty vecs for the CRL and meta areas if we weren't supplied with any, as they're
|
||||||
// optional components.
|
// optional components.
|
||||||
let crl = match crl {
|
let crl = match crl {
|
||||||
@@ -86,16 +108,68 @@ impl Title {
|
|||||||
meta
|
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.
|
/// 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> {
|
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.
|
// Create a new WAD from the data in the Title.
|
||||||
let wad = wad::WAD::from_parts(
|
let wad = wad::WAD::from_parts(
|
||||||
&self.cert_chain,
|
&self.cert_chain,
|
||||||
&self.crl,
|
&self.crl,
|
||||||
&self.ticket,
|
&self.ticket,
|
||||||
&self.tmd,
|
&self.tmd,
|
||||||
&self.content,
|
&content,
|
||||||
&self.meta
|
&self.meta
|
||||||
).map_err(TitleError::WAD)?;
|
).map_err(TitleError::WAD)?;
|
||||||
Ok(wad)
|
Ok(wad)
|
||||||
@@ -107,6 +181,18 @@ impl Title {
|
|||||||
let title = Title::from_wad(&wad)?;
|
let title = Title::from_wad(&wad)?;
|
||||||
Ok(title)
|
Ok(title)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn cert_chain(&self) -> &cert::CertificateChain {
|
||||||
|
&self.cert_chain
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ticket(&self) -> &ticket::Ticket {
|
||||||
|
&self.ticket
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tmd(&self) -> &tmd::TMD {
|
||||||
|
&self.tmd
|
||||||
|
}
|
||||||
|
|
||||||
/// Gets whether the TMD and Ticket of a Title are both fakesigned.
|
/// Gets whether the TMD and Ticket of a Title are both fakesigned.
|
||||||
pub fn is_fakesigned(&self) -> bool {
|
pub fn is_fakesigned(&self) -> bool {
|
||||||
@@ -120,24 +206,176 @@ impl Title {
|
|||||||
self.ticket.fakesign().map_err(TitleError::Ticket)?;
|
self.ticket.fakesign().map_err(TitleError::Ticket)?;
|
||||||
Ok(())
|
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.
|
/// Gets the decrypted content file from the Title at the specified index.
|
||||||
pub fn get_content_by_index(&self, index: usize) -> Result<Vec<u8>, content::ContentError> {
|
pub fn get_content_by_index(&self, index: usize) -> Result<Vec<u8>, TitleError> {
|
||||||
let content = self.content.get_content_by_index(index, self.ticket.dec_title_key())?;
|
let content = self.get_enc_content_by_index(index)?;
|
||||||
Ok(content)
|
// 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.
|
/// Gets the decrypted content file from the Title with the specified Content ID.
|
||||||
pub fn get_content_by_cid(&self, cid: u32) -> Result<Vec<u8>, content::ContentError> {
|
pub fn get_content_by_cid(&self, cid: u32) -> Result<Vec<u8>, TitleError> {
|
||||||
let content = self.content.get_content_by_cid(cid, self.ticket.dec_title_key())?;
|
let index = self.tmd.content_records().iter().position(|x| x.content_id == cid);
|
||||||
Ok(content)
|
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
|
/// 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
|
/// 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.
|
/// 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> {
|
pub fn set_content(&mut self, content: &[u8], index: usize, cid: Option<u32>, content_type: Option<tmd::ContentType>) -> Result<(), TitleError> {
|
||||||
self.content.set_content(content, index, cid, content_type, self.ticket.dec_title_key())?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,7 +384,17 @@ impl Title {
|
|||||||
/// index will be automatically assigned based on the highest index currently recorded in the
|
/// index will be automatically assigned based on the highest index currently recorded in the
|
||||||
/// content records.
|
/// content records.
|
||||||
pub fn add_content(&mut self, content: &[u8], cid: u32, content_type: tmd::ContentType) -> Result<(), TitleError> {
|
pub fn add_content(&mut self, content: &[u8], cid: u32, content_type: tmd::ContentType) -> Result<(), TitleError> {
|
||||||
self.content.add_content(content, cid, content_type, self.ticket.dec_title_key())?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +406,7 @@ impl Title {
|
|||||||
// accurate results.
|
// accurate results.
|
||||||
title_size += self.tmd.to_bytes().map_err(|x| TitleError::TMD(tmd::TMDError::IO(x)))?.len();
|
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();
|
title_size += self.ticket.to_bytes().map_err(|x| TitleError::Ticket(ticket::TicketError::IO(x)))?.len();
|
||||||
for record in self.tmd.content_records.borrow().iter() {
|
for record in self.tmd.content_records().iter() {
|
||||||
if matches!(record.content_type, tmd::ContentType::Shared) {
|
if matches!(record.content_type, tmd::ContentType::Shared) {
|
||||||
if absolute == Some(true) {
|
if absolute == Some(true) {
|
||||||
title_size += record.content_size as usize;
|
title_size += record.content_size as usize;
|
||||||
@@ -191,11 +439,16 @@ impl Title {
|
|||||||
/// Sets a new Title ID for the Title. This will re-encrypt the Title Key in the Ticket, since
|
/// 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.
|
/// 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> {
|
pub fn set_title_id(&mut self, title_id: [u8; 8]) -> Result<(), TitleError> {
|
||||||
self.tmd.set_title_id(title_id)?;
|
self.tmd.set_title_id(title_id);
|
||||||
self.ticket.set_title_id(title_id)?;
|
self.ticket.set_title_id(title_id);
|
||||||
Ok(())
|
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) {
|
pub fn set_cert_chain(&mut self, cert_chain: cert::CertificateChain) {
|
||||||
self.cert_chain = cert_chain;
|
self.cert_chain = cert_chain;
|
||||||
}
|
}
|
||||||
@@ -216,7 +469,7 @@ impl Title {
|
|||||||
self.tmd = tmd;
|
self.tmd = tmd;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set_content_region(&mut self, content: content::ContentRegion) {
|
pub fn set_contents(&mut self, content: Vec<Vec<u8>>) {
|
||||||
self.content = content;
|
self.content = content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// title/nus.rs from rustii (c) 2025 NinjaCheetah & Contributors
|
// title/nus.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
|
||||||
// https://github.com/NinjaCheetah/rustii
|
// https://github.com/NinjaCheetah/rustwii
|
||||||
//
|
//
|
||||||
// Implements the functions required for downloading data from the NUS.
|
// Implements the functions required for downloading data from the NUS.
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ use std::str;
|
|||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use reqwest;
|
use reqwest;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use crate::title::{cert, tmd, ticket, content};
|
use crate::title::{cert, tmd, ticket};
|
||||||
use crate::title;
|
use crate::title;
|
||||||
|
|
||||||
const WII_NUS_ENDPOINT: &str = "http://nus.cdn.shop.wii.com/ccs/download/";
|
const WII_NUS_ENDPOINT: &str = "http://nus.cdn.shop.wii.com/ccs/download/";
|
||||||
@@ -25,8 +25,6 @@ pub enum NUSError {
|
|||||||
TMD(#[from] tmd::TMDError),
|
TMD(#[from] tmd::TMDError),
|
||||||
#[error("Ticket processing error")]
|
#[error("Ticket processing error")]
|
||||||
Ticket(#[from] ticket::TicketError),
|
Ticket(#[from] ticket::TicketError),
|
||||||
#[error("Content processing error")]
|
|
||||||
Content(#[from] content::ContentError),
|
|
||||||
#[error("an error occurred while assembling a Title from the downloaded data")]
|
#[error("an error occurred while assembling a Title from the downloaded data")]
|
||||||
Title(#[from] title::TitleError),
|
Title(#[from] title::TitleError),
|
||||||
#[error("data could not be downloaded from the NUS")]
|
#[error("data could not be downloaded from the NUS")]
|
||||||
@@ -80,7 +78,7 @@ pub fn download_content(title_id: [u8; 8], content_id: u32, wiiu_endpoint: bool)
|
|||||||
|
|
||||||
/// Downloads all contents from the specified title from the NUS.
|
/// 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> {
|
pub fn download_contents(tmd: &tmd::TMD, wiiu_endpoint: bool) -> Result<Vec<Vec<u8>>, NUSError> {
|
||||||
let content_ids: Vec<u32> = tmd.content_records.borrow().iter().map(|record| { record.content_id }).collect();
|
let content_ids: Vec<u32> = tmd.content_records().iter().map(|record| { record.content_id }).collect();
|
||||||
let mut contents: Vec<Vec<u8>> = Vec::new();
|
let mut contents: Vec<Vec<u8>> = Vec::new();
|
||||||
for id in content_ids {
|
for id in content_ids {
|
||||||
contents.push(download_content(tmd.title_id(), id, wiiu_endpoint)?);
|
contents.push(download_content(tmd.title_id(), id, wiiu_endpoint)?);
|
||||||
@@ -112,8 +110,8 @@ pub fn download_title(title_id: [u8; 8], title_version: Option<u16>, wiiu_endpoi
|
|||||||
let cert_chain = cert::CertificateChain::from_bytes(&download_cert_chain(wiiu_endpoint)?)?;
|
let 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 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 tik = ticket::Ticket::from_bytes(&download_ticket(title_id, wiiu_endpoint)?)?;
|
||||||
let content_region = content::ContentRegion::from_contents(download_contents(&tmd, wiiu_endpoint)?, tmd.content_records.clone())?;
|
let contents = download_contents(&tmd, wiiu_endpoint)?;
|
||||||
let title = title::Title::from_parts(cert_chain, None, tik, tmd, content_region, None)?;
|
let title = title::Title::from_parts_with_content(cert_chain, None, tik, tmd, contents, None)?;
|
||||||
Ok(title)
|
Ok(title)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,8 +124,8 @@ pub fn download_tmd(title_id: [u8; 8], title_version: Option<u16>, wiiu_endpoint
|
|||||||
} else {
|
} else {
|
||||||
WII_NUS_ENDPOINT.to_owned()
|
WII_NUS_ENDPOINT.to_owned()
|
||||||
};
|
};
|
||||||
let tmd_url = if title_version.is_some() {
|
let tmd_url = if let Some(title_version) = title_version {
|
||||||
format!("{}{}/tmd.{}", endpoint_url, &hex::encode(title_id), title_version.unwrap())
|
format!("{}{}/tmd.{}", endpoint_url, &hex::encode(title_id), title_version)
|
||||||
} else {
|
} else {
|
||||||
format!("{}{}/tmd", endpoint_url, &hex::encode(title_id))
|
format!("{}{}/tmd", endpoint_url, &hex::encode(title_id))
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// title/tik.rs from rustii (c) 2025 NinjaCheetah & Contributors
|
// title/tik.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
|
||||||
// https://github.com/NinjaCheetah/rustii
|
// https://github.com/NinjaCheetah/rustwii
|
||||||
//
|
//
|
||||||
// Implements the structures and methods required for Ticket parsing and editing.
|
// Implements the structures and methods required for Ticket parsing and editing.
|
||||||
|
|
||||||
@@ -30,31 +30,31 @@ pub struct TitleLimit {
|
|||||||
pub limit_max: u32,
|
pub limit_max: u32,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug, Clone)]
|
||||||
/// A structure that represents a Wii Ticket file.
|
/// A structure that represents a Wii Ticket file.
|
||||||
pub struct Ticket {
|
pub struct Ticket {
|
||||||
pub signature_type: u32,
|
signature_type: u32,
|
||||||
pub signature: [u8; 256],
|
signature: [u8; 256],
|
||||||
padding1: [u8; 60],
|
padding1: [u8; 60],
|
||||||
pub signature_issuer: [u8; 64],
|
signature_issuer: [u8; 64],
|
||||||
pub ecdh_data: [u8; 60],
|
ecdh_data: [u8; 60],
|
||||||
pub ticket_version: u8,
|
ticket_version: u8,
|
||||||
reserved1: [u8; 2],
|
reserved1: [u8; 2],
|
||||||
pub title_key: [u8; 16],
|
title_key: [u8; 16],
|
||||||
unknown1: [u8; 1],
|
unknown1: [u8; 1],
|
||||||
pub ticket_id: [u8; 8],
|
ticket_id: [u8; 8],
|
||||||
pub console_id: [u8; 4],
|
console_id: [u8; 4],
|
||||||
title_id: [u8; 8],
|
title_id: [u8; 8],
|
||||||
unknown2: [u8; 2],
|
unknown2: [u8; 2],
|
||||||
pub title_version: u16,
|
title_version: u16,
|
||||||
pub permitted_titles_mask: [u8; 4],
|
permitted_titles_mask: [u8; 4],
|
||||||
pub permit_mask: [u8; 4],
|
permit_mask: [u8; 4],
|
||||||
pub title_export_allowed: u8,
|
title_export_allowed: u8,
|
||||||
pub common_key_index: u8,
|
common_key_index: u8,
|
||||||
unknown3: [u8; 48],
|
unknown3: [u8; 48],
|
||||||
pub content_access_permission: [u8; 64],
|
content_access_permission: [u8; 64],
|
||||||
padding2: [u8; 2],
|
padding2: [u8; 2],
|
||||||
pub title_limits: [TitleLimit; 8],
|
title_limits: [TitleLimit; 8],
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Ticket {
|
impl Ticket {
|
||||||
@@ -169,8 +169,91 @@ impl Ticket {
|
|||||||
Ok(buf)
|
Ok(buf)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Gets the type of the signature on the Ticket.
|
||||||
|
pub fn signature_type(&self) -> u32 {
|
||||||
|
self.signature_type
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the signature of the Ticket.
|
||||||
|
pub fn signature(&self) -> [u8; 256] {
|
||||||
|
self.signature
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the ECDH data listed in the Ticket.
|
||||||
|
pub fn ecdh_data(&self) -> [u8; 60] {
|
||||||
|
self.ecdh_data
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the version of the Ticket file.
|
||||||
|
pub fn ticket_version(&self) -> u8 {
|
||||||
|
self.ticket_version
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the raw encrypted Title Key from the Ticket.
|
||||||
|
pub fn title_key(&self) -> [u8; 16] {
|
||||||
|
self.title_key
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_title_key(&mut self, title_key: [u8; 16]) {
|
||||||
|
self.title_key = title_key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the Ticket ID listed in the Ticket.
|
||||||
|
pub fn ticket_id(&self) -> [u8; 8] {
|
||||||
|
self.ticket_id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the console ID listed in the Ticket.
|
||||||
|
pub fn console_id(&self) -> [u8; 4] {
|
||||||
|
self.console_id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the version of the title listed in the Ticket.
|
||||||
|
pub fn title_version(&self) -> u16 {
|
||||||
|
self.title_version
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_title_version(&mut self, version: u16) {
|
||||||
|
self.title_version = version;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the permitted titles mask listed in the Ticket.
|
||||||
|
pub fn permitted_titles_mask(&self) -> [u8; 4] {
|
||||||
|
self.permitted_titles_mask
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the permit mask listed in the Ticket.
|
||||||
|
pub fn permit_mask(&self) -> [u8; 4] {
|
||||||
|
self.permit_mask
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets whether title export is allowed by the Ticket.
|
||||||
|
pub fn title_export_allowed(&self) -> bool {
|
||||||
|
self.title_export_allowed == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the index of the common key used by the Ticket.
|
||||||
|
pub fn common_key_index(&self) -> u8 {
|
||||||
|
self.common_key_index
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the index of the common key used by the Ticket.
|
||||||
|
pub fn set_common_key_index(&mut self, index: u8) {
|
||||||
|
self.common_key_index = index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the content access permissions listed in the Ticket.
|
||||||
|
pub fn content_access_permission(&self) -> [u8; 64] {
|
||||||
|
self.content_access_permission
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the title usage limits listed in the Ticket.
|
||||||
|
pub fn title_limits(&self) -> [TitleLimit; 8] {
|
||||||
|
self.title_limits
|
||||||
|
}
|
||||||
|
|
||||||
/// Gets the decrypted version of the Title Key stored in a Ticket.
|
/// Gets the decrypted version of the Title Key stored in a Ticket.
|
||||||
pub fn dec_title_key(&self) -> [u8; 16] {
|
pub fn title_key_dec(&self) -> [u8; 16] {
|
||||||
// Get the dev status of this Ticket so decrypt_title_key knows the right common key.
|
// Get the dev status of this Ticket so decrypt_title_key knows the right common key.
|
||||||
let is_dev = self.is_dev();
|
let is_dev = self.is_dev();
|
||||||
decrypt_title_key(self.title_key, self.common_key_index, self.title_id, is_dev)
|
decrypt_title_key(self.title_key, self.common_key_index, self.title_id, is_dev)
|
||||||
@@ -241,10 +324,9 @@ impl Ticket {
|
|||||||
|
|
||||||
/// Sets a new Title ID for the Ticket. This will re-encrypt the Title Key, since the 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.
|
/// is used as the IV for decrypting the Title Key.
|
||||||
pub fn set_title_id(&mut self, title_id: [u8; 8]) -> Result<(), TicketError> {
|
pub fn set_title_id(&mut self, title_id: [u8; 8]) {
|
||||||
let new_enc_title_key = crypto::encrypt_title_key(self.dec_title_key(), self.common_key_index, title_id, self.is_dev());
|
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_key = new_enc_title_key;
|
||||||
self.title_id = title_id;
|
self.title_id = title_id;
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
208
src/title/tmd.rs
208
src/title/tmd.rs
@@ -1,13 +1,11 @@
|
|||||||
// title/tmd.rs from rustii (c) 2025 NinjaCheetah & Contributors
|
// title/tmd.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
|
||||||
// https://github.com/NinjaCheetah/rustii
|
// https://github.com/NinjaCheetah/rustwii
|
||||||
//
|
//
|
||||||
// Implements the structures and methods required for TMD parsing and editing.
|
// Implements the structures and methods required for TMD parsing and editing.
|
||||||
|
|
||||||
use std::cell::RefCell;
|
|
||||||
use std::fmt;
|
use std::fmt;
|
||||||
use std::io::{Cursor, Read, Write};
|
use std::io::{Cursor, Read, Write};
|
||||||
use std::ops::Index;
|
use std::ops::Index;
|
||||||
use std::rc::Rc;
|
|
||||||
use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt};
|
use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt};
|
||||||
use sha1::{Sha1, Digest};
|
use sha1::{Sha1, Digest};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
@@ -26,6 +24,8 @@ pub enum TMDError {
|
|||||||
InvalidContentType(u16),
|
InvalidContentType(u16),
|
||||||
#[error("encountered unknown title type `{0}`")]
|
#[error("encountered unknown title type `{0}`")]
|
||||||
InvalidTitleType(String),
|
InvalidTitleType(String),
|
||||||
|
#[error("content with requested Content ID {0} could not be found")]
|
||||||
|
CIDNotFound(u32),
|
||||||
#[error("TMD data is not in a valid format")]
|
#[error("TMD data is not in a valid format")]
|
||||||
IO(#[from] std::io::Error),
|
IO(#[from] std::io::Error),
|
||||||
}
|
}
|
||||||
@@ -81,8 +81,8 @@ pub enum AccessRight {
|
|||||||
DVDVideo = 1,
|
DVDVideo = 1,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
/// A structure that represents the metadata of a content file in a digital Wii title.
|
/// A structure that represents the metadata of a content file in a digital Wii title.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
pub struct ContentRecord {
|
pub struct ContentRecord {
|
||||||
pub content_id: u32,
|
pub content_id: u32,
|
||||||
pub index: u16,
|
pub index: u16,
|
||||||
@@ -91,83 +91,83 @@ pub struct ContentRecord {
|
|||||||
pub content_hash: [u8; 20],
|
pub content_hash: [u8; 20],
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
|
||||||
/// A structure that represents a Wii TMD (Title Metadata) file.
|
/// A structure that represents a Wii TMD (Title Metadata) file.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
pub struct TMD {
|
pub struct TMD {
|
||||||
pub signature_type: u32,
|
signature_type: u32,
|
||||||
pub signature: [u8; 256],
|
signature: [u8; 256],
|
||||||
padding1: [u8; 60],
|
padding1: [u8; 60],
|
||||||
pub signature_issuer: [u8; 64],
|
signature_issuer: [u8; 64],
|
||||||
pub tmd_version: u8,
|
tmd_version: u8,
|
||||||
pub ca_crl_version: u8,
|
ca_crl_version: u8,
|
||||||
pub signer_crl_version: u8,
|
signer_crl_version: u8,
|
||||||
pub is_vwii: u8,
|
is_vwii: u8,
|
||||||
ios_tid: [u8; 8],
|
ios_tid: [u8; 8],
|
||||||
title_id: [u8; 8],
|
title_id: [u8; 8],
|
||||||
title_type: [u8; 4],
|
title_type: [u8; 4],
|
||||||
pub group_id: u16,
|
group_id: u16,
|
||||||
padding2: [u8; 2],
|
padding2: [u8; 2],
|
||||||
region: u16,
|
region: u16,
|
||||||
pub ratings: [u8; 16],
|
ratings: [u8; 16],
|
||||||
reserved1: [u8; 12],
|
reserved1: [u8; 12],
|
||||||
pub ipc_mask: [u8; 12],
|
ipc_mask: [u8; 12],
|
||||||
reserved2: [u8; 18],
|
reserved2: [u8; 18],
|
||||||
pub access_rights: u32,
|
access_rights: u32,
|
||||||
pub title_version: u16,
|
title_version: u16,
|
||||||
pub num_contents: u16,
|
num_contents: u16,
|
||||||
pub boot_index: u16,
|
boot_index: u16,
|
||||||
pub minor_version: u16, // Normally unused, but good for fakesigning!
|
minor_version: u16, // Normally unused, but useful when fakesigning.
|
||||||
pub content_records: Rc<RefCell<Vec<ContentRecord>>>,
|
content_records: Vec<ContentRecord>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TMD {
|
impl TMD {
|
||||||
/// Creates a new TMD instance from the binary data of a TMD file.
|
/// Creates a new TMD instance from the binary data of a TMD file.
|
||||||
pub fn from_bytes(data: &[u8]) -> Result<Self, TMDError> {
|
pub fn from_bytes(data: &[u8]) -> Result<Self, TMDError> {
|
||||||
let mut buf = Cursor::new(data);
|
let mut buf = Cursor::new(data);
|
||||||
let signature_type = buf.read_u32::<BigEndian>().map_err(TMDError::IO)?;
|
let signature_type = buf.read_u32::<BigEndian>()?;
|
||||||
let mut signature = [0u8; 256];
|
let mut signature = [0u8; 256];
|
||||||
buf.read_exact(&mut signature).map_err(TMDError::IO)?;
|
buf.read_exact(&mut signature)?;
|
||||||
// Maybe this can be read differently?
|
// Maybe this can be read differently?
|
||||||
let mut padding1 = [0u8; 60];
|
let mut padding1 = [0u8; 60];
|
||||||
buf.read_exact(&mut padding1).map_err(TMDError::IO)?;
|
buf.read_exact(&mut padding1)?;
|
||||||
let mut signature_issuer = [0u8; 64];
|
let mut signature_issuer = [0u8; 64];
|
||||||
buf.read_exact(&mut signature_issuer).map_err(TMDError::IO)?;
|
buf.read_exact(&mut signature_issuer)?;
|
||||||
let tmd_version = buf.read_u8().map_err(TMDError::IO)?;
|
let tmd_version = buf.read_u8()?;
|
||||||
let ca_crl_version = buf.read_u8().map_err(TMDError::IO)?;
|
let ca_crl_version = buf.read_u8()?;
|
||||||
let signer_crl_version = buf.read_u8().map_err(TMDError::IO)?;
|
let signer_crl_version = buf.read_u8()?;
|
||||||
let is_vwii = buf.read_u8().map_err(TMDError::IO)?;
|
let is_vwii = buf.read_u8()?;
|
||||||
let mut ios_tid = [0u8; 8];
|
let mut ios_tid = [0u8; 8];
|
||||||
buf.read_exact(&mut ios_tid).map_err(TMDError::IO)?;
|
buf.read_exact(&mut ios_tid)?;
|
||||||
let mut title_id = [0u8; 8];
|
let mut title_id = [0u8; 8];
|
||||||
buf.read_exact(&mut title_id).map_err(TMDError::IO)?;
|
buf.read_exact(&mut title_id)?;
|
||||||
let mut title_type = [0u8; 4];
|
let mut title_type = [0u8; 4];
|
||||||
buf.read_exact(&mut title_type).map_err(TMDError::IO)?;
|
buf.read_exact(&mut title_type)?;
|
||||||
let group_id = buf.read_u16::<BigEndian>().map_err(TMDError::IO)?;
|
let group_id = buf.read_u16::<BigEndian>()?;
|
||||||
// Same here...
|
// Same here...
|
||||||
let mut padding2 = [0u8; 2];
|
let mut padding2 = [0u8; 2];
|
||||||
buf.read_exact(&mut padding2).map_err(TMDError::IO)?;
|
buf.read_exact(&mut padding2)?;
|
||||||
let region = buf.read_u16::<BigEndian>().map_err(TMDError::IO)?;
|
let region = buf.read_u16::<BigEndian>()?;
|
||||||
let mut ratings = [0u8; 16];
|
let mut ratings = [0u8; 16];
|
||||||
buf.read_exact(&mut ratings).map_err(TMDError::IO)?;
|
buf.read_exact(&mut ratings)?;
|
||||||
// ...and here...
|
// ...and here...
|
||||||
let mut reserved1 = [0u8; 12];
|
let mut reserved1 = [0u8; 12];
|
||||||
buf.read_exact(&mut reserved1).map_err(TMDError::IO)?;
|
buf.read_exact(&mut reserved1)?;
|
||||||
let mut ipc_mask = [0u8; 12];
|
let mut ipc_mask = [0u8; 12];
|
||||||
buf.read_exact(&mut ipc_mask).map_err(TMDError::IO)?;
|
buf.read_exact(&mut ipc_mask)?;
|
||||||
// ...and here.
|
// ...and here.
|
||||||
let mut reserved2 = [0u8; 18];
|
let mut reserved2 = [0u8; 18];
|
||||||
buf.read_exact(&mut reserved2).map_err(TMDError::IO)?;
|
buf.read_exact(&mut reserved2)?;
|
||||||
let access_rights = buf.read_u32::<BigEndian>().map_err(TMDError::IO)?;
|
let access_rights = buf.read_u32::<BigEndian>()?;
|
||||||
let title_version = buf.read_u16::<BigEndian>().map_err(TMDError::IO)?;
|
let title_version = buf.read_u16::<BigEndian>()?;
|
||||||
let num_contents = buf.read_u16::<BigEndian>().map_err(TMDError::IO)?;
|
let num_contents = buf.read_u16::<BigEndian>()?;
|
||||||
let boot_index = buf.read_u16::<BigEndian>().map_err(TMDError::IO)?;
|
let boot_index = buf.read_u16::<BigEndian>()?;
|
||||||
let minor_version = buf.read_u16::<BigEndian>().map_err(TMDError::IO)?;
|
let minor_version = buf.read_u16::<BigEndian>()?;
|
||||||
// Build content records by iterating over the rest of the data num_contents times.
|
// Build content records by iterating over the rest of the data num_contents times.
|
||||||
let mut content_records = Vec::with_capacity(num_contents as usize);
|
let mut content_records = Vec::with_capacity(num_contents as usize);
|
||||||
for _ in 0..num_contents {
|
for _ in 0..num_contents {
|
||||||
let content_id = buf.read_u32::<BigEndian>().map_err(TMDError::IO)?;
|
let content_id = buf.read_u32::<BigEndian>()?;
|
||||||
let index = buf.read_u16::<BigEndian>().map_err(TMDError::IO)?;
|
let index = buf.read_u16::<BigEndian>()?;
|
||||||
let type_int = buf.read_u16::<BigEndian>().map_err(TMDError::IO)?;
|
let type_int = buf.read_u16::<BigEndian>()?;
|
||||||
let content_type = match type_int {
|
let content_type = match type_int {
|
||||||
1 => ContentType::Normal,
|
1 => ContentType::Normal,
|
||||||
2 => ContentType::Development,
|
2 => ContentType::Development,
|
||||||
@@ -176,9 +176,9 @@ impl TMD {
|
|||||||
32769 => ContentType::Shared,
|
32769 => ContentType::Shared,
|
||||||
_ => return Err(TMDError::InvalidContentType(type_int))
|
_ => return Err(TMDError::InvalidContentType(type_int))
|
||||||
};
|
};
|
||||||
let content_size = buf.read_u64::<BigEndian>().map_err(TMDError::IO)?;
|
let content_size = buf.read_u64::<BigEndian>()?;
|
||||||
let mut content_hash = [0u8; 20];
|
let mut content_hash = [0u8; 20];
|
||||||
buf.read_exact(&mut content_hash).map_err(TMDError::IO)?;
|
buf.read_exact(&mut content_hash)?;
|
||||||
content_records.push(ContentRecord {
|
content_records.push(ContentRecord {
|
||||||
content_id,
|
content_id,
|
||||||
index,
|
index,
|
||||||
@@ -211,7 +211,7 @@ impl TMD {
|
|||||||
num_contents,
|
num_contents,
|
||||||
boot_index,
|
boot_index,
|
||||||
minor_version,
|
minor_version,
|
||||||
content_records: Rc::new(RefCell::new(content_records)),
|
content_records,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,11 +238,11 @@ impl TMD {
|
|||||||
buf.write_all(&self.reserved2)?;
|
buf.write_all(&self.reserved2)?;
|
||||||
buf.write_u32::<BigEndian>(self.access_rights)?;
|
buf.write_u32::<BigEndian>(self.access_rights)?;
|
||||||
buf.write_u16::<BigEndian>(self.title_version)?;
|
buf.write_u16::<BigEndian>(self.title_version)?;
|
||||||
buf.write_u16::<BigEndian>(self.content_records.borrow().len() as u16)?;
|
buf.write_u16::<BigEndian>(self.content_records.len() as u16)?;
|
||||||
buf.write_u16::<BigEndian>(self.boot_index)?;
|
buf.write_u16::<BigEndian>(self.boot_index)?;
|
||||||
buf.write_u16::<BigEndian>(self.minor_version)?;
|
buf.write_u16::<BigEndian>(self.minor_version)?;
|
||||||
// Iterate over content records and write out content record data.
|
// Iterate over content records and write out content record data.
|
||||||
for content in self.content_records.borrow().iter() {
|
for content in self.content_records.iter() {
|
||||||
buf.write_u32::<BigEndian>(content.content_id)?;
|
buf.write_u32::<BigEndian>(content.content_id)?;
|
||||||
buf.write_u16::<BigEndian>(content.index)?;
|
buf.write_u16::<BigEndian>(content.index)?;
|
||||||
match content.content_type {
|
match content.content_type {
|
||||||
@@ -258,6 +258,76 @@ impl TMD {
|
|||||||
Ok(buf)
|
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.
|
/// Gets whether a TMD is fakesigned using the strncmp (trucha) bug or not.
|
||||||
pub fn is_fakesigned(&self) -> bool {
|
pub fn is_fakesigned(&self) -> bool {
|
||||||
// Can't be fakesigned without a null signature.
|
// Can't be fakesigned without a null signature.
|
||||||
@@ -325,17 +395,21 @@ impl TMD {
|
|||||||
self.title_type = new_type;
|
self.title_type = new_type;
|
||||||
Ok(())
|
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.
|
/// Gets the type of content described by a content record in a TMD.
|
||||||
pub fn content_type(&self, index: usize) -> ContentType {
|
pub fn content_type(&self, index: usize) -> ContentType {
|
||||||
// Find possible content indices, because the provided one could exist while the indices
|
// Find possible content indices, because the provided one could exist while the indices
|
||||||
// are out of order, which could cause problems finding the content.
|
// are out of order, which could cause problems finding the content.
|
||||||
let mut content_indices = Vec::new();
|
let mut content_indices = Vec::new();
|
||||||
for record in self.content_records.borrow().iter() {
|
for record in self.content_records.iter() {
|
||||||
content_indices.push(record.index);
|
content_indices.push(record.index);
|
||||||
}
|
}
|
||||||
let target_index = content_indices.index(index);
|
let target_index = content_indices.index(index);
|
||||||
match self.content_records.borrow()[*target_index as usize].content_type {
|
match self.content_records[*target_index as usize].content_type {
|
||||||
ContentType::Normal => ContentType::Normal,
|
ContentType::Normal => ContentType::Normal,
|
||||||
ContentType::Development => ContentType::Development,
|
ContentType::Development => ContentType::Development,
|
||||||
ContentType::HashTree => ContentType::HashTree,
|
ContentType::HashTree => ContentType::HashTree,
|
||||||
@@ -365,10 +439,15 @@ impl TMD {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gets whether a TMD describes a vWii title or not.
|
/// Gets whether a TMD describes a vWii title.
|
||||||
pub fn is_vwii(&self) -> bool {
|
pub fn is_vwii(&self) -> bool {
|
||||||
self.is_vwii == 1
|
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.
|
/// Gets the Title ID of a TMD.
|
||||||
pub fn title_id(&self) -> [u8; 8] {
|
pub fn title_id(&self) -> [u8; 8] {
|
||||||
@@ -376,9 +455,8 @@ impl TMD {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Sets a new Title ID for a TMD.
|
/// Sets a new Title ID for a TMD.
|
||||||
pub fn set_title_id(&mut self, title_id: [u8; 8]) -> Result<(), TMDError> {
|
pub fn set_title_id(&mut self, title_id: [u8; 8]) {
|
||||||
self.title_id = title_id;
|
self.title_id = title_id;
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Gets the Title ID of the IOS required by a TMD.
|
/// Gets the Title ID of the IOS required by a TMD.
|
||||||
@@ -400,4 +478,18 @@ impl TMD {
|
|||||||
self.ios_tid = ios_tid;
|
self.ios_tid = ios_tid;
|
||||||
Ok(())
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// title/versions.rs from rustii (c) 2025 NinjaCheetah & Contributors
|
// title/versions.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
|
||||||
// https://github.com/NinjaCheetah/rustii
|
// https://github.com/NinjaCheetah/rustwii
|
||||||
//
|
//
|
||||||
// Handles converting Title version formats, and provides Wii Menu version constants.
|
// Handles converting Title version formats, and provides Wii Menu version constants.
|
||||||
|
|
||||||
@@ -78,8 +78,6 @@ pub fn dec_to_standard(version: u16, title_id: &str, vwii: Option<bool>) -> Opti
|
|||||||
let map = wii_menu_versions_map(vwii);
|
let map = wii_menu_versions_map(vwii);
|
||||||
map.get(&version).cloned()
|
map.get(&version).cloned()
|
||||||
} else {
|
} else {
|
||||||
let version_upper = (version as f64 / 256.0).floor() as u16;
|
Some(format!("{}.{}", version >> 8, version & 0xF))
|
||||||
let version_lower = version % 256;
|
|
||||||
Some(format!("{}.{}", version_upper, version_lower))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// title/wad.rs from rustii (c) 2025 NinjaCheetah & Contributors
|
// title/wad.rs from ruswtii (c) 2025 NinjaCheetah & Contributors
|
||||||
// https://github.com/NinjaCheetah/rustii
|
// https://github.com/NinjaCheetah/rustwii
|
||||||
//
|
//
|
||||||
// Implements the structures and methods required for WAD parsing and editing.
|
// Implements the structures and methods required for WAD parsing and editing.
|
||||||
|
|
||||||
@@ -7,7 +7,7 @@ use std::str;
|
|||||||
use std::io::{Cursor, Read, Seek, SeekFrom, Write};
|
use std::io::{Cursor, Read, Seek, SeekFrom, Write};
|
||||||
use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt};
|
use byteorder::{BigEndian, ReadBytesExt, WriteBytesExt};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use crate::title::{cert, tmd, ticket, content};
|
use crate::title::{cert, tmd, ticket};
|
||||||
use crate::title::ticket::TicketError;
|
use crate::title::ticket::TicketError;
|
||||||
use crate::title::tmd::TMDError;
|
use crate::title::tmd::TMDError;
|
||||||
|
|
||||||
@@ -32,16 +32,16 @@ pub enum WADType {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
/// A structure that represents an entire WAD file as a separate header and body.
|
/// A structure that represents an entire WAD file as a separate header and body.
|
||||||
pub struct WAD {
|
pub struct WAD {
|
||||||
pub header: WADHeader,
|
header: WADHeader,
|
||||||
pub body: WADBody,
|
body: WADBody,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
/// A structure that represents the header of a WAD file.
|
/// A structure that represents the header of a WAD file.
|
||||||
pub struct WADHeader {
|
pub struct WADHeader {
|
||||||
pub header_size: u32,
|
header_size: u32,
|
||||||
pub wad_type: WADType,
|
wad_type: WADType,
|
||||||
pub wad_version: u16,
|
wad_version: u16,
|
||||||
cert_chain_size: u32,
|
cert_chain_size: u32,
|
||||||
crl_size: u32,
|
crl_size: u32,
|
||||||
ticket_size: u32,
|
ticket_size: u32,
|
||||||
@@ -93,18 +93,63 @@ impl WADHeader {
|
|||||||
};
|
};
|
||||||
Ok(header)
|
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 {
|
impl WADBody {
|
||||||
/// Creates a new WADBody instance from instances of the components stored in a WAD file.
|
/// 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,
|
pub fn from_parts(cert_chain: &cert::CertificateChain, crl: &[u8], ticket: &ticket::Ticket, tmd: &tmd::TMD,
|
||||||
content: &content::ContentRegion, meta: &[u8]) -> Result<WADBody, WADError> {
|
content: &[u8], meta: &[u8]) -> Result<WADBody, WADError> {
|
||||||
let body = WADBody {
|
let body = WADBody {
|
||||||
cert_chain: cert_chain.to_bytes().map_err(WADError::IO)?,
|
cert_chain: cert_chain.to_bytes().map_err(WADError::IO)?,
|
||||||
crl: crl.to_vec(),
|
crl: crl.to_vec(),
|
||||||
ticket: ticket.to_bytes().map_err(WADError::IO)?,
|
ticket: ticket.to_bytes().map_err(WADError::IO)?,
|
||||||
tmd: tmd.to_bytes().map_err(WADError::IO)?,
|
tmd: tmd.to_bytes().map_err(WADError::IO)?,
|
||||||
content: content.to_bytes().map_err(WADError::IO)?,
|
content: content.to_vec(),
|
||||||
meta: meta.to_vec(),
|
meta: meta.to_vec(),
|
||||||
};
|
};
|
||||||
Ok(body)
|
Ok(body)
|
||||||
@@ -194,7 +239,7 @@ impl WAD {
|
|||||||
/// Creates a new WAD instance from instances of the components stored in a WAD file. This
|
/// 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.
|
/// 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,
|
pub fn from_parts(cert_chain: &cert::CertificateChain, crl: &[u8], ticket: &ticket::Ticket, tmd: &tmd::TMD,
|
||||||
content: &content::ContentRegion, meta: &[u8]) -> Result<WAD, WADError> {
|
content: &[u8], meta: &[u8]) -> Result<WAD, WADError> {
|
||||||
let body = WADBody::from_parts(cert_chain, crl, ticket, tmd, content, meta)?;
|
let body = WADBody::from_parts(cert_chain, crl, ticket, tmd, content, meta)?;
|
||||||
let header = WADHeader::from_body(&body)?;
|
let header = WADHeader::from_body(&body)?;
|
||||||
let wad = WAD {
|
let wad = WAD {
|
||||||
@@ -236,6 +281,11 @@ impl WAD {
|
|||||||
buf.resize((buf.len() + 63) & !63, 0);
|
buf.resize((buf.len() + 63) & !63, 0);
|
||||||
Ok(buf)
|
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_size(&self) -> u32 { self.header.cert_chain_size }
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user