diff --git a/.markdownlint.yaml b/.markdownlint.yaml index e5b2ce7..d315e36 100644 --- a/.markdownlint.yaml +++ b/.markdownlint.yaml @@ -32,7 +32,6 @@ MD040: [ 'bash', 'c', - 'cpp', 'cmake', 'diff', 'dockerfile', diff --git a/CHANGELOG.md b/CHANGELOG.md index 811c7e5..99c57c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **wow-m2**: Added texture filename parsing functionality - **wow-m2**: Added support for old skin format in the skin-info command - **warcraft-rs**: Added `cargo-deny` configuration for dependency security scanning and duplicate management +- **wow-mpq**: SQLite database support for persistent MPQ filename hash storage and resolution +- **wow-mpq**: Support for both traditional MPQ hashes (hash_a, hash_b) and HET hashes (40/48/56/64 bit) +- **wow-mpq**: Database import functionality supporting listfiles, MPQ archives, and directory scanning +- **wow-mpq**: Automatic filename resolution through database lookup during archive listing operations +- **wow-mpq**: Database connection management with automatic schema initialization +- **wow-mpq**: Batch processing for efficient bulk filename imports +- **warcraft-rs CLI**: New `mpq db` subcommand with status, import, analyze, lookup, export, and list operations +- **warcraft-rs CLI**: `--use-db` flag for `mpq list` to enable database lookups for unknown filenames +- **warcraft-rs CLI**: `--record-to-db` flag for `mpq list` to store discovered filenames in database ### Changed @@ -32,6 +41,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **storm-ffi**: Archive handles now support both read-only and mutable operations with internal enum-based dispatch - **wow-mpq**: Enhanced attributes file block count detection with automatic fallback logic - **wow-mpq**: `MutableArchive` now provides convenience methods for common read operations +- **wow-m2**: Replaced custom BLP texture implementation with dependency on `wow-blp` crate +- **wow-m2**: `BlpTexture` is now a re-export of `wow_blp::BlpImage` for backwards compatibility ### Fixed @@ -44,6 +55,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **wow-m2**: Fixed BLP texture parsing to properly handle JPEG header information and palette data - **wow-mpq**: Applied rustfmt formatting fixes and resolved clippy warnings for cleaner code +### Removed + +- **wow-m2**: Custom BLP parsing implementation (moved to using `wow-blp` crate instead) + ## [0.2.0](https://github.com/wowemulation-dev/warcraft-rs/releases/tag/v0.2.0) - 2025-06-28 ### Added diff --git a/Cargo.lock b/Cargo.lock index 24f30e2..fcc4a17 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,6 +14,18 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.3" @@ -658,6 +670,15 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "directories" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a49173b84e034382284f27f1af4dcbbd231ffa358c0fe316541a7337f376a35" +dependencies = [ + "dirs-sys", +] + [[package]] name = "dirs-next" version = "2.0.0" @@ -668,6 +689,18 @@ dependencies = [ "dirs-sys-next", ] +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "dirs-sys-next" version = "0.1.2" @@ -771,6 +804,18 @@ dependencies = [ "zune-inflate", ] +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + [[package]] name = "fastrand" version = "2.3.0" @@ -899,9 +944,9 @@ dependencies = [ [[package]] name = "glam" -version = "0.30.4" +version = "0.30.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50a99dbe56b72736564cfa4b85bf9a33079f16ae8b74983ab06af3b1a3696b11" +checksum = "f2d1aab06663bdce00d6ca5e5ed586ec8d18033a771906c993a1e3755b368d85" dependencies = [ "serde", ] @@ -922,12 +967,30 @@ dependencies = [ "crunchy", ] +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + [[package]] name = "hashbrown" version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + [[package]] name = "heck" version = "0.5.0" @@ -1025,7 +1088,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.15.4", ] [[package]] @@ -1205,6 +1268,17 @@ dependencies = [ "libc", ] +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linux-raw-sys" version = "0.9.4" @@ -1437,6 +1511,12 @@ version = "11.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "overload" version = "0.1.1" @@ -1981,6 +2061,20 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.9.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -2556,6 +2650,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.0" @@ -2810,6 +2910,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -2828,6 +2937,21 @@ dependencies = [ "windows-targets 0.53.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -2860,6 +2984,12 @@ dependencies = [ "windows_x86_64_msvc 0.53.0", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -2872,6 +3002,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -2884,6 +3020,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -2908,6 +3050,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -2920,6 +3068,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -2932,6 +3086,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -2944,6 +3104,12 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -3043,6 +3209,7 @@ dependencies = [ "tempfile", "test-case", "thiserror 2.0.12", + "wow-blp", ] [[package]] @@ -3053,10 +3220,13 @@ dependencies = [ "assert_cmd", "bytes", "bzip2", + "chrono", "crc32fast", "criterion", + "directories", "env_logger", "flate2", + "glob", "implode", "log", "lzma-rs", @@ -3070,6 +3240,7 @@ dependencies = [ "rand 0.9.2", "rayon", "rsa", + "rusqlite", "sha1", "tempfile", "thiserror 2.0.12", diff --git a/file-formats/archives/wow-mpq/Cargo.toml b/file-formats/archives/wow-mpq/Cargo.toml index bf99994..cb40a5a 100644 --- a/file-formats/archives/wow-mpq/Cargo.toml +++ b/file-formats/archives/wow-mpq/Cargo.toml @@ -43,6 +43,12 @@ tempfile = { workspace = true } # Parallel processing rayon = "1.10" +# Database support +rusqlite = { version = "0.32", features = ["bundled"] } +directories = "5.0" +chrono = "0.4" +glob = "0.3" + [dev-dependencies] criterion = { workspace = true } diff --git a/file-formats/archives/wow-mpq/src/archive.rs b/file-formats/archives/wow-mpq/src/archive.rs index 23e7f48..ba3b775 100644 --- a/file-formats/archives/wow-mpq/src/archive.rs +++ b/file-formats/archives/wow-mpq/src/archive.rs @@ -1567,6 +1567,59 @@ impl Archive { Ok(entries) } + /// List files in the archive with database lookup for names + pub fn list_with_db(&mut self, db: &crate::database::Database) -> Result> { + use crate::database::HashLookup; + + // Get all entries with hashes + let mut entries = self.list_all_with_hashes()?; + + // Look up names in database + for entry in &mut entries { + if let Some((hash_a, hash_b)) = entry.hashes { + if let Ok(Some(filename)) = db.lookup_filename(hash_a, hash_b) { + entry.name = filename; + } + } + } + + Ok(entries) + } + + /// Record all filenames from the archive's listfile to the database + pub fn record_listfile_to_db(&mut self, db: &crate::database::Database) -> Result { + use crate::database::HashLookup; + + // Try to find and read (listfile) + if let Some(_listfile_info) = self.find_file("(listfile)")? { + if let Ok(listfile_data) = self.read_file("(listfile)") { + if let Ok(filenames) = special_files::parse_listfile(&listfile_data) { + // Record all filenames from listfile to database + let source = format!("archive:{}", self.path.display()); + let filenames_with_source: Vec<(&str, Option<&str>)> = filenames + .iter() + .map(|f| (f.as_str(), Some(source.as_str()))) + .collect(); + + match db.store_filenames(&filenames_with_source) { + Ok((new_count, updated_count)) => { + log::info!( + "Recorded {new_count} new and {updated_count} updated filenames from listfile to database" + ); + return Ok(new_count + updated_count); + } + Err(e) => { + log::error!("Failed to store filenames in database: {e}"); + return Err(Error::Crypto(format!("Database error: {e}"))); + } + } + } + } + } + + Ok(0) + } + /// List all files in the archive by enumerating tables with hash information pub fn list_all_with_hashes(&mut self) -> Result> { let mut entries = Vec::new(); diff --git a/file-formats/archives/wow-mpq/src/database/connection.rs b/file-formats/archives/wow-mpq/src/database/connection.rs new file mode 100644 index 0000000..67399aa --- /dev/null +++ b/file-formats/archives/wow-mpq/src/database/connection.rs @@ -0,0 +1,92 @@ +//! Database connection management + +use directories::ProjectDirs; +use rusqlite::{Connection, Result as SqlResult}; +use std::path::{Path, PathBuf}; +use thiserror::Error; + +use super::schema; + +/// Errors that can occur during database operations +#[derive(Error, Debug)] +pub enum DatabaseError { + /// SQLite database error + #[error("SQLite error: {0}")] + Sqlite(#[from] rusqlite::Error), + + /// Filesystem I/O error + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + /// User's home directory could not be determined + #[error("Home directory not found")] + NoHomeDirectory, +} + +pub(super) type Result = std::result::Result; + +/// Database connection wrapper +#[derive(Debug)] +pub struct Database { + conn: Connection, + path: PathBuf, +} + +impl Database { + /// Open or create a database at the default location + pub fn open_default() -> Result { + let path = Self::default_path()?; + Self::open(&path) + } + + /// Open or create a database at the specified path + pub fn open(path: &Path) -> Result { + // Ensure parent directory exists + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + + let conn = Connection::open(path)?; + schema::init_schema(&conn)?; + + Ok(Self { + conn, + path: path.to_path_buf(), + }) + } + + /// Get the default database path + pub fn default_path() -> Result { + if let Some(proj_dirs) = ProjectDirs::from("network", "kogito", "warcraft-rs") { + let data_dir = proj_dirs.data_dir(); + Ok(data_dir.join("mpq-hashes.db")) + } else { + Err(DatabaseError::NoHomeDirectory) + } + } + + /// Get a reference to the underlying connection + pub fn connection(&self) -> &Connection { + &self.conn + } + + /// Get a mutable reference to the underlying connection + pub fn connection_mut(&mut self) -> &mut Connection { + &mut self.conn + } + + /// Get the database file path + pub fn path(&self) -> &Path { + &self.path + } + + /// Begin a transaction + pub fn transaction(&mut self) -> SqlResult> { + self.conn.transaction() + } + + /// Execute a batch of SQL statements + pub fn execute_batch(&self, sql: &str) -> SqlResult<()> { + self.conn.execute_batch(sql) + } +} diff --git a/file-formats/archives/wow-mpq/src/database/import.rs b/file-formats/archives/wow-mpq/src/database/import.rs new file mode 100644 index 0000000..39e01ec --- /dev/null +++ b/file-formats/archives/wow-mpq/src/database/import.rs @@ -0,0 +1,232 @@ +//! Import functionality for populating the database + +use glob; +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::Path; +use thiserror::Error; + +use super::{Database, DatabaseError}; + +use crate::Archive; +use crate::database::HashLookup; + +#[derive(Error, Debug)] +pub enum ImportError { + #[error("Database error: {0}")] + Database(#[from] DatabaseError), + + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + #[error("Archive error: {0}")] + Archive(#[from] crate::Error), +} + +pub(super) type ImportResult = std::result::Result; + +/// Source types for importing filenames +#[derive(Debug, Clone, Copy)] +pub enum ImportSource { + /// Import from a listfile (text file with one filename per line) + Listfile, + /// Import from an MPQ archive's internal listfile + Archive, + /// Scan a directory for filenames matching WoW patterns + Directory, +} + +/// Import statistics +#[derive(Debug, Default)] +pub struct ImportStats { + pub files_processed: usize, + pub new_entries: usize, + pub updated_entries: usize, + pub errors: usize, +} + +/// Filename importer +#[derive(Debug)] +pub struct Importer<'a> { + db: &'a Database, +} + +impl<'a> Importer<'a> { + /// Create a new importer + pub fn new(db: &'a Database) -> Self { + Self { db } + } + + /// Import filenames from a source + pub fn import(&self, path: &Path, source_type: ImportSource) -> ImportResult { + match source_type { + ImportSource::Listfile => self.import_listfile(path), + ImportSource::Archive => self.import_archive(path), + ImportSource::Directory => self.import_directory(path), + } + } + + /// Import from a listfile + fn import_listfile(&self, path: &Path) -> ImportResult { + let mut stats = ImportStats::default(); + let file = File::open(path)?; + let reader = BufReader::new(file); + let source = format!("listfile:{}", path.display()); + + let mut batch = Vec::new(); + + for line in reader.lines() { + stats.files_processed += 1; + + match line { + Ok(filename) => { + let filename = filename.trim(); + if !filename.is_empty() && !filename.starts_with('#') { + batch.push((filename.to_string(), Some(source.clone()))); + + // Process in batches + if batch.len() >= 1000 { + match self.process_batch(&mut batch, &mut stats) { + Ok(_) => {} + Err(e) => { + log::error!("Error processing batch: {e}"); + stats.errors += batch.len(); + } + } + } + } + } + Err(e) => { + log::error!("Error reading line: {e}"); + stats.errors += 1; + } + } + } + + // Process remaining + if !batch.is_empty() { + self.process_batch(&mut batch, &mut stats)?; + } + + Ok(stats) + } + + /// Import from an MPQ archive + fn import_archive(&self, path: &Path) -> ImportResult { + let mut stats = ImportStats::default(); + let mut archive = Archive::open(path)?; + let source = format!("archive:{}", path.display()); + + // Get list of files from the archive + let files = archive.list()?; + stats.files_processed = files.len(); + + let mut batch = Vec::new(); + + for file in files { + if !file.name.is_empty() && !file.name.starts_with('#') { + batch.push((file.name, Some(source.clone()))); + + if batch.len() >= 1000 { + self.process_batch(&mut batch, &mut stats)?; + } + } + } + + // Process remaining + if !batch.is_empty() { + self.process_batch(&mut batch, &mut stats)?; + } + + Ok(stats) + } + + /// Import from a directory (scan for WoW file patterns) + fn import_directory(&self, path: &Path) -> ImportResult { + let mut stats = ImportStats::default(); + let source = format!("directory:{}", path.display()); + + // Common WoW file patterns + let patterns = [ + "**/*.blp", + "**/*.m2", + "**/*.wmo", + "**/*.adt", + "**/*.wdt", + "**/*.wdl", + "**/*.dbc", + "**/*.db2", + "**/*.anim", + "**/*.skin", + "**/*.bone", + "**/*.phys", + "**/*.skel", + "**/*.mp3", + "**/*.ogg", + "**/*.wav", + "**/*.txt", + "**/*.xml", + "**/*.lua", + "**/*.toc", + "**/*.zmp", + "**/*.bls", + "**/*.wfx", + "**/*.lit", + ]; + + let mut batch = Vec::new(); + + for pattern in &patterns { + let pattern_path = path.join(pattern); + if let Ok(entries) = glob::glob(&pattern_path.to_string_lossy()) { + for entry in entries.filter_map(std::result::Result::ok) { + stats.files_processed += 1; + + // Convert to relative path from the base directory + if let Ok(relative) = entry.strip_prefix(path) { + let filename = relative.to_string_lossy().replace('/', "\\"); + batch.push((filename.to_string(), Some(source.clone()))); + + if batch.len() >= 1000 { + self.process_batch(&mut batch, &mut stats)?; + } + } + } + } + } + + // Process remaining + if !batch.is_empty() { + self.process_batch(&mut batch, &mut stats)?; + } + + Ok(stats) + } + + /// Process a batch of filenames + fn process_batch( + &self, + batch: &mut Vec<(String, Option)>, + stats: &mut ImportStats, + ) -> ImportResult<()> { + let filenames: Vec<(&str, Option<&str>)> = batch + .iter() + .map(|(f, s)| (f.as_str(), s.as_deref())) + .collect(); + + match self.db.store_filenames(&filenames) { + Ok((new_count, updated_count)) => { + stats.new_entries += new_count; + stats.updated_entries += updated_count; + } + Err(e) => { + log::error!("Error storing batch: {e}"); + stats.errors += batch.len(); + return Err(e.into()); + } + } + + batch.clear(); + Ok(()) + } +} diff --git a/file-formats/archives/wow-mpq/src/database/lookup.rs b/file-formats/archives/wow-mpq/src/database/lookup.rs new file mode 100644 index 0000000..19e942c --- /dev/null +++ b/file-formats/archives/wow-mpq/src/database/lookup.rs @@ -0,0 +1,288 @@ +//! Hash lookup functionality + +use rusqlite::{OptionalExtension, params}; + +use super::Database; +use crate::database::{calculate_het_hashes, calculate_mpq_hashes}; + +type Result = crate::database::connection::Result; + +/// Trait for traditional MPQ hash lookup operations +pub trait HashLookup { + /// Look up a filename by its traditional MPQ hash values + fn lookup_filename(&self, hash_a: u32, hash_b: u32) -> Result>; + + /// Look up multiple filenames by their hash values + fn lookup_filenames(&self, hashes: &[(u32, u32)]) -> Result)>>; + + /// Store a filename and calculate all its hashes + fn store_filename(&self, filename: &str, source: Option<&str>) -> Result<()>; + + /// Store multiple filenames and return (new_entries, updated_entries) + fn store_filenames(&self, filenames: &[(&str, Option<&str>)]) -> Result<(usize, usize)>; + + /// Check if a filename exists in the database + fn filename_exists(&self, filename: &str) -> Result; + + /// Get statistics about unknown hashes in an archive + fn get_unknown_hashes(&self, hashes: &[(u32, u32)]) -> Result>; +} + +/// Trait for HET hash lookup operations +pub trait HetHashLookup { + /// Look up a filename by its HET hash values + fn lookup_filename_het( + &self, + file_hash: u64, + name_hash: u64, + hash_bits: u8, + ) -> Result>; + + /// Look up multiple filenames by their HET hash values + fn lookup_filenames_het( + &self, + hashes: &[(u64, u64)], + hash_bits: u8, + ) -> Result)>>; +} + +impl HashLookup for Database { + fn lookup_filename(&self, hash_a: u32, hash_b: u32) -> Result> { + let mut stmt = self + .connection() + .prepare("SELECT filename FROM filenames WHERE hash_a = ?1 AND hash_b = ?2 LIMIT 1")?; + + Ok(stmt + .query_row(params![hash_a, hash_b], |row| row.get(0)) + .optional()?) + } + + fn lookup_filenames(&self, hashes: &[(u32, u32)]) -> Result)>> { + if hashes.is_empty() { + return Ok(Vec::new()); + } + + let mut results = Vec::with_capacity(hashes.len()); + let conn = self.connection(); + + // Use a prepared statement for efficiency + let mut stmt = conn + .prepare("SELECT filename FROM filenames WHERE hash_a = ?1 AND hash_b = ?2 LIMIT 1")?; + + for &(hash_a, hash_b) in hashes { + let filename: Option = stmt + .query_row(params![hash_a, hash_b], |row| row.get(0)) + .optional()?; + results.push((hash_a, hash_b, filename)); + } + + Ok(results) + } + + fn store_filename(&self, filename: &str, source: Option<&str>) -> Result<()> { + let (hash_a, hash_b, hash_offset) = calculate_mpq_hashes(filename); + + // Calculate HET hashes for common bit sizes + let het_40 = calculate_het_hashes(filename, 40); + let het_48 = calculate_het_hashes(filename, 48); + let het_56 = calculate_het_hashes(filename, 56); + let het_64 = calculate_het_hashes(filename, 64); + + self.connection().execute( + "INSERT OR REPLACE INTO filenames ( + filename, hash_a, hash_b, hash_offset, + het_hash_40_file, het_hash_40_name, + het_hash_48_file, het_hash_48_name, + het_hash_56_file, het_hash_56_name, + het_hash_64_file, het_hash_64_name, + source + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + params![ + filename, + hash_a, + hash_b, + hash_offset, + het_40.0 as i64, + het_40.1 as i64, + het_48.0 as i64, + het_48.1 as i64, + het_56.0 as i64, + het_56.1 as i64, + het_64.0 as i64, + het_64.1 as i64, + source + ], + )?; + + Ok(()) + } + + fn store_filenames(&self, filenames: &[(&str, Option<&str>)]) -> Result<(usize, usize)> { + if filenames.is_empty() { + return Ok((0, 0)); + } + + let conn = self.connection(); + let mut new_entries = 0; + let mut updated_entries = 0; + + // Check existing entries first + let mut check_stmt = conn.prepare("SELECT 1 FROM filenames WHERE filename = ?1")?; + + // Use a prepared statement for insert/update + let mut stmt = conn.prepare( + "INSERT OR REPLACE INTO filenames ( + filename, hash_a, hash_b, hash_offset, + het_hash_40_file, het_hash_40_name, + het_hash_48_file, het_hash_48_name, + het_hash_56_file, het_hash_56_name, + het_hash_64_file, het_hash_64_name, + source + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + )?; + + for (filename, source) in filenames { + // Check if entry exists + let exists: bool = check_stmt + .query_row(params![filename], |_| Ok(true)) + .optional()? + .is_some(); + + let (hash_a, hash_b, hash_offset) = calculate_mpq_hashes(filename); + let het_40 = calculate_het_hashes(filename, 40); + let het_48 = calculate_het_hashes(filename, 48); + let het_56 = calculate_het_hashes(filename, 56); + let het_64 = calculate_het_hashes(filename, 64); + + stmt.execute(params![ + filename, + hash_a, + hash_b, + hash_offset, + het_40.0 as i64, + het_40.1 as i64, + het_48.0 as i64, + het_48.1 as i64, + het_56.0 as i64, + het_56.1 as i64, + het_64.0 as i64, + het_64.1 as i64, + source + ])?; + + if exists { + updated_entries += 1; + } else { + new_entries += 1; + } + } + + Ok((new_entries, updated_entries)) + } + + fn filename_exists(&self, filename: &str) -> Result { + let (hash_a, hash_b, _) = calculate_mpq_hashes(filename); + + let count: i64 = self.connection().query_row( + "SELECT COUNT(*) FROM filenames WHERE hash_a = ?1 AND hash_b = ?2", + params![hash_a, hash_b], + |row| row.get(0), + )?; + + Ok(count > 0) + } + + fn get_unknown_hashes(&self, hashes: &[(u32, u32)]) -> Result> { + let mut unknown = Vec::new(); + + for &(hash_a, hash_b) in hashes { + if self.lookup_filename(hash_a, hash_b)?.is_none() { + unknown.push((hash_a, hash_b)); + } + } + + Ok(unknown) + } +} + +impl HetHashLookup for Database { + fn lookup_filename_het( + &self, + file_hash: u64, + name_hash: u64, + hash_bits: u8, + ) -> Result> { + let column_file = match hash_bits { + 40 => "het_hash_40_file", + 48 => "het_hash_48_file", + 56 => "het_hash_56_file", + 64 => "het_hash_64_file", + _ => return Ok(None), // Unsupported bit size + }; + + let column_name = match hash_bits { + 40 => "het_hash_40_name", + 48 => "het_hash_48_name", + 56 => "het_hash_56_name", + 64 => "het_hash_64_name", + _ => return Ok(None), + }; + + let query = format!( + "SELECT filename FROM filenames WHERE {column_file} = ?1 AND {column_name} = ?2 LIMIT 1" + ); + + let mut stmt = self.connection().prepare(&query)?; + + Ok(stmt + .query_row(params![file_hash as i64, name_hash as i64], |row| { + row.get(0) + }) + .optional()?) + } + + fn lookup_filenames_het( + &self, + hashes: &[(u64, u64)], + hash_bits: u8, + ) -> Result)>> { + if hashes.is_empty() { + return Ok(Vec::new()); + } + + let column_file = match hash_bits { + 40 => "het_hash_40_file", + 48 => "het_hash_48_file", + 56 => "het_hash_56_file", + 64 => "het_hash_64_file", + _ => return Ok(vec![(0, 0, None); hashes.len()]), + }; + + let column_name = match hash_bits { + 40 => "het_hash_40_name", + 48 => "het_hash_48_name", + 56 => "het_hash_56_name", + 64 => "het_hash_64_name", + _ => return Ok(vec![(0, 0, None); hashes.len()]), + }; + + let mut results = Vec::with_capacity(hashes.len()); + let conn = self.connection(); + + let query = format!( + "SELECT filename FROM filenames WHERE {column_file} = ?1 AND {column_name} = ?2 LIMIT 1" + ); + let mut stmt = conn.prepare(&query)?; + + for &(file_hash, name_hash) in hashes { + let filename: Option = stmt + .query_row(params![file_hash as i64, name_hash as i64], |row| { + row.get(0) + }) + .optional()?; + results.push((file_hash, name_hash, filename)); + } + + Ok(results) + } +} diff --git a/file-formats/archives/wow-mpq/src/database/mod.rs b/file-formats/archives/wow-mpq/src/database/mod.rs new file mode 100644 index 0000000..36dfcd0 --- /dev/null +++ b/file-formats/archives/wow-mpq/src/database/mod.rs @@ -0,0 +1,35 @@ +//! Database functionality for storing and retrieving MPQ filename-to-hash mappings +//! +//! This module provides a persistent SQLite database that records relationships between +//! filenames and their MPQ hash values, enabling file resolution even when archives +//! lack internal listfiles. + +mod connection; +mod import; +mod lookup; +mod models; +mod schema; + +pub use connection::{Database, DatabaseError}; +pub use import::{ImportSource, Importer}; +pub use lookup::{HashLookup, HetHashLookup}; +pub use models::{ArchiveRecord, FileRecord, HashType}; + +use crate::crypto::{hash_string, hash_type, het_hash}; + +/// Calculate traditional MPQ hash values for a filename +pub fn calculate_mpq_hashes(filename: &str) -> (u32, u32, u32) { + let normalized = filename.replace('/', "\\").to_uppercase(); + let hash_a = hash_string(&normalized, hash_type::NAME_A); + let hash_b = hash_string(&normalized, hash_type::NAME_B); + let hash_offset = hash_string(&normalized, hash_type::TABLE_OFFSET); + (hash_a, hash_b, hash_offset) +} + +/// Calculate HET (Hash Extended Table) hash values for a filename +/// Returns (file_hash, name_hash) used by HET tables +pub fn calculate_het_hashes(filename: &str, hash_bits: u8) -> (u64, u64) { + let normalized = filename.replace('/', "\\"); + let (file_hash, name_hash_u8) = het_hash(&normalized, hash_bits as u32); + (file_hash, name_hash_u8 as u64) +} diff --git a/file-formats/archives/wow-mpq/src/database/models.rs b/file-formats/archives/wow-mpq/src/database/models.rs new file mode 100644 index 0000000..28ffa49 --- /dev/null +++ b/file-formats/archives/wow-mpq/src/database/models.rs @@ -0,0 +1,79 @@ +//! Database models and data structures + +use chrono::{DateTime, Utc}; + +/// Type of hash used in the archive +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HashType { + /// Traditional MPQ hash table + Traditional, + /// HET (Hash Extended Table) with specified bit size + Het(u8), +} + +/// Represents a filename and all its associated hashes +#[derive(Debug, Clone)] +pub struct FileRecord { + /// Unique database identifier + pub id: Option, + /// The filename as stored in the archive + pub filename: String, + /// Traditional MPQ hash A value + pub hash_a: u32, + /// Traditional MPQ hash B value + pub hash_b: u32, + /// Traditional MPQ hash offset value + pub hash_offset: u32, + /// HET 40-bit hash pair (file_hash, name_hash) + pub het_hash_40: Option<(u64, u64)>, + /// HET 48-bit hash pair (file_hash, name_hash) + pub het_hash_48: Option<(u64, u64)>, + /// HET 56-bit hash pair (file_hash, name_hash) + pub het_hash_56: Option<(u64, u64)>, + /// HET 64-bit hash pair (file_hash, name_hash) + pub het_hash_64: Option<(u64, u64)>, + /// Source of the filename (e.g., archive path, listfile) + pub source: Option, + /// Timestamp when the record was created + pub created_at: DateTime, +} + +/// Represents an analyzed MPQ archive +#[derive(Debug, Clone)] +pub struct ArchiveRecord { + /// Unique database identifier + pub id: Option, + /// Path to the MPQ archive file + pub archive_path: String, + /// Hash of the archive file for integrity checking + pub archive_hash: Option, + /// Timestamp when the archive was analyzed + pub analysis_date: DateTime, + /// MPQ format version (1, 2, 3, or 4) + pub mpq_version: Option, + /// Number of files in the archive + pub file_count: Option, +} + +/// Represents a file found in an archive (unused, for future functionality) +#[allow(dead_code)] +#[derive(Debug, Clone)] +pub(super) struct ArchiveFileRecord { + pub archive_id: i64, + pub hash_a: u32, + pub hash_b: u32, + pub file_size: Option, + pub compressed_size: Option, + pub flags: Option, + pub filename_id: Option, +} + +/// Statistics about the database (unused, for future functionality) +#[allow(dead_code)] +#[derive(Debug)] +pub(super) struct DatabaseStats { + pub total_filenames: usize, + pub total_archives: usize, + pub total_archive_files: usize, + pub filenames_by_source: Vec<(String, usize)>, +} diff --git a/file-formats/archives/wow-mpq/src/database/schema.rs b/file-formats/archives/wow-mpq/src/database/schema.rs new file mode 100644 index 0000000..1b1b695 --- /dev/null +++ b/file-formats/archives/wow-mpq/src/database/schema.rs @@ -0,0 +1,135 @@ +//! Database schema and migration management + +use rusqlite::{Connection, Result}; + +/// Current schema version +pub(super) const SCHEMA_VERSION: i32 = 1; + +/// Initialize the database schema +pub(super) fn init_schema(conn: &Connection) -> Result<()> { + // Enable foreign keys + conn.execute("PRAGMA foreign_keys = ON", [])?; + + // Create version table + conn.execute( + "CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )", + [], + )?; + + // Check current version + let current_version: Option = conn + .query_row("SELECT MAX(version) FROM schema_version", [], |row| { + row.get(0) + }) + .unwrap_or(None); + + // Apply migrations + if current_version.is_none() || current_version.unwrap() < 1 { + migrate_v1(conn)?; + } + + Ok(()) +} + +/// Migration to version 1: Initial schema +fn migrate_v1(conn: &Connection) -> Result<()> { + // Unified table for all filename-hash mappings + conn.execute( + "CREATE TABLE IF NOT EXISTS filenames ( + id INTEGER PRIMARY KEY, + filename TEXT NOT NULL UNIQUE, + -- Traditional MPQ hashes + hash_a INTEGER NOT NULL, + hash_b INTEGER NOT NULL, + hash_offset INTEGER NOT NULL, + -- HET hashes (stored for multiple bit sizes) + het_hash_40_file INTEGER, -- 40-bit HET file hash + het_hash_40_name INTEGER, -- 40-bit HET name hash + het_hash_48_file INTEGER, -- 48-bit HET file hash + het_hash_48_name INTEGER, -- 48-bit HET name hash + het_hash_56_file INTEGER, -- 56-bit HET file hash + het_hash_56_name INTEGER, -- 56-bit HET name hash + het_hash_64_file INTEGER, -- 64-bit HET file hash + het_hash_64_name INTEGER, -- 64-bit HET name hash + source TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + )", + [], + )?; + + // Archive analysis results + conn.execute( + "CREATE TABLE IF NOT EXISTS archive_analysis ( + id INTEGER PRIMARY KEY, + archive_path TEXT NOT NULL, + archive_hash TEXT, + analysis_date TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + mpq_version INTEGER, + file_count INTEGER + )", + [], + )?; + + // Files found in archives + conn.execute( + "CREATE TABLE IF NOT EXISTS archive_files ( + archive_id INTEGER REFERENCES archive_analysis(id), + hash_a INTEGER NOT NULL, + hash_b INTEGER NOT NULL, + file_size INTEGER, + compressed_size INTEGER, + flags INTEGER, + filename_id INTEGER REFERENCES filenames(id), + PRIMARY KEY (archive_id, hash_a, hash_b) + )", + [], + )?; + + // Create indices for fast lookups + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_filename_hashes ON filenames(hash_a, hash_b)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_archive_files_hashes ON archive_files(hash_a, hash_b)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_archive_analysis_path ON archive_analysis(archive_path)", + [], + )?; + + // Indices for HET table lookups at different bit sizes + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_het_40 ON filenames(het_hash_40_file, het_hash_40_name)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_het_48 ON filenames(het_hash_48_file, het_hash_48_name)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_het_56 ON filenames(het_hash_56_file, het_hash_56_name)", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_het_64 ON filenames(het_hash_64_file, het_hash_64_name)", + [], + )?; + + // Record migration + conn.execute( + "INSERT INTO schema_version (version) VALUES (?1)", + [SCHEMA_VERSION], + )?; + + Ok(()) +} diff --git a/file-formats/archives/wow-mpq/src/lib.rs b/file-formats/archives/wow-mpq/src/lib.rs index 74c9aac..0aff446 100644 --- a/file-formats/archives/wow-mpq/src/lib.rs +++ b/file-formats/archives/wow-mpq/src/lib.rs @@ -77,6 +77,7 @@ pub mod builder; pub mod compare; pub mod compression; pub mod crypto; +pub mod database; pub mod error; pub mod header; pub mod io; diff --git a/file-formats/graphics/wow-m2/CHANGELOG.md b/file-formats/graphics/wow-m2/CHANGELOG.md index df0afba..1a46276 100644 --- a/file-formats/graphics/wow-m2/CHANGELOG.md +++ b/file-formats/graphics/wow-m2/CHANGELOG.md @@ -12,14 +12,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Support for WotLK (Wrath of the Lich King) M2 model and skin formats - Texture filename parsing functionality - Support for old skin format in the skin-info command -- JPEG header information support for BLP textures embedded in M2 files + +### Changed + +- Replaced custom BLP texture implementation with dependency on `wow-blp` crate +- `BlpTexture` is now a re-export of `wow_blp::BlpImage` for backwards compatibility ### Fixed -- Fixed BLP texture parsing in M2 models - corrected header field order, width/height types (u16 to u32), and added version field reading -- Fixed BLP texture parsing to properly handle JPEG header information and palette data for uncompressed formats - Fixed mipmap offset and size arrays to use fixed-size arrays ([u32; 16]) instead of dynamic vectors +### Removed + +- Custom BLP parsing implementation (moved to using `wow-blp` crate instead) + ## [0.2.0] - 2025-06-28 ### Added diff --git a/file-formats/graphics/wow-m2/Cargo.toml b/file-formats/graphics/wow-m2/Cargo.toml index ed409a5..0d29bb2 100644 --- a/file-formats/graphics/wow-m2/Cargo.toml +++ b/file-formats/graphics/wow-m2/Cargo.toml @@ -22,6 +22,7 @@ memchr = { workspace = true } bitflags = { workspace = true } glam = { workspace = true } anyhow = { workspace = true } +wow-blp = { path = "../wow-blp", version = "0.2.0" } [dev-dependencies] criterion = { workspace = true } diff --git a/file-formats/graphics/wow-m2/README.md b/file-formats/graphics/wow-m2/README.md index 7e8d05b..ba2dbb9 100644 --- a/file-formats/graphics/wow-m2/README.md +++ b/file-formats/graphics/wow-m2/README.md @@ -17,7 +17,7 @@ A Rust library for parsing, validating, and converting World of Warcraft M2 mode - **M2 Models** (`.m2`/`.mdx`) - 3D character, creature, and object models - **Skin Files** (`.skin`) - Level-of-detail and submesh information - **Animation Files** (`.anim`) - External animation sequences -- **BLP Textures** (`.blp`) - Texture files used by models +- **BLP Texture References** - Re-exports BLP support from the [wow-blp](https://crates.io/crates/wow-blp) crate ## Features diff --git a/file-formats/graphics/wow-m2/src/blp.rs b/file-formats/graphics/wow-m2/src/blp.rs deleted file mode 100644 index 43138a4..0000000 --- a/file-formats/graphics/wow-m2/src/blp.rs +++ /dev/null @@ -1,491 +0,0 @@ -use crate::io_ext::{ReadExt, WriteExt}; -use std::fs::File; -use std::io::{Read, Seek, SeekFrom, Write}; -use std::path::Path; - -use crate::error::{M2Error, Result}; - -/// Magic signature for BLP files ("BLP2") -pub const BLP2_MAGIC: [u8; 4] = *b"BLP2"; - -/// BLP compression type -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BlpCompressionType { - /// JPEG compression (legacy, not used in modern clients) - Jpeg = 0, - /// Uncompressed paletted - Uncompressed = 1, - /// DXT compression - Dxt = 2, - /// Uncompressed ARGB - UncompressedArgb = 3, -} - -impl BlpCompressionType { - /// Parse from integer value - pub fn from_u8(value: u8) -> Option { - match value { - 0 => Some(Self::Jpeg), - 1 => Some(Self::Uncompressed), - 2 => Some(Self::Dxt), - 3 => Some(Self::UncompressedArgb), - _ => None, - } - } -} - -/// BLP pixel format -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum BlpPixelFormat { - /// DXT1 compression (1-bit alpha) - Dxt1 = 0, - /// DXT3 compression (explicit alpha) - Dxt3 = 1, - /// DXT5 compression (interpolated alpha) - Dxt5 = 2, - /// ARGB 8888 (32-bit) - Argb8888 = 3, - /// RGB 888 (24-bit) - Rgb888 = 4, - /// ARGB 1555 (16-bit) - Argb1555 = 5, - /// ARGB 4444 (16-bit) - Argb4444 = 6, - /// RGB 565 (16-bit) - Rgb565 = 7, - /// DXT1 with 1-bit alpha - DxtAlpha1 = 8, - /// Index using alpha table - AIndex8 = 9, -} - -impl BlpPixelFormat { - /// Parse from integer value - pub fn from_u8(value: u8) -> Option { - match value { - 0 => Some(Self::Dxt1), - 1 => Some(Self::Dxt3), - 2 => Some(Self::Dxt5), - 3 => Some(Self::Argb8888), - 4 => Some(Self::Rgb888), - 5 => Some(Self::Argb1555), - 6 => Some(Self::Argb4444), - 7 => Some(Self::Rgb565), - 8 => Some(Self::DxtAlpha1), - 9 => Some(Self::AIndex8), - _ => None, - } - } - - /// Get the bits per pixel for this format - pub fn bits_per_pixel(&self) -> u32 { - match self { - Self::Dxt1 | Self::DxtAlpha1 => 4, // 4 bits per pixel - Self::Dxt3 | Self::Dxt5 => 8, // 8 bits per pixel - Self::Argb8888 => 32, // 32 bits per pixel - Self::Rgb888 => 24, // 24 bits per pixel - Self::Argb1555 | Self::Argb4444 | Self::Rgb565 => 16, // 16 bits per pixel - Self::AIndex8 => 8, // 8 bits per pixel - } - } - - /// Check if this format is compressed - pub fn is_compressed(&self) -> bool { - matches!(self, Self::Dxt1 | Self::Dxt3 | Self::Dxt5 | Self::DxtAlpha1) - } -} - -/// BLP file header -#[derive(Debug, Clone)] -pub struct BlpHeader { - /// Magic signature ("BLP2") - pub magic: [u8; 4], - /// Compression type - pub compression_type: BlpCompressionType, - /// Alpha channel bits (0, 1, 4, or 8) - pub alpha_bits: u8, - /// Width of the texture - pub width: u32, - /// Height of the texture - pub height: u32, - /// Pixel format - pub pixel_format: BlpPixelFormat, - /// Mipmap levels - pub mipmap_levels: u8, -} - -impl BlpHeader { - /// Parse a BLP header from a reader - pub fn parse(reader: &mut R) -> Result { - // Read and check magic - let mut magic = [0u8; 4]; - reader.read_exact(&mut magic)?; - - if magic != BLP2_MAGIC { - return Err(M2Error::InvalidMagic { - expected: String::from_utf8_lossy(&BLP2_MAGIC).to_string(), - actual: String::from_utf8_lossy(&magic).to_string(), - }); - } - - if reader.read_u32_le()? != 1 { - return Err(M2Error::ParseError(String::from("Invalid version"))); - } - - // Read type - let compression_type_raw = reader.read_u8()?; - let compression_type = - BlpCompressionType::from_u8(compression_type_raw).ok_or_else(|| { - M2Error::ParseError(format!( - "Invalid BLP compression type: {compression_type_raw}" - )) - })?; - - // Read alpha bits - let alpha_bits = reader.read_u8()?; - - // Read pixel format - let pixel_format_raw = reader.read_u8()?; - let pixel_format = BlpPixelFormat::from_u8(pixel_format_raw).ok_or_else(|| { - M2Error::ParseError(format!("Invalid BLP pixel format: {pixel_format_raw}")) - })?; - - // Read mipmap levels - let mipmap_levels = reader.read_u8()?; - - // Read dimensions - let width = reader.read_u32_le()?; - let height = reader.read_u32_le()?; - - Ok(Self { - magic, - compression_type, - alpha_bits, - width, - height, - pixel_format, - mipmap_levels, - }) - } - - /// Write a BLP header to a writer - pub fn write(&self, writer: &mut W) -> Result<()> { - // Write magic - writer.write_all(&self.magic)?; - - // write version - writer.write_u32_le(0x1)?; - - // Write compression type - writer.write_u8(self.compression_type as u8)?; - - // Write alpha bits - writer.write_u8(self.alpha_bits)?; - - // Write pixel format - writer.write_u8(self.pixel_format as u8)?; - - // Write mipmap levels - writer.write_u8(self.mipmap_levels)?; - - // Write dimensions - writer.write_u32_le(self.width)?; - writer.write_u32_le(self.height)?; - - Ok(()) - } -} - -/// BLP mipmap level -#[derive(Debug, Clone)] -pub struct BlpMipmap { - /// Mipmap data - pub data: Vec, - /// Width of the mipmap - pub width: u32, - /// Height of the mipmap - pub height: u32, -} - -#[derive(Debug, Clone)] -pub struct JpegInfo { - pub header_size: u32, - pub header_data: Vec, -} - -/// Represents a BLP texture file -#[derive(Debug, Clone)] -pub struct BlpTexture { - /// BLP header - pub header: BlpHeader, - /// Mipmap data - pub mipmaps: Vec, - /// Color palette (for paletted formats) - pub palette: Option>, - pub jpeg_info: Option, -} - -impl BlpTexture { - /// Parse a BLP texture from a reader - pub fn parse(reader: &mut R) -> Result { - // Parse header - let header = BlpHeader::parse(reader)?; - - // Read mipmap offsets and sizes - let mut mipmap_offsets = [0u32; 16]; - let mut mipmap_sizes = [0u32; 16]; - - for offset in &mut mipmap_offsets { - *offset = reader.read_u32_le()?; - } - - for size in &mut mipmap_sizes { - *size = reader.read_u32_le()?; - } - - let (palette, jpeg_info) = if header.compression_type == BlpCompressionType::Uncompressed { - let mut palette_data = Vec::with_capacity(256); - for _ in 0..256 { - palette_data.push(reader.read_u32_le()?); - } - (Some(palette_data), None) - } else { - let header_size = reader.read_u32_le()?; - let mut header_data = [0u8; 1020]; - reader.read_exact(&mut header_data)?; - ( - None, - Some(JpegInfo { - header_size, - header_data: header_data.to_vec(), - }), - ) - }; - - // Read mipmaps - let mut mipmaps = Vec::with_capacity(header.mipmap_levels as usize); - let width = header.width; - let height = header.height; - - for i in 0..header.mipmap_levels as usize { - let offset = mipmap_offsets[i]; - let size = mipmap_sizes[i]; - - if offset > 0 && size > 0 { - reader.seek(SeekFrom::Start(offset as u64))?; - - let mipmap_width = (width >> i as u32).max(1); - let mipmap_height = (height >> i as u32).max(1); - - let mut data = vec![0u8; size as usize]; - reader.read_exact(&mut data)?; - - mipmaps.push(BlpMipmap { - data, - width: mipmap_width, - height: mipmap_height, - }); - } - } - - Ok(Self { - header, - mipmaps, - palette, - jpeg_info, - }) - } - - /// Load a BLP texture from a file - pub fn load>(path: P) -> Result { - let mut file = File::open(path)?; - Self::parse(&mut file) - } - - /// Save a BLP texture to a file - pub fn save>(&self, path: P) -> Result<()> { - let mut file = File::create(path)?; - self.write(&mut file) - } - - /// Write a BLP texture to a writer - pub fn write(&self, writer: &mut W) -> Result<()> { - // Write header - self.header.write(writer)?; - - // Calculate offsets - let header_size = 20; // Magic + compression + alpha + width + height + format + mipmaps - let mipmap_info_size = 8 * 4 + 8 * 4; // 8 offsets + 8 sizes - let palette_size = if self.palette.is_some() { 256 * 4 } else { 0 }; - - let mut current_offset = header_size + mipmap_info_size + palette_size; - let mut mipmap_offsets = Vec::with_capacity(self.mipmaps.len()); - - for mipmap in &self.mipmaps { - mipmap_offsets.push(current_offset); - current_offset += mipmap.data.len() as u32; - } - - // Write mipmap offsets - for &offset in &mipmap_offsets { - writer.write_u32_le(offset)?; - } - - // Write unused offsets - for _ in mipmap_offsets.len()..8 { - writer.write_u32_le(0)?; - } - - // Write mipmap sizes - for mipmap in &self.mipmaps { - writer.write_u32_le(mipmap.data.len() as u32)?; - } - - // Write unused sizes - for _ in self.mipmaps.len()..8 { - writer.write_u32_le(0)?; - } - - // Write palette - if let Some(ref palette) = self.palette { - for &color in palette { - writer.write_u32_le(color)?; - } - } - - // Write mipmap data - for mipmap in &self.mipmaps { - writer.write_all(&mipmap.data)?; - } - - Ok(()) - } - - /// Create a new BLP texture with default values - pub fn new(width: u32, height: u32, pixel_format: BlpPixelFormat) -> Self { - let header = BlpHeader { - magic: BLP2_MAGIC, - compression_type: match pixel_format { - BlpPixelFormat::Dxt1 - | BlpPixelFormat::Dxt3 - | BlpPixelFormat::Dxt5 - | BlpPixelFormat::DxtAlpha1 => BlpCompressionType::Dxt, - BlpPixelFormat::Argb8888 - | BlpPixelFormat::Rgb888 - | BlpPixelFormat::Argb1555 - | BlpPixelFormat::Argb4444 - | BlpPixelFormat::Rgb565 => BlpCompressionType::UncompressedArgb, - BlpPixelFormat::AIndex8 => BlpCompressionType::Uncompressed, - }, - alpha_bits: match pixel_format { - BlpPixelFormat::Argb8888 => 8, - BlpPixelFormat::Argb4444 => 4, - BlpPixelFormat::Argb1555 | BlpPixelFormat::Dxt1 | BlpPixelFormat::DxtAlpha1 => 1, - _ => 0, - }, - width, - height, - pixel_format, - mipmap_levels: 1, - }; - - Self { - header, - mipmaps: Vec::new(), - palette: if pixel_format == BlpPixelFormat::AIndex8 { - Some(vec![0; 256]) - } else { - None - }, - jpeg_info: None, - } - } - - /// Get the main mipmap data (highest resolution) - pub fn main_data(&self) -> Option<&[u8]> { - if self.mipmaps.is_empty() { - None - } else { - Some(&self.mipmaps[0].data) - } - } - - /// Calculate the size of a mipmap level - pub fn calculate_mipmap_size(&self, level: usize) -> usize { - let width = (self.header.width as usize >> level).max(1); - let height = (self.header.height as usize >> level).max(1); - - match self.header.compression_type { - BlpCompressionType::Dxt => { - match self.header.pixel_format { - BlpPixelFormat::Dxt1 | BlpPixelFormat::DxtAlpha1 => { - // DXT1 uses 4 bits per pixel - width.div_ceil(4) * height.div_ceil(4) * 8 - } - BlpPixelFormat::Dxt3 | BlpPixelFormat::Dxt5 => { - // DXT3/5 uses 8 bits per pixel - width.div_ceil(4) * height.div_ceil(4) * 16 - } - _ => 0, - } - } - BlpCompressionType::UncompressedArgb => { - // Calculate based on bits per pixel - let bits_per_pixel = self.header.pixel_format.bits_per_pixel() as usize; - (width * height * bits_per_pixel) / 8 - } - BlpCompressionType::Uncompressed => { - // Paletted format uses 1 byte per pixel index - width * height - } - _ => 0, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Cursor; - - #[test] - fn test_blp_header_parse_write() { - let header = BlpHeader { - magic: BLP2_MAGIC, - compression_type: BlpCompressionType::Dxt, - alpha_bits: 8, - width: 256, - height: 256, - pixel_format: BlpPixelFormat::Dxt5, - mipmap_levels: 8, - }; - - let mut data = Vec::new(); - header.write(&mut data).unwrap(); - - let mut cursor = Cursor::new(data); - let parsed_header = BlpHeader::parse(&mut cursor).unwrap(); - - assert_eq!(parsed_header.magic, BLP2_MAGIC); - assert_eq!(parsed_header.compression_type, BlpCompressionType::Dxt); - assert_eq!(parsed_header.alpha_bits, 8); - assert_eq!(parsed_header.width, 256); - assert_eq!(parsed_header.height, 256); - assert_eq!(parsed_header.pixel_format, BlpPixelFormat::Dxt5); - assert_eq!(parsed_header.mipmap_levels, 8); - } - - #[test] - fn test_pixel_format_properties() { - assert_eq!(BlpPixelFormat::Dxt1.bits_per_pixel(), 4); - assert_eq!(BlpPixelFormat::Dxt5.bits_per_pixel(), 8); - assert_eq!(BlpPixelFormat::Argb8888.bits_per_pixel(), 32); - assert_eq!(BlpPixelFormat::Rgb888.bits_per_pixel(), 24); - assert_eq!(BlpPixelFormat::Rgb565.bits_per_pixel(), 16); - - assert!(BlpPixelFormat::Dxt1.is_compressed()); - assert!(BlpPixelFormat::Dxt5.is_compressed()); - assert!(!BlpPixelFormat::Argb8888.is_compressed()); - assert!(!BlpPixelFormat::Rgb888.is_compressed()); - } -} diff --git a/file-formats/graphics/wow-m2/src/lib.rs b/file-formats/graphics/wow-m2/src/lib.rs index a787a77..c2c624a 100644 --- a/file-formats/graphics/wow-m2/src/lib.rs +++ b/file-formats/graphics/wow-m2/src/lib.rs @@ -27,7 +27,6 @@ // Re-export main components pub mod anim; -pub mod blp; pub mod chunks; pub mod common; pub mod converter; @@ -40,12 +39,14 @@ pub mod version; // Re-export common types pub use anim::AnimFile; -pub use blp::BlpTexture; pub use converter::M2Converter; pub use error::{M2Error, Result}; pub use model::M2Model; pub use skin::{OldSkin, Skin}; pub use version::M2Version; +// Re-export BLP types from wow-blp crate for backwards compatibility +pub use wow_blp::BlpImage as BlpTexture; + /// Library version pub const VERSION: &str = env!("CARGO_PKG_VERSION"); diff --git a/warcraft-rs/src/commands/m2.rs b/warcraft-rs/src/commands/m2.rs index 484c777..fccebb6 100644 --- a/warcraft-rs/src/commands/m2.rs +++ b/warcraft-rs/src/commands/m2.rs @@ -4,8 +4,9 @@ use anyhow::{Context, Result}; use clap::Subcommand; use std::path::PathBuf; +use wow_blp::parser::load_blp; use wow_m2::{ - AnimFile, BlpTexture, M2Converter, M2Model, M2Version, Skin, + AnimFile, M2Converter, M2Model, M2Version, Skin, skin::{OldSkinHeader, SkinG, SkinHeader, SkinHeaderT}, }; @@ -334,7 +335,7 @@ fn handle_anim_convert(input: PathBuf, output: PathBuf, version_str: String) -> fn handle_blp_info(path: PathBuf, detailed: bool) -> Result<()> { println!("Loading BLP texture: {}", path.display()); - let _blp = BlpTexture::load(&path) + let _blp = load_blp(&path) .with_context(|| format!("Failed to load BLP texture from {}", path.display()))?; println!("\n=== BLP Texture Information ==="); diff --git a/warcraft-rs/src/commands/mpq.rs b/warcraft-rs/src/commands/mpq.rs index a7896ee..deea071 100644 --- a/warcraft-rs/src/commands/mpq.rs +++ b/warcraft-rs/src/commands/mpq.rs @@ -83,6 +83,14 @@ pub enum MpqCommands { /// Filter files by pattern (supports wildcards) #[arg(short, long)] filter: Option, + + /// Use hash database for name resolution + #[arg(long)] + use_db: bool, + + /// Automatically record found filenames to database + #[arg(long)] + record_to_db: bool, }, /// Extract files from an MPQ archive @@ -273,6 +281,85 @@ pub enum MpqCommands { #[arg(long)] all: bool, }, + + /// Database operations for MPQ hash resolution + #[command(subcommand)] + Db(DbCommands), +} + +#[derive(Subcommand)] +pub enum DbCommands { + /// Initialize or show status of the hash database + Status { + /// Show detailed statistics + #[arg(long)] + detailed: bool, + }, + + /// Import filenames from various sources + Import { + /// Path to import from (listfile, MPQ archive, or directory) + path: String, + + /// Source type + #[arg(value_enum)] + source_type: ImportSourceArg, + + /// Show progress + #[arg(long)] + show_progress: bool, + }, + + /// Analyze an MPQ archive and record its filenames + Analyze { + /// Path to the MPQ archive + archive: String, + + /// Also record anonymous files with generated names + #[arg(long)] + include_anonymous: bool, + }, + + /// Look up a filename's hash values + Lookup { + /// Filename to look up + filename: String, + }, + + /// Export database to listfile format + Export { + /// Output file path + output: String, + + /// Filter by source + #[arg(long)] + source: Option, + }, + + /// List entries in the database + List { + /// Filter entries by pattern (supports wildcards) + #[arg(short, long)] + filter: Option, + + /// Show detailed information + #[arg(short, long)] + long: bool, + + /// Limit number of results + #[arg(short = 'n', long, default_value = "100")] + limit: usize, + }, +} + +#[derive(ValueEnum, Clone, Debug)] +pub enum ImportSourceArg { + /// Import from a listfile + Listfile, + /// Import from an MPQ archive's internal listfile + Archive, + /// Scan a directory for WoW file patterns + Directory, } pub fn execute(command: MpqCommands) -> Result<()> { @@ -281,7 +368,9 @@ pub fn execute(command: MpqCommands) -> Result<()> { archive, long, filter, - } => list_archive(&archive, long, filter), + use_db, + record_to_db, + } => list_archive(&archive, long, filter, use_db, record_to_db), MpqCommands::Extract { archive, output, @@ -393,15 +482,55 @@ pub fn execute(command: MpqCommands) -> Result<()> { find_file: find, raw_dump: raw, }), + MpqCommands::Db(db_command) => execute_db_command(db_command), } } -fn list_archive(path: &str, long: bool, filter: Option) -> Result<()> { +fn list_archive( + path: &str, + long: bool, + filter: Option, + use_db: bool, + record_to_db: bool, +) -> Result<()> { + use wow_mpq::database::Database; + let spinner = create_spinner("Opening archive..."); let mut archive = Archive::open(path).context("Failed to open archive")?; spinner.finish_and_clear(); - let files: Vec = archive.list()?.into_iter().map(|e| e.name).collect(); + // Open database if needed + let db = if use_db || record_to_db { + Some(Database::open_default().context("Failed to open database")?) + } else { + None + }; + + // Record filenames to database if requested + if record_to_db { + if let Some(ref db) = db { + let count = archive.record_listfile_to_db(db)?; + if count > 0 { + println!("Recorded {count} filenames to database"); + } + } + } + + // Get file list + let files: Vec = if use_db { + if let Some(ref db) = db { + archive + .list_with_db(db)? + .into_iter() + .map(|e| e.name) + .collect() + } else { + archive.list()?.into_iter().map(|e| e.name).collect() + } + } else { + archive.list()?.into_iter().map(|e| e.name).collect() + }; + let pattern = filter.as_deref().unwrap_or("*"); let mut filtered_files: Vec<_> = files @@ -1675,3 +1804,269 @@ fn show_entry_at_index(archive: &mut Archive, index: usize, _raw_dump: bool) -> Ok(()) } + +// Database command implementation +fn execute_db_command(command: DbCommands) -> Result<()> { + use std::io::Write; + use wow_mpq::database::{Database, HashLookup, ImportSource, Importer}; + use wow_mpq::database::{calculate_het_hashes, calculate_mpq_hashes}; + + match command { + DbCommands::Status { detailed } => { + let db = Database::open_default().context("Failed to open database")?; + let conn = db.connection(); + + // Get basic statistics + let filename_count: i64 = + conn.query_row("SELECT COUNT(*) FROM filenames", [], |row| row.get(0))?; + + println!("MPQ Hash Database Status"); + println!("========================"); + println!("Database location: {}", db.path().display()); + println!("Total filenames: {filename_count}"); + + if detailed { + println!("\nDetailed Statistics:"); + + // Count by source + let mut stmt = conn.prepare( + "SELECT source, COUNT(*) FROM filenames GROUP BY source ORDER BY COUNT(*) DESC", + )?; + let sources = stmt.query_map([], |row| { + Ok((row.get::<_, Option>(0)?, row.get::<_, i64>(1)?)) + })?; + + println!("\nFilenames by source:"); + for source in sources { + let (src, count) = source?; + println!( + " {}: {}", + src.unwrap_or_else(|| "unknown".to_string()), + count + ); + } + + // Recent additions + let mut stmt = conn.prepare( + "SELECT filename, created_at FROM filenames ORDER BY created_at DESC LIMIT 10", + )?; + let recent = stmt.query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + })?; + + println!("\nMost recent additions:"); + for entry in recent { + let (filename, created_at) = entry?; + println!(" {filename} - {created_at}"); + } + } + + Ok(()) + } + + DbCommands::Import { + path, + source_type, + show_progress, + } => { + let db = Database::open_default().context("Failed to open database")?; + let importer = Importer::new(&db); + + let import_source = match source_type { + ImportSourceArg::Listfile => ImportSource::Listfile, + ImportSourceArg::Archive => ImportSource::Archive, + ImportSourceArg::Directory => ImportSource::Directory, + }; + + let spinner = if show_progress { + Some(create_spinner(&format!("Importing from {path}..."))) + } else { + None + }; + + let stats = importer + .import(Path::new(&path), import_source) + .context("Import failed")?; + + if let Some(s) = spinner { + s.finish_and_clear(); + } + + println!("Import completed:"); + println!(" Files processed: {}", stats.files_processed); + println!(" New entries added: {}", stats.new_entries); + println!(" Existing entries updated: {}", stats.updated_entries); + if stats.errors > 0 { + println!(" Errors: {}", stats.errors); + } + + Ok(()) + } + + DbCommands::Analyze { + archive, + include_anonymous, + } => { + let db = Database::open_default().context("Failed to open database")?; + let mut mpq = Archive::open(&archive).context("Failed to open archive")?; + + let spinner = create_spinner("Analyzing archive..."); + + // Record listfile entries + let count = mpq.record_listfile_to_db(&db)?; + + spinner.finish_with_message(format!("Recorded {count} filenames from listfile")); + + if include_anonymous { + // TODO: Could also record anonymous entries with generated names + println!("Note: Recording anonymous entries not yet implemented"); + } + + Ok(()) + } + + DbCommands::Lookup { filename } => { + let db = Database::open_default().context("Failed to open database")?; + + // Calculate and display hashes + let (hash_a, hash_b, hash_offset) = calculate_mpq_hashes(&filename); + let het_40 = calculate_het_hashes(&filename, 40); + let het_48 = calculate_het_hashes(&filename, 48); + let het_56 = calculate_het_hashes(&filename, 56); + let het_64 = calculate_het_hashes(&filename, 64); + + println!("Filename: {filename}"); + println!("\nTraditional MPQ hashes:"); + println!(" Hash A (Name1): 0x{hash_a:08X}"); + println!(" Hash B (Name2): 0x{hash_b:08X}"); + println!(" Table Offset: 0x{hash_offset:08X}"); + + println!("\nHET hashes:"); + println!( + " 40-bit: file=0x{:010X}, name=0x{:010X}", + het_40.0, het_40.1 + ); + println!( + " 48-bit: file=0x{:012X}, name=0x{:012X}", + het_48.0, het_48.1 + ); + println!( + " 56-bit: file=0x{:014X}, name=0x{:014X}", + het_56.0, het_56.1 + ); + println!( + " 64-bit: file=0x{:016X}, name=0x{:016X}", + het_64.0, het_64.1 + ); + + // Check if it exists in database + if db.filename_exists(&filename)? { + println!("\n✓ Filename exists in database"); + } else { + println!("\n✗ Filename not found in database"); + } + + Ok(()) + } + + DbCommands::Export { output, source } => { + let db = Database::open_default().context("Failed to open database")?; + let conn = db.connection(); + + let mut query = "SELECT DISTINCT filename FROM filenames".to_string(); + let mut params: Vec = Vec::new(); + + if let Some(src) = source { + query.push_str(" WHERE source = ?1"); + params.push(src); + } + + query.push_str(" ORDER BY filename"); + + let mut stmt = conn.prepare(&query)?; + let filenames: Vec = if params.is_empty() { + stmt.query_map([], |row| row.get::<_, String>(0))? + .collect::, _>>()? + } else { + stmt.query_map([¶ms[0]], |row| row.get::<_, String>(0))? + .collect::, _>>()? + }; + + let mut file = fs::File::create(&output).context("Failed to create output file")?; + let mut count = 0; + + for filename in filenames { + writeln!(file, "{filename}")?; + count += 1; + } + + println!("Exported {count} filenames to {output}"); + + Ok(()) + } + + DbCommands::List { + filter, + long, + limit, + } => { + let db = Database::open_default().context("Failed to open database")?; + let conn = db.connection(); + + let mut query = "SELECT filename, hash_a, hash_b, source FROM filenames".to_string(); + let mut params: Vec = Vec::new(); + + if let Some(pattern) = filter { + query.push_str(" WHERE filename LIKE ?1"); + params.push(pattern.replace('*', "%")); + } + + query.push_str(&format!(" ORDER BY filename LIMIT {limit}")); + + let mut stmt = conn.prepare(&query)?; + let rows: Vec<(String, u32, u32, Option)> = if params.is_empty() { + stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, u32>(1)?, + row.get::<_, u32>(2)?, + row.get::<_, Option>(3)?, + )) + })? + .collect::, _>>()? + } else { + stmt.query_map([¶ms[0]], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, u32>(1)?, + row.get::<_, u32>(2)?, + row.get::<_, Option>(3)?, + )) + })? + .collect::, _>>()? + }; + + if long { + let mut table = create_table(vec!["Filename", "Hash A", "Hash B", "Source"]); + for (filename, hash_a, hash_b, source) in rows { + add_table_row( + &mut table, + vec![ + filename, + format!("0x{:08X}", hash_a), + format!("0x{:08X}", hash_b), + source.unwrap_or_else(|| "unknown".to_string()), + ], + ); + } + println!("{table}"); + } else { + for (filename, _, _, _) in rows { + println!("{filename}"); + } + } + + Ok(()) + } + } +}