diff --git a/Cargo.toml b/Cargo.toml index e00d79a..b450cd1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ ] [workspace.package] -version = "0.2.0" +version = "0.3.0" edition = "2021" authors = ["RitoShark"] diff --git a/README.md b/README.md index 69a21e8..c5a221f 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Release Rust Windows - Tests + Tests License

@@ -23,9 +23,12 @@ Fix rules are defined in JSON config, so new fixes can be added without recompil - **Auto-detect mode** — runs all applicable fixes with zero configuration - **Config-driven fixes** — add new fix rules via JSON, no code changes needed - **Full write-back** — modified files are written back to disk (BIN, WAD, Fantome) -- **Batch processing** — process entire directories of skin files at once +- **Batch processing** — drag-and-drop folders, process multiple files with progress tracking +- **Windows CMD optimized** — colored output, ASCII-friendly symbols, proper ANSI support - **LMDB hash system** — loads 1.8M game hashes in under 1 second - **Remote config** — fetches latest fix rules from GitHub with offline fallback +- **Check mode** — detect issues without modifying files, shows champion/skin info +- **Verbosity levels** — clean output by default, verbose mode for debugging - **Security hardened** — ZIP bomb protection, path traversal prevention, size limits - **Dry-run mode** — preview what would be fixed before modifying files - **JSON output** — machine-readable results for automation pipelines @@ -61,7 +64,10 @@ hematite-cli "skin.fantome" --json > results.json | **Broken Particles** | Fixes particle texture paths recursively | `--particles` | | **Champion Data** | Removes outdated champion BIN entries | `--remove-champion-bins` | | **Audio Files** | Removes BNK files with incompatible Wwise versions | `--remove-bnk` | +| **Animations** | Removes .anm animation files from mod | `--remove-anm` | | **VFX Shape** | Migrates VFX shape data to 14.1+ format | `--vfx-shape` | +| **Invalid Shaders** | Replaces invalid shader references with closest match | `--fix-shaders` | +| **Unreferenced Entries** | Removes CAD/AnimGraph/GearSkinUpgrade entries | `--validate-entries` | Use `--all` or pass no flags to apply everything. @@ -79,12 +85,13 @@ Use `--all` or pass no flags to apply everything. hematite-cli [OPTIONS] Arguments: - File or directory to process + File or directory to process (.bin, .wad.client, .fantome, .zip, or folder) Options: -o, --output Output path (default: creates .fixed.* next to input) -a, --all Enable all fixes --dry-run Show what would be fixed without modifying files + --check Check mode: detect issues without fixing, show skin info --json JSON output for automation -v, --verbosity Verbosity: quiet, normal, verbose, trace [default: normal] --small-mod Skip fallback assets (for texture-only mods) @@ -97,7 +104,10 @@ Fix flags: --particles Fix broken particle textures --remove-champion-bins Remove outdated champion data --remove-bnk Remove incompatible audio files + --remove-anm Remove .anm animation files --vfx-shape Fix VFX shape format (14.1+) + --fix-shaders Fix invalid shader references + --validate-entries Remove unreferenced entries -h, --help Print help -V, --version Print version @@ -191,7 +201,7 @@ cargo build --release --bin hematite-cli ### Test ```bash -cargo test --workspace # 76 tests +cargo test --workspace # 84 tests cargo clippy --workspace # Lint check cargo fmt --all -- --check # Format check ``` @@ -201,8 +211,8 @@ cargo fmt --all -- --check # Format check Releases are automated via GitHub Actions. Push a version tag to trigger: ```bash -git tag v0.2.0 -git push origin v0.2.0 +git tag v0.3.0 +git push origin v0.3.0 # CI: generates changelog (git-cliff) → builds binary → creates GitHub Release ``` diff --git a/crates/hematite-cli/src/args.rs b/crates/hematite-cli/src/args.rs index 378fb98..0244075 100644 --- a/crates/hematite-cli/src/args.rs +++ b/crates/hematite-cli/src/args.rs @@ -26,9 +26,16 @@ use std::path::PathBuf; #[derive(Parser, Debug)] #[command(name = "hematite-cli")] #[command(about = "League of Legends custom skin fixer")] +#[command( + long_about = "League of Legends custom skin fixer\n\n\ + Automatically detects and fixes common issues in custom skins.\n\ + Supports .bin, .wad.client, .fantome, and .zip files.\n\n\ + By default, all fixes are applied automatically when no specific flags are provided.\n\ + Drag and drop files or folders to process multiple skins at once." +)] #[command(version)] pub struct Cli { - /// Input file or directory to process + /// Input file or directory to process (.bin, .wad.client, .fantome, .zip, or folder) pub input: PathBuf, /// Output path (default: overwrite input) @@ -94,7 +101,12 @@ pub struct Cli { #[arg(long, help = "Process all skins found in mod (not just primary skin)")] pub all_skins: bool, - #[arg(short = 'v', long, default_value = "normal", help = "Verbosity level")] + #[arg( + short = 'v', + long, + default_value = "normal", + help = "Verbosity: quiet (errors only), normal (clean output), verbose (debug info), trace (all logs)" + )] pub verbosity: Verbosity, } @@ -121,6 +133,28 @@ const ALL_FIX_IDS: &[&str] = &[ "entry_validator", ]; +/// WAD-level fixes (operate on file lists, not BIN property trees). +const WAD_LEVEL_FIXES: &[&str] = &["bnk_remover", "anm_remover", "dds_to_tex"]; + +/// Filter out WAD-level fixes from a fix list (for BIN-only processing). +pub fn filter_bin_fixes(fixes: &[String]) -> Vec { + fixes + .iter() + .filter(|f| !WAD_LEVEL_FIXES.contains(&f.as_str())) + .cloned() + .collect() +} + +/// Filter to only WAD-level fixes. +#[allow(dead_code)] +pub fn filter_wad_fixes(fixes: &[String]) -> Vec { + fixes + .iter() + .filter(|f| WAD_LEVEL_FIXES.contains(&f.as_str())) + .cloned() + .collect() +} + /// Collect selected fix IDs based on CLI flags. /// /// If `--all` is set or no flags are passed, returns all fix IDs. diff --git a/crates/hematite-cli/src/logging.rs b/crates/hematite-cli/src/logging.rs index 884925d..6138d28 100644 --- a/crates/hematite-cli/src/logging.rs +++ b/crates/hematite-cli/src/logging.rs @@ -7,30 +7,40 @@ use tracing_subscriber::EnvFilter; /// Initialize the tracing subscriber based on verbosity level. pub fn init(verbosity: &Verbosity, json_mode: bool) { - let level = match verbosity { - Verbosity::Quiet => Level::ERROR, - Verbosity::Normal => Level::INFO, - Verbosity::Verbose => Level::DEBUG, - Verbosity::Trace => Level::TRACE, - }; + // Enable ANSI color support on Windows + #[cfg(windows)] + { + let _ = colored::control::set_virtual_terminal(true); + } - let filter = EnvFilter::from_default_env() - .add_directive(level.into()) - .add_directive( - "hematite_cli=debug" - .parse() - .expect("BUG: hardcoded directive is invalid"), - ) - .add_directive( - "hematite_core=debug" - .parse() - .expect("BUG: hardcoded directive is invalid"), - ) - .add_directive( - "hematite_ltk=debug" - .parse() - .expect("BUG: hardcoded directive is invalid"), - ); + let filter = match verbosity { + Verbosity::Quiet => { + // Only errors + EnvFilter::from_default_env() + .add_directive(Level::ERROR.into()) + } + Verbosity::Normal => { + // INFO and above, no debug spam + EnvFilter::from_default_env() + .add_directive(Level::INFO.into()) + } + Verbosity::Verbose => { + // DEBUG level for hematite crates + EnvFilter::from_default_env() + .add_directive(Level::INFO.into()) + .add_directive("hematite_cli=debug".parse().unwrap()) + .add_directive("hematite_core=debug".parse().unwrap()) + .add_directive("hematite_ltk=debug".parse().unwrap()) + } + Verbosity::Trace => { + // TRACE everything + EnvFilter::from_default_env() + .add_directive(Level::TRACE.into()) + .add_directive("hematite_cli=trace".parse().unwrap()) + .add_directive("hematite_core=trace".parse().unwrap()) + .add_directive("hematite_ltk=trace".parse().unwrap()) + } + }; if json_mode { // JSON output for automation @@ -39,128 +49,202 @@ pub fn init(verbosity: &Verbosity, json_mode: bool) { .with_env_filter(filter) .init(); } else { - // Human-readable colored output + // Human-readable colored output (hide timestamps in normal mode) tracing_subscriber::fmt() .with_env_filter(filter) .with_target(false) - .with_level(true) + .with_level(matches!(verbosity, Verbosity::Verbose | Verbosity::Trace)) + .without_time() .init(); } } /// Log a session start banner (human-readable mode only). pub fn log_session_start(input: &str, selected_fixes: &[String]) { - println!("{}", "═".repeat(60).bright_cyan()); + println!(); + println!("{}", "=".repeat(70).bright_cyan()); println!( "{}", - " Hematite — League of Legends Skin Fixer" + " Hematite - League of Legends Skin Fixer" .bright_cyan() .bold() ); - println!("{}", "═".repeat(60).bright_cyan()); - println!("{}: {}", "Input".bright_white().bold(), input); + println!("{}", "=".repeat(70).bright_cyan()); + println!(); + println!(" {}: {}", "Input".bright_white().bold(), input.bright_yellow()); if selected_fixes.is_empty() { - println!( - "{}: {}", - "Mode".bright_white().bold(), - "Auto-detect (all fixes)".yellow() - ); + println!(" {}: {}", "Mode".bright_white().bold(), "Auto-detect (all fixes)".green()); } else { println!( - "{}: {} selected", + " {}: {} selected", "Fixes".bright_white().bold(), - selected_fixes.len() + selected_fixes.len().to_string().cyan() ); for fix_id in selected_fixes { - println!(" {} {}", "•".bright_cyan(), fix_id.bright_white()); + println!(" {} {}", ">".bright_cyan(), fix_id.bright_white()); } } println!(); + println!("{}", "=".repeat(70).bright_cyan()); + println!(); } /// Log session summary. pub fn log_session_summary(result: &hematite_types::result::ProcessResult, duration: f64) { println!(); - println!("{}", "═".repeat(60).bright_cyan()); + println!("{}", "=".repeat(70).bright_cyan()); println!("{}", " Summary".bright_cyan().bold()); - println!("{}", "═".repeat(60).bright_cyan()); + println!("{}", "=".repeat(70).bright_cyan()); + println!(); println!( - "{}: {}", + " {}: {}", "Files processed".bright_white().bold(), - result.files_processed + result.files_processed.to_string().cyan() ); println!( - "{}: {}", + " {}: {}", "Fixes applied".bright_white().bold(), - result.fixes_applied.to_string().green() - ); - println!( - "{}: {}", - "Fixes failed".bright_white().bold(), - result.fixes_failed.to_string().red() + result.fixes_applied.to_string().green().bold() ); + if result.fixes_failed > 0 { + println!( + " {}: {}", + "Fixes failed".bright_white().bold(), + result.fixes_failed.to_string().red().bold() + ); + } + if !result.errors.is_empty() { - println!("\n{}:", "Errors".red().bold()); + println!(); + println!(" {}:", "Errors".red().bold()); for error in &result.errors { - println!(" {} {}", "•".red(), error); + println!(" {} {}", "X".red().bold(), error); } } - println!("\n{}: {:.2}s", "Duration".bright_white().bold(), duration); - println!("{}", "═".repeat(60).bright_cyan()); + println!(); + println!(" {}: {:.2}s", "Duration".bright_white().bold(), duration.to_string().yellow()); + println!(); + println!("{}", "=".repeat(70).bright_cyan()); + println!(); + + // Final status message + if result.errors.is_empty() && result.fixes_applied > 0 { + println!("{}", " Success! All fixes applied.".green().bold()); + } else if result.errors.is_empty() { + println!("{}", " Complete! No issues detected.".green().bold()); + } else { + println!("{}", " Completed with errors.".yellow().bold()); + } + println!(); } /// Log check-mode summary (human-readable). pub fn log_check_summary(result: &hematite_types::result::ProcessResult) { println!(); - println!("{}", "═".repeat(60).bright_cyan()); + println!("{}", "=".repeat(70).bright_cyan()); println!("{}", " Check Mode Results".bright_cyan().bold()); - println!("{}", "═".repeat(60).bright_cyan()); + println!("{}", "=".repeat(70).bright_cyan()); + println!(); if let Some(info) = &result.check_info { println!( - "{}: {}", + " {}: {}", "Champion".bright_white().bold(), - info.champion.as_deref().unwrap_or("unknown").yellow() + info.champion.as_deref().unwrap_or("unknown").yellow().bold() ); println!( - "{}: {}", + " {}: {}", "Skin Number".bright_white().bold(), info.skin_number .map(|n| n.to_string()) .unwrap_or_else(|| "none".to_string()) .yellow() + .bold() ); println!( - "{}: {}", + " {}: {}", "Binless Mod".bright_white().bold(), if info.is_binless { - "yes".red().to_string() + "yes".red().bold() } else { - "no".green().to_string() + "no".green().bold() } ); + println!(); + if info.detected_issues.is_empty() { println!( - "\n{}", - "No issues detected — mod looks clean!".green().bold() + " {}", + "No issues detected - mod looks clean!".green().bold() ); } else { println!( - "\n{} ({}):", + " {} ({}):", "Detected Issues".red().bold(), info.detected_issues.len() ); + println!(); for issue in &info.detected_issues { - println!(" {} {}", "•".red(), issue.bright_white()); + println!(" {} {}", ">".red().bold(), issue.bright_white()); } } } else { - println!("{}", "No check info available".yellow()); + println!(" {}", "No check info available".yellow()); } - println!("{}", "═".repeat(60).bright_cyan()); + println!(); + println!("{}", "=".repeat(70).bright_cyan()); + println!(); +} + +/// Log batch processing start. +pub fn log_batch_start(count: usize) { + println!(); + println!( + " {} {}", + "Found".bright_white(), + format!("{} file(s) to process", count).cyan().bold() + ); + println!(); +} + +/// Log individual file processing in batch mode. +pub fn log_file_progress(current: usize, total: usize, path: &str) { + let progress = format!("[{}/{}]", current, total); + println!( + " {} {} {}", + progress.bright_cyan().bold(), + "Processing:".bright_white(), + path.yellow() + ); +} + +/// Log file completion in batch mode. +pub fn log_file_complete(path: &str, fixes_applied: u32, success: bool) { + if success { + if fixes_applied > 0 { + println!( + " {} {} ({} fixes applied)", + "OK".green().bold(), + path.bright_white(), + fixes_applied.to_string().green() + ); + } else { + println!( + " {} {} (no issues found)", + "OK".green().bold(), + path.bright_white() + ); + } + } else { + println!( + " {} {}", + "FAILED".red().bold(), + path.bright_white() + ); + } } diff --git a/crates/hematite-cli/src/main.rs b/crates/hematite-cli/src/main.rs index 7dd35ee..aaa103b 100644 --- a/crates/hematite-cli/src/main.rs +++ b/crates/hematite-cli/src/main.rs @@ -78,6 +78,11 @@ fn main() -> Result<()> { logging::log_session_summary(&result, duration); } + // Wait for user input before exiting (only in non-JSON mode) + if !cli.json { + wait_for_keypress(); + } + // Exit with appropriate code if result.errors.is_empty() { Ok(()) @@ -86,6 +91,18 @@ fn main() -> Result<()> { } } +/// Wait for user to press any key before exiting. +fn wait_for_keypress() { + use std::io::{self, Read, Write}; + + print!("\nPress any key to continue..."); + let _ = io::stdout().flush(); + + // Read a single byte (works on both Windows and Unix) + let mut buffer = [0u8; 1]; + let _ = io::stdin().read(&mut buffer); +} + /// Output check-mode results as JSON. fn output_check_json(result: &hematite_types::result::ProcessResult) -> Result<()> { if let Some(check_info) = &result.check_info { diff --git a/crates/hematite-cli/src/process.rs b/crates/hematite-cli/src/process.rs index f03e9fb..26632c5 100644 --- a/crates/hematite-cli/src/process.rs +++ b/crates/hematite-cli/src/process.rs @@ -29,12 +29,12 @@ fn load_hash_provider() -> Result> { // Try LMDB first match LmdbHashProvider::load_from_appdata() { Ok(provider) => { - tracing::info!("Using LMDB hash provider"); + tracing::debug!("Using LMDB hash provider"); return Ok(Arc::new(provider)); } Err(e) => { tracing::warn!("LMDB hash provider unavailable: {}", e); - tracing::info!("Falling back to TXT hash provider"); + tracing::debug!("Falling back to TXT hash provider"); } } @@ -59,21 +59,50 @@ pub fn process_input( let mut total_result = ProcessResult::default(); if input.is_dir() { - for entry in WalkDir::new(input) { - let entry = entry.context("Failed to read directory entry")?; - let path = entry.path(); - - if path.is_file() && is_supported_file(path) { - let result = process_file_with_hashes( - path, - config, - selected_fixes, - champions, - dry_run, - &hash_provider, - check, - )?; - total_result.merge(result); + // Collect all files first to show progress + let files: Vec<_> = WalkDir::new(input) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.path().is_file() && is_supported_file(e.path())) + .map(|e| e.path().to_path_buf()) + .collect(); + + if files.is_empty() { + tracing::warn!("No supported files found in directory"); + return Ok(total_result); + } + + // Log batch processing start + crate::logging::log_batch_start(files.len()); + + // Process each file with progress + for (index, path) in files.iter().enumerate() { + crate::logging::log_file_progress(index + 1, files.len(), &path.display().to_string()); + + match process_file_with_hashes( + path, + config, + selected_fixes, + champions, + dry_run, + &hash_provider, + check, + ) { + Ok(result) => { + let fixes_applied = result.fixes_applied; + let success = result.errors.is_empty(); + total_result.merge(result); + crate::logging::log_file_complete( + &path.display().to_string(), + fixes_applied, + success, + ); + } + Err(e) => { + tracing::error!("Failed to process {}: {}", path.display(), e); + total_result.errors.push(format!("{}: {}", path.display(), e)); + crate::logging::log_file_complete(&path.display().to_string(), 0, false); + } } } } else { @@ -175,7 +204,7 @@ fn process_bin_file( hash_provider: &Arc, check: bool, ) -> Result { - tracing::info!("Processing BIN: {}", file.display()); + tracing::debug!("Processing BIN: {}", file.display()); // Initialize BIN provider let bin_provider = LtkBinProvider; @@ -215,8 +244,9 @@ fn process_bin_file( shader_validator: shader_validator.as_ref(), }; - // Run fixes - let mut result = apply_fixes(&mut ctx, config, selected_fixes, dry_run); + // Run fixes (filter out WAD-level fixes for standalone BIN) + let bin_fixes = crate::args::filter_bin_fixes(selected_fixes); + let mut result = apply_fixes(&mut ctx, config, &bin_fixes, dry_run); // In check mode, populate CheckInfo from detected issues if check { @@ -244,8 +274,8 @@ fn process_bin_file( std::fs::write(&output_path, &modified_bytes) .context("Failed to save modified BIN file")?; - tracing::info!("✓ Wrote fixed BIN to: {}", output_path.display()); - tracing::info!( + tracing::info!("Saved: {}", output_path.display()); + tracing::debug!( " {} fixes applied, {} bytes written", result.fixes_applied, modified_bytes.len() @@ -271,7 +301,7 @@ fn process_wad_file( use hematite_core::wad_pipeline; use hematite_ltk::wad_adapter::WadFile; - tracing::info!("Processing WAD: {}", file.display()); + tracing::debug!("Processing WAD: {}", file.display()); let bin_provider = LtkBinProvider; @@ -290,7 +320,7 @@ fn process_wad_file( .cloned() .collect(); - tracing::info!( + tracing::debug!( "WAD has {} total entries, {} BIN files", wad_provider.hash_count(), bin_chunks.len() @@ -309,7 +339,7 @@ fn process_wad_file( // Track WAD-level fixes applied for wad_fix in &wad_output.applied_fixes { - tracing::info!( + tracing::debug!( "WAD-level fix '{}' affected {} files", wad_fix.fix_name, wad_fix.files_affected @@ -325,7 +355,7 @@ fn process_wad_file( let mut conversion_count = 0u32; if !wad_output.files_to_convert.is_empty() { - tracing::info!( + tracing::debug!( "Converting {} file formats...", wad_output.files_to_convert.len() ); @@ -338,8 +368,8 @@ fn process_wad_file( let old_size = bytes.len(); *bytes = converted_bytes; conversion_count += 1; - tracing::info!( - "✓ Converted {} from .{} to .{} ({} → {} bytes)", + tracing::debug!( + "Converted {} from .{} to .{} ({} -> {} bytes)", conversion.path, conversion.from_ext, conversion.to_ext, @@ -349,7 +379,7 @@ fn process_wad_file( } Err(e) => { tracing::warn!( - "✗ Converter '{}' failed for {}: {}", + "Converter '{}' failed for {}: {}", conversion.converter, conversion.path, e @@ -443,7 +473,9 @@ fn process_wad_file( shader_validator: shader_validator.as_ref(), }; - let result = apply_fixes(&mut ctx, config, selected_fixes, dry_run); + // Filter out WAD-level fixes (they're handled by wad_pipeline above) + let bin_fixes = crate::args::filter_bin_fixes(selected_fixes); + let result = apply_fixes(&mut ctx, config, &bin_fixes, dry_run); let fixes_applied = result.fixes_applied; total_result.merge(result); @@ -513,7 +545,7 @@ fn process_wad_file( use std::io::Write; use xxhash_rust::xxh64::xxh64; - tracing::info!("Building modified WAD..."); + tracing::debug!("Building modified WAD..."); let mut builder = WadBuilder::default(); let mut chunks_included = 0; @@ -547,15 +579,15 @@ fn process_wad_file( Ok(()) })?; - tracing::info!("✓ Wrote fixed WAD to: {}", output_path.display()); - tracing::info!( - " {} chunks included, {} files removed", + tracing::info!("Saved: {}", output_path.display()); + tracing::debug!( + " {} chunks included, {} files removed, {} fixes applied", chunks_included, - shared_files_to_remove.len() + shared_files_to_remove.len(), + total_result.fixes_applied ); - tracing::info!(" {} total fixes applied", total_result.fixes_applied); } else if !dry_run { - tracing::info!("No changes detected - WAD not modified"); + tracing::debug!("No changes detected - WAD not modified"); } Ok(total_result) @@ -573,7 +605,7 @@ fn process_fantome_file( hash_provider: &Arc, check: bool, ) -> Result { - tracing::info!("Processing Fantome: {}", file.display()); + tracing::debug!("Processing Fantome: {}", file.display()); let zip_file = std::fs::File::open(file).context("Failed to open fantome/zip file")?; let mut archive = zip::ZipArchive::new(std::io::BufReader::new(zip_file)) @@ -660,9 +692,11 @@ fn process_fantome_file( return Ok(ProcessResult::default()); } - tracing::info!("Found {} WAD file(s) in archive", wad_paths.len()); + tracing::debug!("Found {} WAD file(s) in archive", wad_paths.len()); let mut total_result = ProcessResult::default(); + let mut fixed_wad_paths = Vec::new(); + for wad_path in &wad_paths { let result = process_wad_file( wad_path, @@ -673,8 +707,104 @@ fn process_fantome_file( hash_provider, check, )?; + + // Track the fixed WAD path (original.wad.client -> original.fixed.wad.client) + let has_fixes = result.fixes_applied > 0; total_result.merge(result); + + if !dry_run && has_fixes { + let fixed_path = wad_path.with_extension("fixed.wad.client"); + if fixed_path.exists() { + fixed_wad_paths.push((wad_path.clone(), fixed_path)); + } + } + } + + // Rebuild the fantome/zip archive with fixed WAD files + if !dry_run && !fixed_wad_paths.is_empty() && !check { + rebuild_fantome_archive(file, &temp_dir, &fixed_wad_paths)?; + tracing::debug!( + "Rebuilt fantome with {} fixed WAD file(s)", + fixed_wad_paths.len() + ); } Ok(total_result) } + +/// Rebuild a fantome/zip archive, replacing WAD files with their fixed versions. +fn rebuild_fantome_archive( + original_file: &Path, + _temp_dir: &tempfile::TempDir, + fixed_wads: &[(std::path::PathBuf, std::path::PathBuf)], +) -> Result<()> { + use std::io::{Read, Write}; + + // Read the original archive to copy non-WAD files + let original_zip = std::fs::File::open(original_file)?; + let mut original_archive = zip::ZipArchive::new(std::io::BufReader::new(original_zip))?; + + // Create output path: original.fantome -> original.fixed.fantome + let output_path = if original_file.extension().and_then(|e| e.to_str()) == Some("fantome") { + original_file.with_extension("fixed.fantome") + } else { + original_file.with_extension("fixed.zip") + }; + + let output_file = std::fs::File::create(&output_path)?; + let mut output_archive = zip::ZipWriter::new(output_file); + + // Create a map of original WAD paths to fixed WAD paths + let fixed_map: std::collections::HashMap = fixed_wads + .iter() + .map(|(orig, fixed)| { + ( + orig.file_name() + .unwrap() + .to_string_lossy() + .to_string() + .replace(".wad.client", "") + + ".wad.client", + fixed.as_path(), + ) + }) + .collect(); + + // Copy all files from original archive, replacing WADs with fixed versions + for i in 0..original_archive.len() { + let mut entry = original_archive.by_index(i)?; + let entry_name = entry.name().to_string(); + + // Use same compression method as original + let options = zip::write::FileOptions::default() + .compression_method(entry.compression()) + .unix_permissions(entry.unix_mode().unwrap_or(0o644)); + + output_archive.start_file(&entry_name, options)?; + + // Check if this is a WAD file that was fixed + let is_wad = entry_name.to_lowercase().ends_with(".wad.client"); + let fixed_wad = if is_wad { + fixed_map.get(&entry_name.to_lowercase()) + } else { + None + }; + + if let Some(fixed_path) = fixed_wad { + // Write the fixed WAD instead of the original + let fixed_data = std::fs::read(fixed_path)?; + output_archive.write_all(&fixed_data)?; + tracing::debug!("Replaced {} with fixed version", entry_name); + } else { + // Copy original file as-is + let mut buffer = Vec::new(); + entry.read_to_end(&mut buffer)?; + output_archive.write_all(&buffer)?; + } + } + + output_archive.finish()?; + tracing::info!("Saved: {}", output_path.display()); + + Ok(()) +}