Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/wadtools/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,4 @@ camino = "1.1"
convert_case = "0.9.0"
ureq = "2.12"
rayon = "1.10"
dashmap = "6"
51 changes: 43 additions & 8 deletions crates/wadtools/src/commands/extract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use league_toolkit::{file::LeagueFileKind, wad::Wad};

use crate::{
extractor::Extractor,
utils::{create_filter_pattern, WadHashtable},
utils::{create_filter_pattern, format_size, WadHashtable},
};
use convert_case::{Case, Casing};

Expand All @@ -18,6 +18,7 @@ pub struct ExtractArgs {
pub hash: Option<Vec<u64>>,
pub filter_invert: bool,
pub overwrite: bool,
pub show_stats: bool,
}

pub fn extract(args: ExtractArgs, hashtable: &WadHashtable) -> eyre::Result<()> {
Expand All @@ -42,17 +43,51 @@ pub fn extract(args: ExtractArgs, hashtable: &WadHashtable) -> eyre::Result<()>
parent.join(stem)
}
};
let (extracted_count, skipped_existing) =
let stats =
extractor.extract_chunks(&output_dir, args.filter_type.as_deref(), args.overwrite)?;

if skipped_existing > 0 {
tracing::info!(
"extracted {} chunks, skipped {} existing :)",
extracted_count,
skipped_existing
if args.show_stats {
println!();
println!(
"{}: {}",
"WAD".bright_cyan().bold(),
args.input.bright_white()
);
println!(
"{}: {} chunks ({})",
"Extracted".bright_cyan().bold(),
stats.extracted_count.to_string().bright_green(),
format_size(stats.bytes_written).bright_white()
);
println!(
"{}: {} existing",
"Skipped".bright_cyan().bold(),
stats.skipped_existing.to_string().bright_yellow()
);
if !stats.by_type.is_empty() {
println!();
println!("{}:", "By type".bright_cyan().bold());
let mut type_entries: Vec<_> = stats.by_type.iter().collect();
type_entries.sort_by(|a, b| b.1.cmp(a.1));
for (kind, count) in type_entries {
let name = format!("{:?}", kind).to_case(Case::Snake);
println!(
" {:24} {}",
name.bright_magenta(),
count.to_string().bright_white()
);
}
}
} else {
tracing::info!("extracted {} chunks :)", extracted_count);
if stats.skipped_existing > 0 {
tracing::info!(
"extracted {} chunks, skipped {} existing :)",
stats.extracted_count,
stats.skipped_existing
);
} else {
tracing::info!("extracted {} chunks :)", stats.extracted_count);
}
}

Ok(())
Expand Down
18 changes: 1 addition & 17 deletions crates/wadtools/src/commands/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use std::fs::File;

use crate::{
extractor::{should_skip_hash, should_skip_pattern, should_skip_type},
utils::{create_filter_pattern, format_chunk_path_hash, WadHashtable},
utils::{create_filter_pattern, format_chunk_path_hash, format_size, WadHashtable},
};

#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)]
Expand Down Expand Up @@ -228,19 +228,3 @@ fn print_table(output: &ListOutput, show_stats: bool) {
);
}
}

fn format_size(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;

if bytes >= GB {
format!("{:.2} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.2} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.2} KB", bytes as f64 / KB as f64)
} else {
format!("{} B", bytes)
}
}
66 changes: 48 additions & 18 deletions crates/wadtools/src/extractor.rs
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
use crate::utils::{is_hex_chunk_path, truncate_middle, WadHashtable};
use camino::{Utf8Path, Utf8PathBuf};
use color_eyre::eyre::{self, Ok};
use dashmap::DashMap;
use eyre::Context;
use fancy_regex::Regex;
use league_toolkit::{
file::LeagueFileKind,
wad::{decompress_raw, Wad, WadChunk},
};
use std::{
collections::HashMap,
fs::{self, File, OpenOptions},
io::{self, Write},
sync::{
atomic::{AtomicUsize, Ordering},
atomic::{AtomicU64, AtomicUsize, Ordering},
mpsc,
},
};
Expand All @@ -20,8 +22,15 @@ use tracing_indicatif::style::ProgressStyle;

const MAX_LOG_PATH_LEN: usize = 120;

pub struct ExtractStats {
pub extracted_count: usize,
pub skipped_existing: usize,
pub bytes_written: u64,
pub by_type: HashMap<LeagueFileKind, usize>,
}

