diff --git a/crates/wadtools/src/commands/extract.rs b/crates/wadtools/src/commands/extract.rs index c721869..a32b3a7 100644 --- a/crates/wadtools/src/commands/extract.rs +++ b/crates/wadtools/src/commands/extract.rs @@ -15,6 +15,7 @@ pub struct ExtractArgs { pub output: Option, pub filter_type: Option>, pub pattern: Option, + pub hash: Option>, pub filter_invert: bool, pub overwrite: bool, } @@ -29,6 +30,7 @@ pub fn extract(args: ExtractArgs, hashtable: &WadHashtable) -> eyre::Result<()> let filter_pattern = create_filter_pattern(args.pattern)?; extractor.set_filter_pattern(filter_pattern); + extractor.set_hash_filter(args.hash); extractor.set_filter_invert(args.filter_invert); let output_dir: Utf8PathBuf = match &args.output { Some(path) => Utf8PathBuf::from(path.as_str()), diff --git a/crates/wadtools/src/commands/list.rs b/crates/wadtools/src/commands/list.rs index 13485e7..2ab4854 100644 --- a/crates/wadtools/src/commands/list.rs +++ b/crates/wadtools/src/commands/list.rs @@ -5,7 +5,7 @@ use serde::Serialize; use std::fs::File; use crate::{ - extractor::{should_skip_pattern, should_skip_type}, + extractor::{should_skip_hash, should_skip_pattern, should_skip_type}, utils::{create_filter_pattern, format_chunk_path_hash, WadHashtable}, }; @@ -26,6 +26,7 @@ pub struct ListArgs { pub input: String, pub filter_type: Option>, pub pattern: Option, + pub hash: Option>, pub filter_invert: bool, pub format: ListOutputFormat, pub show_stats: bool, @@ -65,6 +66,11 @@ pub fn list(args: ListArgs, hashtable: &WadHashtable) -> eyre::Result<()> { let mut total_uncompressed: u64 = 0; for chunk in wad.chunks() { + // Apply hash filter (cheapest check, before resolving path) + if should_skip_hash(chunk.path_hash, args.hash.as_deref(), args.filter_invert) { + continue; + } + let path_str = hashtable.resolve_path(chunk.path_hash); // Apply pattern filter diff --git a/crates/wadtools/src/extractor.rs b/crates/wadtools/src/extractor.rs index 619190b..e4803ba 100644 --- a/crates/wadtools/src/extractor.rs +++ b/crates/wadtools/src/extractor.rs @@ -30,6 +30,7 @@ pub struct Extractor<'a> { wad: &'a mut Wad, hashtable: &'a WadHashtable, filter_pattern: Option, + hash_filter: Option>, filter_invert: bool, } @@ -39,6 +40,7 @@ impl<'a> Extractor<'a> { wad, hashtable, filter_pattern: None, + hash_filter: None, filter_invert: false, } } @@ -47,6 +49,10 @@ impl<'a> Extractor<'a> { self.filter_pattern = filter_pattern; } + pub fn set_hash_filter(&mut self, hash_filter: Option>) { + self.hash_filter = hash_filter; + } + pub fn set_filter_invert(&mut self, filter_invert: bool) { self.filter_invert = filter_invert; } @@ -135,6 +141,17 @@ impl<'a> Extractor<'a> { break; } + // Hash filter is the cheapest check — do it before resolving path + if should_skip_hash( + chunk.path_hash, + self.hash_filter.as_deref(), + self.filter_invert, + ) { + let done = counter.fetch_add(1, Ordering::Relaxed) + 1; + span.pb_set_position(done as u64); + continue; + } + let chunk_path_str = self.hashtable.resolve_path(chunk.path_hash); span.pb_set_message(&truncate_middle(chunk_path_str.as_ref(), MAX_LOG_PATH_LEN)); @@ -300,6 +317,15 @@ pub(crate) fn should_skip_pattern( false } +/// Returns true if the chunk should be skipped based on the hash filter. +pub(crate) fn should_skip_hash( + path_hash: u64, + hash_filter: Option<&[u64]>, + filter_invert: bool, +) -> bool { + hash_filter.is_some_and(|hashes| hashes.contains(&path_hash) == filter_invert) +} + /// Returns true if the chunk should be skipped based on the type filter. pub(crate) fn should_skip_type( chunk_kind: LeagueFileKind, diff --git a/crates/wadtools/src/main.rs b/crates/wadtools/src/main.rs index c816b26..9e2634f 100644 --- a/crates/wadtools/src/main.rs +++ b/crates/wadtools/src/main.rs @@ -119,6 +119,10 @@ pub enum Commands { )] pattern: Option, + /// Only include chunks whose path hash matches one of these 16-char hex values + #[arg(long, value_name = "HASH", num_args = 1..)] + hash: Option>, + /// Invert the -f and -x filters (exclude matching files instead of including them) #[arg(short = 'v', long = "filter-invert")] filter_invert: bool, @@ -186,6 +190,10 @@ pub enum Commands { )] pattern: Option, + /// Only include chunks whose path hash matches one of these 16-char hex values + #[arg(long, value_name = "HASH", num_args = 1..)] + hash: Option>, + /// Invert the -f and -x filters (exclude matching files instead of including them) #[arg(short = 'v', long = "filter-invert")] filter_invert: bool, @@ -242,6 +250,7 @@ fn main() -> eyre::Result<()> { hashtable, filter_type, pattern, + hash, filter_invert, list_filters, overwrite, @@ -256,6 +265,7 @@ fn main() -> eyre::Result<()> { } let hashtable_dir = args.hashtable_dir.or_else(|| config.hashtable_dir.clone()); let ht = load_hashtable(hashtable_dir.as_deref(), hashtable.as_deref())?; + let hash_filter = parse_hashes(hash)?; for path in resolved { extract( ExtractArgs { @@ -263,6 +273,7 @@ fn main() -> eyre::Result<()> { output: output.clone(), filter_type: filter_type.clone(), pattern: pattern.clone(), + hash: hash_filter.clone(), filter_invert, overwrite, }, @@ -296,6 +307,7 @@ fn main() -> eyre::Result<()> { hashtable, filter_type, pattern, + hash, filter_invert, format, stats, @@ -306,12 +318,14 @@ fn main() -> eyre::Result<()> { } let hashtable_dir = args.hashtable_dir.or_else(|| config.hashtable_dir.clone()); let ht = load_hashtable(hashtable_dir.as_deref(), hashtable.as_deref())?; + let hash_filter = parse_hashes(hash)?; for path in resolved { list( ListArgs { input: path, filter_type: filter_type.clone(), pattern: pattern.clone(), + hash: hash_filter.clone(), filter_invert, format, show_stats: stats, @@ -422,6 +436,23 @@ fn parse_filter_type(s: &str) -> Result { } } +fn parse_hashes(raw: Option>) -> eyre::Result>> { + let Some(strings) = raw else { + return Ok(None); + }; + let mut hashes = Vec::with_capacity(strings.len()); + for s in &strings { + let h = u64::from_str_radix(s, 16).map_err(|_| { + eyre::eyre!( + "invalid hash '{}': expected a 16-character hexadecimal value (e.g. 0a1b2c3d4e5f6789)", + s + ) + })?; + hashes.push(h); + } + Ok(Some(hashes)) +} + fn cli_styles() -> Styles { Styles::styled() .header(AnsiColor::Yellow.on_default().bold())