Added base for rustii CLI EmuNAND commands (mostly library-side)

The rustii CLI now offers setting decrypt/encrypt commands, as well as a WIP emunand install-title command. This command currently only supports installing single WADs and not a folder of WADs like WiiPy.
To make EmuNANDs happen, library modules for parsing and editing setting.txt, uid.sys, and content.map have been added and have feature parity with libWiiPy. The basics of the library EmuNAND module also exist, offering the code for title installation and not much else yet.
This commit is contained in:
2025-05-01 19:55:15 -04:00
parent 15947ceff3
commit 26138c02be
12 changed files with 620 additions and 30 deletions

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

@@ -0,0 +1,150 @@
// nand/emunand.rs from rustii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustii
//
// Implements the structures and methods required for handling Wii EmuNANDs.
use std::fs;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use thiserror::Error;
use crate::nand::sys;
use crate::title;
use crate::title::{cert, content, ticket, tmd};
#[derive(Debug, Error)]
pub enum EmuNANDError {
#[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("content processing error")]
Content(#[from] content::ContentError),
#[error("io error occurred during EmuNAND operation")]
IO(#[from] std::io::Error),
}
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_root: PathBuf,
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("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_root,
emunand_dirs,
})
}
/// Install the provided title to the EmuNAND, mimicking a WAD installation performed by ES.
pub fn install_title(&self, title: title::Title) -> 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.content.to_bytes()?)?;
for i in 0..title.content.content_records.borrow().len() {
if matches!(title.content.content_records.borrow()[i].content_type, tmd::ContentType::Normal) {
let content_path = title_dir.join(format!("{:08X}.app", title.content.content_records.borrow()[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() {
content::SharedContentMap::from_bytes(&fs::read(&content_map_path)?)?
} else {
content::SharedContentMap::new()
};
for i in 0..title.content.content_records.borrow().len() {
if matches!(title.content.content_records.borrow()[i].content_type, tmd::ContentType::Shared) {
if let Some(file_name) = content_map.add(&title.content.content_records.borrow()[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.
let meta_data = 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(())
}
}

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

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

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

@@ -0,0 +1,102 @@
// nand/setting.rs from rustii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustii
//
// 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();
println!("{:?}", setting_str);
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() {
println!("{}", line);
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)
}
}

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

@@ -0,0 +1,87 @@
// nand/sys.rs from rustii (c) 2025 NinjaCheetah & Contributors
// https://github.com/NinjaCheetah/rustii
//
// 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 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() % 12) != 0 {
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))
}
}