10 Commits

Author SHA1 Message Date
96acb36767 update TU3 list file 2025-10-02 21:15:02 +08:00
6e401c86cf update deps, add reqwest and clippy 2025-10-02 20:37:04 +08:00
1686114181 fix: error setting new sub patch id
Some checks failed
Release Build / build (push) Has been cancelled
2025-08-17 21:14:07 +08:00
4ca65979c9 workflows 2025-08-15 15:04:03 +08:00
6e493b3fdc update to TU2.5 list file 2025-08-15 14:59:10 +08:00
5401200add DLC pak support
Some checks failed
Release Build / build (push) Has been cancelled
2025-08-15 11:23:43 +08:00
312c3bc90e update dependencies 2025-08-15 10:48:32 +08:00
7222ee1790 Path-like chunk name component parser 2025-08-15 10:47:51 +08:00
d628400f6a fix when patch not deleted when not exists higher patches 2025-07-28 20:15:40 +08:00
18d8138dc4 remove local relative path dependencies
Some checks failed
Release Build / build (push) Has been cancelled
2025-07-28 19:43:57 +08:00
7 changed files with 1677 additions and 421 deletions

View File

@@ -3,6 +3,7 @@ name: Release Build
on: on:
push: push:
tags: ["v*"] tags: ["v*"]
workflow_dispatch:
permissions: permissions:
contents: write contents: write
@@ -30,8 +31,6 @@ jobs:
run: | run: |
mkdir release mkdir release
copy target/release/${{ env.BINARY_NAME }}.exe release/ copy target/release/${{ env.BINARY_NAME }}.exe release/
copy README.md release/
copy LICENSE release/
- name: Upload artifact - name: Upload artifact
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4

1530
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,22 +1,26 @@
[package] [package]
name = "mhws-tex-decompressor" name = "mhws-tex-decompressor"
version = "0.2.0" version = "0.3.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
# local development # # local development
re-tex = { path = "../re-tex" } # re-tex = { path = "../re-tex" }
ree-pak-core = { path = "../../ree-pak-rs/ree-pak-core" } # ree-pak-core = { path = "../../ree-pak-rs/ree-pak-core" }
# re-tex = { git = "https://github.com/eigeen/re-tex.git", branch = "main" } re-tex = { git = "https://github.com/eigeen/re-tex.git", branch = "main" }
# ree-pak-core = { git = "https://github.com/eigeen/ree-pak-rs.git", branch = "main" } ree-pak-core = { git = "https://github.com/eigeen/ree-pak-rs.git", branch = "main" }
dialoguer = "0.11" # UI
color-eyre = "0.6.5" dialoguer = "0.12"
indicatif = "0.18" indicatif = "0.18"
rayon = "1.10"
parking_lot = "0.12"
colored = "3.0" colored = "3.0"
fs-err = "3.1.1"
serde = { version = "1.0.219", features = ["derive"] } color-eyre = "0.6"
serde_json = "1.0.141" rayon = "1.11"
parking_lot = "0.12"
fs-err = "3.1"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1.47", features = ["parking_lot", "rt-multi-thread", "macros"] }
reqwest = { version = "0.12", features = ["json"] }

Binary file not shown.

View File

