diff --git a/src/bin/rustwii/main.rs b/src/bin/rustwii/main.rs index 66ba69d..284e04b 100644 --- a/src/bin/rustwii/main.rs +++ b/src/bin/rustwii/main.rs @@ -60,6 +60,11 @@ enum Commands { #[command(subcommand)] command: nand::setting::Commands }, + /// Edit a TMD file + Tmd { + #[command(subcommand)] + command: title::tmd::Commands + }, /// Pack/unpack a U8 archive U8 { #[command(subcommand)] @@ -144,6 +149,16 @@ fn main() -> Result<()> { } } }, + Some(Commands::Tmd { command}) => { + match command { + title::tmd::Commands::Edit { input, output, edits} => { + title::tmd::tmd_edit(input, output, edits)? + }, + title::tmd::Commands::Remove { input, output, identifier } => { + title::tmd::tmd_remove(input, output, identifier)? + } + } + }, Some(Commands::U8 { command }) => { match command { archive::u8::Commands::Pack { input, output } => { @@ -157,25 +172,25 @@ fn main() -> Result<()> { Some(Commands::Wad { command }) => { match command { 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::convert_wad(input, target, output)? + title::wad::wad_convert(input, target, output)? }, 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::pack_wad(input, output)? + title::wad::wad_pack(input, output)? }, 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::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::unpack_wad(input, output)? + title::wad::wad_unpack(input, output)? }, } }, diff --git a/src/bin/rustwii/title/mod.rs b/src/bin/rustwii/title/mod.rs index 63405a1..315bc46 100644 --- a/src/bin/rustwii/title/mod.rs +++ b/src/bin/rustwii/title/mod.rs @@ -4,3 +4,5 @@ pub mod fakesign; pub mod nus; pub mod wad; +pub mod tmd; +mod shared; diff --git a/src/bin/rustwii/title/shared.rs b/src/bin/rustwii/title/shared.rs new file mode 100644 index 0000000..e8a5594 --- /dev/null +++ b/src/bin/rustwii/title/shared.rs @@ -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 rustii 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, + /// The Content ID of the target content + #[arg(short, long)] + pub cid: Option, +} + +#[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, + /// A new Title ID for this title (formatted as 4 ASCII characters, e.g. HADE) + #[arg(long)] + pub tid: Option, + /// A new type for this title (valid options are "System", "Channel", "SystemChannel", + /// "GameChannel", "DLC", "HiddenChannel") + #[arg(long)] + pub r#type: Option, +} + +/// 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, 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 { + 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) +} diff --git a/src/bin/rustwii/title/tmd.rs b/src/bin/rustwii/title/tmd.rs new file mode 100644 index 0000000..75f8b7f --- /dev/null +++ b/src/bin/rustwii/title/tmd.rs @@ -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 rustii 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, + #[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, + #[command(flatten)] + identifier: ContentIdentifier, + }, +} + +pub fn tmd_edit(input: &str, output: &Option, 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 = 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 = 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, 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(()) +} diff --git a/src/bin/rustwii/title/wad.rs b/src/bin/rustwii/title/wad.rs index 10b4d0a..fd27e4b 100644 --- a/src/bin/rustwii/title/wad.rs +++ b/src/bin/rustwii/title/wad.rs @@ -10,9 +10,9 @@ use clap::{Subcommand, Args}; use glob::glob; use hex::FromHex; use rand::prelude::*; -use regex::RegexBuilder; use rustwii::title::{cert, crypto, tmd, ticket, content, wad}; use rustwii::title; +use crate::title::shared::{validate_target_ios, validate_target_tid, validate_target_type, ContentIdentifier, TitleModifications}; #[derive(Subcommand)] #[command(arg_required_else_help = true)] @@ -52,7 +52,7 @@ pub enum Commands { #[arg(short, long)] output: Option, #[command(flatten)] - edits: WadModifications + edits: TitleModifications }, /// Pack a directory into a WAD file Pack { @@ -110,35 +110,6 @@ pub struct ConvertTargets { 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, - /// The Content ID of the target content - #[arg(short, long)] - cid: Option, -} - -#[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, - /// A new Title ID for this WAD (formatted as 4 ASCII characters, e.g. HADE) - #[arg(long)] - tid: Option, - /// A new type for this WAD (valid options are "System", "Channel", "SystemChannel", - /// "GameChannel", "DLC", "HiddenChannel") - #[arg(long)] - r#type: Option, -} - enum Target { Retail, Dev, @@ -155,7 +126,7 @@ impl fmt::Display for Target { } } -pub fn add_wad(input: &str, content: &str, output: &Option, cid: &Option, ctype: &Option) -> Result<()> { +pub fn wad_add(input: &str, content: &str, output: &Option, cid: &Option, ctype: &Option) -> Result<()> { let in_path = Path::new(input); if !in_path.exists() { bail!("Source WAD \"{}\" could not be found.", in_path.display()); @@ -209,7 +180,7 @@ pub fn add_wad(input: &str, content: &str, output: &Option, cid: &Option Ok(()) } -pub fn convert_wad(input: &str, target: &ConvertTargets, output: &Option) -> Result<()> { +pub fn wad_convert(input: &str, target: &ConvertTargets, output: &Option) -> Result<()> { let in_path = Path::new(input); if !in_path.exists() { bail!("Source WAD \"{}\" could not be found.", in_path.display()); @@ -281,10 +252,10 @@ pub fn convert_wad(input: &str, target: &ConvertTargets, output: &Option Ok(()) } -pub fn edit_wad(input: &str, output: &Option, edits: &WadModifications) -> Result<()> { +pub fn wad_edit(input: &str, output: &Option, edits: &TitleModifications) -> Result<()> { let in_path = Path::new(input); 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() { PathBuf::from(output.clone().unwrap()).with_extension("wad") @@ -298,44 +269,32 @@ pub fn edit_wad(input: &str, output: &Option, edits: &WadModifications) // These are joined, because that way if both are selected we only need to set the TID (and by // extension, re-encrypt the Title Key) a single time. if edits.tid.is_some() || edits.r#type.is_some() { - let tid_high = if edits.r#type.is_some() { - let new_type = match edits.r#type.clone().unwrap().to_ascii_lowercase().as_str() { - "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.", edits.r#type.clone().unwrap()), - }; + let tid_high = if let Some(new_type) = &edits.r#type { + let new_type = validate_target_type(&new_type.to_ascii_lowercase())?; changes_summary.push(format!("Changed title type from \"{}\" to \"{}\"", title.tmd.title_type()?, new_type)); Vec::from_hex(format!("{:08X}", new_type as u32))? } else { title.tmd.title_id()[0..4].to_vec() }; - let tid_low = if edits.tid.is_some() { - let re = RegexBuilder::new(r"^[a-z0-9!@#$%^&*]{4}$").case_insensitive(true).build()?; - let new_tid_low = edits.tid.clone().unwrap().to_ascii_uppercase(); - 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 \"!@#$%&*\"."); - } - 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))? + + let tid_low = if let Some(new_tid) = &edits.tid { + let new_tid = validate_target_tid(&new_tid.to_ascii_uppercase())?; + changes_summary.push(format!("Changed Title ID from \"{}\" to \"{}\"", hex::encode(&title.tmd.title_id()[4..8]).to_ascii_uppercase(), hex::encode(&new_tid).to_ascii_uppercase())); + new_tid } else { title.tmd.title_id()[4..8].to_vec() }; + let new_tid: Vec = tid_high.iter().chain(&tid_low).copied().collect(); title.set_title_id(new_tid.try_into().unwrap())?; } - if let Some(ios) = edits.ios { - let new_ios = ios; - 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))?; - changes_summary.push(format!("Changed required IOS from IOS{} to IOS{}", title.tmd.ios_tid().last().unwrap(), new_ios)); + + if let Some(new_ios) = edits.ios { + let new_ios_tid = validate_target_ios(new_ios)?; title.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.fakesign()?; fs::write(&out_path, title.to_wad()?.to_bytes()?).with_context(|| format!("Could not open output file \"{}\" for writing.", out_path.display()))?; println!("Successfully edited WAD file \"{}\"!\nSummary of changes:", out_path.display()); @@ -345,7 +304,7 @@ pub fn edit_wad(input: &str, output: &Option, edits: &WadModifications) 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); if !in_path.exists() { bail!("Source directory \"{}\" does not exist.", in_path.display()); @@ -415,7 +374,7 @@ pub fn pack_wad(input: &str, output: &str) -> Result<()> { Ok(()) } -pub fn remove_wad(input: &str, output: &Option, identifier: &ContentIdentifier) -> Result<()> { +pub fn wad_remove(input: &str, output: &Option, identifier: &ContentIdentifier) -> Result<()> { let in_path = Path::new(input); if !in_path.exists() { bail!("Source WAD \"{}\" could not be found.", in_path.display()); @@ -449,7 +408,7 @@ pub fn remove_wad(input: &str, output: &Option, identifier: &ContentIden Ok(()) } -pub fn set_wad(input: &str, content: &str, output: &Option, identifier: &ContentIdentifier, ctype: &Option) -> Result<()> { +pub fn wad_set(input: &str, content: &str, output: &Option, identifier: &ContentIdentifier, ctype: &Option) -> Result<()> { let in_path = Path::new(input); if !in_path.exists() { bail!("Source WAD \"{}\" could not be found.", in_path.display()); @@ -501,7 +460,7 @@ pub fn set_wad(input: &str, content: &str, output: &Option, identifier: 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); if !in_path.exists() { bail!("Source WAD \"{}\" could not be found.", input);