Refactored entire way that title content is handled

The ContentRegion has been entirely dissolved. Its fields were not particularly useful, as the content records were just a duplicate from the TMD, the file data itself, and then two integers that were assigned during construction and then literally never referenced.
Instead, the only copy of the content records now lives in the TMD, and the content is stored within the title directly since that was the only meaningful field. All the content related methods were moved from the ContentRegion struct over to the Title struct, since the content just lives there now.
This should hopefully make things much easier to deal with as you no longer need to worry about keeping two separate copies of the content records in sync.
This also might all change again in the future idk
This commit is contained in:
2026-03-02 00:31:53 -05:00
parent 0d34fbc383
commit 326bb56ece
17 changed files with 547 additions and 545 deletions

View File

@@ -8,9 +8,9 @@ use std::collections::HashMap;
use std::path::PathBuf;
use glob::glob;
use thiserror::Error;
use crate::nand::sys;
use crate::nand::{sharedcontentmap, sys};
use crate::title;
use crate::title::{cert, content, ticket, tmd};
use crate::title::{cert, ticket, tmd};
#[derive(Debug, Error)]
pub enum EmuNANDError {
@@ -28,8 +28,10 @@ pub enum EmuNANDError {
TMD(#[from] tmd::TMDError),
#[error("Ticket processing error")]
Ticket(#[from] ticket::TicketError),
#[error("content processing error")]
Content(#[from] content::ContentError),
#[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),
}
@@ -163,12 +165,12 @@ impl EmuNAND {
/// 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]);
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()?)?;
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);
@@ -183,10 +185,10 @@ impl EmuNAND {
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.content.content_records().len() {
if matches!(title.content.content_records()[i].content_type, tmd::ContentType::Normal) {
let content_path = title_dir.join(format!("{:08X}.app", title.content.content_records()[i].content_id).to_ascii_lowercase());
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)?)?;
}
}
@@ -196,13 +198,13 @@ impl EmuNAND {
// 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)?)?
sharedcontentmap::SharedContentMap::from_bytes(&fs::read(&content_map_path)?)?
} else {
content::SharedContentMap::new()
sharedcontentmap::SharedContentMap::new()
};
for i in 0..title.content.content_records().len() {
if matches!(title.content.content_records()[i].content_type, tmd::ContentType::Shared) {
if let Some(file_name) = content_map.add(&title.content.content_records()[i].content_hash)? {
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)?)?;
}
@@ -232,7 +234,7 @@ impl EmuNAND {
} else {
sys::UidSys::new()
};
uid_sys.add(&title.tmd.title_id())?;
uid_sys.add(&title.tmd().title_id())?;
fs::write(&uid_sys_path, &uid_sys.to_bytes()?)?;
Ok(())
}

View File

@@ -6,3 +6,4 @@
pub mod emunand;
pub mod setting;
pub mod sys;
pub mod sharedcontentmap;

View File

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