mirror of
https://gitea.tendokyu.moe/beerpsi/fsdecrypt.git
synced 2026-09-27 00:49:43 +03:00
feat: auto-merge delta VHD updates for APP containers
When multiple APP files are provided (base seq=0 + delta seq>0), fsdecrypt now automatically links and merges the differencing VHD into the parent using Set-VHD + Merge-VHD (Windows/Hyper-V only). Triggers a single UAC prompt for elevation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
4461e5169e
commit
c896f362ba
@@ -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] <FILES>...
|
||||
|
||||
```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:
|
||||
|
||||
+145
-1
@@ -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<SystemTime> {
|
||||
@@ -208,6 +222,74 @@ fn extract_internal_vhd(image_path: &Path, sequence_number: u8) -> Result<PathBu
|
||||
Ok(output_path)
|
||||
}
|
||||
|
||||
/// Strip the `\\?\` extended-path prefix that `canonicalize` adds on Windows.
|
||||
fn strip_extended_path_prefix(path: PathBuf) -> 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<InputFile> = 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<ExtractedVhd> = 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<String, Vec<&ExtractedVhd>> = 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(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user