enum ChunkResult {
Extracted,
Extracted(LeagueFileKind, u64),
SkippedFilter,
SkippedExisting,
}
Expand Down Expand Up @@ -62,7 +71,7 @@ impl<'a> Extractor<'a> {
extract_directory: impl AsRef<Utf8Path>,
filter_type: Option<&[LeagueFileKind]>,
overwrite: bool,
) -> eyre::Result<(usize, usize)> {
) -> eyre::Result<ExtractStats> {
let extract_directory = extract_directory.as_ref().to_path_buf();

let chunks: Vec<WadChunk> = self.wad.chunks().iter().copied().collect();
Expand All @@ -85,6 +94,8 @@ impl<'a> Extractor<'a> {
let counter = AtomicUsize::new(0);
let extracted_counter = AtomicUsize::new(0);
let skipped_existing_counter = AtomicUsize::new(0);
let bytes_written_counter = AtomicU64::new(0);
let by_type: DashMap<LeagueFileKind, usize> = DashMap::new();
let filter_invert = self.filter_invert;
let extract_dir = &extract_directory;
let err_holder: std::sync::Mutex<Option<eyre::Report>> = std::sync::Mutex::new(None);
Expand All @@ -97,6 +108,8 @@ impl<'a> Extractor<'a> {
let counter = &counter;
let extracted_counter = &extracted_counter;
let skipped_existing_counter = &skipped_existing_counter;
let bytes_written_counter = &bytes_written_counter;
let by_type = &by_type;
let err_holder = &err_holder;
let progress_span = &span;

Expand All @@ -112,8 +125,10 @@ impl<'a> Extractor<'a> {
);

match result {
std::result::Result::Ok(ChunkResult::Extracted) => {
std::result::Result::Ok(ChunkResult::Extracted(kind, size)) => {
extracted_counter.fetch_add(1, Ordering::Relaxed);
bytes_written_counter.fetch_add(size, Ordering::Relaxed);
*by_type.entry(kind).or_insert(0) += 1;
}
std::result::Result::Ok(ChunkResult::SkippedExisting) => {
skipped_existing_counter.fetch_add(1, Ordering::Relaxed);
Expand Down Expand Up @@ -185,10 +200,14 @@ impl<'a> Extractor<'a> {
return Err(err);
}

Ok((
extracted_counter.load(Ordering::Relaxed),
skipped_existing_counter.load(Ordering::Relaxed),
))
let by_type_map: HashMap<LeagueFileKind, usize> = by_type.into_iter().collect();

Ok(ExtractStats {
extracted_count: extracted_counter.load(Ordering::Relaxed),
skipped_existing: skipped_existing_counter.load(Ordering::Relaxed),
bytes_written: bytes_written_counter.load(Ordering::Relaxed),
by_type: by_type_map,
})
}
}

Expand Down Expand Up @@ -220,8 +239,14 @@ fn process_chunk(
fs::create_dir_all(parent.as_std_path())?;
}

let size = chunk_data.len() as u64;
match write_chunk_file(full_path.as_std_path(), &chunk_data, overwrite) {
std::result::Result::Ok(result) => return Ok(result),
std::result::Result::Ok(ChunkWriteResult::Written) => {
return Ok(ChunkResult::Extracted(chunk_kind, size));
}
std::result::Result::Ok(ChunkWriteResult::SkippedExisting) => {
return Ok(ChunkResult::SkippedExisting);
}
Err(error) if error.kind() == io::ErrorKind::InvalidFilename => {
return write_long_filename_chunk(
chunk,
Expand All @@ -241,27 +266,32 @@ fn process_chunk(
}
}

enum ChunkWriteResult {
Written,
SkippedExisting,
}

/// Writes chunk data to a file. When `overwrite` is false, uses `create_new(true)` for an
/// atomic existence check, returning `SkippedExisting` on `AlreadyExists`. This avoids the
/// TOCTOU race of a separate exists() check followed by write().
fn write_chunk_file(
path: &std::path::Path,
data: &[u8],
overwrite: bool,
) -> io::Result<ChunkResult> {
) -> io::Result<ChunkWriteResult> {
if overwrite {
fs::write(path, data)?;
return std::result::Result::Ok(ChunkResult::Extracted);
return std::result::Result::Ok(ChunkWriteResult::Written);
}

match OpenOptions::new().write(true).create_new(true).open(path) {
std::result::Result::Ok(mut file) => {
file.write_all(data)?;
std::result::Result::Ok(ChunkResult::Extracted)
std::result::Result::Ok(ChunkWriteResult::Written)
}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
tracing::debug!("skipping existing file: {}", path.display());
std::result::Result::Ok(ChunkResult::SkippedExisting)
std::result::Result::Ok(ChunkWriteResult::SkippedExisting)
}
Err(e) => Err(e),
}
Expand Down Expand Up @@ -358,11 +388,11 @@ fn write_long_filename_chunk(
&hashed_path
);

Ok(write_chunk_file(
full_path.as_std_path(),
chunk_data,
overwrite,
)?)
let size = chunk_data.len() as u64;
match write_chunk_file(full_path.as_std_path(), chunk_data, overwrite)? {
ChunkWriteResult::Written => Ok(ChunkResult::Extracted(chunk_kind, size)),
ChunkWriteResult::SkippedExisting => Ok(ChunkResult::SkippedExisting),
}
}

#[cfg(test)]
Expand Down
6 changes: 6 additions & 0 deletions crates/wadtools/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,10 @@ pub enum Commands {
/// Overwrite existing files (default: skip existing)
#[arg(long)]
overwrite: bool,

/// Show summary statistics after extraction: true/false (default: true). Example: --stats=false
#[arg(short = 's', long, value_name = "true|false", default_missing_value = "true", num_args = 0..=1, default_value_t = true)]
stats: bool,
},
/// Compare two wad files
///
Expand Down Expand Up @@ -254,6 +258,7 @@ fn main() -> eyre::Result<()> {
filter_invert,
list_filters,
overwrite,
stats,
} => {
if list_filters {
print_supported_filters();
Expand All @@ -276,6 +281,7 @@ fn main() -> eyre::Result<()> {
hash: hash_filter.clone(),
filter_invert,
overwrite,
show_stats: stats,
},
&ht,
)?;
Expand Down
16 changes: 16 additions & 0 deletions crates/wadtools/src/utils/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,22 @@ pub fn default_hashtable_dir() -> Option<Utf8PathBuf> {
None
}

pub fn format_size(bytes: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;

if bytes >= GB {
format!("{:.2} GB", bytes as f64 / GB as f64)
} else if bytes >= MB {
format!("{:.2} MB", bytes as f64 / MB as f64)
} else if bytes >= KB {
format!("{:.2} KB", bytes as f64 / KB as f64)
} else {
format!("{} B", bytes)
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down