diff --git a/README.md b/README.md index 53b7217..a9a90af 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Handles AES-128-CBC encrypted container images used on SEGA Nu/ALLS arcade hardw - Decrypts OS, APP, and OPTION (DLC) containers - Extracts NTFS-based containers (OS/APP) including internal VHD images - Extracts ExFAT-based containers (OPTION/DLC packs) +- **Auto-merges delta update VHDs** with their base on Windows (Hyper-V) - Preserves file timestamps during extraction - Built-in key database for 70+ game titles - Supports external key files for unlisted games @@ -44,18 +45,35 @@ fsdecrypt [OPTIONS] ... ```bash # Decrypt and extract an APP container -fsdecrypt SDXX_1.00.00_20240101120000_0.app +fsdecrypt ABCD_1.00.00_20240101120000_0.app # Decrypt and extract an OPTION container -fsdecrypt SDXX_A001_20240101120000_0.opt +fsdecrypt ABCD_A001_20240101120000_0.opt # Process multiple containers at once fsdecrypt game_v1.app game_v2.app extras.opt # Decrypt only, skip extraction -fsdecrypt --no-extract SDXX_1.00.00_20240101120000_0.app +fsdecrypt --no-extract ABCD_1.00.00_20240101120000_0.app ``` +### Delta Updates + +When a game ships incremental updates, you get a base `.app` (seq=0) and one or more delta `.app` files (seq>0). Pass them all together and fsdecrypt handles the rest: + +```bash +# Base + delta update: fsdecrypt extracts both VHDs, then auto-merges +fsdecrypt ABCD_1.00.00_20240101120000_0.app ABCD_1.01.00_20240215143000_1_1.00.00.app +``` + +The merge workflow (Windows only, requires Hyper-V): +1. Both containers are decrypted and their internal VHDs extracted +2. `Set-VHD` links the delta (differencing) VHD to its parent +3. `Merge-VHD` merges the delta into the base VHD +4. A UAC prompt will appear since these cmdlets require elevation + +If you only provide the delta without its base, fsdecrypt will extract the VHD and print a warning with instructions. + ### Output By default, the tool extracts container contents directly: @@ -63,7 +81,7 @@ By default, the tool extracts container contents directly: | Type | Extracted contents | |--------|--------------------| | OS | `internal_{seq}.vhd` | -| APP | `internal_{seq}.vhd` | +| APP | `internal_{seq}.vhd` (auto-merged if delta + base provided) | | OPTION | Directory with all DLC files | With `--no-extract`, a raw decrypted image is written instead: diff --git a/src/main.rs b/src/main.rs index fded235..9c3e00e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,4 +1,5 @@ use std::{ + collections::HashMap, fs::{create_dir_all, File, FileTimes}, io::{BufRead, BufReader, BufWriter, Write}, path::{Path, PathBuf}, @@ -23,6 +24,19 @@ mod bootid; mod crypto; mod stream; +/// Info collected from each input file for sorting and post-extraction merge. +struct InputFile { + path: PathBuf, + sequence_number: u8, +} + +/// Info about an extracted VHD, used for delta merge. +struct ExtractedVhd { + vhd_path: PathBuf, + sequence_number: u8, + game_id: String, +} + fn exfat_timestamp_to_system_time( timestamp: &exfat_fs::timestamp::Timestamp, ) -> Result { @@ -208,6 +222,74 @@ fn extract_internal_vhd(image_path: &Path, sequence_number: u8) -> Result PathBuf { + let s = path.to_string_lossy(); + if let Some(stripped) = s.strip_prefix(r"\\?\") { + PathBuf::from(stripped) + } else { + path + } +} + +/// Merge a differencing VHD into its parent using Hyper-V PowerShell cmdlets. +/// +/// Steps: +/// 1. `Set-VHD` links the delta VHD to its parent +/// 2. `Merge-VHD` merges the delta's changes into the parent (modifies parent in-place) +/// +/// Requires elevation (triggers UAC prompt). +#[cfg(windows)] +fn merge_vhd(base_vhd: &Path, delta_vhd: &Path) -> Result<()> { + let base_abs = strip_extended_path_prefix(std::fs::canonicalize(base_vhd)?); + let delta_abs = strip_extended_path_prefix(std::fs::canonicalize(delta_vhd)?); + + println!("Merging VHDs..."); + println!(" Base (parent): {}", base_abs.display()); + println!(" Delta (child): {}", delta_abs.display()); + + // Set-VHD links the differencing disk to its parent, then + // Merge-VHD (without -DestinationPath) merges the child into its immediate parent. + let ps_commands = format!( + "Set-VHD -Path '{}' -ParentPath '{}'; Merge-VHD -Path '{}' -Force", + delta_abs.display(), + base_abs.display(), + delta_abs.display(), + ); + + let error_log = delta_vhd.with_extension("merge_error.txt"); + let error_log_abs = strip_extended_path_prefix(std::path::absolute(&error_log)?); + + // Wrap in try/catch to capture errors from the elevated process + let wrapped = format!( + "try {{ {} }} catch {{ $_ | Out-File '{}' -Encoding UTF8; throw }}", + ps_commands, + error_log_abs.display(), + ); + + let status = std::process::Command::new("powershell") + .args([ + "-Command", + &format!( + "Start-Process powershell -Verb RunAs -Wait -ArgumentList '-Command', '{}'", + wrapped.replace('\'', "''") + ), + ]) + .status()?; + + if error_log.exists() { + let error_text = std::fs::read_to_string(&error_log).unwrap_or_default(); + std::fs::remove_file(&error_log).ok(); + println!("WARNING: VHD merge failed: {}", error_text.trim()); + } else if status.success() { + println!("Merged delta into base VHD: {}", base_abs.display()); + } else { + println!("WARNING: PowerShell exited with: {status}. Check UAC was accepted."); + } + + Ok(()) +} + #[derive(Parser)] #[command(version, about = "decryptor for some SEGA containers", long_about = None)] struct Cli { @@ -221,7 +303,24 @@ struct Cli { fn main() -> Result<()> { let cli = Cli::parse(); + // Pre-read bootids for all files to sort by sequence number (base first) + let mut inputs: Vec = Vec::new(); for path in &cli.files { + let file = FscryptDecryptor::new(File::open(path)?).map_err(|e| anyhow!(e))?; + inputs.push(InputFile { + path: path.clone(), + sequence_number: file.bootid.sequence_number, + }); + } + + // Sort so seq=0 (base) is processed before seq>0 (deltas) + inputs.sort_by_key(|f| f.sequence_number); + + // Track extracted VHDs for post-extraction merge + let mut extracted_vhds: Vec = Vec::new(); + + for input in &inputs { + let path = &input.path; let file = FscryptDecryptor::new(File::open(path)?).map_err(|e| anyhow!(e))?; let bootid = file.bootid.clone(); let output_filename = file.filename()?; @@ -263,7 +362,15 @@ fn main() -> Result<()> { match bootid.container_type { ContainerType::OS | ContainerType::APP => { match extract_internal_vhd(&path, bootid.sequence_number) { - Ok(_) => {} + Ok(vhd_path) => { + let game_id = + std::str::from_utf8(&bootid.game_id)?.trim_end().to_string(); + extracted_vhds.push(ExtractedVhd { + vhd_path, + sequence_number: bootid.sequence_number, + game_id, + }); + } Err(e) => { println!("WARNING: Failed to extract internal VHD: {e:#?}"); } @@ -282,5 +389,42 @@ fn main() -> Result<()> { } } + // Auto-merge delta VHDs with their base (Windows only) + #[cfg(windows)] + if !cli.no_extract && !extracted_vhds.is_empty() { + // Group by game_id + let mut by_game: HashMap> = HashMap::new(); + for vhd in &extracted_vhds { + by_game.entry(vhd.game_id.clone()).or_default().push(vhd); + } + + for (game_id, vhds) in &by_game { + let base = vhds.iter().find(|v| v.sequence_number == 0); + let deltas: Vec<_> = vhds.iter().filter(|v| v.sequence_number > 0).collect(); + + if deltas.is_empty() { + continue; + } + + let Some(base) = base else { + println!( + "WARNING: Delta VHD(s) found for {game_id} but no base (seq=0) VHD was extracted." + ); + println!(" Provide the base .app file to enable automatic merging."); + continue; + }; + + for delta in deltas { + if let Err(e) = merge_vhd(&base.vhd_path, &delta.vhd_path) { + println!( + "WARNING: Failed to merge {} with {}: {e:#?}", + delta.vhd_path.display(), + base.vhd_path.display() + ); + } + } + } + } + Ok(()) }