@@ -1,6 +1,6 @@
use std::{ use std::{
io::{self, Write}, io::{self, Write},
path::Path, path::{Path, PathBuf},
sync::{ sync::{
Arc, Arc,
atomic::{AtomicUsize, Ordering}, atomic::{AtomicUsize, Ordering},
@@ -18,15 +18,12 @@ use parking_lot::Mutex;
use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
use re_tex::tex::Tex; use re_tex::tex::Tex;
use ree_pak_core::{ use ree_pak_core::{
filename::{FileNameExt, FileNameTable}, filename::FileNameTable, pak::PakEntry, read::archive::PakArchiveReader,
pak::PakEntry, utf16_hash::Utf16HashExt, write::FileOptions,
read::archive::PakArchiveReader,
write::FileOptions,
}; };
use crate::{chunk::ChunkName, metadata::PakMetadata, util::human_bytes}; use crate::{chunk::ChunkName, metadata::PakMetadata, util::human_bytes};
const FILE_NAME_LIST: &[u8] = include_bytes!("../assets/MHWs_STM_Release.list.zst");
const AUTO_CHUNK_SELECTION_SIZE_THRESHOLD: usize = 50 * 1024 * 1024; // 50MB const AUTO_CHUNK_SELECTION_SIZE_THRESHOLD: usize = 50 * 1024 * 1024; // 50MB
const FALSE_TRUE_SELECTION: [&str; 2] = ["False", "True"]; const FALSE_TRUE_SELECTION: [&str; 2] = ["False", "True"];
@@ -48,9 +45,11 @@ impl Mode {
} }
} }
#[derive(Clone)]
struct ChunkSelection { struct ChunkSelection {
chunk_name: ChunkName, chunk_name: ChunkName,
file_size: u64, file_size: u64,
full_path: PathBuf,
} }
impl std::fmt::Display for ChunkSelection { impl std::fmt::Display for ChunkSelection {
@@ -68,17 +67,15 @@ pub struct App {
impl App { impl App {
pub fn run(&mut self) -> color_eyre::Result<()> { pub fn run(&mut self) -> color_eyre::Result<()> {
println!("Version v{} - Tool by @Eigeen", env!("CARGO_PKG_VERSION")); println!("Version v{} - Tool by @Eigeen", env!("CARGO_PKG_VERSION"));
println!("Get updates at https://github.com/eigeen/mhws-tex-decompressor"); println!("Get updates from https://github.com/eigeen/mhws-tex-decompressor");
println!(); println!();
println!("Loading embedded file name table..."); println!("Loading embedded file name table...");
let filename_table = FileNameTable::from_bytes(FILE_NAME_LIST)?;
self.filename_table = Some(filename_table);
// Mode selection // Mode selection
let mode = Select::with_theme(&ColorfulTheme::default()) let mode = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Select mode") .with_prompt("Select mode")
.items(&["Automatic", "Manual", "Restore"]) .items(["Automatic", "Manual", "Restore"])
.default(0) .default(0)
.interact()?; .interact()?;
let mode = Mode::from_index(mode)?; let mode = Mode::from_index(mode)?;
@@ -94,6 +91,89 @@ impl App {
self.filename_table.as_ref().unwrap() 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<Vec<ChunkSelection>> {
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<ChunkSelection>,
) -> 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( fn process_chunk(
&self, &self,
filename_table: &FileNameTable, filename_table: &FileNameTable,
@@ -192,7 +272,7 @@ impl App {
} }
bar.inc(1); bar.inc(1);
if bar.position() % 100 == 0 { if bar.position().is_multiple_of(100) {
bar.set_message( bar.set_message(
HumanBytes(bytes_written.load(Ordering::SeqCst) as u64).to_string(), HumanBytes(bytes_written.load(Ordering::SeqCst) as u64).to_string(),
); );
@@ -242,51 +322,15 @@ I'm sure I've checked the list, press Enter to continue"#,
bail!("game directory not exists."); bail!("game directory not exists.");
} }
// scan for pak files // scan for pak files in main game directory and DLC directory
let dir = fs::read_dir(game_dir)?; let all_chunk_selections = self.scan_all_pak_files(game_dir)?;
let mut all_chunks: Vec<ChunkName> = vec![];
for entry in dir {
let entry = entry?;
if !entry.file_type()?.is_file() {
continue;
}
let file_name = entry.file_name().to_string_lossy().to_string();
if !file_name.ends_with(".pak") || !file_name.starts_with("re_chunk_") {
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;
}
};
all_chunks.push(chunk_name);
}
all_chunks.sort();
// show chunks for selection // show chunks for selection
// only show sub chunks // only show sub chunks
let chunk_selections = all_chunks let chunk_selections: Vec<&ChunkSelection> = all_chunk_selections
.iter() .iter()
.filter_map(|chunk| { .filter(|chunk_selection| chunk_selection.chunk_name.sub_id().is_some())
if chunk.sub_id.is_some() { .collect();
Some(chunk.to_string())
} else {
None
}
})
.map(|file_name| {
let file_path = game_dir.join(&file_name);
let file_size = fs::metadata(file_path)?.len();
Ok(ChunkSelection {
chunk_name: ChunkName::try_from_str(&file_name)?,
file_size,
})
})
.collect::<color_eyre::Result<Vec<_>>>()?;
if chunk_selections.is_empty() { if chunk_selections.is_empty() {
bail!("No available pak files found."); bail!("No available pak files found.");
} }
@@ -294,9 +338,9 @@ I'm sure I've checked the list, press Enter to continue"#,
let selected_chunks: Vec<bool> = chunk_selections let selected_chunks: Vec<bool> = chunk_selections
.iter() .iter()
.map(|chunk_selection| { .map(|chunk_selection| {
Ok(chunk_selection.file_size >= AUTO_CHUNK_SELECTION_SIZE_THRESHOLD as u64) chunk_selection.file_size >= AUTO_CHUNK_SELECTION_SIZE_THRESHOLD as u64
}) })
.collect::<color_eyre::Result<Vec<_>>>()?; .collect();
let selected_chunks: Option<Vec<usize>> = let selected_chunks: Option<Vec<usize>> =
MultiSelect::with_theme(&ColorfulTheme::default()) MultiSelect::with_theme(&ColorfulTheme::default())
@@ -308,10 +352,10 @@ I'm sure I've checked the list, press Enter to continue"#,
bail!("No chunks selected."); bail!("No chunks selected.");
}; };
let selected_chunks = selected_chunks let selected_chunk_selections: Vec<&ChunkSelection> = selected_chunks
.iter() .iter()
.map(|i| chunk_selections[*i].chunk_name.clone()) .map(|i| chunk_selections[*i])
.collect::<Vec<_>>(); .collect();
// replace mode: replace original files with uncompressed files // replace mode: replace original files with uncompressed files
// patch mode: generate patch files after original patch files // patch mode: generate patch files after original patch files
@@ -320,47 +364,56 @@ I'm sure I've checked the list, press Enter to continue"#,
"Replace original files with uncompressed files? (Will automatically backup original files)", "Replace original files with uncompressed files? (Will automatically backup original files)",
) )
.default(0) .default(0)
.items(&FALSE_TRUE_SELECTION) .items(FALSE_TRUE_SELECTION)
.interact() .interact()
.unwrap(); .unwrap();
let use_replace_mode = use_replace_mode == 1; let use_replace_mode = use_replace_mode == 1;
// all chunk names for patch ID tracking
let mut all_chunk_names: Vec<ChunkName> = all_chunk_selections
.iter()
.map(|cs| cs.chunk_name.clone())
.collect();
// start processing // start processing
for chunk_name in selected_chunks { for chunk_selection in selected_chunk_selections {
let chunk_path = game_dir.join(chunk_name.to_string()); let chunk_path = &chunk_selection.full_path;
let chunk_name = &chunk_selection.chunk_name;
let output_path = if use_replace_mode { let output_path = if use_replace_mode {
// In replace mode, first generate a temporary decompressed file // In replace mode, first generate a temporary decompressed file
chunk_path.with_extension("pak.temp") chunk_path.with_extension("pak.temp")
} else { } else {
// In patch mode // In patch mode
// Find the max patch id for the current chunk series // Find the max patch id for the current chunk series
let max_patch_id = all_chunks let max_patch_id = all_chunk_names
.iter() .iter()
.filter(|c| { .filter(|c| {
c.major_id == chunk_name.major_id c.major_id() == chunk_name.major_id()
&& c.patch_id == chunk_name.patch_id && c.patch_id() == chunk_name.patch_id()
&& c.sub_id == chunk_name.sub_id && c.sub_id() == chunk_name.sub_id()
}) })
.filter_map(|c| c.sub_patch_id) .filter_map(|c| c.sub_patch_id())
.max() .max()
.unwrap_or(0); .unwrap_or(0);
let new_patch_id = max_patch_id + 1; let new_patch_id = max_patch_id + 1;
// Create a new chunk name // Create a new chunk name
let mut output_chunk_name = chunk_name.clone(); let output_chunk_name = chunk_name.set_sub_patch(new_patch_id);
output_chunk_name.sub_patch_id = Some(new_patch_id);
// Add the new patch to the chunk list so it can be found in subsequent processing // Add the new patch to the chunk list so it can be found in subsequent processing
all_chunks.push(output_chunk_name.clone()); all_chunk_names.push(output_chunk_name.clone());
game_dir.join(output_chunk_name.to_string()) // 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()); println!("Output patch file: {}", output_path.display());
self.process_chunk( self.process_chunk(
self.filename_table(), self.filename_table(),
&chunk_path, chunk_path,
&output_path, &output_path,
use_replace_mode, use_replace_mode,
true, true,
@@ -374,9 +427,9 @@ I'm sure I've checked the list, press Enter to continue"#,
if backup_path.exists() { if backup_path.exists() {
fs::remove_file(&backup_path)?; fs::remove_file(&backup_path)?;
} }
fs::rename(&chunk_path, &backup_path)?; fs::rename(chunk_path, &backup_path)?;
// Rename the temporary file to the original file name // Rename the temporary file to the original file name
fs::rename(&output_path, &chunk_path)?; fs::rename(&output_path, chunk_path)?;
} }
println!(); println!();
} }
@@ -404,7 +457,7 @@ I'm sure I've checked the list, press Enter to continue"#,
"Package all files, including non-tex files (for replacing original files)", "Package all files, including non-tex files (for replacing original files)",
) )
.default(0) .default(0)
.items(&FALSE_TRUE_SELECTION) .items(FALSE_TRUE_SELECTION)
.interact() .interact()
.unwrap(); .unwrap();
let use_full_package_mode = use_full_package_mode == 1; let use_full_package_mode = use_full_package_mode == 1;
@@ -412,7 +465,7 @@ I'm sure I've checked the list, press Enter to continue"#,
let use_feature_clone = Select::with_theme(&ColorfulTheme::default()) let use_feature_clone = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Clone feature flags from original file?") .with_prompt("Clone feature flags from original file?")
.default(1) .default(1)
.items(&FALSE_TRUE_SELECTION) .items(FALSE_TRUE_SELECTION)
.interact() .interact()
.unwrap(); .unwrap();
let use_feature_clone = use_feature_clone == 1; let use_feature_clone = use_feature_clone == 1;
@@ -447,40 +500,27 @@ I'm sure I've checked the list, press Enter to continue"#,
// scan all pak files, find files generated by this tool // scan all pak files, find files generated by this tool
println!("Scanning tool generated files..."); println!("Scanning tool generated files...");
let dir = fs::read_dir(game_dir)?;
let mut tool_generated_files = Vec::new(); let mut tool_generated_files = Vec::new();
let mut backup_files = Vec::new(); let mut backup_files = Vec::new();
let mut all_chunks = Vec::new(); let mut all_chunks = Vec::new();
for entry in dir { // Scan main directory
let entry = entry?; self.scan_tool_files_in_directory(
if !entry.file_type()?.is_file() { game_dir,
continue; &mut tool_generated_files,
} &mut backup_files,
&mut all_chunks,
)?;
let file_name = entry.file_name().to_string_lossy().to_string(); // Scan DLC directory if exists
let file_path = entry.path(); let dlc_dir = game_dir.join("dlc");
if dlc_dir.is_dir() {
// check backup files self.scan_tool_files_in_directory(
if file_name.ends_with(".pak.backup") { &dlc_dir,
backup_files.push(file_path); &mut tool_generated_files,
continue; &mut backup_files,
} &mut all_chunks,
)?;
// check pak files
if !file_name.ends_with(".pak") || !file_name.starts_with("re_chunk_") {
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));
}
} }
if tool_generated_files.is_empty() && backup_files.is_empty() { if tool_generated_files.is_empty() && backup_files.is_empty() {
@@ -532,13 +572,13 @@ I'm sure I've checked the list, press Enter to continue"#,
// Check if there are any patches with higher numbers // Check if there are any patches with higher numbers
let has_higher_patches = all_chunks.iter().any(|c| { let has_higher_patches = all_chunks.iter().any(|c| {
c.major_id == chunk_name.major_id c.major_id() == chunk_name.major_id()
&& c.sub_id == chunk_name.sub_id && c.sub_id() == chunk_name.sub_id()
&& match (c.sub_id, c.sub_patch_id) { && match (c.sub_id(), c.sub_patch_id()) {
(Some(_), Some(patch_id)) => { (Some(_), Some(patch_id)) => {
patch_id > chunk_name.sub_patch_id.unwrap() patch_id > chunk_name.sub_patch_id().unwrap()
} }
(None, Some(patch_id)) => patch_id > chunk_name.patch_id.unwrap(), (None, Some(patch_id)) => patch_id > chunk_name.patch_id().unwrap(),
_ => false, _ => false,
} }
}); });
@@ -550,6 +590,8 @@ I'm sure I've checked the list, press Enter to continue"#,
} else { } else {
// no higher patches exist, safe to delete // no higher patches exist, safe to delete
fs::remove_file(file_path)?; fs::remove_file(file_path)?;
// remove from all_chunks
all_chunks.retain(|c| c != chunk_name);
println!(" Removed patch file"); println!(" Removed patch file");
} }
} }
@@ -559,6 +601,56 @@ I'm sure I've checked the list, press Enter to continue"#,
Ok(()) 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<std::path::PathBuf>,
all_chunks: &mut Vec<ChunkName>,
) -> 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 /// check if the file is generated by this tool, return metadata
fn check_tool_generated_file( fn check_tool_generated_file(
&self, &self,
@@ -601,13 +693,13 @@ fn is_tex_file(hash: u64, file_name_table: &FileNameTable) -> bool {
let Some(file_name) = file_name_table.get_file_name(hash) else { let Some(file_name) = file_name_table.get_file_name(hash) else {
return false; return false;
}; };
file_name.get_name().ends_with(".tex.241106027") file_name.to_string().unwrap().ends_with(".tex.241106027")
} }
fn write_to_pak<W>( fn write_to_pak<W>(
writer: &mut ree_pak_core::write::PakWriter<W>, writer: &mut ree_pak_core::write::PakWriter<W>,
entry: &PakEntry, entry: &PakEntry,
file_name: impl FileNameExt, file_name: impl Utf16HashExt,
data: &[u8], data: &[u8],
use_feature_clone: bool, use_feature_clone: bool,
) -> color_eyre::Result<usize> ) -> color_eyre::Result<usize>

View File

@@ -5,29 +5,36 @@
//! - Patch: re_chunk_XXX.pak.patch_XXX.pak //! - Patch: re_chunk_XXX.pak.patch_XXX.pak
//! - Sub: re_chunk_XXX.pak.sub_XXX.pak //! - Sub: re_chunk_XXX.pak.sub_XXX.pak
//! - Sub Patch: re_chunk_XXX.pak.sub_XXX.pak.patch_XXX.pak //! - Sub Patch: re_chunk_XXX.pak.sub_XXX.pak.patch_XXX.pak
//! - DLC: re_dlc_stm_3308900.pak (and more)
use color_eyre::eyre; use color_eyre::eyre;
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum ChunkComponent {
/// Base chunk with major ID (re_chunk_XXX.pak)
Base(u32),
/// DLC chunk with DLC ID (re_dlc_stm_3308900.pak)
Dlc(String),
/// Patch chunk with patch ID (XXX in .patch_XXX.pak)
Patch(u32),
/// Sub chunk with sub ID (XXX in .sub_XXX.pak)
Sub(u32),
/// Sub patch chunk with sub patch ID (YYY in .sub_XXX.pak.patch_YYY.pak)
SubPatch(u32),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)] #[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ChunkName { pub struct ChunkName {
/// Major chunk ID (XXX in re_chunk_XXX.pak) /// Chunk components
pub major_id: u32, pub components: Vec<ChunkComponent>,
/// Patch number (XXX in .patch_XXX.pak)
pub patch_id: Option<u32>,
/// Sub chunk ID (XXX in .sub_XXX.pak)
pub sub_id: Option<u32>,
/// Patch number for sub chunk (YYY in .sub_XXX.pak.patch_YYY.pak)
pub sub_patch_id: Option<u32>,
} }
impl ChunkName { impl ChunkName {
#[allow(dead_code)]
/// Create a new base chunk name (re_chunk_XXX.pak) /// Create a new base chunk name (re_chunk_XXX.pak)
pub fn new(major_id: u32) -> Self { pub fn new(major_id: u32) -> Self {
Self { Self {
major_id, components: vec![ChunkComponent::Base(major_id)],
patch_id: None,
sub_id: None,
sub_patch_id: None,
} }
} }
@@ -42,36 +49,97 @@ impl ChunkName {
} }
// every 2 parts is a component // every 2 parts is a component
let components = dot_parts let part_pairs = dot_parts
.chunks_exact(2) .chunks_exact(2)
.map(|c| (c[0], c[1])) .map(|c| (c[0], c[1]))
.collect::<Vec<(&str, &str)>>(); .collect::<Vec<(&str, &str)>>();
// check if all parts have the correct extension // check if all parts have the correct extension
if !components.iter().all(|(_, ext)| *ext == "pak") { if !part_pairs.iter().all(|(_, ext)| *ext == "pak") {
return Err(eyre::eyre!( return Err(eyre::eyre!(
"Invalid chunk name with invalid extension: {}", "Invalid chunk name with invalid extension: {}",
name name
)); ));
} }
let mut this = Self::new(0); let mut components = Vec::new();
let mut has_sub = false;
for (name, _) in components.iter() { for (part_name, _) in part_pairs.iter() {
let component = Self::parse_component(name)?; let component = Self::parse_component(part_name)?;
match component { match component {
Component::Major(id) => this.major_id = id, Component::Major(id) => {
Component::Sub(id) => this.sub_id = Some(id), components.push(ChunkComponent::Base(id));
}
Component::Dlc(id) => {
components.push(ChunkComponent::Dlc(id));
}
Component::Sub(id) => {
components.push(ChunkComponent::Sub(id));
has_sub = true;
}
Component::Patch(id) => { Component::Patch(id) => {
if this.sub_id.is_some() { if has_sub {
this.sub_patch_id = Some(id); components.push(ChunkComponent::SubPatch(id));
} else { } else {
this.patch_id = Some(id); components.push(ChunkComponent::Patch(id));
} }
} }
} }
} }
Ok(this) Ok(Self { components })
}
/// Get the major ID (base chunk ID)
pub fn major_id(&self) -> Option<u32> {
self.components.iter().find_map(|c| match c {
ChunkComponent::Base(id) => Some(*id),
_ => None,
})
}
/// Get the patch ID
pub fn patch_id(&self) -> Option<u32> {
self.components.iter().find_map(|c| match c {
ChunkComponent::Patch(id) => Some(*id),
_ => None,
})
}
/// Get the sub ID
pub fn sub_id(&self) -> Option<u32> {
self.components.iter().find_map(|c| match c {
ChunkComponent::Sub(id) => Some(*id),
_ => None,
})
}
/// Get the sub patch ID
pub fn sub_patch_id(&self) -> Option<u32> {
self.components.iter().find_map(|c| match c {
ChunkComponent::SubPatch(id) => Some(*id),
_ => None,
})
}
/// Add or replace a sub patch component with the given ID
pub fn set_sub_patch(&self, patch_id: u32) -> Self {
let mut new_components = self.components.clone();
// Check if SubPatch already exists and replace it
if let Some(pos) = new_components
.iter()
.position(|c| matches!(c, ChunkComponent::SubPatch(_)))
{
new_components[pos] = ChunkComponent::SubPatch(patch_id);
} else {
new_components.push(ChunkComponent::SubPatch(patch_id));
}
Self {
components: new_components,
}
} }
fn parse_component(name: &str) -> color_eyre::Result<Component> { fn parse_component(name: &str) -> color_eyre::Result<Component> {
@@ -82,6 +150,9 @@ impl ChunkName {
.parse::<u32>() .parse::<u32>()
.map_err(|e| eyre::eyre!("Chunk name with invalid major ID: {}", e))?; .map_err(|e| eyre::eyre!("Chunk name with invalid major ID: {}", e))?;
Ok(Component::Major(major_id)) Ok(Component::Major(major_id))
} else if name.starts_with("re_dlc_") {
let dlc_id = name.strip_prefix("re_dlc_").unwrap().to_string();
Ok(Component::Dlc(dlc_id))
} else if name.starts_with("patch_") { } else if name.starts_with("patch_") {
let patch_id = name let patch_id = name
.strip_prefix("patch_") .strip_prefix("patch_")
@@ -107,18 +178,18 @@ impl ChunkName {
impl std::fmt::Display for ChunkName { impl std::fmt::Display for ChunkName {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "re_chunk_{:03}.pak", self.major_id)?; for (i, component) in self.components.iter().enumerate() {
if let Some(patch_id) = self.patch_id { if i > 0 {
write!(f, ".patch_{:03}.pak", patch_id)?; write!(f, ".")?;
return Ok(());
} }
if let Some(sub_id) = self.sub_id { match component {
write!(f, ".sub_{:03}.pak", sub_id)?; ChunkComponent::Base(id) => write!(f, "re_chunk_{:03}.pak", id)?,
ChunkComponent::Dlc(id) => write!(f, "re_dlc_{}.pak", id)?,
ChunkComponent::Patch(id) => write!(f, "patch_{:03}.pak", id)?,
ChunkComponent::Sub(id) => write!(f, "sub_{:03}.pak", id)?,
ChunkComponent::SubPatch(id) => write!(f, "patch_{:03}.pak", id)?,
} }
if let Some(sub_patch_id) = self.sub_patch_id {
write!(f, ".patch_{:03}.pak", sub_patch_id)?;
} }
Ok(()) Ok(())
} }
} }
@@ -131,16 +202,45 @@ impl PartialOrd for ChunkName {
impl Ord for ChunkName { impl Ord for ChunkName {
fn cmp(&self, other: &Self) -> std::cmp::Ordering { fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.major_id // compare by component count first
.cmp(&other.major_id) self.components
.then(self.sub_id.cmp(&other.sub_id)) .len()
.then(self.patch_id.cmp(&other.patch_id)) .cmp(&other.components.len())
.then(self.sub_patch_id.cmp(&other.sub_patch_id)) .then_with(|| {
// compare each component
for (a, b) in self.components.iter().zip(other.components.iter()) {
let cmp = match (a, b) {
(ChunkComponent::Base(a), ChunkComponent::Base(b)) => a.cmp(b),
(ChunkComponent::Dlc(a), ChunkComponent::Dlc(b)) => a.cmp(b),
(ChunkComponent::Patch(a), ChunkComponent::Patch(b)) => a.cmp(b),
(ChunkComponent::Sub(a), ChunkComponent::Sub(b)) => a.cmp(b),
(ChunkComponent::SubPatch(a), ChunkComponent::SubPatch(b)) => a.cmp(b),
// compare by component type priority
(ChunkComponent::Base(_), _) => std::cmp::Ordering::Less,
(_, ChunkComponent::Base(_)) => std::cmp::Ordering::Greater,
(ChunkComponent::Dlc(_), _) => std::cmp::Ordering::Less,
(_, ChunkComponent::Dlc(_)) => std::cmp::Ordering::Greater,
(ChunkComponent::Sub(_), _) => std::cmp::Ordering::Less,
(_, ChunkComponent::Sub(_)) => std::cmp::Ordering::Greater,
(ChunkComponent::Patch(_), ChunkComponent::SubPatch(_)) => {
std::cmp::Ordering::Less
}
(ChunkComponent::SubPatch(_), ChunkComponent::Patch(_)) => {
std::cmp::Ordering::Greater
}
};
if cmp != std::cmp::Ordering::Equal {
return cmp;
}
}
std::cmp::Ordering::Equal
})
} }
} }
enum Component { enum Component {
Major(u32), Major(u32),
Dlc(String),
Patch(u32), Patch(u32),
Sub(u32), Sub(u32),
} }
@@ -170,5 +270,45 @@ mod tests {
sub_patch.to_string(), sub_patch.to_string(),
"re_chunk_000.pak.sub_000.pak.patch_001.pak" "re_chunk_000.pak.sub_000.pak.patch_001.pak"
); );
// Test DLC chunk
let dlc = ChunkName::try_from_str("re_dlc_stm_3308900.pak").unwrap();
assert_eq!(dlc.to_string(), "re_dlc_stm_3308900.pak");
}
#[test]
fn test_chunk_helper_methods() {
// Test base chunk helper methods
let base = ChunkName::new(123);
assert_eq!(base.major_id(), Some(123));
assert_eq!(base.patch_id(), None);
assert_eq!(base.sub_id(), None);
assert_eq!(base.sub_patch_id(), None);
// Test complex chunk helper methods
let complex =
ChunkName::try_from_str("re_chunk_456.pak.sub_789.pak.patch_012.pak").unwrap();
assert_eq!(complex.major_id(), Some(456));
assert_eq!(complex.patch_id(), None);
assert_eq!(complex.sub_id(), Some(789));
assert_eq!(complex.sub_patch_id(), Some(12));
// Test DLC chunk helper methods
let dlc = ChunkName::try_from_str("re_dlc_stm_3308900.pak").unwrap();
assert_eq!(dlc.major_id(), None);
}
#[test]
fn test_set_sub_patch() {
let base = ChunkName::try_from_str("re_chunk_000.pak.sub_001.pak").unwrap();
let with_patch = base.set_sub_patch(99);
assert_eq!(with_patch.major_id(), Some(0));
assert_eq!(with_patch.sub_id(), Some(1));
assert_eq!(with_patch.sub_patch_id(), Some(99));
assert_eq!(
with_patch.to_string(),
"re_chunk_000.pak.sub_001.pak.patch_099.pak"
);
} }
} }

View File

@@ -3,9 +3,9 @@
use std::io::{self, Read, Write}; use std::io::{self, Read, Write};
use ree_pak_core::{ use ree_pak_core::{
filename::{FileNameExt, FileNameFull},
pak::PakArchive, pak::PakArchive,
read::archive::PakArchiveReader, read::archive::PakArchiveReader,
utf16_hash::Utf16HashExt,
write::{FileOptions, PakWriter}, write::{FileOptions, PakWriter},
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -37,11 +37,10 @@ impl PakMetadata {
where where
R: io::Read + io::Seek, R: io::Read + io::Seek,
{ {
let key_name = FileNameFull::new(METADATA_KEY);
let entry = pak_archive let entry = pak_archive
.entries() .entries()
.iter() .iter()
.find(|entry| entry.hash() == key_name.hash_mixed()); .find(|entry| entry.hash() == METADATA_KEY.hash_mixed());
if let Some(entry) = entry { if let Some(entry) = entry {
// read file // read file