use std::{ io::{self, Write}, path::{Path, PathBuf}, sync::{ Arc, atomic::{AtomicUsize, Ordering}, }, time::Duration, }; use fs_err as fs; use color_eyre::eyre::bail; use dialoguer::{Input, MultiSelect, Select, theme::ColorfulTheme}; use fs::OpenOptions; use indicatif::{HumanBytes, ProgressBar, ProgressStyle}; use parking_lot::Mutex; use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use re_tex::tex::Tex; use ree_pak_core::{ filename::FileNameTable, pak::PakEntry, read::archive::PakArchiveReader, utf16_hash::Utf16HashExt, write::FileOptions, }; use crate::{chunk::ChunkName, metadata::PakMetadata, util::human_bytes}; const AUTO_CHUNK_SELECTION_SIZE_THRESHOLD: usize = 50 * 1024 * 1024; // 50MB const FALSE_TRUE_SELECTION: [&str; 2] = ["False", "True"]; #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum Mode { Automatic = 0, Manual = 1, Restore = 2, } impl Mode { fn from_index(index: usize) -> color_eyre::Result { match index { 0 => Ok(Mode::Automatic), 1 => Ok(Mode::Manual), 2 => Ok(Mode::Restore), _ => bail!("Invalid mode index: {index}"), } } } #[derive(Clone)] struct ChunkSelection { chunk_name: ChunkName, file_size: u64, full_path: PathBuf, } impl std::fmt::Display for ChunkSelection { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{} ({})", self.chunk_name, human_bytes(self.file_size))?; Ok(()) } } #[derive(Default)] pub struct App { filename_table: Option, } impl App { pub fn run(&mut self) -> color_eyre::Result<()> { println!("Version v{} - Tool by @Eigeen", env!("CARGO_PKG_VERSION")); println!("Get updates from https://github.com/eigeen/mhws-tex-decompressor"); println!(); println!("Loading embedded file name table..."); // Mode selection let mode = Select::with_theme(&ColorfulTheme::default()) .with_prompt("Select mode") .items(["Automatic", "Manual", "Restore"]) .default(0) .interact()?; let mode = Mode::from_index(mode)?; match mode { Mode::Automatic => self.auto_mode(), Mode::Manual => self.manual_mode(), Mode::Restore => self.restore_mode(), } } fn filename_table(&self) -> &FileNameTable { self.filename_table.as_ref().unwrap() } /// Scan for all pak files in the game directory, including DLC directory fn scan_all_pak_files(&self, game_dir: &Path) -> color_eyre::Result> { let mut main_chunks = Vec::new(); let mut dlc_chunks = Vec::new(); // Scan main game directory self.scan_pak_files_in_dir(game_dir, &mut main_chunks)?; // Scan DLC directory if it exists let dlc_dir = game_dir.join("dlc"); if dlc_dir.is_dir() { self.scan_pak_files_in_dir(&dlc_dir, &mut dlc_chunks)?; } // If both main and DLC have files, ask user which locations to process let selected_locations = if !main_chunks.is_empty() && !dlc_chunks.is_empty() { let locations = vec!["Main game directory", "DLC directory"]; MultiSelect::with_theme(&ColorfulTheme::default()) .with_prompt("Select locations to process (Space to select, Enter to confirm)") .items(&locations) .defaults(&[true, true]) .interact()? } else if !main_chunks.is_empty() { vec![0] } else if !dlc_chunks.is_empty() { vec![1] } else { vec![] }; let mut all_chunks = Vec::new(); for &location_idx in &selected_locations { match location_idx { 0 => all_chunks.extend(main_chunks.iter().cloned()), 1 => all_chunks.extend(dlc_chunks.iter().cloned()), _ => {} } } all_chunks.sort_by(|a, b| a.chunk_name.cmp(&b.chunk_name)); Ok(all_chunks) } /// Scan pak files in a specific directory fn scan_pak_files_in_dir( &self, dir: &Path, all_chunks: &mut Vec, ) -> color_eyre::Result<()> { let entries = fs::read_dir(dir)?; for entry in entries { let entry = entry?; if !entry.file_type()?.is_file() { continue; } let file_name = entry.file_name().to_string_lossy().to_string(); let file_path = entry.path(); if !file_name.ends_with(".pak") { continue; } let chunk_name = match ChunkName::try_from_str(&file_name) { Ok(chunk_name) => chunk_name, Err(e) => { println!("Invalid chunk name, skipped: {e}"); continue; } }; let file_size = fs::metadata(&file_path)?.len(); all_chunks.push(ChunkSelection { chunk_name, file_size, full_path: file_path, }); } Ok(()) } fn process_chunk( &self, filename_table: &FileNameTable, input_path: &Path, output_path: &Path, use_full_package_mode: bool, use_feature_clone: bool, ) -> color_eyre::Result<()> { println!("Processing chunk: {}", input_path.display()); let file = fs::File::open(input_path)?; let mut reader = io::BufReader::new(file); let pak_archive = ree_pak_core::read::read_archive(&mut reader)?; let archive_reader = PakArchiveReader::new(reader, &pak_archive); let archive_reader_mtx = Mutex::new(archive_reader); // filtered entries let entries = if use_full_package_mode { pak_archive.entries().iter().collect::>() } else { println!("Filtering entries..."); pak_archive .entries() .iter() .filter(|entry| is_tex_file(entry.hash(), filename_table)) .collect::>() }; // new pak archive let out_file = OpenOptions::new() .create(true) .truncate(true) .write(true) .open(output_path)?; // +1 for metadata let mut pak_writer = ree_pak_core::write::PakWriter::new(out_file, (entries.len() as u64) + 1); // write metadata let metadata = PakMetadata::new(use_full_package_mode); metadata.write_to_pak(&mut pak_writer)?; let pak_writer_mtx = Arc::new(Mutex::new(pak_writer)); let bar = ProgressBar::new(entries.len() as u64); bar.set_style( ProgressStyle::default_bar() .template("Bytes written: {msg}\n{pos}/{len} {wide_bar}")?, ); bar.enable_steady_tick(Duration::from_millis(200)); let pak_writer_mtx1 = Arc::clone(&pak_writer_mtx); let bar1 = bar.clone(); let bytes_written = AtomicUsize::new(0); let err = entries .par_iter() .try_for_each(move |&entry| -> color_eyre::Result<()> { let pak_writer_mtx = &pak_writer_mtx1; let bar = &bar1; // read raw tex file // parse tex file let mut entry_reader = { let mut archive_reader = archive_reader_mtx.lock(); archive_reader.owned_entry_reader(entry.clone())? }; if !is_tex_file(entry.hash(), filename_table) { // plain file, just copy let mut buf = vec![]; std::io::copy(&mut entry_reader, &mut buf)?; let mut pak_writer = pak_writer_mtx.lock(); let write_bytes = write_to_pak( &mut pak_writer, entry, entry.hash(), &buf, use_feature_clone, )?; bytes_written.fetch_add(write_bytes, Ordering::SeqCst); } else { let mut tex = Tex::from_reader(&mut entry_reader)?; // decompress mipmaps tex.batch_decompress()?; let tex_bytes = tex.as_bytes()?; let mut pak_writer = pak_writer_mtx.lock(); let write_bytes = write_to_pak( &mut pak_writer, entry, entry.hash(), &tex_bytes, use_feature_clone, )?; bytes_written.fetch_add(write_bytes, Ordering::SeqCst); } bar.inc(1); if bar.position().is_multiple_of(100) { bar.set_message( HumanBytes(bytes_written.load(Ordering::SeqCst) as u64).to_string(), ); } Ok(()) }); if let Err(e) = err { eprintln!("Error occurred when processing tex: {e}"); eprintln!( "The process terminated early, we'll save the current processed tex files to pak file." ); } match Arc::try_unwrap(pak_writer_mtx) { Ok(pak_writer) => pak_writer.into_inner().finish()?, Err(_) => panic!("Arc::try_unwrap failed"), }; bar.finish(); Ok(()) } fn auto_mode(&mut self) -> color_eyre::Result<()> { let current_dir = std::env::current_dir()?; wait_for_enter( r#"Check list: 1. Your game is already updated to the latest version. 2. Uninstalled all the mods, or the generated files will break mods. I'm sure I've checked the list, press Enter to continue"#, ); let game_dir: String = Input::::with_theme(&ColorfulTheme::default()) .show_default(true) .default(current_dir.to_string_lossy().to_string()) .with_prompt("Input MonsterHunterWilds directory path") .interact_text() .unwrap() .trim_matches(|c| c == '\"' || c == '\'') .to_string(); let game_dir = Path::new(&game_dir); if !game_dir.is_dir() { bail!("game directory not exists."); } // scan for pak files in main game directory and DLC directory let all_chunk_selections = self.scan_all_pak_files(game_dir)?; // show chunks for selection // only show sub chunks let chunk_selections: Vec<&ChunkSelection> = all_chunk_selections .iter() .filter(|chunk_selection| chunk_selection.chunk_name.sub_id().is_some()) .collect(); if chunk_selections.is_empty() { bail!("No available pak files found."); } let selected_chunks: Vec = chunk_selections .iter() .map(|chunk_selection| { chunk_selection.file_size >= AUTO_CHUNK_SELECTION_SIZE_THRESHOLD as u64 }) .collect(); let selected_chunks: Option> = MultiSelect::with_theme(&ColorfulTheme::default()) .with_prompt("Select chunks to process (Space to select, Enter to confirm)") .items(&chunk_selections) .defaults(&selected_chunks) .interact_opt()?; let Some(selected_chunks) = selected_chunks else { bail!("No chunks selected."); }; let selected_chunk_selections: Vec<&ChunkSelection> = selected_chunks .iter() .map(|i| chunk_selections[*i]) .collect(); // replace mode: replace original files with uncompressed files // patch mode: generate patch files after original patch files let use_replace_mode = Select::with_theme(&ColorfulTheme::default()) .with_prompt( "Replace original files with uncompressed files? (Will automatically backup original files)", ) .default(0) .items(FALSE_TRUE_SELECTION) .interact() .unwrap(); let use_replace_mode = use_replace_mode == 1; // all chunk names for patch ID tracking let mut all_chunk_names: Vec = all_chunk_selections .iter() .map(|cs| cs.chunk_name.clone()) .collect(); // start processing for chunk_selection in selected_chunk_selections { let chunk_path = &chunk_selection.full_path; let chunk_name = &chunk_selection.chunk_name; let output_path = if use_replace_mode { // In replace mode, first generate a temporary decompressed file chunk_path.with_extension("pak.temp") } else { // In patch mode // Find the max patch id for the current chunk series let max_patch_id = all_chunk_names .iter() .filter(|c| { c.major_id() == chunk_name.major_id() && c.patch_id() == chunk_name.patch_id() && c.sub_id() == chunk_name.sub_id() }) .filter_map(|c| c.sub_patch_id()) .max() .unwrap_or(0); let new_patch_id = max_patch_id + 1; // Create a new chunk name let output_chunk_name = chunk_name.set_sub_patch(new_patch_id); // Add the new patch to the chunk list so it can be found in subsequent processing all_chunk_names.push(output_chunk_name.clone()); // Determine output directory based on original chunk location let output_dir = chunk_path.parent().unwrap(); output_dir.join(output_chunk_name.to_string()) }; println!("Output patch file: {}", output_path.display()); self.process_chunk( self.filename_table(), chunk_path, &output_path, use_replace_mode, true, )?; // In replace mode, backup the original file // and rename the temporary file to the original file name if use_replace_mode { // Backup the original file let backup_path = chunk_path.with_extension("pak.backup"); if backup_path.exists() { fs::remove_file(&backup_path)?; } fs::rename(chunk_path, &backup_path)?; // Rename the temporary file to the original file name fs::rename(&output_path, chunk_path)?; } println!(); } Ok(()) } fn manual_mode(&mut self) -> color_eyre::Result<()> { let input: String = Input::with_theme(&ColorfulTheme::default()) .show_default(true) .default("re_chunk_000.pak.sub_000.pak".to_string()) .with_prompt("Input .pak file path") .interact_text() .unwrap() .trim_matches(|c| c == '\"' || c == '\'') .to_string(); let input_path = Path::new(&input); if !input_path.is_file() { bail!("input file not exists."); } let use_full_package_mode = Select::with_theme(&ColorfulTheme::default()) .with_prompt( "Package all files, including non-tex files (for replacing original files)", ) .default(0) .items(FALSE_TRUE_SELECTION) .interact() .unwrap(); let use_full_package_mode = use_full_package_mode == 1; let use_feature_clone = Select::with_theme(&ColorfulTheme::default()) .with_prompt("Clone feature flags from original file?") .default(1) .items(FALSE_TRUE_SELECTION) .interact() .unwrap(); let use_feature_clone = use_feature_clone == 1; self.process_chunk( self.filename_table(), input_path, &input_path.with_extension("uncompressed.pak"), use_full_package_mode, use_feature_clone, )?; Ok(()) } fn restore_mode(&mut self) -> color_eyre::Result<()> { let current_dir = std::env::current_dir()?; let game_dir: String = Input::::with_theme(&ColorfulTheme::default()) .show_default(true) .default(current_dir.to_string_lossy().to_string()) .with_prompt("Input MonsterHunterWilds directory path") .interact_text() .unwrap() .trim_matches(|c| c == '\"' || c == '\'') .to_string(); let game_dir = Path::new(&game_dir); if !game_dir.is_dir() { bail!("game directory not exists."); } // scan all pak files, find files generated by this tool println!("Scanning tool generated files..."); let mut tool_generated_files = Vec::new(); let mut backup_files = Vec::new(); let mut all_chunks = Vec::new(); // Scan main directory self.scan_tool_files_in_directory( game_dir, &mut tool_generated_files, &mut backup_files, &mut all_chunks, )?; // Scan DLC directory if exists let dlc_dir = game_dir.join("dlc"); if dlc_dir.is_dir() { self.scan_tool_files_in_directory( &dlc_dir, &mut tool_generated_files, &mut backup_files, &mut all_chunks, )?; } if tool_generated_files.is_empty() && backup_files.is_empty() { println!("No files found to restore."); return Ok(()); } println!( "Found {} tool generated files and {} backup files", tool_generated_files.len(), backup_files.len() ); // restore let mut patch_files_to_remove = Vec::new(); for (file_path, metadata) in &tool_generated_files { if metadata.is_full_package() { // restore full package mode (replace mode) // this is a replace mode generated file, find the corresponding backup file let backup_path = file_path.with_extension("pak.backup"); if backup_path.exists() { println!("Restore replace mode file: {}", file_path.display()); // delete the current file and restore the backup fs::remove_file(file_path)?; fs::rename(&backup_path, file_path)?; println!(" Restore backup file: {}", backup_path.display()); } else { println!("Warning: backup file not found {}", backup_path.display()); } } else { // restore patch mode // this is a patch mode generated file if let Ok(chunk_name) = ChunkName::try_from_str(&file_path.file_name().unwrap().to_string_lossy()) { patch_files_to_remove.push((file_path.clone(), chunk_name)); } } } // remove patch files if !patch_files_to_remove.is_empty() { println!("Remove patch files..."); for (file_path, chunk_name) in patch_files_to_remove.iter().rev() { println!("Remove patch file: {}", file_path.display()); // Check if there are any patches with higher numbers let has_higher_patches = all_chunks.iter().any(|c| { c.major_id() == chunk_name.major_id() && c.sub_id() == chunk_name.sub_id() && match (c.sub_id(), c.sub_patch_id()) { (Some(_), Some(patch_id)) => { patch_id > chunk_name.sub_patch_id().unwrap() } (None, Some(patch_id)) => patch_id > chunk_name.patch_id().unwrap(), _ => false, } }); if has_higher_patches { // create an empty patch file instead of deleting, to keep the patch sequence continuous self.create_empty_patch_file(file_path)?; println!(" Create empty patch file to keep sequence continuous"); } else { // no higher patches exist, safe to delete fs::remove_file(file_path)?; // remove from all_chunks all_chunks.retain(|c| c != chunk_name); println!(" Removed patch file"); } } } println!("Restore completed!"); Ok(()) } /// Scan tool generated files in a specific directory fn scan_tool_files_in_directory( &self, dir: &Path, tool_generated_files: &mut Vec<(std::path::PathBuf, PakMetadata)>, backup_files: &mut Vec, all_chunks: &mut Vec, ) -> color_eyre::Result<()> { let entries = fs::read_dir(dir)?; for entry in entries { let entry = entry?; if !entry.file_type()?.is_file() { continue; } let file_name = entry.file_name().to_string_lossy().to_string(); let file_path = entry.path(); // check backup files if file_name.ends_with(".pak.backup") { backup_files.push(file_path); continue; } // check pak files if !file_name.ends_with(".pak") { continue; } // Check if it's a chunk or DLC file let is_chunk = file_name.starts_with("re_chunk_"); let is_dlc = file_name.starts_with("re_dlc_"); if !is_chunk && !is_dlc { continue; } // collect chunk info if let Ok(chunk_name) = ChunkName::try_from_str(&file_name) { all_chunks.push(chunk_name.clone()); } // check if the file is generated by this tool if let Ok(Some(metadata)) = self.check_tool_generated_file(&file_path) { tool_generated_files.push((file_path, metadata)); } } Ok(()) } /// check if the file is generated by this tool, return metadata fn check_tool_generated_file( &self, file_path: &Path, ) -> color_eyre::Result> { let file = match fs::File::open(file_path) { Ok(file) => file, Err(_) => return Ok(None), }; let mut reader = io::BufReader::new(file); let pak_archive = match ree_pak_core::read::read_archive(&mut reader) { Ok(archive) => archive, Err(_) => return Ok(None), }; PakMetadata::from_pak_archive(reader, &pak_archive) } /// create an empty patch file fn create_empty_patch_file(&self, file_path: &Path) -> color_eyre::Result<()> { let out_file = OpenOptions::new() .create(true) .truncate(true) .write(true) .open(file_path)?; let mut pak_writer = ree_pak_core::write::PakWriter::new(out_file, 1); // write metadata to mark this is an empty patch file let metadata = PakMetadata::new(false); metadata.write_to_pak(&mut pak_writer)?; pak_writer.finish()?; Ok(()) } } fn is_tex_file(hash: u64, file_name_table: &FileNameTable) -> bool { let Some(file_name) = file_name_table.get_file_name(hash) else { return false; }; file_name.to_string().unwrap().ends_with(".tex.241106027") } fn write_to_pak( writer: &mut ree_pak_core::write::PakWriter, entry: &PakEntry, file_name: impl Utf16HashExt, data: &[u8], use_feature_clone: bool, ) -> color_eyre::Result where W: io::Write + io::Seek, { let mut file_options = FileOptions::default(); if use_feature_clone { file_options = file_options.with_unk_attr(*entry.unk_attr()) } writer.start_file(file_name, file_options)?; writer.write_all(data)?; Ok(data.len()) } fn wait_for_enter(msg: &str) { let _: String = Input::with_theme(&ColorfulTheme::default()) .with_prompt(msg) .allow_empty(true) .interact_text() .unwrap(); }