warcraft-rs Documentation
This documentation covers all supported World of Warcraft file formats and provides examples for parsing and handling them.
Getting Started
- Quick Start Guide - Get up and running quickly
- Installation - Detailed installation instructions
- Basic Usage - Common usage patterns
File Formats
Archives
- MPQ Format - Blizzard’s archive format (100% StormLib compatible)
Terrain and World Data
- ADT Format - Terrain tiles
- WDL Format - Low-resolution world maps
- WDT Format - World tables
Graphics & Models
- BLP Format - Texture format
- M2 Format - 3D models
- .anim Files - Animation data
- .skin Files - Mesh data
- .phys Files - Physics data
- WMO Format - World map objects
Client Database
- DBC Format - Client database files
Guides
MPQ Archives
- Working with MPQ Archives
- MPQ CLI Usage
- MPQ Digital Signatures
- StormLib vs wow-mpq Differences
- WoW Patch Chain Summary
Terrain and World Tools
Graphics & Rendering
- Rendering ADT Terrain
- Loading M2 Models
- Model Rendering Guide
- WMO Rendering Guide
- Texture Loading
- Animation System
- LOD System
Client Database Tools
API Reference
Resources
- Glossary - Common terms and abbreviations
- Version Support - WoW version compatibility
- External Links - Helpful external resources
Contributing
See our Contributing Guide for information on how to contribute to this project.
Quick Start Guide
Get started with warcraft-rs in just a few minutes!
Prerequisites
- Rust 1.92 or later
- Basic familiarity with Rust
- WoW game files to parse
Installation
Add the specific crates you need to your Cargo.toml:
[dependencies]
wow-mpq = "0.6" # For MPQ archive support
wow-wdt = "0.6" # For WDT world table files
wow-wdl = "0.6" # For WDL low-resolution heightmaps
wow-cdbc = "0.6" # For DBC database files
wow-blp = "0.6" # For BLP textures
# Add other crates as needed
Or install the CLI tool:
# From crates.io
cargo install warcraft-rs
# From source
git clone https://github.com/wowemulation-dev/warcraft-rs
cd warcraft-rs
cargo install --path warcraft-rs
Basic Example
Here’s a simple example of reading an MPQ archive:
use wow_mpq::Archive;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// Open an MPQ archive
let mut archive = Archive::open("path/to/file.mpq")?;
// List files in the archive (requires listfile)
if let Ok(entries) = archive.list() {
for entry in entries {
println!("Found file: {} ({} bytes)", entry.name, entry.size);
}
}
// Extract a specific file
// Note: Both forward and backslashes work - they're automatically converted
let data = archive.read_file("Interface/Icons/INV_Misc_Bag_07.blp")?;
// or: archive.read_file("Interface\\Icons\\INV_Misc_Bag_07.blp")?;
println!("Extracted {} bytes", data.len());
Ok(())
}
Next Steps
- Installation Guide - Detailed setup instructions
- Basic Usage - More examples and patterns
- File Format Reference - Detailed format documentation
Getting Help
- Check the API documentation
- Visit our GitHub repository
- Read troubleshooting documentation
Installation Guide
This guide will walk you through installing warcraft-rs and its dependencies.
Prerequisites
Required
- Rust: Version 1.92 or later
- Install from rustup.rs
- Verify:
rustc --version
- Git: For cloning the repository
Optional
-
Cross: For cross-platform builds
cargo install cross -
cargo-workspaces: For workspace management
cargo install cargo-workspaces
Installation Methods
From crates.io
Install the CLI Tool
cargo install warcraft-rs
Add Individual Crates
[dependencies]
wow-mpq = "0.6" # MPQ archive support
wow-blp = "0.6" # BLP texture support
wow-adt = "0.6" # ADT terrain support
wow-wdl = "0.6" # WDL low-resolution terrain support
wow-wdt = "0.6" # WDT map definition support
wow-wmo = "0.6" # WMO world map object support
wow-m2 = "0.6" # M2 model support
wow-cdbc = "0.6" # DBC database support
Or use cargo add:
cargo add wow-mpq wow-blp wow-adt
From Source
Clone and build the repository:
# Clone the repository
git clone https://github.com/wowemulation-dev/warcraft-rs.git
cd warcraft-rs
# Build all crates
cargo build --release
# Run tests
cargo test
# Install CLI tool with all features
cargo install --path warcraft-rs --features full
# Or install with specific features only
cargo install --path warcraft-rs --features "mpq wdl wdt"
Development Setup
For contributing to warcraft-rs:
# Clone with full history
git clone https://github.com/wowemulation-dev/warcraft-rs.git
cd warcraft-rs
# Setup pre-commit hooks
cp .githooks/pre-commit .git/hooks/
chmod +x .git/hooks/pre-commit
# Verify setup
cargo fmt --all -- --check
cargo check --all-features --all-targets
cargo clippy --all-targets --all-features
cargo test
Feature Flags
The warcraft-rs CLI supports feature flags to include only the formats you need:
# Default build (all formats included)
cargo build --release
# Build with all features including extras
cargo build --release --features full
# Build with specific features only
cargo build --release --no-default-features --features "mpq wdl wdt"
cargo run --features wdt -- wdt info map.wdt
Available features:
mpq- MPQ archive support (always available)dbc- DBC database support (enabled by default)blp- BLP texture support (enabled by default)m2- M2 model support (enabled by default)wmo- WMO object support (enabled by default)adt- ADT terrain support (enabled by default)wdt- WDT map definition support (enabled by default)wdl- WDL low-resolution terrain support (enabled by default)serde- JSON/YAML serialization supportextract- ADT data extraction featuresparallel- Parallel processing supportyaml- YAML support for DBC schemasfull- All features including extras
Platform-Specific Notes
Windows
- Visual Studio Build Tools or full Visual Studio required
- Use PowerShell or cmd for commands
- Paths use backslashes:
World\Maps\Azeroth
macOS
-
Xcode Command Line Tools required:
xcode-select --install -
Case-sensitive filesystem recommended
Linux
-
Development packages may be needed:
# Ubuntu/Debian sudo apt-get install build-essential # Fedora sudo dnf install gcc
Verifying Installation
Create a test project:
cargo new wow-test
cd wow-test
Add to Cargo.toml:
[dependencies]
wow-mpq = "0.6"
Create src/main.rs:
use wow_mpq::Archive;
fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("wow-mpq installed successfully!");
// Test opening an MPQ if you have one
if let Ok(mut archive) = Archive::open("path/to/archive.mpq") {
println!("Successfully opened MPQ archive!");
}
Ok(())
}
Build and run:
cargo run
Troubleshooting
Common Issues
Rust Version Error
error: package `warcraft-rs v0.6.4` cannot be built because it requires rustc 1.92 or newer
Solution: Update Rust with rustup update
Missing Dependencies
error: linker `cc` not found
Solution: Install platform build tools (see platform notes above)
Out of Memory
error: could not compile `warcraft-rs` (bin "warcraft-rs") due to previous error
Solution: Close other applications or add swap space
Getting Help
- Open an issue on GitHub
- Join our Discord community
Next Steps
- Read the Quick Start Guide
- Explore Basic Usage
- Browse Example Projects
Basic Usage
Learn the fundamental patterns for using warcraft-rs with World of Warcraft files.
Current Support Status:
- ✅ MPQ Archives - Fully implemented with 100% StormLib compatibility
- ✅ DBC Format - Client databases (full implementation with JSON/CSV export)
- ✅ BLP Format - Textures (full implementation)
- ✅ M2 Format - Models (full implementation)
- ✅ WMO Format - World objects (full implementation)
- ✅ ADT Format - Terrain data (full implementation)
- ✅ WDT Format - World table files (full implementation)
- ✅ WDL Format - Low-resolution terrain heightmaps (full implementation)
Core Concepts
File Loading Pattern
Each crate has its own parsing approach. There is no shared loading trait:
#![allow(unused)]
fn main() {
// wow-mpq: static open method
use wow_mpq::Archive;
let mut archive = Archive::open("archive.mpq")?;
// wow-blp: load function
use wow_blp::parser::load_blp;
let blp = load_blp("texture.blp")?;
// wow-wdt: reader struct
use wow_wdt::{WdtReader, version::WowVersion};
let reader = WdtReader::new(BufReader::new(file), WowVersion::WotLK);
let wdt = reader.read()?;
// wow-m2: free function returning M2Format enum
use wow_m2::parse_m2;
let format = parse_m2(&mut cursor)?;
let model = format.model();
// wow-adt: standalone function
use wow_adt::api::parse_adt;
let adt = parse_adt(&mut reader)?;
}
Error Handling
Each crate defines its own error type using thiserror:
#![allow(unused)]
fn main() {
use wow_mpq::Archive;
// Errors propagate with the ? operator
fn extract_file(path: &str) -> Result<Vec<u8>, wow_mpq::error::Error> {
let mut archive = Archive::open(path)?;
archive.read_file("Interface/FrameXML/UIParent.lua")
}
}
See Error Handling for the full list of error types.
Working with Archives (MPQ)
Opening and Reading Files
#![allow(unused)]
fn main() {
use wow_mpq::Archive;
// Open an MPQ archive
let mut archive = Archive::open("Data/patch.mpq")?;
// Read file data (both path styles work - auto-converted)
let data = archive.read_file("Interface/FrameXML/UIParent.lua")?;
// or: archive.read_file("Interface\\FrameXML\\UIParent.lua")?;
println!("File size: {} bytes", data.len());
// Check if file exists
if let Ok(Some(file_info)) = archive.find_file("Interface/FrameXML/UIParent.lua") {
println!("File found: {} bytes", file_info.file_size);
}
// List files from listfile
if let Ok(entries) = archive.list() {
for entry in entries {
println!("{}: {} bytes (compressed: {} bytes)",
entry.name,
entry.size,
entry.compressed_size
);
}
}
// Or list ALL files by scanning tables (includes files not in listfile)
if let Ok(entries) = archive.list_all() {
for entry in entries {
println!("{}: {} bytes", entry.name, entry.size);
}
}
}
Extracting Archives
#![allow(unused)]
fn main() {
use wow_mpq::{Archive, path::mpq_path_to_system};
use std::path::Path;
use std::fs;
let mut archive = Archive::open("Data/art.mpq")?;
// Extract all files (use list_all() to include files not in listfile)
if let Ok(entries) = archive.list_all() {
for entry in entries {
if let Ok(data) = archive.read_file(&entry.name) {
// Convert MPQ path to system path
let system_path = mpq_path_to_system(&entry.name);
let output_path = Path::new("output").join(&system_path);
// Create directories
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent)?;
}
// Write file
fs::write(output_path, data)?;
println!("Extracted: {}", entry.name);
}
}
}
// Extract specific files with proper path handling
let files = vec![
"Character/Human/Male/HumanMale.m2",
"Character/Human/Male/HumanMaleSkin00.skin",
];
for file in files {
if let Ok(data) = archive.read_file(file) {
let system_path = mpq_path_to_system(file);
let output_path = Path::new("extracted").join(&system_path);
fs::create_dir_all(output_path.parent().unwrap())?;
fs::write(output_path, data)?;
}
}
}
Loading Textures (BLP)
Basic Texture Loading
#![allow(unused)]
fn main() {
use wow_blp::{parser::load_blp, convert::blp_to_image};
// Load BLP texture
let blp = load_blp("Textures/Minimap/MinimapMask.blp")?;
println!("Texture info:");
println!(" Size: {}x{}", blp.header.width, blp.header.height);
println!(" Version: {:?}", blp.header.version);
println!(" Has mipmaps: {}", blp.header.has_mipmaps());
// Convert to standard image format
let image = blp_to_image(&blp, 0)?; // mipmap level 0
// Save as PNG
image.save("minimap_mask.png")?;
}
Working with World Data (WDL)
Basic WDL Operations
#![allow(unused)]
fn main() {
use wow_wdl::parser::WdlParser;
use std::io::Cursor;
// Parse a WDL file
let data = std::fs::read("World/Maps/Azeroth/Azeroth.wdl")?;
let mut parser = WdlParser::new();
let wdl = parser.parse(&mut Cursor::new(data))?;
// The WdlFile struct contains the parsed chunk data
println!("WDL version: {:?}", wdl.version);
}
Loading Models (M2)
Basic Model Loading
#![allow(unused)]
fn main() {
use wow_m2::{parse_m2, parse_skin};
// Load M2 model
let data = std::fs::read("Creature/Murloc/Murloc.m2")?;
let mut cursor = std::io::Cursor::new(data);
let format = parse_m2(&mut cursor)?;
let model = format.model();
// Access model data through the header and parsed fields
println!("Model version: {:?}", model.header.version);
// Load associated skin file
let skin_data = std::fs::read("Creature/Murloc/Murloc00.skin")?;
let mut skin_cursor = std::io::Cursor::new(skin_data);
let skin = parse_skin(&mut skin_cursor)?;
}
Loading World Data
Working with World Tables (WDT)
#![allow(unused)]
fn main() {
use wow_wdt::{WdtReader, version::WowVersion};
use std::io::BufReader;
use std::fs::File;
// Load a WDT file to see which ADT tiles exist
let file = File::open("World/Maps/Azeroth/Azeroth.wdt")?;
let mut reader = WdtReader::new(BufReader::new(file), WowVersion::WotLK);
let wdt = reader.read()?;
// Check map properties
println!("Map info:");
println!(" Is WMO-only: {}", wdt.is_wmo_only());
println!(" Existing tiles: {}", wdt.count_existing_tiles());
// Check which ADT tiles exist
for y in 0..64 {
for x in 0..64 {
if let Some(tile_info) = wdt.get_tile(x, y) {
if tile_info.has_adt {
println!("ADT tile exists at [{}, {}] - Area ID: {}",
x, y, tile_info.area_id);
}
}
}
}
// For WMO-only maps (like dungeons)
if wdt.is_wmo_only() {
if let Some(ref mwmo) = wdt.mwmo {
for name in &mwmo.filenames {
println!("Global WMO: {}", name);
}
}
if let Some(ref modf) = wdt.modf {
for placement in &modf.entries {
println!("WMO placed at: {:?}", placement.position);
}
}
}
// Convert coordinates between systems
use wow_wdt::{tile_to_world, world_to_tile};
// Convert tile coordinates to world coordinates
let (world_x, world_y) = tile_to_world(32, 32); // Map center
println!("Tile [32, 32] is at world coordinates ({}, {})", world_x, world_y);
// Convert world coordinates back to tile coordinates
let (tile_x, tile_y) = world_to_tile(world_x, world_y);
println!("World coords ({}, {}) is tile [{}, {}]", world_x, world_y, tile_x, tile_y);
}
Working with Terrain (ADT)
#![allow(unused)]
fn main() {
use wow_adt::api::parse_adt;
use std::io::Cursor;
// Load and parse terrain tile
let data = std::fs::read("World/Maps/Azeroth/Azeroth_32_48.adt")?;
let adt = parse_adt(&mut Cursor::new(data))?;
// The ParsedAdt struct contains all parsed chunk data
// Access MCNK terrain chunks, MDDF doodad placements, MODF WMO placements, etc.
}
Working with World Objects (WMO)
#![allow(unused)]
fn main() {
use wow_wmo::api::parse_wmo;
use std::io::Cursor;
// Load and parse WMO root file
let data = std::fs::read("World/wmo/Dungeon/KL_Orgrimmar/Orgrimmar.wmo")?;
let wmo = parse_wmo(&mut Cursor::new(data))?;
// The ParsedWmo struct contains all parsed WMO data:
// header, materials, group info, doodad sets, etc.
}
Reading Databases (DBC)
Basic DBC Reading
#![allow(unused)]
fn main() {
use wow_cdbc::parser::DbcParser;
use std::io::Cursor;
// Parse DBC file
let data = std::fs::read("DBFilesClient/Item.dbc")?;
let dbc = DbcParser::parse(&mut Cursor::new(data))?;
// Access header information
println!("Records: {}", dbc.header().record_count);
println!("Fields per record: {}", dbc.header().field_count);
// The CLI provides additional functionality:
// - Schema discovery and validation
// - Export to JSON/CSV formats
// - Performance analysis
// Use `warcraft-rs dbc --help` for CLI commands.
}
Best Practices
Memory Management
#![allow(unused)]
fn main() {
// Use Arc for shared data
use std::sync::Arc;
use wow_blp::parser::load_blp;
let texture = Arc::new(load_blp("expensive_texture.blp")?);
// Clone is cheap - just increments reference count
let texture_ref = Arc::clone(&texture);
// For MPQ archives, read files on demand
use wow_mpq::Archive;
let mut archive = Archive::open("huge.mpq")?;
// Read file when needed
let data = archive.read_file("large_file.dat")?;
// Process in chunks if needed
for chunk in data.chunks(4096) {
// Process chunk...
}
}
Error Recovery
#![allow(unused)]
fn main() {
use wow_blp::parser::load_blp;
use wow_blp::BlpImage;
fn load_texture_with_fallback(path: &str, fallback: &str) -> Result<BlpImage, wow_blp::parser::LoadError> {
match load_blp(path) {
Ok(texture) => Ok(texture),
Err(_) => {
eprintln!("Texture {} not found, using fallback", path);
load_blp(fallback)
}
}
}
}
Performance Tips
#![allow(unused)]
fn main() {
// Batch operations
let files_to_extract = vec!["file1.blp", "file2.blp", "file3.blp"];
let mut results = Vec::new();
for file in files_to_extract {
if let Ok(data) = archive.read_file(file) {
results.push((file, data));
}
}
// Use parallel processing for CPU-intensive tasks
use rayon::prelude::*;
use wow_blp::parser::load_blp;
let textures: Vec<_> = texture_paths
.par_iter()
.filter_map(|path| load_blp(path).ok())
.collect();
}
Next Steps
- Explore File Format Reference
- Read format-specific guides in Guides
- Check API Documentation
- See Example Projects
File Format Reference
This section contains detailed documentation for all World of Warcraft file
formats supported by warcraft-rs.
Categories
Archives
Archive formats for storing and compressing game assets.
- MPQ - Blizzard’s main archive format
World Data
Formats for terrain, maps, and world geometry.
- ADT - Terrain tiles with height, textures, and objects
- WDL - Low-resolution terrain for distant views
- WDT - World definition tables
Graphics & Models
3D models, textures, and visual assets.
- BLP - Texture format with DXT compression
- M2 - Animated 3D models (characters, creatures, props)
- WMO - Large static world objects (buildings, dungeons)
Database
Client-side data storage.
- DBC - Database files with game data tables
Format Overview
| Format | Type | Description | Typical Size |
|---|---|---|---|
| MPQ | Archive | Compressed archive containing other files | 10MB - 4GB |
| ADT | Terrain | Map tile with terrain mesh and textures | 1-5MB |
| WDL | Terrain | Low-detail world map | 100-500KB |
| WDT | Map Info | Map configuration and ADT references | 10-50KB |
| BLP | Texture | 2D images with compression and mipmaps | 10KB - 2MB |
| M2 | Model | 3D models with animations | 50KB - 10MB |
| WMO | Model | Large world objects | 100KB - 50MB |
| DBC | Database | Tabular data storage | 1KB - 10MB |
Version Compatibility
Different WoW versions use different format versions:
- Classic (1.12.x): Original formats
- TBC (2.4.3): Some format updates, new DBC columns
- WotLK (3.3.5): Major M2 updates, new terrain features
- Cataclysm (4.3.4): Terrain streaming, updated formats
- MoP (5.4.8): Latest supported version
Quick Reference
Each crate has its own parsing approach. See Traits & Interfaces for the different patterns used across crates.
Tools & Utilities
warcraft-rs provides CLI subcommands for each format:
# Extract MPQ archive
warcraft-rs mpq extract archive.mpq --output output/
# Convert BLP to PNG
warcraft-rs blp convert texture.blp texture.png
# Export DBC as CSV
warcraft-rs dbc export Spell.dbc --format csv
Archive Formats
Archive formats are used to store and compress World of Warcraft game assets.
Supported Formats
MPQ Format
The primary archive format used by Blizzard games. MPQ archives can contain any type of game asset including models, textures, sounds, and data files.
Key Features:
- Multiple compression algorithms (ZLIB, PKWare, BZip2, LZMA)
- File encryption support
- Patching capability
- Efficient hash-based file lookup
Common Use Cases
Extracting Game Assets
#![allow(unused)]
fn main() {
use wow_mpq::Archive;
let mut archive = Archive::open("Data/art.mpq")?;
let entries = archive.list()?;
for entry in entries {
println!("{}: {} bytes", entry.name, entry.size);
}
}
Working with Patches
WoW uses a patch chain system where newer MPQs override files in older ones:
base-*.mpq- Base game filespatch-*.mpq- Incremental patcheslocale-*.mpq- Localization files
Tools
warcraft-rs mpq- CLI commands for MPQ operations- Ladik’s MPQ Editor - Popular Windows MPQ editor
Performance Tips
- Caching: Cache frequently accessed files in memory
- Streaming: Read files on demand rather than extracting all at once
- Parallel Extraction: Use
--threadsflag in CLI for concurrent extraction - Compression: Choose appropriate compression for your use case
See Also
MPQ Format
MPQ (Mo’PaQ, named after its creator Mike O’Brien) is Blizzard’s proprietary archive format used to store game assets. The format has evolved through multiple versions to support larger files and improved security.
Overview
- Extension:
.mpq - Magic Numbers:
MPQ\x1A(0x1A51504D) - Standard MPQ headerMPQ\x1B(0x1B51504D) - MPQ user data header
- Alignment: Headers must start at offsets aligned to 512 (0x200) bytes
- Versions:
- v1: Original format (up to The Burning Crusade)
- v2: Extended format with large file support (The Burning Crusade)
- v3: HET/BET tables support (Cataclysm beta)
- v4: Enhanced security with MD5 hashes (Cataclysm)
- StormLib Compatibility: ⚠️ Compatible via separate
storm-fficrate - Blizzard Compatibility: Full support for all official WoW archives (1.12.1 - 5.4.8)
File Layout
MPQ archives have a flexible structure that allows embedding in other files:
- Pre-archive data (optional) - Allows MPQs to be appended to executables
- User data header (optional) - Custom data used in Starcraft II maps
- MPQ header (required) - Main archive header
- File data - Actual archived file contents
- Special files (optional) -
(listfile),(attributes),(signature) - HET table (optional, v3+) - Extended hash table
- BET table (optional, v3+) - Extended block table
- Hash table (optional in v3+) - File lookup table
- Block table (optional in v3+) - File information table
- High block table (optional, v2+) - Upper 16 bits of file offsets
- Strong signature (optional) - RSA signature for security
Data Structures
User Data Header
Found in some archives (particularly Starcraft II maps) at aligned offsets:
#![allow(unused)]
fn main() {
#[repr(C)]
struct UserDataHeader {
/// Magic signature 'MPQ\x1B'
signature: [u8; 4],
/// Maximum size of user data area
user_data_max_size: u32,
/// Offset to MPQ header from start of this structure
archive_header_offset: u32,
/// Size of this user data header
user_data_header_size: u32,
}
}
MPQ Headers
The MPQ header structure varies by version:
#![allow(unused)]
fn main() {
#[repr(C)]
struct ArchiveHeaderV1 {
/// Magic signature 'MPQ\x1A'
signature: [u8; 4],
/// Size of this header structure
header_size: u32,
/// Size of the entire archive (deprecated in v2+)
archive_size: u32,
/// Format version (0 = v1, 1 = v2, 2 = v3, 3 = v4)
format_version: u16,
/// Block size as power of two: 512 * 2^block_size_shift
block_size_shift: u16,
/// Offset to hash table from archive start
hash_table_offset: u32,
/// Offset to block table from archive start
block_table_offset: u32,
/// Number of hash table entries (must be power of 2)
hash_table_count: u32,
/// Number of block table entries
block_table_count: u32,
}
#[repr(C)]
struct ArchiveHeaderV2 {
// ... includes all V1 fields ...
/// High 32 bits of block table offset for archives > 4GB
extended_block_table_offset: u64,
/// High 16 bits of hash table offset
hash_table_offset_high: u16,
/// High 16 bits of block table offset
block_table_offset_high: u16,
}
#[repr(C)]
struct ArchiveHeaderV3 {
// ... includes all V2 fields ...
/// 64-bit archive size
archive_size_64: u64,
/// Position of BET (Block Extended Table)
bet_table_offset: u64,
/// Position of HET (Hash Extended Table)
het_table_offset: u64,
}
#[repr(C)]
struct ArchiveHeaderV4 {
// ... includes all V3 fields ...
/// Compressed size of hash table
compressed_hash_table_size: u64,
/// Compressed size of block table
compressed_block_table_size: u64,
/// Compressed size of high block table
compressed_high_block_table_size: u64,
/// Compressed size of HET table
compressed_het_table_size: u64,
/// Compressed size of BET table
compressed_bet_table_size: u64,
/// Size of raw data chunks for MD5 calculation
md5_chunk_size: u32,
/// MD5 of block table before decryption
md5_block_table: [u8; 16],
/// MD5 of hash table before decryption
md5_hash_table: [u8; 16],
/// MD5 of high block table
md5_high_block_table: [u8; 16],
/// MD5 of BET table before decryption
md5_bet_table: [u8; 16],
/// MD5 of HET table before decryption
md5_het_table: [u8; 16],
/// MD5 of MPQ header from signature through md5_het_table
md5_header: [u8; 16],
}
}
Hash Table Entry
Used for fast file lookups. Each entry is 16 bytes:
#![allow(unused)]
fn main() {
#[repr(C)]
struct HashTableEntry {
/// First half of filename hash
name_hash_a: u32,
/// Second half of filename hash
name_hash_b: u32,
/// File locale (0 = default/neutral)
locale: u16,
/// Platform (always 0 in practice)
platform: u16,
/// Index into block table, or special values:
/// 0xFFFFFFFF = Empty, never used
/// 0xFFFFFFFE = Empty, but was deleted
block_index: u32,
}
}
Block Table Entry
Contains file location and metadata. Each entry is 16 bytes:
#![allow(unused)]
fn main() {
#[repr(C)]
struct BlockTableEntry {
/// File offset from archive start
file_offset: u32,
/// Compressed file size
compressed_size: u32,
/// Uncompressed file size
uncompressed_size: u32,
/// File flags (see FileFlags below)
flags: u32,
}
bitflags! {
struct FileFlags: u32 {
/// File is compressed using PKWARE Data Compression Library
const IMPLODE = 0x00000100;
/// File is compressed using combination of algorithms
const COMPRESS = 0x00000200;
/// File is encrypted
const ENCRYPTED = 0x00010000;
/// Encryption key adjusted by file offset
const KEY_ADJUSTED = 0x00020000;
/// File is a patch file
const PATCH_FILE = 0x00100000;
/// File is stored as single unit (not split into sectors)
const SINGLE_UNIT = 0x01000000;
/// File is marked for deletion
const DELETE_MARKER = 0x02000000;
/// File has checksums for each sector (ADLER32, not CRC32)
const SECTOR_CRC = 0x04000000;
/// File exists in the archive
const EXISTS = 0x80000000;
}
}
}
High Block Table
For archives larger than 4GB (v2+), stores upper 16 bits of file offsets:
#![allow(unused)]
fn main() {
type HighBlockTable = Vec<u16>;
}
HET (Hash Extended Table)
Improved hash table for v3+ archives:
#![allow(unused)]
fn main() {
#[repr(C)]
struct HetTable {
/// Signature 'HET\x1A'
signature: [u8; 4],
/// Version (always 1)
version: u32,
/// Size of the contained data
data_size: u32,
/// Total size including header
table_size: u32,
/// Maximum number of files
max_file_count: u32,
/// Size of hash table in bytes
hash_table_size: u32,
/// Size of each hash entry in bits
hash_entry_size: u32,
/// Total index size in bits
index_size_total: u32,
/// Extra index bits
index_size_extra: u32,
/// Effective index size in bits
index_size: u32,
/// Size of block index array in bytes
block_index_size: u32,
}
}
BET (Block Extended Table)
Improved block table for v3+ archives:
#![allow(unused)]
fn main() {
#[repr(C)]
struct BetTable {
/// Signature 'BET\x1A'
signature: [u8; 4],
/// Version (always 1)
version: u32,
/// Size of the contained data
data_size: u32,
/// Total size including header
table_size: u32,
/// Number of files in table
file_count: u32,
/// Unknown field (always 0x10)
unknown: u32,
/// Size of one table entry in bits
table_entry_size: u32,
/// Bit offset of file position in entry
file_position_bits: u32,
/// Bit offset of file size in entry
file_size_bits: u32,
/// Bit offset of compressed size in entry
compressed_size_bits: u32,
/// Bit offset of flag index in entry
flag_index_bits: u32,
/// Bit offset of unknown field in entry
unknown_bits: u32,
/// Bit count for file position
file_position_bit_count: u32,
/// Bit count for file size
file_size_bit_count: u32,
/// Bit count for compressed size
compressed_size_bit_count: u32,
/// Bit count for flag index
flag_index_bit_count: u32,
/// Bit count for unknown field
unknown_bit_count: u32,
/// Total size of name hash
name_hash_size_total: u32,
/// Extra bits in name hash
name_hash_size_extra: u32,
/// Effective name hash size in bits
name_hash_size: u32,
/// Size of name hash array in bytes
name_hash_array_size: u32,
/// Number of flag entries following
flag_count: u32,
}
}
Algorithms
Hash Algorithms Overview
MPQ archives use different hash algorithms depending on the table type:
- MPQ Hash Algorithm - Used for hash and block tables (v1/v2)
- Jenkins one-at-a-time - Used for BET tables (v3+)
- Jenkins hashlittle2 - Used for HET tables (v3+)
MPQ Hash Calculation
The classic MPQ hash algorithm is used for hash table lookups and encryption keys:
#![allow(unused)]
fn main() {
use wow_mpq::crypto::{hash_string, hash_type};
// Hash types used in MPQ
const HASH_TABLE_OFFSET: u32 = 0;
const HASH_NAME_A: u32 = 1;
const HASH_NAME_B: u32 = 2;
const HASH_FILE_KEY: u32 = 3;
// Example: Calculate hashes for a filename
fn calculate_hashes(filename: &str) -> (u32, u32, u32) {
let hash_index = hash_string(filename, HASH_TABLE_OFFSET);
let hash_a = hash_string(filename, HASH_NAME_A);
let hash_b = hash_string(filename, HASH_NAME_B);
(hash_index, hash_a, hash_b)
}
// The hash_string function is implemented in wow_mpq and uses
// a pre-computed encryption table for performance
}
Jenkins Hash Algorithms (v3+)
MPQ v3+ archives use Jenkins hash algorithms for the HET and BET tables:
Jenkins one-at-a-time (BET tables)
#![allow(unused)]
fn main() {
use wow_mpq::crypto::jenkins_hash;
// Used for BET table name hashes
let bet_hash = jenkins_hash("Units\\Human\\Footman.mdx");
// The algorithm:
// 1. Normalizes path separators (/ to \)
// 2. Converts to lowercase
// 3. Applies Jenkins one-at-a-time hash
// 4. Returns 64-bit hash value
}
Jenkins hashlittle2 (HET tables)
#![allow(unused)]
fn main() {
use wow_mpq::crypto::het_hash;
// Used for HET table lookups
let (file_hash, name_hash1) = het_hash("(attributes)", 48);
// The algorithm:
// 1. Normalizes path separators and case
// 2. Applies Jenkins hashlittle2 with seeds 1 and 2
// 3. Combines results into 64-bit hash
// 4. Applies bit masking based on hash_bits parameter
// 5. Extracts 8-bit NameHash1 for quick comparison
// Example with different hash sizes:
let (hash_48bit, name1_48) = het_hash("file.txt", 48); // 48-bit hash
let (hash_64bit, name1_64) = het_hash("file.txt", 64); // 64-bit hash
}
File Search Algorithm
Finding a file in an MPQ using hash and block tables:
#![allow(unused)]
fn main() {
use wow_mpq::{Archive, crypto::{hash_string, hash_type}};
// This is how Archive::find_file() works internally
fn find_file_example(archive: &Archive, filename: &str) -> Option<FileInfo> {
// The archive provides this method directly:
archive.find_file(filename).ok().flatten()
}
// Note: MPQ archives use backslash (\) as the path separator
// The wow-mpq implementation automatically converts forward slashes (/)
// to backslashes for convenience, but internally all paths use backslashes
let file = archive.find_file("Units/Human/Footman.mdx")?; // OK - converted to Units\Human\Footman.mdx
let file = archive.find_file("Units\\Human\\Footman.mdx")?; // OK - native format
// For educational purposes, here's the algorithm:
fn find_file_manual(archive: &Archive, filename: &str) -> Option<FileInfo> {
// Calculate three hash values
let hash_index = hash_string(filename, 0); // HASH_TABLE_OFFSET
let hash_a = hash_string(filename, 1); // HASH_NAME_A
let hash_b = hash_string(filename, 2); // HASH_NAME_B
// Get hash table
let hash_table = archive.hash_table()?;
let hash_table_size = hash_table.len();
let mut index = (hash_index & (hash_table_size as u32 - 1)) as usize;
// Search the hash table
loop {
let entry = &hash_table.entries()[index];
// Check if we found the file
if entry.name1 == hash_a && entry.name2 == hash_b {
if entry.block_index != 0xFFFFFFFF {
// In real implementation, this would build FileInfo
// from block table entry
return Some(FileInfo { /* ... */ });
}
}
// Empty slot - file not found
if entry.block_index == 0xFFFFFFFF {
return None;
}
// Continue searching
index = (index + 1) % hash_table_size;
}
}
}
HET/BET Search Algorithm (v3+)
For MPQ v3+ archives using HET/BET tables:
#![allow(unused)]
fn main() {
use wow_mpq::{Archive, crypto::{het_hash, jenkins_hash}};
// HET/BET tables provide more efficient file lookup for v3+ archives
fn find_file_het_bet_example(archive: &Archive, filename: &str) -> Option<FileInfo> {
// The archive handles HET/BET lookup automatically
archive.find_file(filename).ok().flatten()
}
// Educational example of the HET/BET algorithm:
fn het_bet_algorithm_overview(archive: &Archive, filename: &str) {
// HET tables use Jenkins hashlittle2 algorithm
let (het_file_hash, het_name_hash1) = het_hash(filename, 48); // 48-bit hash example
// BET tables use Jenkins one-at-a-time algorithm
let bet_name_hash = jenkins_hash(filename);
// The HET table stores 8-bit name hashes for quick lookup
// The BET table stores extended file information with 64-bit hashes
// In practice, the Archive implementation handles all of this
// complexity internally when you call find_file()
}
}
Compression
Compression Methods
Files can be compressed using multiple algorithms, identified by the first byte after decompression:
#![allow(unused)]
fn main() {
use wow_mpq::compression::{decompress, compress, flags};
// Compression flags used in MPQ
mod compression_flags {
pub const HUFFMAN: u8 = 0x01; // Huffman (WAVE files)
pub const ZLIB: u8 = 0x02; // Deflate/zlib
pub const IMPLODE: u8 = 0x04; // PKWare Implode
pub const PKWARE: u8 = 0x08; // PKWare DCL
pub const BZIP2: u8 = 0x10; // BZip2
pub const SPARSE: u8 = 0x20; // Sparse/RLE
pub const ADPCM_MONO: u8 = 0x40; // IMA ADPCM mono
pub const ADPCM_STEREO: u8 = 0x80; // IMA ADPCM stereo
pub const LZMA: u8 = 0x12; // LZMA (not a flag combination)
}
// Example: Decompress file data
fn decompress_example(compressed_data: &[u8], method: u8, expected_size: usize) -> Result<Vec<u8>, wow_mpq::Error> {
// The decompress function handles all compression types
// including multi-compression (multiple algorithms applied)
decompress(compressed_data, method, expected_size)
}
// Example: Compress data
fn compress_example(data: &[u8]) -> Result<Vec<u8>, wow_mpq::Error> {
// Compress with zlib
compress(data, flags::ZLIB)
}
}
Sector-Based Storage
Files larger than the sector size are split into sectors:
#![allow(unused)]
fn main() {
// The wow_mpq library handles sector-based storage internally.
// This example shows the concept:
use wow_mpq::Archive;
fn sector_storage_concept(archive: &Archive) {
// Get the sector size from the archive header
let sector_size = archive.header().get_sector_size();
// Files larger than sector_size are split into multiple sectors
// Each sector is compressed/encrypted independently
// When you call archive.read_file(), it automatically:
// 1. Reads the sector offset table (if multi-sector)
// 2. Processes each sector (decompress/decrypt)
// 3. Combines sectors into the complete file
// For single-unit files (smaller than sector size or with
// SINGLE_UNIT flag), the entire file is one compressed block
}
// The actual implementation is handled internally by Archive::read_file()
}
Encryption
Key Generation
Files can be encrypted using a key derived from the filename:
#![allow(unused)]
fn main() {
use wow_mpq::crypto::{hash_string, decrypt_block, encrypt_block};
use wow_mpq::tables::BlockEntry;
// Key generation constants
const HASH_FILE_KEY: u32 = 3;
const BLOCK_TABLE_KEY: u32 = 0xEC83B3A3;
fn calculate_file_key(filename: &str, block: &BlockEntry) -> u32 {
// Calculate base key from filename
let mut key = hash_string(filename, HASH_FILE_KEY);
// Adjust key if FIX_KEY flag is set
if block.flags & 0x00020000 != 0 { // KEY_ADJUSTED flag
key = (key + block.file_pos) ^ block.file_size;
}
key
}
// Example: Decrypt data
fn decrypt_example(encrypted_data: &mut [u8], key: u32) {
// Note: decrypt_block works with u32 arrays, so conversion is needed
// In practice, the Archive handles this internally
// For working with raw data, you'd need to convert:
// 1. Convert u8 array to u32 array
// 2. Call decrypt_block
// 3. Convert back to u8 array
}
// Example: Encrypt data
fn encrypt_example(data: &mut [u8], key: u32) {
// Note: encrypt_block works with u32 arrays, so conversion is needed
// In practice, the ArchiveBuilder handles this internally
}
}
Usage Example
use wow_mpq::{Archive, ArchiveBuilder, FormatVersion};
use std::path::Path;
fn main() -> Result<(), Box<dyn std::error::Error>> {
// ✅ Open an existing MPQ archive
let mut archive = Archive::open(Path::new("Data/patch.mpq"))?;
// ✅ Search for a file
if let Some(file_info) = archive.find_file("Interface\\Glues\\Models\\UI_Human\\UI_Human.m2")? {
println!("File found:");
println!(" Offset: 0x{:08X}", file_info.file_pos);
println!(" Size: {} bytes", file_info.file_size);
println!(" Compressed: {} bytes", file_info.compressed_size);
println!(" Encrypted: {}", file_info.is_encrypted());
// ✅ Extract the file
let data = archive.read_file("Interface\\Glues\\Models\\UI_Human\\UI_Human.m2")?;
std::fs::write("UI_Human.m2", data)?;
}
// ✅ List all files (requires listfile)
match archive.list() {
Ok(entries) => {
for entry in entries {
println!("{}: {} bytes", entry.name, entry.size);
}
}
Err(_) => println!("No (listfile) found - cannot enumerate files"),
}
// ✅ Create a new MPQ archive
ArchiveBuilder::new()
.version(FormatVersion::V2)
.add_file_data(b"Hello, MPQ!".to_vec(), "readme.txt")
.add_file("path/to/file.dat", "data/file.dat")
.build("new_archive.mpq")?;
Ok(())
}
Archive Modification
Implementation Status: ⚠️ Partial - File addition not fully implemented
The MutableArchive type provides an API for modifying existing MPQ archives:
#![allow(unused)]
fn main() {
use wow_mpq::{MutableArchive, AddFileOptions, compression::CompressionMethod};
fn modify_archive_example() -> Result<(), Box<dyn std::error::Error>> {
// Open an archive for modification
let mut archive = MutableArchive::open("my_archive.mpq")?;
// ❌ File addition not yet implemented
// This will return an error: "In-place file addition not yet implemented"
// archive.add_file("new_file.txt", "data/new_file.txt", AddFileOptions::new())?;
// ⚠️ Other operations may depend on add_file functionality
// archive.remove_file("old_file.txt")?;
// archive.rename_file("readme.txt", "README.TXT")?;
// ✅ Read files from a mutable archive
let data = archive.read_file("some_file.txt")?;
// ✅ List files (convenience method)
let files = archive.list()?;
for entry in files {
println!("{}: {} bytes", entry.name, entry.size);
}
// ⚠️ Flush and compact depend on modification functionality
// archive.flush()?;
// archive.compact()?;
Ok(())
}
}
Special Files
MPQ archives may contain special metadata files:
(listfile): Plain text list of all filenames in the archive(attributes): Extended attributes for files (CRC32, timestamps, MD5 hashes)- Blizzard archives have attributes files that are 28 bytes larger than the specification
- The
wow-mpqimplementation handles this discrepancy automatically
(signature): Digital signature for archive verification(patch_metadata): Information for incremental patching
Implementation Notes
Header Search
When opening an MPQ, search for the header at aligned offsets:
#![allow(unused)]
fn main() {
use wow_mpq::signatures::{MPQ_ARCHIVE, MPQ_USERDATA};
fn find_mpq_header(data: &[u8]) -> Option<usize> {
// MPQ headers must be aligned to 512-byte boundaries
const HEADER_ALIGNMENT: usize = 0x200;
for offset in (0..data.len()).step_by(HEADER_ALIGNMENT) {
if data.len() >= offset + 4 {
// Read potential signature
let mut sig_bytes = [0u8; 4];
sig_bytes.copy_from_slice(&data[offset..offset + 4]);
let signature = u32::from_le_bytes(sig_bytes);
match signature {
MPQ_USERDATA => {
// User data header - read offset to actual MPQ header
if data.len() >= offset + 12 {
let header_offset = u32::from_le_bytes(
data[offset + 8..offset + 12].try_into().unwrap()
) as usize;
return Some(offset + header_offset);
}
}
MPQ_ARCHIVE => {
// Found MPQ header directly
return Some(offset);
}
_ => continue,
}
}
}
None
}
}
Table Encryption
Hash and block tables are encrypted and must be decrypted after reading:
#![allow(unused)]
fn main() {
use wow_mpq::crypto::decrypt_block;
// Key constants for table decryption
const HASH_TABLE_KEY: u32 = 0xC3AF3770;
const BLOCK_TABLE_KEY: u32 = 0xEC83B3A3;
// Decrypt hash table with fixed key
fn decrypt_hash_table(table_data: &mut [u32]) {
decrypt_block(table_data, HASH_TABLE_KEY);
}
// Decrypt block table with fixed key
fn decrypt_block_table(table_data: &mut [u32]) {
decrypt_block(table_data, BLOCK_TABLE_KEY);
}
// Note: The wow_mpq library handles table decryption automatically
// when loading archives, so you don't need to do this manually.
// The decrypt_block function works with u32 arrays for efficiency.
}
References
Patch Chaining in World of Warcraft
MPQ archives in World of Warcraft use a patch chain system where multiple archives are loaded in a specific order, with higher priority archives overriding files from lower priority ones. This system evolved significantly across WoW versions.
Loading Order and Priorities
The patch chain system uses numeric priorities where higher numbers override lower ones:
- 0-99: Base game archives
- 100-999: Locale-specific base archives
- 1000-1999: General patch archives
- 2000-2999: Locale-specific patch archives
- 3000+: Update archives (Cataclysm and later)
Version-Specific Implementation
WoW 1.12.1 (Vanilla)
- Simple 2-tier system: base archives → patches
- 7 total archives with linear override
- Example:
dbc.MPQ→patch.MPQ→patch-2.MPQ
WoW 2.4.3 (The Burning Crusade)
- Introduced locale system with 4-tier priority
- Archives:
common.MPQ,expansion.MPQ, locale archives, patches - Locale patches have highest priority (2000+)
WoW 3.3.5a (Wrath of the Lich King)
- Most organized structure with clear expansion separation
- TrinityCore documented exact loading order
- 13 archives: base → expansion → lichking → patches
WoW 4.3.4 (Cataclysm)
- Reorganized by content type:
art.MPQ,sound.MPQ,world.MPQ - Introduced
wow-update-#####.MPQsystem - Added DB2 format alongside DBC
WoW 5.4.8 (Mists of Pandaria)
- Peak complexity with 100+ potential archives
- Extensive
wow-updatesystem (13156-18500) - Last version before switching to CASC (6.0)
For detailed information about patch chaining implementation and examples, see the WoW Patch Chain Summary.
See Also
- Working with MPQ Archives Guide
- WoW Patch Chain Summary
- MPQ API Reference
- MPQ Sector CRC Implementation Details
World Data Formats
World data formats define terrain, maps, and the game world structure.
Supported Formats
ADT Format
Azeroth Data Terrain - Individual map tiles containing terrain mesh, textures, and object placement.
- 533.33 x 533.33 yard tiles
- Height maps and normal maps
- Texture layers with alpha blending
- Doodad and WMO placement
WDT Format
World Data Table - Map definition files that specify which ADT tiles exist and global map properties.
- References to ADT files
- Map flags and settings
- Global WMO (like Stormwind)
- Ocean level definition
WDL Format
World Data Low-resolution - Low-detail terrain used for distant rendering and the world map.
- 64x64 low-res heightmap
- Basic texture information
- Used for far-view rendering
- Minimap data source
World Structure
World/
└── Maps/
└── Azeroth/ # Map name
├── Azeroth.wdt # Map definition
├── Azeroth.wdl # Low-detail version
├── Azeroth_32_48.adt # Terrain tile (x=32, y=48)
├── Azeroth_32_48_tex0.adt # Texture info
├── Azeroth_32_48_obj0.adt # Object placement
└── Azeroth_32_48_lod.adt # Level of detail
Coordinate System
- Global: Maps divided into 64x64 ADT grid
- ADT: Each ADT has 16x16 chunks
- Chunk: Each chunk has 9x9 vertices
- Units: 1 yard = 0.5 world units
Common Patterns
Loading a Map Section
#![allow(unused)]
fn main() {
use wow_wdt::{WdtReader, version::WowVersion};
use std::fs::File;
use std::io::BufReader;
// Load map definition
let file = File::open("World/Maps/Azeroth/Azeroth.wdt")?;
let mut reader = WdtReader::new(BufReader::new(file), WowVersion::WotLK);
let wdt = reader.read()?;
// Check if tile exists
if let Some(tile_info) = wdt.get_tile(32, 48) {
if tile_info.has_adt {
// Load the terrain tile
// let adt = Adt::open("World/Maps/Azeroth/Azeroth_32_48.adt")?;
println!("ADT tile exists at [32, 48] - Area ID: {}", tile_info.area_id);
}
}
}
Streaming Large Worlds
#![allow(unused)]
fn main() {
use wow_wdt::{WdtReader, version::WowVersion, tile_to_world, world_to_tile};
use std::collections::HashSet;
// This example shows how you might implement world streaming
// (actual implementation would be in your game engine)
struct WorldStreamer {
existing_tiles: HashSet<(usize, usize)>,
loaded_tiles: HashSet<(usize, usize)>,
view_distance: usize,
}
impl WorldStreamer {
fn new(wdt_path: &str) -> Result<Self, Box<dyn std::error::Error>> {
let file = std::fs::File::open(wdt_path)?;
let mut reader = WdtReader::new(std::io::BufReader::new(file), WowVersion::WotLK);
let wdt = reader.read()?;
let mut existing_tiles = HashSet::new();
for y in 0..64 {
for x in 0..64 {
if let Some(tile_info) = wdt.get_tile(x, y) {
if tile_info.has_adt {
existing_tiles.insert((x, y));
}
}
}
}
Ok(Self {
existing_tiles,
loaded_tiles: HashSet::new(),
view_distance: 5,
})
}
fn update(&mut self, player_x: f32, player_y: f32) {
let (center_tile_x, center_tile_y) = world_to_tile(player_x, player_y);
// Determine which tiles should be loaded
let mut needed_tiles = HashSet::new();
for dy in -(self.view_distance as i32)..=(self.view_distance as i32) {
for dx in -(self.view_distance as i32)..=(self.view_distance as i32) {
let tile_x = (center_tile_x as i32 + dx).max(0).min(63) as usize;
let tile_y = (center_tile_y as i32 + dy).max(0).min(63) as usize;
if self.existing_tiles.contains(&(tile_x, tile_y)) {
needed_tiles.insert((tile_x, tile_y));
}
}
}
// Load new tiles, unload distant ones
for &tile in &needed_tiles {
if !self.loaded_tiles.contains(&tile) {
// Load ADT tile here
println!("Loading tile {:?}", tile);
self.loaded_tiles.insert(tile);
}
}
self.loaded_tiles.retain(|tile| needed_tiles.contains(tile));
}
}
}
Rendering Considerations
- Level of Detail: Use WDL for distant terrain
- Frustum Culling: Cull ADT chunks outside view
- Texture Streaming: Load textures on demand
- Height Queries: Efficient terrain collision
See Also
ADT Format 🏔️
ADT (Area Data Terrain) files contain the terrain and object information for a single map tile in World of Warcraft. The world is divided into a grid of 64x64 maps, with each map consisting of 16x16 chunks. Each ADT file represents one map tile, which is 533.33333 yards (1600 feet) on each side.
Overview
- Extension:
.adt - Purpose: Terrain geometry, textures, water, and object placement
- Grid Size: 16x16 chunks per ADT, 64x64 ADTs per continent
- Chunk Size: 33.33333 yards (100 feet) per chunk
- Format: Chunk-based binary format
File Types
Since Cataclysm (4.x), ADT data is split across multiple files. Confirmed in Cataclysm Preservation Project TrinityCore 4.3.4:
| File Pattern | Description | Content | Chunks Present |
|---|---|---|---|
MapName_XX_YY.adt | Root file | Terrain structure and header | MVER, MHDR, MCNK, MFBO |
MapName_XX_YY_tex0.adt | Texture file | Texture data and amplitude | MVER, MTEX, MAMP, MCNK, MTXP (MoP+) |
MapName_XX_YY_tex1.adt | Texture file | Additional texture layers | MVER, MTEX, MAMP, MCNK, MTXP (MoP+) |
MapName_XX_YY_obj0.adt | Object file | M2 and WMO placement data | MVER, MMDX, MMID, MWMO, MWID, MDDF, MODF, MCNK |
MapName_XX_YY_obj1.adt | Object file | Additional object data | MVER, MMDX, MMID, MWMO, MWID, MDDF, MODF, MCNK |
MapName_XX_YY_lod.adt | LOD file | Level of detail information | (Not analyzed) |
Where XX and YY are the tile coordinates (0-63).
Coordinate System
World of Warcraft uses a right-handed coordinate system:
- X-axis: North (positive) to South (negative)
- Y-axis: West (positive) to East (negative)
- Z-axis: Up (positive) to Down (negative)
Map coordinates:
- One ADT tile = 533.33333 yards = 1600 feet
- One chunk = 33.33333 yards = 100 feet
- One unit = 0.33333 yards = 1 foot
The world origin (0,0) is at the center of map (32,32).
Chunk Structure
All chunks follow this format:
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct ChunkHeader {
/// Four-character chunk identifier
magic: [u8; 4],
/// Size of chunk data (excluding this header)
size: u32,
}
}
Main ADT File Structure
MVER - Version
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct MVERChunk {
header: ChunkHeader, // Magic: "MVER"
version: u32, // ADT version number (consistently 18)
}
}
Version history (based on analysis of original MPQ files):
- 18 = All versions analyzed (1.12.1 through 5.4.8) use version 18
- Note: Earlier documentation suggesting version 17 for TBC appears to be incorrect
MHDR - Header
Contains offsets to all other chunks in the file:
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct MHDRChunk {
header: ChunkHeader, // Magic: "MHDR"
/// All offsets are relative to start of file (0 if chunk not present)
flags: u32, // Always 0 in ADT files
mcin_offset: u32, // Offset to MCIN chunk
mtex_offset: u32, // Offset to MTEX chunk
mmdx_offset: u32, // Offset to MMDX chunk
mmid_offset: u32, // Offset to MMID chunk
mwmo_offset: u32, // Offset to MWMO chunk
mwid_offset: u32, // Offset to MWID chunk
mddf_offset: u32, // Offset to MDDF chunk
modf_offset: u32, // Offset to MODF chunk
mfbo_offset: u32, // Offset to MFBO chunk (TBC+)
mh2o_offset: u32, // Offset to MH2O chunk (TBC+)
mtxf_offset: u32, // Offset to MTXF chunk (TBC+)
reserved: [u32; 4], // Padding
}
}
MCIN - Chunk Information
Contains offsets and sizes for all 256 (16x16) map chunks:
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct MCINEntry {
/// Absolute offset to MCNK chunk
mcnk_offset: u32,
/// Size of MCNK chunk including header
size: u32,
/// Flags (usually 0)
flags: u32,
/// Async object id (0 if none)
async_id: u32,
}
#[repr(C, packed)]
struct MCINChunk {
header: ChunkHeader, // Magic: "MCIN"
entries: [MCINEntry; 256], // 16x16 grid
}
}
MTEX - Texture List
Contains null-terminated texture filenames:
#![allow(unused)]
fn main() {
struct MTEXChunk {
header: ChunkHeader, // Magic: "MTEX"
/// Concatenated null-terminated strings
/// Example: "Tileset\\Elwynn\\ElwynnGrass01.blp\0"
texture_names: Vec<u8>,
}
}
MMDX/MMID - Model List
- MMDX: Contains null-terminated M2 model filenames
- MMID: Maps model instances to their filename offsets in MMDX
MWMO/MWID - WMO List
- MWMO: Contains null-terminated WMO filenames
- MWID: Maps WMO instances to their filename offsets in MWMO
MDDF - Model (Doodad) Placement
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct MDDFEntry {
/// Index into MMID
mmid_entry: u32,
/// Unique instance ID
unique_id: u32,
/// Position in world coordinates
position: [f32; 3], // x, y, z
/// Rotation in degrees
rotation: [f32; 3], // x, y, z
/// Scale factor (1024 = 1.0)
scale: u16,
/// Flags
flags: u16,
}
// MDDF flags
const MDDF_BIODOME: u16 = 0x0001; // Use for biodome in WMO
const MDDF_SHRUBBERY: u16 = 0x0002; // Shrubbery scale factor
}
MODF - WMO Placement
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct MODFEntry {
/// Index into MWID
mwid_entry: u32,
/// Unique instance ID
unique_id: u32,
/// Position in world coordinates
position: [f32; 3],
/// Rotation in degrees
rotation: [f32; 3],
/// Bounding box
extent_lower: [f32; 3],
extent_upper: [f32; 3],
/// Flags (same as MDDF)
flags: u16,
/// Doodad set index
doodad_set: u16,
/// Name set index
name_set: u16,
/// Scale (Legion+)
scale: u16,
}
}
MH2O - Water Information (WotLK+)
First appeared in Wrath of the Lich King (3.3.5a). Contains water levels and types, replacing the legacy MCLQ system.
Structural Analysis: Complex variable-size chunks with 16×16 grid structure. Validated across multiple TrinityCore versions:
- TrinityCore 3.3.5a (detailed implementation)
- SkyFire 5.4.8 (simplified MoP implementation)
Both versions confirm the 16×16 grid structure with variable liquid instances and attributes.
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct MH2OHeader {
/// Offset to MH2OInformation for this chunk
offset_information: u32,
/// Number of water layers
layer_count: u32,
/// Offset to render mask
offset_render_mask: u32,
}
#[repr(C, packed)]
struct MH2OInformation {
/// Water type (ocean, lake, etc)
liquid_type: u16,
/// Flags
flags: u16,
/// Height levels
height_level1: f32,
height_level2: f32,
/// Position within chunk
x_offset: u8,
y_offset: u8,
width: u8,
height: u8,
/// Offset to vertex data
offset_vertex_data: u32,
}
// MH2O Flags
const MH2O_OCEAN: u16 = 0x0002;
const MH2O_DEEP: u16 = 0x0004;
const MH2O_FISHABLE: u16 = 0x0008;
}
MFBO - Flight Bounds (TBC+)
Contains flight ceiling information. First appeared in The Burning Crusade (2.4.3):
Structural Analysis: All analyzed MFBO chunks are consistently 36 bytes. Validated against TrinityCore implementation which defines the structure as two planes with 9 int16 coordinates each.
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct MFBOPlane {
/// 9 coordinate values defining a plane
coords: [i16; 9], // 18 bytes
}
#[repr(C, packed)]
struct MFBOChunk {
header: ChunkHeader, // Magic: "MFBO"
/// Maximum flight bounds plane
max: MFBOPlane, // 18 bytes
/// Minimum flight bounds plane
min: MFBOPlane, // 18 bytes
}
}
Total data size: 36 bytes (18 + 18), confirmed by empirical analysis and validated across multiple TrinityCore versions:
- TrinityCore 3.3.5a (WotLK)
- Cataclysm Preservation Project TrinityCore 4.3.4
Usage: Both TrinityCore versions extract these as int16 flight_box_max[3][3] and int16 flight_box_min[3][3] arrays for flight ceiling calculations.
Validation: Structure confirmed across multiple production WoW server emulator implementations spanning TBC through Cataclysm.
Note: SkyFire 5.4.8 (MoP) does not implement MFBO chunks in their map extractor, suggesting flight bounds may be handled differently in later expansions or not required for server-side processing.
MAMP - Amplitude Map (Cataclysm+)
First appeared in Cataclysm (4.3.4). Contains amplitude or deformation data for terrain:
Structural Analysis: Always 4 bytes, appears to be flags or simple values. Structure based on empirical analysis only.
Server Implementation Status:
- Not implemented in Cataclysm Preservation Project TrinityCore 4.3.4
- Not implemented in SkyFire 5.4.8 (MoP)
- Suggests this chunk is texture-file specific or client-rendering optimization only
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct MAMPChunk {
header: ChunkHeader, // Magic: "MAMP"
/// Amplitude value or flags
/// Common values: 0x00000000, 0x00000001
value: u32,
}
}
MTXP - Texture Parameters (MoP+)
First appeared in Mists of Pandaria (5.4.8). Contains texture parameters for advanced material properties:
Structural Analysis: Variable size, contains arrays of 16-byte entries. Average size 154 bytes. Structure based on empirical analysis only.
Server Implementation Status:
- Not implemented in SkyFire 5.4.8 (MoP)
- Suggests this chunk is client-rendering specific for advanced texture material properties
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct MTXPEntry {
/// Texture parameter data (16 bytes per entry)
/// Structure appears to be 4 u32 values
params: [u32; 4],
}
#[repr(C, packed)]
struct MTXPChunk {
header: ChunkHeader, // Magic: "MTXP"
entries: Vec<MTXPEntry>, // Variable count
}
}
Note: The exact meaning of the parameter values requires further analysis.
MTXF - Texture Flags (Legacy)
Rarely found in analyzed files. May be deprecated in favor of MTXP:
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct MTXFChunk {
header: ChunkHeader, // Magic: "MTXF"
flags: Vec<u32>, // One per texture
}
// Texture flags
const MTXF_DISABLE_ALPHA: u32 = 0x0001;
const MTXF_USE_CUBE_MAP: u32 = 0x0002;
}
Map Chunk (MCNK) Structure
Each MCNK chunk represents a 33.33x33.33 yard square of terrain:
MCNK Header
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct MCNKHeader {
/// Flags for this chunk
flags: u32,
/// Index of this chunk
index_x: u32,
index_y: u32,
/// Number of texture layers (max 4)
n_layers: u32,
/// Number of doodad references
n_doodad_refs: u32,
/// High-res holes (8x8 grid)
holes_high_res: u64,
/// Offsets to sub-chunks (relative to MCNK data start)
offset_mcly: u32, // Texture layers
offset_mcrf: u32, // References
offset_mcal: u32, // Alpha maps
size_mcal: u32, // Alpha map size
offset_mcsh: u32, // Shadow map
size_mcsh: u32, // Shadow map size
/// Area ID
area_id: u32,
/// Number of WMO references
n_map_obj_refs: u32,
/// Low-res holes (4x4 grid)
holes_low_res: u16,
/// Unknown
unknown_0x3C: u16,
/// Low-res texture map (8x8 grid)
low_res_texture_map: [u16; 8],
/// No effect doodad
no_effect_doodad: u32,
/// Sound emitters
offset_mcse: u32,
n_sound_emitters: u32,
/// Liquid
offset_mclq: u32,
size_mclq: u32,
/// Position of chunk
position: [f32; 3],
/// Vertex colors
offset_mccv: u32,
/// Vertex lighting (unused)
offset_mclv: u32,
/// Unused
unused: u32,
}
// MCNK flags
const MCNK_HAS_MCSH: u32 = 0x0001; // Has shadow map
const MCNK_IMPASS: u32 = 0x0002; // Impassable
const MCNK_LQ_RIVER: u32 = 0x0004; // River
const MCNK_LQ_OCEAN: u32 = 0x0008; // Ocean
const MCNK_LQ_MAGMA: u32 = 0x0010; // Magma
const MCNK_LQ_SLIME: u32 = 0x0020; // Slime
const MCNK_HAS_MCCV: u32 = 0x0040; // Has vertex colors
const MCNK_DO_NOT_FIX_ALPHA_MAP: u32 = 0x8000; // Don't fix alpha map
const MCNK_HIGH_RES_HOLES: u32 = 0x10000; // Use high-res holes
}
MCVT - Vertex Heights
Contains 145 height values (9x9 outer + 8x8 inner vertices):
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct MCVTChunk {
header: ChunkHeader, // Magic: "MCVT"
/// Height values relative to MCNK position
/// Order: 9x9 outer vertices, then 8x8 inner vertices
heights: [f32; 145], // 9*9 + 8*8 = 145
}
}
Vertex layout:
Outer vertices (9x9): Grid corners
Inner vertices (8x8): Center of each quad
MCCV - Vertex Colors
Optional vertex colors for terrain shading:
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct MCCVColor {
r: u8,
g: u8,
b: u8,
a: u8, // Usually 127 or 255
}
#[repr(C, packed)]
struct MCCVChunk {
header: ChunkHeader, // Magic: "MCCV"
colors: [MCCVColor; 145], // Same layout as MCVT
}
}
MCNR - Normals
Normal vectors for lighting:
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct MCNREntry {
normal: [i8; 3], // X, Y, Z components (-127 to 127)
}
#[repr(C, packed)]
struct MCNRChunk {
header: ChunkHeader, // Magic: "MCNR"
normals: [MCNREntry; 145], // Same layout as MCVT
padding: [u8; 13], // Unknown padding
}
}
MCLY - Texture Layers
Defines up to 4 texture layers:
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct MCLYEntry {
/// Texture index in MTEX
texture_id: u32,
/// Flags for this layer
flags: u32,
/// Offset to alpha map in MCAL
offset_in_mcal: u32,
/// Effect ID (usually 0)
effect_id: u32,
}
// MCLY flags
const MCLY_ANIMATION_ROTATION: u32 = 0x040; // 45° rotation
const MCLY_ANIMATION_SPEED_FAST: u32 = 0x080; // Faster animation
const MCLY_ANIMATION_SPEED_FASTER: u32 = 0x100; // Even faster
const MCLY_ANIMATION_SPEED_FASTEST: u32 = 0x200; // Fastest
const MCLY_ANIMATE: u32 = 0x400; // Enable animation
const MCLY_USE_ALPHA_MAP: u32 = 0x800; // Use alpha map
const MCLY_USE_CUBE_MAP_REFLECTION: u32 = 0x1000; // Skybox reflection
}
MCRF - References
Lists doodad and object references for this chunk:
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct MCRFChunk {
header: ChunkHeader, // Magic: "MCRF"
/// Indices into MDDF and MODF
doodad_refs: Vec<u32>,
object_refs: Vec<u32>,
}
}
MCSH - Shadow Map
64x64 bit shadow map:
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct MCSHChunk {
header: ChunkHeader, // Magic: "MCSH"
/// 1 bit per terrain quad
shadow_map: [u8; 512], // 64 * 64 / 8
}
}
MCAL - Alpha Maps
Alpha maps control texture blending. The first texture layer has no alpha map (it’s the base):
#![allow(unused)]
fn main() {
enum AlphaMapFormat {
/// Uncompressed 64x64 (4096 bytes)
Uncompressed4096,
/// Uncompressed 32x32 (2048 bytes)
Uncompressed2048,
/// Compressed (variable size)
Compressed,
}
}
Compression algorithm:
#![allow(unused)]
fn main() {
fn decompress_alpha_map(compressed: &[u8], output: &mut [u8]) {
let mut input_pos = 0;
let mut output_pos = 0;
while output_pos < output.len() && input_pos < compressed.len() {
let count = compressed[input_pos] & 0x7F;
let fill = (compressed[input_pos] & 0x80) != 0;
input_pos += 1;
for _ in 0..count {
if fill {
// Repeat single value
output[output_pos] = compressed[input_pos];
output_pos += 1;
} else {
// Copy values
output[output_pos] = compressed[input_pos];
input_pos += 1;
output_pos += 1;
}
}
if fill {
input_pos += 1;
}
}
}
}
MCLQ - Liquid (Legacy, removed in WotLK)
Deprecated: Removed in Wrath of the Lich King (3.3.5a+), replaced by MH2O chunk.
Pre-WotLK liquid data:
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct MCLQHeader {
min_height: f32,
max_height: f32,
}
#[repr(C, packed)]
struct MCLQVertex {
/// Water height
height: f32,
/// Flow data
flow: [u8; 4],
}
#[repr(C, packed)]
struct MCLQChunk {
header: ChunkHeader, // Magic: "MCLQ"
liquid_header: MCLQHeader,
/// 9x9 vertex heights
vertices: [MCLQVertex; 81],
/// 8x8 flags
flags: [u8; 64],
}
// MCLQ flags
const MCLQ_HIDDEN: u8 = 0x01;
const MCLQ_FISHABLE: u8 = 0x02;
const MCLQ_SHARED: u8 = 0x04;
}
MCSE - Sound Emitters
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct MCSEEntry {
/// Sound ID
sound_id: u32,
/// Position relative to chunk
position: [f32; 3],
/// Size/radius
size: [f32; 3],
}
}
Height Calculation Algorithm
To calculate terrain height at any position:
#![allow(unused)]
fn main() {
/// Get interpolated height at position within chunk
pub fn get_height_at_position(
mcvt: &MCVTChunk,
x: f32, // 0.0 to 33.33333
y: f32, // 0.0 to 33.33333
) -> f32 {
// Convert to cell coordinates
let cell_x = (x / (33.33333 / 8.0)).min(7.999);
let cell_y = (y / (33.33333 / 8.0)).min(7.999);
let ix = cell_x as usize;
let iy = cell_y as usize;
let fx = cell_x - ix as f32;
let fy = cell_y - iy as f32;
// Get the four corner heights
let h00 = mcvt.get_outer(ix, iy);
let h01 = mcvt.get_outer(ix, iy + 1);
let h10 = mcvt.get_outer(ix + 1, iy);
let h11 = mcvt.get_outer(ix + 1, iy + 1);
// Get center height
let hc = mcvt.get_inner(ix, iy);
// Determine which triangle and interpolate
if fx + fy < 1.0 {
// Lower triangle
if fx < fy {
// Left triangle
h00 * (1.0 - fx - fy) + h01 * fy + hc * fx
} else {
// Bottom triangle
h00 * (1.0 - fx - fy) + h10 * fx + hc * fy
}
} else {
// Upper triangle
if fx > fy {
// Right triangle
h11 * (fx + fy - 1.0) + h10 * (1.0 - fy) + hc * (1.0 - fx)
} else {
// Top triangle
h11 * (fx + fy - 1.0) + h01 * (1.0 - fx) + hc * (1.0 - fy)
}
}
}
}
Coordinate Transformations
#![allow(unused)]
fn main() {
/// World position to ADT tile coordinates
pub fn world_to_adt(world_x: f32, world_y: f32) -> (i32, i32) {
// World origin is at center of map (32, 32)
let adt_x = 32 - (world_x / 533.33333);
let adt_y = 32 - (world_y / 533.33333);
(adt_x as i32, adt_y as i32)
}
/// ADT tile to world coordinates (tile corner)
pub fn adt_to_world(adt_x: i32, adt_y: i32) -> (f32, f32) {
let world_x = (32 - adt_x) as f32 * 533.33333;
let world_y = (32 - adt_y) as f32 * 533.33333;
(world_x, world_y)
}
/// Position within ADT to chunk index
pub fn position_to_chunk(x: f32, y: f32) -> (usize, usize) {
let chunk_x = (x / 33.33333) as usize;
let chunk_y = (y / 33.33333) as usize;
(chunk_x.min(15), chunk_y.min(15))
}
}
Usage Examples
Loading and Parsing ADT (Current Implementation)
Implementation Status: ⚠️ Basic Parsing Only - Advanced terrain processing not implemented
#![allow(unused)]
fn main() {
use wow_adt::{Adt, AdtVersion};
// ✅ Load ADT file with correct API
let adt = Adt::from_path("World/Maps/Azeroth/Azeroth_32_48.adt")?;
// ✅ Check detected version
println!("Detected version: {}", adt.version());
// ✅ Access terrain chunk data
println!("MCNK chunks: {}", adt.mcnk_chunks.len());
if adt.mfbo.is_some() {
println!("Has flight boundaries (TBC+)");
}
if adt.mh2o.is_some() {
println!("Has water data (WotLK+)");
}
// ❌ Height interpolation algorithms not implemented
// ❌ export_heightmap() method does not exist
// ❌ High-level texture/doodad access APIs not implemented
// ❌ Advanced terrain processing not available
}
Texture Blending (Format Specification)
Implementation Status: ⚠️ Format Specification Only - Mathematical blending approach documented
ADT files support up to 4 texture layers per chunk with alpha map blending. The official format specification describes a complex blending algorithm involving:
- Layer Weight Calculation - Based on alpha map values
- Height-based Scaling - Terrain height influences blending
- Vertex Color Integration - MCCV chunk data affects final output
// From Legion terrain shader (format specification):
// layer_pct = layer_pct / vec4(sum(layer_pct));
//
// Current implementation provides access to raw texture and alpha data
// through parsed MCNK chunks, but blending logic must be implemented separately
Liquid Rendering
#![allow(unused)]
fn main() {
// MH2O water (WotLK+)
if let Some(water_chunk) = &chunk.water {
for layer in &water_chunk.layers {
let water_type = layer.liquid_type;
let height = layer.height_level1;
// Render water surface
render_water_layer(layer, water_type);
}
}
// Legacy MCLQ water
if let Some(liquid) = &chunk.liquid {
for x in 0..9 {
for y in 0..9 {
let vertex = &liquid.vertices[y * 9 + x];
let depth = vertex.height;
let flow = vertex.flow;
// Render water vertex
}
}
}
}
Implementation Notes
Reading ADT Files
#![allow(unused)]
fn main() {
pub struct ADTReader {
data: Vec<u8>,
position: usize,
}
impl ADTReader {
pub fn read_chunk(&mut self) -> Result<(ChunkHeader, &[u8]), ADTError> {
if self.position + 8 > self.data.len() {
return Err(ADTError::UnexpectedEof);
}
// Read header
let header = unsafe {
*(self.data.as_ptr().add(self.position) as *const ChunkHeader)
};
self.position += 8;
// Get chunk data
let chunk_size = header.size as usize;
if self.position + chunk_size > self.data.len() {
return Err(ADTError::InvalidChunkSize);
}
let chunk_data = &self.data[self.position..self.position + chunk_size];
self.position += chunk_size;
Ok((header, chunk_data))
}
}
}
Performance Optimizations
- Memory mapping: Use memory-mapped files for large ADT files
- Lazy loading: Only parse chunks when needed
- Caching: Cache frequently accessed data like height maps
- LOD: Use WDL files for distant terrain
- Frustum culling: Only render visible chunks
- Texture atlasing: Combine texture layers to reduce draw calls
Common Pitfalls
- Byte order: All values are little-endian
- Chunk alignment: Chunks are not always aligned to 4-byte boundaries
- String parsing: Strings in MTEX/MMDX/MWMO are null-terminated
- Coordinate systems: Y-axis is north/south (not up/down)
- Height interpolation: Must use the center vertices for proper interpolation
- Alpha map compression: Check MCLY flags to determine format
- Scale values: MDDF scale is 1024 = 1.0, not a float
Chunk Version Evolution
Based on analysis of original ADT files across World of Warcraft versions:
Core Chunks (1.12.1+)
These chunks are present in all analyzed versions:
- MVER - Version information
- MHDR - File header with offsets
- MCIN - Chunk index table (pre-Cataclysm) / distributed in split files (Cataclysm+)
- MTEX - Texture filename list
- MMDX - M2 model filename list
- MMID - M2 model indices
- MWMO - WMO filename list
- MWID - WMO indices
- MDDF - M2 model placement data
- MODF - WMO placement data
- MCNK - Map chunk data (terrain)
Legacy Chunks (Removed)
- MCLQ - Legacy liquid data (1.12.1 - 2.4.3, replaced by MH2O in WotLK)
The Burning Crusade Additions (2.4.3+)
- MFBO - Flight bounds data (appears in ~34% of TBC ADT files)
Wrath of the Lich King Additions (3.3.5a+)
- MH2O - New water system (replaces legacy MCLQ)
Cataclysm Additions (4.3.4+)
- MAMP - Amplitude map data for terrain deformation (4 bytes, flag/value)
- MTXF - Enhanced texture flags (evolution from earlier texture systems)
Mists of Pandaria Additions (5.4.8+)
- MTXP - Texture parameters for advanced material properties (16-byte entries, variable count)
Structural Changes Across Versions
Analysis of chunk sizes and structures reveals several evolution patterns:
MHDR Structure Evolution
- Pre-Cataclysm: All files have MCIN offsets (centralized chunk index)
- Cataclysm+: MCIN offsets removed from many files (distributed across split files)
- MFBO Support: 90% of TBC files, 75% of WotLK files, 67% of Cataclysm+ files
- MH2O Support: 0% in TBC, 63% in WotLK, variable in later versions
Chunk Size Patterns
- MVER: Consistently 4 bytes across all versions
- MHDR: Consistently 64 bytes across all versions
- MCIN: Consistently 4096 bytes (256 entries × 16 bytes) when present
- MCNK: Highly variable sizes, trend toward larger chunks in later versions
- 1.12.1: avg 4905 bytes
- 2.4.3: avg 2892 bytes
- 3.3.5a: avg 5428 bytes
- 4.3.4+: avg 383-940 bytes (split file architecture)
Split File Architecture Impact (Cataclysm+)
- Root ADT files become much smaller (mainly MHDR + MCNK structure)
- Texture files contain MAMP, MTEX, MTXP chunks
- Object files contain model/WMO placement data
- Average MCNK size drops significantly due to data redistribution
Analysis Results
- ✅ WoW 1.12.1: 11 chunk types, monolithic files (avg 4905 byte MCNK)
- ✅ WoW 2.4.3: 12 chunk types (+MFBO 36 bytes), MFBO in 90% of files
- ✅ WoW 3.3.5a: 13 chunk types (+MH2O variable size), MH2O in 63% of files
- ✅ WoW 4.3.4: 14 chunk types (+MAMP 4 bytes), split file architecture introduced
- ✅ WoW 5.4.8: 14 chunk types (+MTXP 16-byte entries), MTXP in texture files
Server Implementation Validation
Our analysis has been cross-validated against production WoW server emulator implementations:
Validation Sources
- TrinityCore 3.3.5a (WotLK) - Reference implementation
- Cataclysm Preservation Project TrinityCore 4.3.4 - Cataclysm support
- SkyFire 5.4.8 - Mists of Pandaria support
Implementation Confidence Levels
| Chunk | Confidence | Server Validation | Usage |
|---|---|---|---|
| MFBO | Very High | TrinityCore 3.3.5a + Cataclysm 4.3.4 | Flight bounds for gameplay |
| MH2O | Very High | TrinityCore 3.3.5a + SkyFire 5.4.8 | Water collision/rendering |
| Split Files | High | Cataclysm 4.3.4 + SkyFire 5.4.8 | Data organization |
| MAMP | Medium | None (empirical only) | Client rendering optimization |
| MTXP | Medium | None (empirical only) | Client texture enhancement |
Key Insight: Server-validated chunks are essential for gameplay mechanics, while empirical-only chunks appear to be client-side rendering optimizations.
Version History
Based on analysis of original MPQ archives:
| MVER Version | Game Versions | Major Changes |
|---|---|---|
| 18 | All analyzed versions (1.12.1 - 5.4.8) | Consistent version across all expansions |
Format Evolution by Game Version
| Game Version | MVER | Changes |
|---|---|---|
| 1.12.1 (Vanilla) | 18 | Core ADT format established |
| 2.4.3 (The Burning Crusade) | 18 | Added MFBO chunk |
| 3.3.5a (Wrath of the Lich King) | 18 | Added MH2O water, replaced MCLQ |
| 4.3.4 (Cataclysm) | 18 | Split files (_tex0,_obj0, _obj1), added MAMP |
| 5.4.8 (Mists of Pandaria) | 18 | Added MTXP chunk |
References
- WoWDev Wiki - ADT Format
- Map Coordinates System
- Trinity Core Map Extractor
- libwarcraft ADT Implementation
See Also
- Rendering ADT Terrain Guide
- WDT Format - World tables that reference ADTs
- WDL Format - Low-detail world data
- Coordinate System - Detailed coordinate information
WDL Format 🌍
WDL (World Data Low-resolution) files contain low-detail heightmap and water data for entire continents in World of Warcraft. These files are part of the terrain Level of Detail (LoD) system, providing efficient rendering of distant terrain and supporting world map generation.
Overview
- Extension:
.wdl- ✅ Implemented - Purpose: Low-resolution terrain for distant viewing and world maps - ✅ Implemented
- Coverage: Entire continent in one file - ✅ Implemented
- Resolution: 17x17 height values per ADT tile - ✅ Implemented
- Use Case: Map view, flight paths, distant terrain, minimap generation - ⚠️ Parsing Implemented, Rendering Not Implemented
- Format: Chunk-based binary format (similar to other Blizzard formats) - ✅ Implemented
Version History
Based on analysis of WDL files from WoW versions 1.12.1 through 5.4.8:
| Version | WoW Versions | Notes |
|---|---|---|
| 18 | All versions (1.12.1 - 5.4.8) | Consistent across all tested versions |
File Structure
WDL files follow the standard Blizzard chunk-based format:
[File Header]
[Chunk 1: Header + Data]
[Chunk 2: Header + Data]
[...]
[Chunk N: Header + Data]
Chunk Header Structure
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct ChunkHeader {
/// Four-character chunk identifier (e.g., b"MVER", b"MAOF")
magic: [u8; 4],
/// Size of the chunk data (not including this header)
size: u32,
}
}
Chunk Evolution Timeline
Based on empirical analysis of WDL files across WoW versions:
Classic (1.12.1)
- Core chunks: MVER, MAOF, MARE
- MAOF size: 16384 bytes (64×64×4 bytes)
- MARE size: 1090 bytes (when present)
The Burning Crusade (2.4.3)
- New chunk: MAHO (height holes/occlusion)
- MAHO size: Variable (typically 32-2176 bytes)
- All other chunks unchanged
Wrath of the Lich King (3.3.5a)
- No new chunks
- MAHO more commonly present
- Structure remains stable
Cataclysm (4.3.4) - MAJOR CHANGES
- New chunks: MWID, MWMO, MODF
- MWID: WMO instance IDs (often 0 bytes)
- MWMO: WMO filenames (often 0 bytes)
- MODF: WMO placement data (often 0 bytes)
- Support for WMO references in low-res world
Mists of Pandaria (5.4.8)
- No structural changes from Cataclysm
- Same chunk set as 4.3.4
Main Chunks
| Chunk | Size | Description | First Seen | Required |
|---|---|---|---|---|
| MVER | 4 | Version number (always 18) | 1.12.1 | ✅ |
| MAOF | 16384 | Map area offset table (64×64×4) | 1.12.1 | ✅ |
| MARE | 1090 | Map area terrain heights | 1.12.1 | ❌ |
| MAHO | 32-2176 | Map area height holes | 2.4.3 | ❌ |
| MWID | Variable | WMO instance IDs | 4.3.4 | ❌ |
| MWMO | Variable | WMO filenames | 4.3.4 | ❌ |
| MODF | Variable | WMO placement data | 4.3.4 | ❌ |
MVER - Version Chunk
Always appears first in the file:
#![allow(unused)]
fn main() {
struct MverChunk {
header: ChunkHeader, // magic = b"MVER", size = 4
version: u32, // Always 18 in all tested versions (1.12.1-5.4.8)
}
}
MAOF - Area Offset Chunk
Contains offset information for area data. Always 16384 bytes (64×64×4):
#![allow(unused)]
fn main() {
struct MaofChunk {
header: ChunkHeader, // magic = b"MAOF", size = 16384
offsets: [[u32; 64]; 64], // 64×64 grid of offsets
}
}
Notes:
- Most entries are zero (86-100% zeros in tested files)
- Non-zero values indicate tiles with terrain data
- Grid corresponds to ADT tile layout
MARE - Area Information Chunk
Contains low-resolution heightmap data. Consistently 1090 bytes when present:
#![allow(unused)]
fn main() {
struct MareChunk {
header: ChunkHeader, // magic = b"MARE", size = 1090
data: [u8; 1090], // Height and area data
}
}
Structure (preliminary analysis):
- Contains 17×17 height grid per tile (545 int16 values = 1090 bytes)
- Provides low-resolution heightmap for distant terrain
- Not present in all WDL files (only maps with terrain)
MAHO - Map Area Height Occlusion (TBC+)
Added in The Burning Crusade, contains height hole/occlusion data:
#![allow(unused)]
fn main() {
struct MahoChunk {
header: ChunkHeader, // magic = b"MAHO", size varies (32-2176 bytes)
data: Vec<i16>, // Height occlusion values
}
}
Notes:
- Size varies based on map complexity
- Often contains many zero values
- Used for occlusion culling optimization
MWID - WMO Instance IDs (Cataclysm+)
Added in Cataclysm, maps WMO instances:
#![allow(unused)]
fn main() {
struct MwidChunk {
header: ChunkHeader, // magic = b"MWID", size varies (often 0)
wmo_ids: Vec<u32>, // WMO instance IDs
}
}
MWMO - WMO Filenames (Cataclysm+)
Added in Cataclysm, contains WMO filename strings:
#![allow(unused)]
fn main() {
struct MwmoChunk {
header: ChunkHeader, // magic = b"MWMO", size varies (often 0)
filenames: Vec<CString>, // Null-terminated WMO filenames
}
}
MODF - WMO Placement Data (Cataclysm+)
Added in Cataclysm, defines WMO positions:
#![allow(unused)]
fn main() {
struct ModfChunk {
header: ChunkHeader, // magic = b"MODF", size varies (often 0)
entries: Vec<ModfEntry>, // WMO placement entries (64 bytes each)
}
struct ModfEntry {
id: u32, // Index into MWMO
unique_id: u32, // Unique instance ID
position: [f32; 3], // X, Y, Z position
rotation: [f32; 3], // X, Y, Z rotation (radians)
lower_bounds: [f32; 3], // Bounding box min
upper_bounds: [f32; 3], // Bounding box max
flags: u16, // WMO flags
doodad_set: u16, // Doodad set index
name_set: u16, // Name set index
scale: u16, // Scale factor (1024 = 1.0)
}
}
Notes:
- Often empty (0 bytes) in WDL files
- When present, uses same structure as ADT MODF chunks
- Primarily for major landmarks visible from distance
#![allow(unused)]
fn main() {
struct MareChunk {
header: ChunkHeader, // magic = b"MARE"
// Contains:
// - Height map data at low resolution
// - Texture information
// - Possibly color/lighting data
// - Area identification information
}
}
Height Data Structure (Tentative)
#![allow(unused)]
fn main() {
struct WdlHeight {
base_height: i16, // Base terrain height
height_map: [[i8; 17]; 17], // Height offsets from base
unknown: [[i8; 16]; 16], // Unknown data (possibly normals)
}
struct WdlWater {
height_level: i16, // Water surface height
height_map: [[i8; 17]; 17], // Water depth values
}
}
Coordinate System
WDL files use World of Warcraft’s standard coordinate system:
- World Units: 1 yard = 1 coordinate unit
- Coverage: 64x64 ADT tiles per continent
- Tile Size: 533.33333 units (needs verification)
- Origin Offset: 17066.666 units (needs verification)
- Total Resolution: ~1024x1024 height values for entire continent
Coordinate Conversion
#![allow(unused)]
fn main() {
/// Convert world coordinates to WDL tile coordinates
pub fn world_to_wdl_coords(world_x: f32, world_y: f32) -> (u32, u32) {
const TILE_SIZE: f32 = 533.33333;
const ORIGIN_OFFSET: f32 = 17066.666;
let tile_x = ((ORIGIN_OFFSET - world_x) / TILE_SIZE) as u32;
let tile_y = ((ORIGIN_OFFSET - world_y) / TILE_SIZE) as u32;
(tile_x, tile_y)
}
/// Convert WDL tile coordinates back to world coordinates
pub fn wdl_to_world_coords(tile_x: u32, tile_y: u32) -> (f32, f32) {
const TILE_SIZE: f32 = 533.33333;
const ORIGIN_OFFSET: f32 = 17066.666;
let world_x = ORIGIN_OFFSET - (tile_x as f32 * TILE_SIZE);
let world_y = ORIGIN_OFFSET - (tile_y as f32 * TILE_SIZE);
(world_x, world_y)
}
}
Level of Detail System
WDL files are part of WoW’s LoD hierarchy:
WDT (World Directory Table)
├── ADT (Area Data Table) - High detail, close terrain
└── WDL (World LoD) - Low detail, distant terrain
Usage Patterns
- Distance-Based Switching: Engine switches between ADT and WDL based on camera distance
- World Map Generation: WDL data generates world map imagery
- Minimap Support: Low-resolution data for minimap rendering
- Memory Optimization: Allows unloading high-detail ADT data when not needed
Usage Example - ✅ Implemented
#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::BufReader;
use wow_wdl::parser::WdlParser;
// Open a WDL file
let file = File::open("World/Maps/Azeroth/Azeroth.wdl")?;
let mut reader = BufReader::new(file);
// Parse the file
let parser = WdlParser::new();
let wdl_file = parser.parse(&mut reader)?;
// Use the data
println!("WDL version: {}", wdl_file.version);
println!("Map tiles: {}", wdl_file.heightmap_tiles.len());
// Get heightmap for a specific tile
if let Some(tile) = wdl_file.heightmap_tiles.get(&(32, 32)) {
println!("Tile 32,32 has {} outer height values", tile.outer_values.len());
println!("Tile 32,32 has {} inner height values", tile.inner_values.len());
}
// Check which ADT tiles have data
for ((x, y), _) in &wdl_file.heightmap_tiles {
println!("ADT {}_{} has heightmap data", x, y);
}
// Check for holes data
if let Some(holes) = wdl_file.holes_data.get(&(32, 32)) {
for row in 0..16 {
for col in 0..16 {
if holes.has_hole(row, col) {
println!("Hole at tile 32,32 position ({}, {})", row, col);
}
}
}
}
}
Advanced Features
Version Conversion
#![allow(unused)]
fn main() {
use wow_wdl::parser::WdlParser;
use wow_wdl::version::WdlVersion;
use wow_wdl::conversion::convert_wdl_file;
use std::fs::File;
use std::io::{BufReader, BufWriter};
// Parse an existing file
let file = File::open("input.wdl")?;
let mut reader = BufReader::new(file);
let parser = WdlParser::new();
let wdl_file = parser.parse(&mut reader)?;
// Convert to Legion version
let legion_file = convert_wdl_file(&wdl_file, WdlVersion::Legion)?;
// Save the converted file
let output = File::create("output.wdl")?;
let mut writer = BufWriter::new(output);
let legion_parser = WdlParser::with_version(WdlVersion::Legion);
legion_parser.write(&mut writer, &legion_file)?;
}
Creating New WDL Files
#![allow(unused)]
fn main() {
use wow_wdl::types::{WdlFile, HeightMapTile, HolesData};
use wow_wdl::version::WdlVersion;
use wow_wdl::parser::WdlParser;
use std::io::Cursor;
// Create a new WDL file with WotLK version
let mut file = WdlFile::with_version(WdlVersion::Wotlk);
// Add a heightmap tile
let mut heightmap = HeightMapTile::new();
for i in 0..HeightMapTile::OUTER_COUNT {
heightmap.outer_values[i] = (i as i16) % 100;
}
for i in 0..HeightMapTile::INNER_COUNT {
heightmap.inner_values[i] = ((i + 100) as i16) % 100;
}
file.heightmap_tiles.insert((10, 20), heightmap);
// Add holes data
let mut holes = HolesData::new();
holes.set_hole(5, 7, true);
holes.set_hole(8, 9, true);
file.holes_data.insert((10, 20), holes);
// Write the file
let parser = WdlParser::with_version(WdlVersion::Wotlk);
let mut buffer = Vec::new();
let mut cursor = Cursor::new(&mut buffer);
parser.write(&mut cursor, &file)?;
}
Working with WMO Placements
#![allow(unused)]
fn main() {
use wow_wdl::types::{ModelPlacement, Vec3d, BoundingBox};
// Add WMO data to WDL file
let mut wdl_file = WdlFile::with_version(WdlVersion::Wotlk);
// Add a WMO filename and index
wdl_file.wmo_filenames.push("World/wmo/Azeroth/Buildings/Human_Farm/Farm.wmo".to_string());
wdl_file.wmo_indices.push(0);
// Add a WMO placement
let placement = ModelPlacement {
id: 1,
wmo_id: 0,
position: Vec3d::new(100.0, 200.0, 50.0),
rotation: Vec3d::new(0.0, 0.0, 0.0),
bounds: BoundingBox {
min: Vec3d::new(-10.0, -10.0, -10.0),
max: Vec3d::new(10.0, 10.0, 10.0),
},
flags: 0,
doodad_set: 0,
name_set: 0,
padding: 0,
};
wdl_file.wmo_placements.push(placement);
}
Common Patterns
Iterating Over Heightmap Data
#![allow(unused)]
fn main() {
use wow_wdl::types::WdlFile;
fn analyze_heightmap(wdl_file: &WdlFile) {
// Iterate over all tiles with heightmap data
for ((tile_x, tile_y), heightmap) in &wdl_file.heightmap_tiles {
println!("Tile ({}, {}):", tile_x, tile_y);
// Find min/max heights in the tile
let mut min_height = i16::MAX;
let mut max_height = i16::MIN;
for &height in &heightmap.outer_values {
min_height = min_height.min(height);
max_height = max_height.max(height);
}
for &height in &heightmap.inner_values {
min_height = min_height.min(height);
max_height = max_height.max(height);
}
println!(" Height range: {} to {}", min_height, max_height);
}
}
}
Checking for Data Coverage
#![allow(unused)]
fn main() {
use wow_wdl::types::WdlFile;
fn check_continent_coverage(wdl_file: &WdlFile) {
let mut covered_tiles = 0;
let mut total_tiles = 0;
// Check all possible tile positions (64x64 grid)
for x in 0..64 {
for y in 0..64 {
total_tiles += 1;
if wdl_file.heightmap_tiles.contains_key(&(x, y)) {
covered_tiles += 1;
}
}
}
let coverage = (covered_tiles as f32 / total_tiles as f32) * 100.0;
println!("Continent coverage: {:.1}% ({}/{} tiles)", coverage, covered_tiles, total_tiles);
// Check which tiles have holes
let mut tiles_with_holes = 0;
for (coord, _) in &wdl_file.holes_data {
if wdl_file.heightmap_tiles.contains_key(coord) {
tiles_with_holes += 1;
}
}
println!("Tiles with hole data: {}", tiles_with_holes);
}
}
Implementation Considerations - ✅ Implemented
Error Handling
#![allow(unused)]
fn main() {
use wow_wdl::{WdlError, Result};
use std::fs::File;
use std::io::BufReader;
use wow_wdl::parser::WdlParser;
fn safe_wdl_load(path: &str) -> Result<wow_wdl::WdlFile> {
match File::open(path) {
Ok(file) => {
let mut reader = BufReader::new(file);
let parser = WdlParser::new();
match parser.parse(&mut reader) {
Ok(wdl) => {
println!("Successfully loaded WDL version {}", wdl.version);
Ok(wdl)
}
Err(WdlError::UnsupportedVersion(ver)) => {
eprintln!("WDL version {} is not supported", ver);
Err(WdlError::UnsupportedVersion(ver))
}
Err(e) => {
eprintln!("Failed to parse WDL: {}", e);
Err(e)
}
}
}
Err(e) => {
eprintln!("Failed to open file: {}", e);
Err(WdlError::Io(e))
}
}
}
}
Validation
#![allow(unused)]
fn main() {
use wow_wdl::validation::{validate_wdl_file, ValidationError};
use wow_wdl::types::WdlFile;
fn validate_wdl_data(wdl_file: &WdlFile) -> std::result::Result<(), ValidationError> {
// Use the built-in validation
validate_wdl_file(wdl_file)?;
// Additional custom validation
if wdl_file.heightmap_tiles.is_empty() {
return Err(ValidationError::MissingRequiredData("No heightmap tiles found".into()));
}
// Validate tile coordinates are within bounds
for ((x, y), _) in &wdl_file.heightmap_tiles {
if *x >= 64 || *y >= 64 {
return Err(ValidationError::InvalidCoordinates(*x, *y));
}
}
Ok(())
}
}
Performance Tips
- WDL files are relatively small (~1-2 MB)
- Can be kept in memory for entire session
- Use for distant terrain LOD switching
- Ideal for minimap rendering
- Cache frequently accessed height data
- Use bilinear interpolation for smooth transitions
Common Issues
Height Precision
- WDL uses 8-bit height offsets
- Less precise than ADT heightmaps
- Suitable for distant viewing only
- May show stepping artifacts up close
Water Detection
- Not all water bodies are represented
- Small ponds/streams may be missing
- Ocean height is typically 0.0
- Water data may be incomplete
Coordinate System
- Exact tile size and origin offset need verification
- Coordinate conversion formulas may vary by continent
- Edge cases at continent boundaries
Known Limitations and Research Gaps
Critical Research Areas
- MARE Chunk Structure: Internal format needs reverse engineering
- Height Data Format: Exact encoding of height information unknown
- Texture Mapping: How low-resolution textures are stored/referenced
- Version Differences: Changes between game versions not fully documented
- Coordinate Precision: Exact parameters need verification
Implementation Challenges
- Reverse Engineering Required: Most technical details need verification
- Version Compatibility: Supporting multiple game versions
- Performance Requirements: Real-time terrain rendering demands
- Memory Constraints: Efficient loading and caching strategies
References
- WDL Format (wowdev.wiki)
- Map Coordinates System
- WowDevTools libwarcraft - .NET implementation
- pywowlib - Python implementation
See Also
- ADT Format - High-detail terrain data
- WDT Format - World definition tables
- Coordinate System - Detailed coordinate information
- LoD System Guide - Level of Detail implementation
World of Warcraft WDT Format Documentation
Validation Status: This documentation has been validated against 100 real WDT files from WoW versions 1.12.1 through 5.4.8, achieving 100% parsing success rate.
Table of Contents
- Introduction
- Purpose and Overview
- File Structure
- Chunk Specifications
- Evolution Across Versions
- FileDataID System
- Additional WDT Files
- Implementation Guide
- Example Parser Implementation
- Test Vectors
- Common Issues and Solutions
- References
Introduction
WDT (World Data Table) files are fundamental components of World of Warcraft’s world rendering system. They serve as master indexes that define which map tiles (ADT files) exist in a world and can optionally reference a global World Map Object (WMO) for WMO-only maps like instances.
Purpose and Overview
WDT files serve several critical functions:
- Map Tile Presence: Define which of the potential 64×64 ADT tiles actually exist for a given map - ✅ Implemented
- Global WMO Reference: For indoor/instance maps, reference a single global WMO - ✅ Implemented
- Map Properties: Store various flags and properties that affect how the entire map is rendered - ✅ Implemented
- Lighting Information: Define global lighting properties and light sources - ⚠️ Format Specification Only
- Fog Effects: Control volumetric fog and atmospheric effects - ⚠️ Format Specification Only
- Occlusion Data: Provide low-resolution occlusion information for improved rendering performance - ⚠️ Format Specification Only
File Structure
WDT files follow the standard chunked format used by many WoW file types:
#![allow(unused)]
fn main() {
struct IffChunk {
magic: [u8; 4], // Chunk identifier (e.g., "MVER", "MPHD")
size: u32, // Size of chunk data in bytes
data: Vec<u8>, // Chunk-specific data
}
}
Coordinate Systems
World of Warcraft uses different coordinate systems for different file types and purposes. Understanding these systems is crucial for correctly parsing and rendering WDT data, especially when dealing with WMO placement.
ADT/WDT Terrain Coordinate System
The terrain system uses a right-handed coordinate system:
- X-axis: Points North (decreasing tile Y)
- Y-axis: Points West (decreasing tile X)
- Z-axis: Points Up (vertical height)
- Origin: Map center at tile [32, 32]
- Range: ±17066.666 world units on X and Y axes
In vector notation: position = Vector3.Forward * x + Vector3.Left * y + Vector3.Up * z
M2/WMO Model Coordinate System
Models use a right-handed coordinate system with inverted horizontal axes:
- X-axis: Points North
- Y-axis: Points West
- Z-axis: Points Up
In vector notation: position = Vector3.Backward * x + Vector3.Right * y + Vector3.Up * z
This means model space has inverted X and Y compared to terrain space.
MDDF/MODF Placement Coordinate System
Placement chunks use a right-handed coordinate system with completely different axis orientation:
- X-axis: Points West
- Y-axis: Points Up (vertical)
- Z-axis: Points North
Important transformations for MODF placement:
#![allow(unused)]
fn main() {
// Convert from placement coordinates to world coordinates
let world_x = 32.0 * TILESIZE - placement_x;
let world_z = 32.0 * TILESIZE - placement_z;
let world_y = placement_y; // Y (up) remains the same
}
Rotation order (Euler angles):
- X rotation: Around West/East axis
- Y rotation: Around Up axis
- Z rotation: Around North/South axis
Blender Coordinate System
For exporting to Blender:
- Right-handed, Z-up system
- Right = (1,0,0), Forward = (0,1,0), Up = (0,0,1)
Conversion from WoW to Blender:
#![allow(unused)]
fn main() {
// WoW terrain to Blender
blender_x = wow_x; // North
blender_y = -wow_y; // East (inverted from West)
blender_z = wow_z; // Up
// WoW MODF rotation to Blender (example)
// Requires careful handling of rotation order and axis mapping
}
Key Concepts
- Handedness: All WoW coordinate systems are right-handed
- Units: 1 unit = 1 yard in game world
- Tile Size: Each ADT tile is 533.33333 units
- Map Size: 64×64 tiles = 34133.333 units total
- Array Ordering: Tile arrays use [Y][X] ordering (row-major)
Common Pitfalls
- Array vs World: Tile arrays are indexed [Y][X] but world coordinates are (X, Y, Z)
- Rotation Units: Always radians in files (beware of 2.4.3 DireMaul bug using degrees)
- Model Placement: MODF coordinates need transformation to world space
- Left-handed Renderers: Negate all rotations when converting to left-handed systems
Typical Chunk Order
For main WDT files:
- MVER - Version information (always first, version 18 across all tested versions)
- MPHD - Map header with flags and file references
- MAIN - Map tile presence information
- MAID - FileDataIDs for ADT files (post-8.1)
- MWMO - Global WMO filename (WMO-only maps have data; pre-4.x terrain maps have empty chunk; 4.x+ terrain maps have NO chunk)
- MODF - Global WMO placement (WMO-only maps with HasTerrain flag)
For auxiliary WDT files:
- _lgt.wdt: MVER, MPL2/MPL3, MSLT, MTEX, MLTA
- _occ.wdt: MVER, MAOI, MAOH
- _fogs.wdt: MVER, MVFX, VFOG, VFEX
- _tex.wdt: MVER, MTXF, MTXP (if present)
Chunk Specifications
MVER - Version
The version chunk is always the first chunk in the file.
#![allow(unused)]
fn main() {
struct MVER {
version: u32, // Always 18 for WDT files
}
}
MPHD - Map Header
Contains global flags and references to other map-related files.
#![allow(unused)]
fn main() {
struct MPHD {
flags: u32, // See flag definitions below
// Pre-8.1.0 (Classic through Legion):
something: u32, // Unknown purpose
unused: [u32; 6], // Reserved (always 0)
// Total size: 32 bytes
// Post-8.1.0 (BfA+):
// The 7 uint32 fields above are repurposed as FileDataIDs:
lgt_file_data_id: u32, // _lgt.wdt lighting file
occ_file_data_id: u32, // _occ.wdt occlusion file
fogs_file_data_id: u32, // _fogs.wdt fog file
mpv_file_data_id: u32, // _mpv.wdt particulate volume file
tex_file_data_id: u32, // _tex.wdt texture file
wdl_file_data_id: u32, // _wdl low-resolution heightmap
pd4_file_data_id: u32, // _pd4.wdt file
}
}
Important: The presence of specific chunks depends on the MPHD flags:
- Maps with flag 0x0001 (HasTerrain/WdtUsesGlobalMapObj) will have MWMO and MODF chunks
- Maps without this flag are terrain-based and may not have MWMO/MODF chunks
MPHD Flags
#![allow(unused)]
fn main() {
enum MphdFlags {
WdtUsesGlobalMapObj = 0x0001, // Map is WMO-only (UsesGlobalModels)
AdtHasMccv = 0x0002, // ADTs have vertex colors (UsesVertexShading)
AdtHasBigAlpha = 0x0004, // Alternative terrain shader (UsesEnvironmentMapping)
AdtHasDoodadrefsSortedBySizeCat = 0x0008, // Doodads sorted by size (DisableUnknownRenderingFlag)
AdtHasLightingVertices = 0x0010, // ADTs have MCLV chunk (UsesVertexLighting, deprecated in 8.x)
AdtHasUpsideDownGround = 0x0020, // Flip ground display (FlipGroundNormals)
UnkFirelands = 0x0040, // Universal in 4.3.4+ (all maps have this)
AdtHasHeightTexturing = 0x0080, // Use _h textures (UsesHardAlphaFalloff)
UnkLoadLod = 0x0100, // Load _lod.adt files (UnknownHardAlphaRelated)
WdtHasMaid = 0x0200, // Has MAID chunk with FileDataIDs (8.1.0+)
UnkFlag0x0400 = 0x0400, // Unknown
UnkFlag0x0800 = 0x0800, // Unknown
UnkFlag0x1000 = 0x1000, // Unknown
UnkFlag0x2000 = 0x2000, // Unknown
UnkFlag0x4000 = 0x4000, // Unknown
UnkFlag0x8000 = 0x8000, // Unknown (UnknownContinentRelated)
}
}
MAIN - Map Area Information
Defines which ADT tiles exist in the 64×64 grid.
#![allow(unused)]
fn main() {
struct MAIN {
entries: [[MainEntry; 64]; 64], // [y][x] ordering
}
struct MainEntry {
flags: u32, // See flag definitions below
area_id: u32, // AreaTable.dbc ID (async loading in 0.5.3+)
}
enum MainFlags {
HasAdt = 0x0001, // ADT file exists for this tile (HasTerrainData in libwarcraft)
IsLoaded = 0x0002, // Set at runtime when ADT is loaded (never stored in file)
AllWater = 0x0002, // Special flag for all-water tiles (runtime only)
IsImported = 0x0004, // Marks imported tiles (runtime only)
// Note: Flags 0x0002 and 0x0004 are runtime-only and not stored in the file
}
}
MAID - Map Area ID
Introduced in 8.1.0, contains FileDataIDs for all map files.
#![allow(unused)]
fn main() {
struct MAID {
// Each section contains 64x64 entries (4096 uint32 values)
// Stored in [y][x] order (row-major)
root_adt: [[u32; 64]; 64], // FileDataIDs for root ADT files
obj0_adt: [[u32; 64]; 64], // FileDataIDs for _obj0.adt files
obj1_adt: [[u32; 64]; 64], // FileDataIDs for _obj1.adt files
tex0_adt: [[u32; 64]; 64], // FileDataIDs for _tex0.adt files
lod_adt: [[u32; 64]; 64], // FileDataIDs for _lod.adt files
map_texture: [[u32; 64]; 64], // FileDataIDs for map textures
map_texture_n: [[u32; 64]; 64], // FileDataIDs for normal map textures
minimap_texture: [[u32; 64]; 64], // FileDataIDs for minimap textures
// Note: The exact structure may vary by version
// Some versions may include additional sections
}
}
MWMO - World Map Object
For WMO-only maps, contains the filename of the global WMO.
#![allow(unused)]
fn main() {
struct MWMO {
filename: CString, // Zero-terminated string, max 256 bytes
}
}
Notes:
- In MOP, this chunk is limited to 0x100 bytes due to stack allocation
- Pre-4.x: Both WMO-only and terrain maps have MWMO chunks (terrain maps have 0-byte data)
- 4.x+ (Cataclysm onwards): Only WMO-only maps have MWMO chunks; terrain maps have NO MWMO chunk
- Presence correlates with MPHD flag 0x0001 (HasTerrain/WdtUsesGlobalMapObj)
MODF - Map Object Definition
Placement information for the global WMO (if present). Only appears in WMO-only maps (MPHD flag 0x0001 set).
#![allow(unused)]
fn main() {
struct MODF {
entries: Vec<ModfEntry>, // Only one entry for WDT files
}
struct ModfEntry {
id: u32, // Index into MWMO, unused in WDT
unique_id: u32, // Unique instance ID (0xFFFFFFFF in 1.12.1, 0 in 3.3.5a)
position: [f32; 3], // Position in MODF coordinate system (see below)
rotation: [f32; 3], // Euler angles in radians (X, Y, Z order)
// Note: Some 2.4.3 files incorrectly store degrees
lower_bounds: [f32; 3], // Bounding box minimum corner (MODF coordinates)
upper_bounds: [f32; 3], // Bounding box maximum corner (MODF coordinates)
flags: u16, // WMO flags (see ModfFlags below)
doodad_set: u16, // Doodad set index
name_set: u16, // Name set index
scale: u16, // Scale factor (0 in 1.12.1, 1024 = 1.0 in later versions)
}
// IMPORTANT: MODF uses placement coordinate system!
// To convert MODF position to world coordinates:
// world_x = 32.0 * 533.33333 - modf_position.x
// world_y = modf_position.y
// world_z = 32.0 * 533.33333 - modf_position.z
enum ModfFlags {
Destructible = 0x0001, // WMO is destructible
UseLod = 0x0002, // WMO has LOD levels
Unknown = 0x0004, // Unknown flag
}
}
Light-Related Chunks (_lgt.wdt)
These chunks are specific to _lgt.wdt files, introduced in Legion (7.0) for enhanced lighting systems.
MPL2 - Point Light v2
Introduced in Legion, defines point lights with enhanced properties.
#![allow(unused)]
fn main() {
struct MPL2 {
version: u32, // Always 18
entries: Vec<Mpl2Entry>,
}
struct Mpl2Entry {
id: u32, // Unique light ID
color: [u8; 4], // BGRA format
position: [f32; 3], // X, Y, Z world coordinates
attenuation_start: f32, // Light falloff start distance
attenuation_end: f32, // Light falloff end distance
intensity: f32, // Light intensity multiplier
unknown: [f32; 3], // Unknown values
tile_x: u16, // ADT tile X coordinate
tile_y: u16, // ADT tile Y coordinate
mlta_index: i16, // Index into MLTA chunk (-1 if unused)
mtex_index: i16, // Index into MTEX chunk (-1 if unused)
}
}
MPL3 - Point Light v3
Enhanced version introduced in Shadowlands (9.0) with additional features.
#![allow(unused)]
fn main() {
struct MPL3 {
version: u32, // Version number
entries: Vec<Mpl3Entry>,
}
struct Mpl3Entry {
// All fields from MPL2, plus:
flags: u32, // Light behavior flags
scale: f32, // Light scale factor
shadow_flags: u32, // Shadow casting options
render_flags: u32, // Rendering behavior
// Additional fields may vary by version
}
}
MSLT - Spotlight
Defines directional spotlights with cone properties.
#![allow(unused)]
fn main() {
struct MSLT {
version: u32, // Always 18
entries: Vec<MsltEntry>,
}
struct MsltEntry {
id: u32, // Unique light ID
color: [u8; 4], // BGRA format
position: [f32; 3], // X, Y, Z world coordinates
rotation: [f32; 3], // X, Y, Z rotation (radians)
attenuation_start: f32, // Light falloff start
attenuation_end: f32, // Light falloff end
intensity: f32, // Light intensity
inner_cone_angle: f32, // Inner cone angle (radians)
outer_cone_angle: f32, // Outer cone angle (radians)
tile_x: u16, // ADT tile X
tile_y: u16, // ADT tile Y
mlta_index: i16, // Index into MLTA chunk
mtex_index: i16, // Index into MTEX chunk
}
}
MTEX - Texture References
Contains FileDataIDs for textures used by lights (e.g., projected textures).
#![allow(unused)]
fn main() {
struct MTEX {
texture_file_data_ids: Vec<u32>, // Array of texture FileDataIDs
}
}
MLTA - Map Light Texture Animation
Defines animation properties for light textures.
#![allow(unused)]
fn main() {
struct MLTA {
version: u32, // Version number
entries: Vec<MltaEntry>,
}
struct MltaEntry {
amplitude: f32, // Animation amplitude
frequency: f32, // Animation frequency
function: u32, // Animation function type
}
}
Occlusion Chunks (_occ.wdt)
MAOI/MAOH - Map Area Occlusion Information
Provides occlusion data for improved rendering performance.
#![allow(unused)]
fn main() {
struct MAOI {
version: u32, // Always 18
entries: Vec<MaoiEntry>,
}
struct MaoiEntry {
tile_x: u16, // ADT X coordinate
tile_y: u16, // ADT Y coordinate
offset: u32, // Offset into MAOH data
size: u32, // Always (17*17 + 16*16) * 2
}
struct MAOH {
data: Vec<u8>, // Height data for occlusion
}
}
MTXF - Map Texture Flags
Controls texture-related properties.
#![allow(unused)]
fn main() {
struct MTXF {
version: u32, // Always 18
entries: Vec<MtxfEntry>,
}
struct MtxfEntry {
usage_flags: u32, // Texture usage flags
// Additional texture properties
}
}
Fog Chunks (_fogs.wdt)
These chunks are found in _fogs.wdt files, which were added in Legion but became meaningful in Battle for Azeroth.
MVFX - Map Volumetric Fog Effects
References fog effects used in the map.
#![allow(unused)]
fn main() {
struct MVFX {
version: u32, // Always 2
entries: Vec<MvfxEntry>,
}
struct MvfxEntry {
file_data_id: u32, // Reference to fog effect file
// Additional properties may follow
}
}
VFOG - Volumetric Fog
Defines volumetric fog areas and properties.
#![allow(unused)]
fn main() {
struct VFOG {
version: u32, // Always 2
count: u32,
entries: Vec<VfogEntry>,
}
struct VfogEntry {
id: u32,
radius_start: f32,
radius_end: f32,
fog_start_multiplier: f32,
fog_end_multiplier: f32,
color: [u8; 4], // RGBA format
// Additional fog properties
}
}
VFEX - Volumetric Fog Extended
Extended fog data for backwards compatibility (version 2+).
#![allow(unused)]
fn main() {
struct VFEX {
entries: Vec<VfexEntry>,
}
struct VfexEntry {
unk0: u32, // Default 1
unk1: [f32; 16], // First 3 floats have values, rest are 1.0
vfog_id: u32, // Reference to VFOG entry
unk3: u32, // Default 0
unk4: u32, // Default 0
unk5: u32, // Default 0
unk6: u32, // Default 0
unk7: u32, // Default 0
unk8: u32, // Default 0
}
}
MANM - Map Navigation Mesh (PTR)
Temporarily present during 8.3.0 PTR for navigation/scripting data.
#![allow(unused)]
fn main() {
struct MANM {
// Structure was not fully reverse-engineered
// Contains positions and globally unique IDs
// Often marked roads or walls
}
}
Chunk Evolution Timeline
Based on analysis of WDT files from WoW versions 1.12.1 through 5.4.8:
Core Chunks (Present in all versions)
- MVER: Always present, always version 18
- MPHD: Always present, flags evolve across versions
- MAIN: Always present, defines tile existence
Conditional Chunks
- MWMO:
- 1.12.1-3.3.5a: Present in ALL maps (empty for terrain maps)
- 4.x+: Only in WMO-only maps (flag 0x0001)
- MODF: Only in WMO-only maps with objects (flag 0x0001)
Version-Specific Chunks
- MAID: 8.1.0+ (BfA) - FileDataID system
- Light chunks (_lgt.wdt): 7.0+ (Legion)
- Fog chunks (_fogs.wdt): 7.0+ (functional in 8.0+)
- MANM: 8.3.0 PTR only (removed before release)
Evolution Across Versions
Classic (1.x) - Foundation
- Format: Basic structure with MVER, MPHD, MAIN chunks
- Content Split: ~60% WMO-only, ~40% terrain maps
- Flags: Minimal usage (only 0x0001 for WMO-only)
- MODF Values: UniqueID=0xFFFFFFFF, Scale=0
Burning Crusade (2.x) - Expansion
- No format changes - Complete compatibility
- Content: Added Outland with massive terrain maps
- Known Issues: DireMaul rotation bug (degrees vs radians)
Wrath of the Lich King (3.x) - Feature Enhancement
- Major Flag Adoption (while maintaining format compatibility):
- 0x0002 (MCCV): 60% of maps
- 0x0004 (Big Alpha): 60% of maps
- 0x0008 (Sorted Doodads): 35% of maps
- Content Evolution: 70% terrain maps (shift from WMO-heavy design)
- Advanced Terrain: Death Knight zone, Icecrown Citadel
Cataclysm (4.x) - BREAKING CHANGE
- MAJOR FORMAT CHANGE: Terrain maps NO LONGER have MWMO chunks (only WMO-only maps have them)
- Universal Flag: 0x0040 in 100% of maps (purpose unknown, possibly related to new rendering)
- Near-Universal Features:
- 0x0008 (Sorted): 95% of maps
- Improved terrain blending and sorting
- Phasing Technology: 1-2 tile maps for seamless world updates
- Content: 80% terrain maps
- Flag Pattern Changes:
- Maps without HasTerrain flag (0x0001) no longer have MWMO/MODF chunks
- Clear distinction between WMO-only and terrain-based maps
Mists of Pandaria (5.x) - Refinement
- No structural changes - Stable format
- Flag 0x0080 Active: Height texturing in 20% of maps
- New Content Systems:
- Scenarios: Instanced story content (16-25 tiles)
- Pet Battles: Dedicated battle arenas (9-16 tiles)
- Optimization: All MWMO chunks under 256 bytes
Legion (7.x)
- Added _lgt.wdt files with MPL2, MSLT, MTEX, MLTA chunks
- Enhanced fog system with MVFX/VFOG/VFEX chunks
- Support for point and spot lights with texture projection
- _fogs.wdt files added (initially empty)
Battle for Azeroth (8.x)
- 8.0.1: Added _mpv.wdt files for particulate volume effects
- 8.1.0: Major change - introduction of MAID chunk
- Transition from filename-based to FileDataID system
- Support for _occ,_lgt, _fogs,_mpv, _tex,_pd4 files
- _fogs.wdt files became functional with fog data
- Temporary MANM chunk during 8.3.0 PTR
- Deprecated MPHD flag 0x0010 (AdtHasLightingVertices)
Shadowlands (9.x)
- Enhanced lighting system with MPL3 replacing MPL2
- Additional light properties for shadows and rendering
- Extended MAID structure variations
- Further refinements to fog and volumetric systems
FileDataID System
Starting with patch 8.1.0, WoW transitioned from filename-based file references to FileDataID system:
Pre-8.1.0 System
world/maps/azeroth/azeroth_29_29.adt
world/maps/azeroth/azeroth_29_29_obj0.adt
world/maps/azeroth/azeroth_29_29_tex0.adt
Post-8.1.0 System
- Files are referenced by numeric FileDataIDs
- MAID chunk contains all FileDataIDs for map files
- Allows for more efficient patching and content delivery
Example FileDataID Mapping
#![allow(unused)]
fn main() {
// Example from actual game data
const AZEROTH_29_29_ROOT: u32 = 777332;
const AZEROTH_29_29_OBJ0: u32 = 777333;
const AZEROTH_29_29_OBJ1: u32 = 777334;
const AZEROTH_29_29_TEX0: u32 = 777335;
const AZEROTH_29_29_LOD: u32 = 1287004;
}
Additional WDT Files
Later WoW versions use multiple WDT files per map, each serving specific purposes:
_lgt.wdt - Lighting (Legion 7.0+)
Contains global lighting information and light sources.
- Chunks: MPL2/MPL3 (point lights), MSLT (spotlights), MTEX (textures), MLTA (animations)
- Purpose: Enhanced lighting system with dynamic lights
_occ.wdt - Occlusion
Low-resolution occlusion data for visibility culling.
- Chunks: MAOI (occlusion index), MAOH (occlusion heightmap)
- Purpose: Optimize rendering by culling non-visible areas
_fogs.wdt - Fog Effects (Legion 7.0+, functional in BfA 8.0+)
Volumetric fog definitions and atmospheric effects.
- Chunks: MVFX (fog effects), VFOG (fog volumes), VFEX (extended fog data)
- Purpose: Atmospheric and weather effects
_mpv.wdt - Particulate Volume (BfA 8.0.1+)
Particulate effects and volume data.
- Purpose: Volumetric particle effects like dust, smoke, etc.
_tex.wdt - Textures
Texture-related data and references.
- Chunks: MTXF (texture flags), MTXP (texture parameters)
- Purpose: Additional texture properties and parameters
_pd4.wdt
Purpose not fully documented.
- Note: Related to physics or collision data (speculation)
Implementation Notes from TrinityCore
Based on TrinityCore’s 3.3.5a implementation:
-
Chunk Magic Reversal: TrinityCore stores chunk magics in reversed byte order:
- MPHD is stored as
{ 'D', 'H', 'P', 'M' } - MAIN is stored as
{ 'N', 'I', 'A', 'M' } - MVER is stored as
{ 'R', 'E', 'V', 'M' }
- MPHD is stored as
-
Chunk Offset Calculation: Chunks are located at:
- MVER: Start of file
- MPHD:
version_offset + version->size + 8 - MAIN:
mphd_offset + mphd->size + 8
-
ADT File Existence: Check
adt_list[y][x].exist & 0x1 -
Scale Field: The MODF structure includes a 16-bit scale field (1024 = 1.0)
-
Simple Flag Usage: For 3.3.5a, only the first flag field in MPHD is used
Implementation Status - ✅ Implemented
WDT parsing is implemented in the wow-wdt crate with support for all WoW versions from Classic through later expansions.
Key Features:
- Parse WDT files using
WdtReader::from_reader() - Support for all chunk types (MVER, MPHD, MAIN, MAID, MWMO, MODF)
- Version-aware parsing with validation
- Coordinate system conversion utilities
- 100+ real WDT files tested across all versions
References
-
wowdev.wiki - Primary source for WoW file format documentation
- Contains chunk definitions and flag values
-
libwarcraft - C# implementation by WowDevTools
- Fully compliant WDT read/write support
-
StormLib - C++ MPQ library by Ladislav Zezula
- Reference implementation for reading WoW data files
-
AzerothCore - Open-source WoW server
- Map extractor implementation and MWMO handling
-
Noggit - Open-source WoW map editor
- Practical implementation of WDT generation
-
WoW Modding Community - Various tools and documentation
- FileDataID conversion tools and MAID chunk handling
This documentation represents the collective knowledge of the WoW modding community and is based on reverse engineering efforts. Blizzard Entertainment has not officially documented these formats.
Graphics and Model Formats
Graphics formats handle textures, 3D models, animations, and visual effects.
Supported Formats
BLP Format
Blizzard Picture - Texture format with advanced compression.
- DXT1/3/5 compression
- Uncompressed RGBA
- Built-in mipmaps
- Alpha channel support
M2 Format
Model Version 2 - Animated 3D models for characters, creatures, and objects.
- Skeletal animation
- Particle effects
- Ribbon emitters
- Billboard attachments
- Multiple texture units
M2 Sub-formats
WMO Format
World Map Object - Large static structures like buildings and dungeons.
- Portal-based rendering
- Multiple groups (rooms)
- Lightmaps and vertex colors
- Collision geometry
- Liquid volumes
Model Pipeline
graph LR
M2[M2 Model] --> Skin[.skin file]
M2 --> Anim[.anim files]
M2 --> Phys[.phys file]
Skin --> Render[Renderer]
Anim --> Render
BLP[BLP Textures] --> Render
Common Patterns
Loading a Character Model
#![allow(unused)]
fn main() {
use wow_m2::{parse_m2, parse_skin};
use wow_blp::parser::load_blp;
// Load base model
let data = std::fs::read("Character/Human/Male/HumanMale.m2")?;
let format = parse_m2(&mut std::io::Cursor::new(data))?;
let model = format.model();
// Load skin (mesh data)
let skin_data = std::fs::read("Character/Human/Male/HumanMale00.skin")?;
let skin = parse_skin(&mut std::io::Cursor::new(skin_data))?;
// Load textures
let texture = load_blp("Character/Human/Male/HumanMaleSkin00.blp")?;
}
Loading a Building
#![allow(unused)]
fn main() {
use wow_wmo::api::parse_wmo;
// Load root WMO
let data = std::fs::read("World/wmo/Azeroth/Buildings/Stormwind/Stormwind.wmo")?;
let wmo = parse_wmo(&mut std::io::Cursor::new(data))?;
}
Texture Management
Texture Types
- Diffuse: Base color texture
- Normal: Bump mapping
- Specular: Shininess map
- Environment: Reflection mapping
- Glow: Self-illumination
Loading Textures
#![allow(unused)]
fn main() {
use wow_blp::parser::load_blp;
use wow_blp::convert::blp_to_image;
let blp = load_blp("Textures/Armor/Leather_A_01.blp")?;
// Convert to standard image format
let image = blp_to_image(&blp, 0)?; // Mipmap level 0
image.save("output.png")?;
}
Performance Tips
- Texture Atlasing: Combine small textures
- LOD System: Use lower detail models at distance
- Instancing: Batch render identical models
- Culling: Skip hidden WMO groups
- Animation Caching: Pre-calculate bone matrices
See Also
BLP Format
BLP (Blizzard Picture) is Blizzard’s proprietary texture format used for all textures in Warcraft III and World of Warcraft. The format uses non-standard JPEG compression with BGRA color components (instead of Y′CbCr) and various direct pixel storage methods.
Overview
- Extension:
.blp - Purpose: Compressed texture storage optimized for game engines
- Versions: BLP0 (Warcraft III Beta), BLP1 (Warcraft III), BLP2 (World of Warcraft)
- Compression: JPEG (BLP0/BLP1 only), RAW1 (palettized), RAW3 (uncompressed BGRA), DXT1/3/5 (S3TC)
- Features: Up to 16 mipmaps, alpha channels with variable bit depth, GPU-friendly formats
- Endianness: Little-endian for all multi-byte values
Cross-Version Analysis Results
WoW 1.12.1 (Vanilla)
Based on analysis of 50+ BLP files from original WoW 1.12.1 MPQ archives:
- Format: 100% BLP2 (no BLP0/BLP1 found)
- Content Type: 100% Direct (content_type=1, no JPEG content)
- Primary Compression: 82% DXT (compression=2), 18% RAW1 palettized (compression=1)
- Alpha Usage: 46% use 8-bit alpha, 34% no alpha, 20% 1-bit alpha (no 4-bit alpha found)
- Alpha Types: Only 0, 1, and 8 observed (no alpha_type=7)
- Dimensions: 100% power-of-2, most common: 256x256 (28%), 64x64 (22%), 128x128 (10%)
- Mipmaps: 88% have mipmaps enabled, typically 7-9 levels depending on texture size
WoW 2.4.3 (TBC)
Based on analysis of 28 BLP files from original WoW 2.4.3 (TBC) MPQ archives:
- Format: 100% BLP2 (consistent with 1.12.1)
- Content Type: 100% Direct (content_type=1)
- Primary Compression: 75% DXT (compression=2), 25% RAW1 palettized (compression=1)
- Alpha Usage: 50% use 8-bit alpha, 25% no alpha, 18% use 1-bit alpha, 7% use alpha_type=7
- New Alpha Type: alpha_type=7 appears (14.3% of files) - not seen in 1.12.1
- Dimensions: 100% power-of-2, with 512x512 textures (7.1%) appearing for higher detail
- Mipmaps: 93% have mipmaps enabled, up to 10 levels for 512x512 textures
WoW 3.3.5a (WotLK)
Based on analysis of 28 BLP files from original WoW 3.3.5a (WotLK) MPQ archives:
- Format: 100% BLP2 (consistent across versions)
- Content Type: 100% Direct (content_type=1)
- Primary Compression: 79% DXT (compression=2), 21% RAW1 palettized (compression=1)
- Alpha Usage: 54% use 8-bit alpha, 43% no alpha, 4% use 1-bit alpha
- Alpha Types: alpha_type=7 usage increases to 32.1% (vs 14.3% in TBC), alpha_type=1 drops to 7.1%
- Dimensions: 100% power-of-2, 512x512 textures more common (14.3%), first 16x16 texture observed
- Mipmaps: 89% have mipmaps enabled, with unusual has_mipmaps=2 value appearing (10.7%)
WoW 4.3.4 (Cataclysm)
Based on analysis of 29 BLP files from original WoW 4.3.4 (Cataclysm) MPQ archives:
- Format: 100% BLP2 (consistent across versions)
- Content Type: 100% Direct (content_type=1)
- Primary Compression: 79% DXT (compression=2), 21% RAW1 palettized (compression=1)
- Alpha Usage: 83% use 8-bit alpha, 14% no alpha, 3% use 1-bit alpha (major shift towards 8-bit)
- Alpha Types: alpha_type=7 dominates at 62.1% (vs 32.1% in WotLK), alpha_type=8 drops to 20.7%
- Dimensions: 100% power-of-2, wider variety including non-square (256x128, 512x256) ratios
- Mipmaps: 97% have mipmaps enabled (highest rate), mostly 9-10 levels
WoW 5.4.8 (MoP)
Based on analysis of 8 BLP files from original WoW 5.4.8 (MoP) MPQ archives:
- Format: 100% BLP2 (consistent across versions)
- Content Type: 100% Direct (content_type=1)
- Primary Compression: 100% DXT (compression=2), no RAW1 palettized found
- Alpha Usage: 88% no alpha, 13% use 8-bit alpha (minimap tiles dominate sample)
- Alpha Types: 88% alpha_type=0, 13% alpha_type=7 (limited sample size)
- Dimensions: 100% power-of-2, primarily 256x256 (88%), one 64x128 texture
- Mipmaps: 63% no mipmaps (minimap tiles), 38% have mipmaps enabled
BLP Format Evolution Analysis
Key Trends Across WoW Versions (1.12.1 → 5.4.8)
1. Alpha Type Evolution
- 1.12.1: Only alpha_type values 0, 1, and 8 observed
- 2.4.3: Introduction of alpha_type=7 (14.3% usage)
- 3.3.5a: alpha_type=7 increases to 32.1%
- 4.3.4: alpha_type=7 becomes dominant at 62.1%
- 5.4.8: Limited sample shows alpha_type=0 and 7 only
Key Finding: alpha_type=7 appears to be associated with enhanced alpha blending introduced in TBC and becomes the primary alpha mode by Cataclysm.
2. Compression Method Trends
- 1.12.1: 82% DXT, 18% RAW1 palettized
- 2.4.3: 75% DXT, 25% RAW1 palettized
- 3.3.5a: 79% DXT, 21% RAW1 palettized
- 4.3.4: 79% DXT, 21% RAW1 palettized (stable)
- 5.4.8: 100% DXT (no RAW1 in sample)
Key Finding: DXT compression remains dominant, while RAW1 palettized usage fluctuates but generally decreases over time.
3. Alpha Usage Patterns
- 1.12.1: 46% use 8-bit alpha, 34% no alpha, 20% 1-bit alpha
- 2.4.3: 50% use 8-bit alpha, 25% no alpha, 18% 1-bit alpha
- 3.3.5a: 54% use 8-bit alpha, 43% no alpha, 4% 1-bit alpha
- 4.3.4: 83% use 8-bit alpha, 14% no alpha, 3% 1-bit alpha
- 5.4.8: 13% use 8-bit alpha, 88% no alpha (minimap-heavy sample)
Key Finding: 8-bit alpha usage steadily increases from Vanilla through Cataclysm, indicating more sophisticated transparency effects.
4. Texture Resolution Trends
- 1.12.1: Primarily 256x256 (28%), some 64x64 (22%)
- 2.4.3: 512x512 textures appear (7.1% of sample)
- 3.3.5a: 512x512 usage increases (14.3%), small 16x16 textures appear
- 4.3.4: More diverse ratios including rectangular textures
- 5.4.8: Primarily 256x256 (88% of sample)
Key Finding: Higher resolution textures (512x512) become more common from TBC onward, with Cataclysm introducing more rectangular aspect ratios.
5. Mipmap Behavior Evolution
- 1.12.1: 88% have mipmaps, mostly has_mipmaps=1
- 2.4.3: 93% have mipmaps, mostly has_mipmaps=1
- 3.3.5a: 89% have mipmaps, unusual has_mipmaps=2 appears (10.7%)
- 4.3.4: 97% have mipmaps (highest rate), mostly has_mipmaps=1
- 5.4.8: 38% have mipmaps (minimap tiles don’t need LOD)
Key Finding: Mipmap usage increases through Cataclysm, with WotLK introducing non-standard has_mipmaps=2 values.
File Structure
Header Layout
The header structure varies by version:
BLP0/BLP1 Header (148 bytes):
Offset Size Description
0x00 4 Magic: "BLP0" or "BLP1"
0x04 4 Content type (0=JPEG, 1=Direct)
0x08 4 Alpha bits (0, 1, 4, or 8)
0x0C 4 Width
0x10 4 Height
0x14 4 Extra field (4 for RAW1, 5 for JPEG)
0x18 4 Has mipmaps (0 or 1)
0x1C - No mipmap tables for BLP0 (external mipmaps)
0x1C 128 Mipmap tables for BLP1 (16 offsets + 16 sizes)
BLP2 Header (156 bytes):
Offset Size Description
0x00 4 Magic: "BLP2"
0x04 4 Content type (0=JPEG, 1=Direct)
0x08 1 Compression (0=JPEG, 1=RAW1, 2=DXTC, 3=RAW3)
0x09 1 Alpha bits (0, 1, 4, or 8)
0x0A 1 Alpha type (0, 1, 7, or 8)
0x0B 1 Has mipmaps (0 or 1)
0x0C 4 Width (max 65535)
0x10 4 Height (max 65535)
0x14 64 Mipmap offsets (16 x u32)
0x54 64 Mipmap sizes (16 x u32)
Alpha Type Patterns (WoW 1.12.1)
From empirical analysis, alpha_type correlates with compression and alpha_bits:
- alpha_type=0: Used with DXT compression, 0-bit or 1-bit alpha (48% of files)
- alpha_type=1: Used with DXT compression, 8-bit alpha (34% of files)
- alpha_type=8: Used with RAW1 palettized compression, typically 8-bit alpha (18% of files)
Pattern Rules:
- DXT with no alpha → alpha_bits=0, alpha_type=0
- DXT with binary transparency → alpha_bits=1, alpha_type=0
- DXT with full transparency → alpha_bits=8, alpha_type=1
- RAW1 palettized → alpha_type=8 (regardless of alpha_bits value)
Data Layout
#![allow(unused)]
fn main() {
struct BlpHeader {
magic: [u8; 4], // "BLP0", "BLP1", or "BLP2"
version: BlpVersion, // Format version
content: BlpContentTag, // JPEG (0) or Direct (1)
flags: BlpFlags, // Version-specific flags
width: u32, // Texture width
height: u32, // Texture height
mipmap_locator: MipmapLocator, // Internal or external mipmaps
}
enum BlpVersion {
Blp0, // Warcraft III Beta
Blp1, // Warcraft III
Blp2, // World of Warcraft
}
enum BlpContentTag {
Jpeg = 0, // JPEG compressed (non-standard BGRA)
Direct = 1, // Direct pixel data (RAW1/3, DXT)
}
// Version-specific flags
enum BlpFlags {
// BLP0/BLP1
Old {
alpha_bits: u32, // 0, 1, 4, or 8
extra: u32, // 4 for RAW1, 5 for JPEG
has_mipmaps: u32, // 0 or 1
},
// BLP2
Blp2 {
compression: Compression, // See Compression enum
alpha_bits: u8, // 0, 1, 4, or 8
alpha_type: u8, // Usually 0, affects blending
has_mipmaps: u8, // 0 or 1
}
}
}
Compression Types (BLP2 only)
#![allow(unused)]
fn main() {
enum Compression {
Jpeg = 0, // JPEG (rarely/never used in BLP2 files)
Raw1 = 1, // 256-color palettized
Dxtc = 2, // DXT1/3/5 compression (S3TC)
Raw3 = 3, // Uncompressed BGRA
}
}
Additional Data Sections
For JPEG content:
- 4 bytes: JPEG header size (actual size - 2 due to a bug)
- Variable: JPEG header data
- Image data: JPEG compressed mipmaps
For RAW1 (palettized):
- 1024 bytes: Color palette (256 x BGRA, 4 bytes per color)
- Image data:
- 8-bit palette indices (1 byte per pixel)
- Alpha data (format depends on alpha_bits):
- 0 bits: No alpha data
- 1 bit: Packed 8 pixels per byte
- 4 bits: Packed 2 pixels per byte
- 8 bits: 1 byte per pixel
For DXT:
- 1024 bytes: Unused color map (zeroed)
- Image data: DXT compressed blocks
For RAW3:
- Image data: Raw BGRA pixels (4 bytes per pixel)
Complete File Layout Example (BLP2 DXT5)
Offset Size Description
0x00 4 Magic "BLP2"
0x04 4 Content type (1 for Direct)
0x08 1 Compression (2 for DXTC)
0x09 1 Alpha bits (8 for DXT5)
0x0A 1 Alpha type (0)
0x0B 1 Has mipmaps (1)
0x0C 4 Width (e.g., 512)
0x10 4 Height (e.g., 512)
0x14 64 Mipmap offsets [16 x u32]
0x54 64 Mipmap sizes [16 x u32]
0x94 1024 Unused color map (all zeros for DXT)
0x494 varies Mipmap 0: DXT5 compressed data
... ... Additional mipmaps
Usage Example
#![allow(unused)]
fn main() {
use wow_blp::{parser::load_blp, convert::blp_to_image, encode::save_blp};
use wow_blp::convert::{image_to_blp, BlpTarget, Blp2Format, DxtAlgorithm};
use image::imageops::FilterType;
// ✅ Load BLP texture
let blp = load_blp("texture.blp")?;
// ✅ Get texture information
println!("Size: {}x{}", blp.header.width, blp.header.height);
println!("Version: {:?}", blp.header.version);
println!("Has mipmaps: {}", blp.header.has_mipmaps());
// ✅ Convert to standard format
let image = blp_to_image(&blp, 0)?; // mipmap level 0
image.save("texture.png")?;
// Create BLP from image
let input = image::open("input.png")?;
let new_blp = image_to_blp(
input,
true, // generate mipmaps
BlpTarget::Blp2(Blp2Format::Dxt5 {
has_alpha: true,
compress_algorithm: DxtAlgorithm::ClusterFit
}),
FilterType::Lanczos3
)?;
save_blp(&new_blp, "output.blp")?;
}
Compression Types
DXT Compression (BLP2)
Most common for BLP2 textures:
#![allow(unused)]
fn main() {
use wow_blp::convert::{Blp2Format, DxtAlgorithm};
// DXT1: 4:1 compression, 1-bit alpha
let dxt1 = Blp2Format::Dxt1 {
has_alpha: false,
compress_algorithm: DxtAlgorithm::RangeFit // Fast
};
// DXT3: 4:1 compression, 4-bit explicit alpha
let dxt3 = Blp2Format::Dxt3 {
has_alpha: true,
compress_algorithm: DxtAlgorithm::ClusterFit // Quality
};
// DXT5: 4:1 compression, interpolated alpha
let dxt5 = Blp2Format::Dxt5 {
has_alpha: true,
compress_algorithm: DxtAlgorithm::IterativeClusterFit // Best
};
}
Palettized (RAW1)
256-color palette format:
#![allow(unused)]
fn main() {
use wow_blp::convert::{BlpOldFormat, AlphaBits};
let palettized = BlpOldFormat::Raw1 {
alpha_bits: AlphaBits::Bit8 // 0, 1, 4, or 8 bits
};
}
Uncompressed (RAW3)
Full BGRA format (BLP2 only):
#![allow(unused)]
fn main() {
let uncompressed = Blp2Format::Raw3;
}
WoW 1.12.1 Content Type Analysis
Compression usage by content type:
UI Icons (Interface\Icons*.blp)
- Compression: DXT (compression=2)
- Alpha: Mixed - 1-bit for simple icons, 8-bit for complex icons
- Dimensions: Mostly 64x64 (standard icon size)
- Mipmaps: Usually 7 levels (64→32→16→8→4→2→1)
Character Textures (Character**.blp)
- Compression: RAW1 palettized (compression=1)
- Alpha: Variable (0, 1, or 8-bit) with alpha_type=8
- Dimensions: Rectangular (128x64, 128x32) for face parts
- Usage: Hair, facial features, skin textures
Creature Skins (Creature**.blp)
- Compression: DXT (compression=2)
- Alpha: Often 8-bit alpha (alpha_type=1) for fur/scale details
- Dimensions: 256x256 (high detail creature textures)
- Mipmaps: 9 levels for distance LOD
World Textures (World**.blp)
- Compression: DXT (compression=2)
- Alpha: Mixed - 0-bit for solid objects, 1-bit for cutouts
- Dimensions: Various sizes, always power-of-2
- Usage: Building textures, environmental objects
Spell Effects (Spells*.blp)
- Compression: DXT (compression=2)
- Alpha: Often 0-bit or 8-bit depending on effect type
- Dimensions: 128x128, 256x256 for particle effects
Version-Specific Features
BLP0 (Warcraft III Beta)
- External mipmaps in .b00-.b15 files
- Limited to JPEG and RAW1 compression
- Header size: 28 bytes (no mipmap tables)
- Mipmap files use format:
basename.b##where ## is 00-15
#![allow(unused)]
fn main() {
// BLP0 saves mipmaps as separate files
let blp0_target = BlpTarget::Blp0(BlpOldFormat::Jpeg { has_alpha: true });
}
BLP1 (Warcraft III)
- Internal mipmaps with offset/size tables
- JPEG and RAW1 compression
- Header size: 156 bytes (includes mipmap tables)
- Maximum 16 mipmap levels
#![allow(unused)]
fn main() {
let blp1_target = BlpTarget::Blp1(BlpOldFormat::Raw1 {
alpha_bits: AlphaBits::Bit1
});
}
BLP2 (World of Warcraft)
- All compression types supported (though JPEG is rarely used)
- Internal mipmaps with offset/size tables
- Header size: 156 bytes
- Additional alpha_type field for advanced blending
- DXT compression uses texpresso library
#![allow(unused)]
fn main() {
let blp2_target = BlpTarget::Blp2(Blp2Format::Dxt5 {
has_alpha: true,
compress_algorithm: DxtAlgorithm::ClusterFit
});
}
Advanced Features
Mipmap Handling
#![allow(unused)]
fn main() {
// Access specific mipmap level
let mipmap_2 = blp_to_image(&blp, 2)?;
// Get mipmap count (calculated as max(log2(width), log2(height)))
let count = blp.header.mipmaps_count();
// Mipmap dimensions (each level halves size, minimum 1x1)
let (width, height) = blp.header.mipmap_size(level);
// External mipmap paths (BLP0 only)
use wow_blp::path::make_mipmap_path;
let mip_path = make_mipmap_path("texture.blp", 3)?; // texture.b03
}
Alpha Channel Support
#![allow(unused)]
fn main() {
use wow_blp::convert::AlphaBits;
// Different alpha bit depths
AlphaBits::NoAlpha // No alpha channel (0 bits)
AlphaBits::Bit1 // 1-bit (on/off transparency)
AlphaBits::Bit4 // 4-bit (16 transparency levels)
AlphaBits::Bit8 // 8-bit (256 transparency levels)
}
Alpha Storage by Format
- JPEG: Alpha stored as separate grayscale image after RGB data
- RAW1: Alpha bits packed after palette indices
- 1-bit: 8 pixels per byte
- 4-bit: 2 pixels per byte
- 8-bit: 1 pixel per byte
- DXT1: 1-bit alpha encoded in color endpoints
- DXT3: 4-bit alpha stored explicitly before color data
- DXT5: Alpha endpoints + 3-bit interpolation indices
- RAW3: Alpha interleaved as BGRA pixels
Batch Processing
#![allow(unused)]
fn main() {
use std::fs;
use std::path::Path;
use wow_blp::{parser::load_blp, convert::blp_to_image};
fn convert_directory(input_dir: &str, output_dir: &str) -> Result<(), Box<dyn std::error::Error>> {
for entry in fs::read_dir(input_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension() == Some("blp".as_ref()) {
let blp = load_blp(&path)?;
let image = blp_to_image(&blp, 0)?;
let output_path = Path::new(output_dir)
.join(path.file_stem().unwrap())
.with_extension("png");
image.save(output_path)?;
}
}
Ok(())
}
}
Common Patterns
Icon Extraction from MPQ
#![allow(unused)]
fn main() {
use wow_mpq::Archive;
use wow_blp::{parser::parse_blp, convert::blp_to_image};
use std::path::Path;
fn extract_spell_icons() -> Result<(), Box<dyn std::error::Error>> {
let mut archive = Archive::open("Interface.mpq")?;
for file in archive.list_files() {
if file.starts_with("Interface\\Icons\\") && file.ends_with(".blp") {
let data = archive.read_file(&file)?;
let blp = parse_blp(&data)?.1;
let image = blp_to_image(&blp, 0)?;
let icon_name = Path::new(&file)
.file_stem()
.unwrap()
.to_str()
.unwrap();
image.save(format!("icons/{}.png", icon_name))?;
}
}
Ok(())
}
}
Creating Game-Ready Textures
#![allow(unused)]
fn main() {
use wow_blp::{convert::{image_to_blp, BlpTarget, Blp2Format, DxtAlgorithm}, encode::save_blp};
use image::imageops::FilterType;
fn create_game_texture(input: &str, output: &str) -> Result<(), Box<dyn std::error::Error>> {
let mut img = image::open(input)?;
// Ensure power-of-two dimensions
let width = img.width().next_power_of_two();
let height = img.height().next_power_of_two();
if width != img.width() || height != img.height() {
img = img.resize_exact(width, height, FilterType::Lanczos3);
}
// Convert to BLP with appropriate settings
let blp = image_to_blp(
img,
true, // mipmaps for 3D use
BlpTarget::Blp2(Blp2Format::Dxt5 {
has_alpha: true,
compress_algorithm: DxtAlgorithm::ClusterFit
}),
FilterType::Lanczos3
)?;
save_blp(&blp, output)?;
Ok(())
}
}
Performance Tips
- DXT textures can be uploaded directly to GPU without decompression
- RAW1 (palettized) provides good compression for textures with limited colors
- Use DXT1 for opaque textures to save memory
- Use DXT5 for textures with smooth alpha gradients
- Generate mipmaps for 3D textures to improve rendering performance
Technical Notes from Analysis
Mipmap Behavior in WoW 1.12.1
- has_mipmaps field: Not always reliable indicator
- Some files have has_mipmaps=0 but still contain 1 mipmap (base texture)
- One file observed with has_mipmaps=2 (non-standard value)
- Actual mipmap count: Determined by non-zero offset/size pairs in mipmap tables
- Mipmap progression: Always follows power-of-2 reduction (256→128→64→32→16→8→4→2→1)
Alpha Type Field Clarification
The alpha_type field is more specific than previously documented:
- Not just “blending mode” - directly correlates with compression method
- alpha_type=8: Exclusive to RAW1 palettized textures
- alpha_type=0: Standard for DXT with 0/1-bit alpha
- alpha_type=1: Standard for DXT with 8-bit alpha
File Size Patterns
- 1-10KB: Small UI elements, simple icons (36% of sample)
- 10-100KB: Standard textures with mipmaps (64% of sample)
- No files >1MB found in UI/texture archives (may exist in model textures)
Dimension Distribution
All textures use power-of-2 dimensions exclusively:
- Square textures: 256x256, 128x128, 64x64, 32x32
- Rectangular textures: Used for character parts (128x64, 128x32)
- Unusual ratios: Some UI elements use 64x256, 32x64 for specific layouts
Common Issues
Technical Limitations
Dimension Requirements
- Texture dimensions should be powers of 2 for optimal GPU performance
- Common sizes: 256x256, 512x512, 1024x1024
- Maximum size: 65535x65535 (defined as BLP_MAX_WIDTH/HEIGHT constants)
- Mipmap count: max(log2(width), log2(height))
Format-Specific Details
- JPEG header has a 2-byte discrepancy (stored length = actual length - 2)
- DXT formats include a 1024-byte color map that’s always zeroed
- RAW1 alpha data is stored separately after the indexed color data
- Alpha type field in BLP2 affects blending (usually 0 for standard alpha)
Color Space and Encoding
- All formats use BGRA color order (Blue, Green, Red, Alpha)
- JPEG uses non-standard JFIF compression:
- Compresses raw BGRA values directly
- Does NOT use standard Y′CbCr color space conversion
- This is why BLP JPEG files are incompatible with standard JPEG readers
- DXT compression is applied to BGRA data
- RAW formats store pixels in BGRA order
Compression Characteristics
JPEG (BLP0/BLP1, rarely BLP2)
Implementation Status: ⚠️ Partial - BLP2 JPEG explicitly rejected
- Non-standard BGRA compression
- Can cause color bleeding at block boundaries
- Alpha stored as separate channel
- Note: While JPEG is part of the BLP format specification, BLP2 JPEG files are explicitly rejected in the current implementation
RAW1 (Palettized)
- Limited to 256 colors
- Suitable for textures with limited color palettes
- Alpha precision depends on bit depth (0/1/4/8 bits)
DXT (BLP2)
- 4:1 compression ratio (DXT1) or 6:1 (DXT3/5)
- 4x4 pixel block artifacts on gradients
- DXT1: 1-bit alpha or opaque
- DXT3: 4-bit explicit alpha per pixel
- DXT5: Interpolated alpha (best for smooth gradients)
- Hardware accelerated on GPUs
RAW3 (BLP2)
- Uncompressed BGRA
- Highest quality, largest file size
- No compression artifacts
Key Findings Summary
Based on analysis of 50+ BLP files from WoW 1.12.1:
Format Standardization
- BLP2 Universal: All files use BLP2 format, no legacy BLP0/BLP1 in WoW
- Direct Content Only: No JPEG content found (content_type=1 universal)
- Compression Split: Clear division between DXT (82%) and RAW1 palettized (18%)
Alpha Type Correlation
- alpha_type field directly correlates with compression method, not just blending
- Predictable patterns allow format validation and automatic compression detection
- RAW1 textures consistently use alpha_type=8 regardless of actual alpha bits
Content-Specific Optimization
- Character textures: RAW1 for color palette efficiency
- Creature skins: DXT with 8-bit alpha for detail
- UI elements: DXT with appropriate alpha for purpose
- Effects: DXT optimized for particle rendering
Quality Assurance
- 100% power-of-2 dimensions - no exceptions found
- Consistent mipmap chains - proper LOD progression
- Appropriate compression - format matches content type
References
- BLP Format (wowdev.wiki)
- DXT Compression
- Original image-blp crate documentation
- Empirical Analysis: 50+ BLP files from WoW 1.12.1 MPQ archives (2025)
See Also
- Texture Loading Guide
- M2 Format - Uses BLP textures
- WMO Format - Uses BLP textures
M2 Format 🎭
The M2 format is the primary 3D model format used in World of Warcraft for character models, creatures, and doodads (environmental objects). M2 files contain geometry, textures, animations, and various special effects data.
Overview
- Extension:
.m2(formerly.mdxin Warcraft III) - Magic:
MD20(0x3032444D in big-endian within MD21 chunk) - Purpose: Animated 3D models with bones, textures, and effects
- Structure: Binary format with little-endian byte ordering
- Chunked Format: Since version 264 (Cataclysm) - ✅ Implemented (25/25 chunks)
- Related Files:
.skin- Level of detail and mesh data - ✅ Basic parsing.anim- External animation sequences - ✅ Basic parsing.phys- Physics simulation data - ✅ Basic parsing.bone- Bone data (Legion+) - ❌ Not Implemented.skel- Skeleton data - ❌ Not Implemented
Version History
Based on empirical analysis of original MPQ archives (250+ files analyzed):
| Version | Expansion | Files Analyzed | Notable Changes |
|---|---|---|---|
| 256 | Vanilla WoW (1.12.1) | 50 files | Original format, inline data structure |
| 260 | The Burning Crusade (2.4.3) | 48 files | Structure updates, maintains inline format |
| 264 | Wrath of the Lich King (3.3.5a) | 20 files | Chunked format capability introduced |
| 272 | Cataclysm (4.3.4) | 27 files | Chunked format established |
| 272 | Mists of Pandaria (5.4.8) | 10 files | Same version as Cataclysm, no format changes |
Analysis Results:
Version 256 (Vanilla WoW 1.12.1)
- Magic: MD20
- Structure: Traditional inline data format
- Usage: Universal across all character, creature, and doodad models
- Sample Models: Character races, creatures (Rabbit, Dragon), environmental objects
Version 260 (The Burning Crusade 2.4.3)
- Magic: MD20
- Structure: Enhanced inline format with TBC-specific features
- Usage: All new TBC content (Blood Elves, Draenei, Outland creatures)
- Sample Models: BloodElfMale_Guard, GnomeSpidertank\GnomeBot, Lich, KelThuzad
- Key Finding: 100% of analyzed files use version 260 consistently
Version 264 (Wrath of the Lich King 3.3.5a)
- Magic: MD20
- Structure: First version to support chunked format, but still uses inline data
- Usage: WotLK creatures, Northrend content, updated character models
- Sample Models: SkeletonMale, AncientProtector, BogBeast, FelOrc
- Key Finding: Despite chunked format capability, no external chunks found in any files
Version 272 (Cataclysm 4.3.4 & Mists of Pandaria 5.4.8)
- Magic: MD20
- Structure: Mature chunked format support, continues inline data usage
- Usage: Spans both Cataclysm and MoP content without version increment
- Sample Models:
- Cataclysm: OgreKing, BoneSpider, Karazahn objects, Auchindoun elements
- MoP: Tuskarr races, Northrend updates, spell effects, environmental objects
- Key Finding: Version 272 used consistently across two major expansions
Critical Findings:
- No External Chunks Found: Despite chunked format support from v264+, zero external chunks detected in 105 analyzed files
- Version Consistency: 100% version consistency within each expansion - no mixed versions found
- Inline Data Persistence: All M2 files through MoP 5.4.8 maintain traditional inline data structure
- Forward Compatibility: Chunked format appears to be infrastructure for post-MoP expansions
- Magic Consistency: All files use MD20 magic consistently across all versions
Analysis Methodology:
- Random sampling across multiple MPQ archives per expansion
- Chunk detection with strict validation (4-character printable IDs, valid sizes)
- File size analysis ranging from 512 bytes (spell effects) to 1MB+ (character models)
- Cross-validation across creature, character, environment, and spell model categories
File Structure
Legacy Format (version < 264)
All data is inline in a single file with the header at the beginning.
Chunked Format (version ≥ 264)
Implementation Status: ✅ Implemented - Full Legion+ chunked format support with 25 chunks
File Reference Chunks:
- [MD21 Header Chunk] - Contains MD20 header + inline data - ✅ Implemented
- [PFID Physics File ID] - Physics file reference - ✅ Implemented
- [SFID Skin File ID] - Skin file data IDs - ✅ Implemented
- [AFID Animation File ID] - Animation file data IDs - ✅ Implemented
- [BFID Bone File ID] - Bone file data ID - ✅ Implemented
- [TXID Texture File IDs] - Texture file data IDs - ✅ Implemented
- [SKID Skin Profile IDs] - Skin profile IDs - ✅ Implemented
Rendering Enhancement Chunks:
- [TXAC Texture Animation Chunks] - Texture transforms - ✅ Implemented
- [PABC Particle Bounds Count] - Particle bounds data - ✅ Implemented
- [PADC Particle Data] - Particle system data - ✅ Implemented
- [PSBC Particle Bone Count] - Particle bone references - ✅ Implemented
- [PEDC Particle Emitter Data] - Particle emitter configurations - ✅ Implemented
- [PCOL Particle Colors] - Particle color data - ✅ Implemented
- [PFDC Particle Force Data] - Particle physics forces - ✅ Implemented
- [EDGF Edge Flags] - Edge rendering flags - ✅ Implemented
- [NERF Normals] - Enhanced normal data - ✅ Implemented
- [DETL Detail] - Level of detail enhancement - ✅ Implemented
- [RPID Render Pass IDs] - Multi-pass rendering data - ✅ Implemented
- [GPID GPU Pass IDs] - GPU rendering optimization - ✅ Implemented
- [DBOC Database Object Cache] - Object caching data - ✅ Implemented
Animation System Chunks:
- [AFRA Animation File Range] - Animation file ranges - ✅ Implemented
- [DPIV Pivot Data] - Animation pivot points - ✅ Implemented
Export and Processing Chunks:
- [EXPT Export Data] - Export processing data - ✅ Implemented
Core Data Structures
M2Array
A common pattern for referencing arrays of data within M2 files:
#![allow(unused)]
fn main() {
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct M2Array<T> {
/// Number of elements
pub count: u32,
/// Offset to array data relative to file start (or chunk start in chunked format)
pub offset: u32,
/// Phantom data for type safety
_phantom: std::marker::PhantomData<T>,
}
impl<T> M2Array<T> {
/// Read array elements from a buffer
pub fn read_elements(&self, buffer: &[u8], base_offset: usize) -> Result<Vec<T>, Error>
where
T: Pod + Zeroable
{
if self.count == 0 {
return Ok(Vec::new());
}
let element_size = std::mem::size_of::<T>();
let total_size = self.count as usize * element_size;
let start = base_offset + self.offset as usize;
let end = start + total_size;
if end > buffer.len() {
return Err(Error::BufferTooSmall);
}
let data = &buffer[start..end];
let elements = bytemuck::cast_slice::<u8, T>(data);
Ok(elements.to_vec())
}
}
}
M2Track
Animated values use tracks that interpolate between keyframes:
#![allow(unused)]
fn main() {
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct M2Track<T> {
/// Interpolation type
pub interpolation_type: u16,
/// Global sequence ID (-1 if not used)
pub global_sequence: i16,
/// Timestamps for each animation
pub timestamps: M2Array<M2Array<u32>>,
/// Values for each animation
pub values: M2Array<M2Array<T>>,
}
#[repr(u16)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InterpolationType {
None = 0,
Linear = 1,
Hermite = 2,
Bezier = 3,
}
}
M2Range
Used for defining ranges within sequences:
#![allow(unused)]
fn main() {
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct M2Range {
pub start: u32,
pub end: u32,
}
}
Fixed Point Numbers
Some fields use fixed-point representation:
#![allow(unused)]
fn main() {
/// 2.14 fixed point (used for texture coordinates)
#[repr(transparent)]
#[derive(Debug, Clone, Copy)]
pub struct FP_2_14(i16);
impl FP_2_14 {
pub fn to_f32(self) -> f32 {
self.0 as f32 / 16384.0
}
pub fn from_f32(v: f32) -> Self {
Self((v * 16384.0) as i16)
}
}
/// 6.10 fixed point (used for quaternion components)
#[repr(transparent)]
#[derive(Debug, Clone, Copy)]
pub struct FP_6_10(i16);
impl FP_6_10 {
pub fn to_f32(self) -> f32 {
self.0 as f32 / 1024.0
}
pub fn from_f32(v: f32) -> Self {
Self((v * 1024.0) as i16)
}
}
}
M2 Header
Header Structure
For chunked M2 files (version ≥ 264), the header is contained within the MD21 chunk:
#![allow(unused)]
fn main() {
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct M2Header {
/// Magic: "MD20"
pub magic: [u8; 4],
/// Version number
pub version: u32,
/// Length of the model's name including trailing \0
pub name_length: u32,
/// Offset to name, if name_length > 1
pub name_offset: u32,
/// Global model flags
pub global_flags: u32,
/// Global loops for animations
pub global_sequences: M2Array<u32>,
/// Animation sequences
pub animations: M2Array<M2Animation>,
/// Animation lookups
pub animation_lookups: M2Array<u16>,
/// Bone definitions
pub bones: M2Array<M2Bone>,
/// Bone lookups
pub bone_lookups: M2Array<u16>,
/// Vertex definitions
pub vertices: M2Array<M2Vertex>,
/// Number of skin profiles
pub num_skin_profiles: u32,
/// Color animations
pub colors: M2Array<M2Color>,
/// Texture definitions
pub textures: M2Array<M2Texture>,
/// Texture weight animations
pub texture_weights: M2Array<M2TextureWeight>,
/// Texture transforms
pub texture_transforms: M2Array<M2TextureTransform>,
/// Replaceable texture lookups
pub replaceable_texture_lookups: M2Array<u16>,
/// Material definitions
pub materials: M2Array<M2Material>,
/// Bone combination lookups
pub bone_combination_lookups: M2Array<u16>,
/// Texture lookup table
pub texture_lookups: M2Array<u16>,
/// Texture unit assignments (unused, always 0)
pub texture_units: M2Array<u16>,
/// Transparency weight lookups
pub transparency_lookups: M2Array<u16>,
/// UV animation lookups
pub uv_animation_lookups: M2Array<u16>,
/// Bounding box
pub bounding_box: BoundingBox,
/// Bounding sphere radius
pub bounding_sphere_radius: f32,
/// Collision bounding box
pub collision_box: BoundingBox,
/// Collision sphere radius
pub collision_sphere_radius: f32,
/// Collision triangles
pub collision_triangles: M2Array<u16>,
/// Collision vertices
pub collision_vertices: M2Array<Vec3>,
/// Collision normals
pub collision_normals: M2Array<Vec3>,
/// Attachments (mount points, effects, etc.)
pub attachments: M2Array<M2Attachment>,
/// Attachment lookups
pub attachment_lookups: M2Array<u16>,
/// Events (sounds, footsteps, etc.)
pub events: M2Array<M2Event>,
/// Light definitions
pub lights: M2Array<M2Light>,
/// Camera definitions
pub cameras: M2Array<M2Camera>,
/// Camera lookups
pub camera_lookups: M2Array<u16>,
/// Ribbon emitters (trails)
pub ribbon_emitters: M2Array<M2Ribbon>,
/// Particle emitters
pub particle_emitters: M2Array<M2Particle>,
/// Texture combiner combos (if global_flags & 0x08)
pub texture_combiner_combos: M2Array<u16>,
}
}
Global Flags
#![allow(unused)]
fn main() {
pub mod GlobalFlags {
/// Set for creatures with character-specific textures
pub const TILT_X: u32 = 0x00000001;
/// Set for creatures with character-specific textures
pub const TILT_Y: u32 = 0x00000002;
/// Use texture combiner combos
pub const USE_TEXTURE_COMBINER_COMBOS: u32 = 0x00000008;
/// Load phys data (collision)
pub const LOAD_PHYS_DATA: u32 = 0x00000020;
/// Unknown, set for some creatures
pub const UNK_0x80: u32 = 0x00000080;
/// Has camera data
pub const CAMERA_RELATED: u32 = 0x00000100;
/// Set for skyboxes
pub const NEW_PARTICLE_RECORD: u32 = 0x00000200;
/// Chunked format indicator
pub const CHUNKED_ANIM_FILES: u32 = 0x00000800;
/// Unknown flags
pub const UNK_0x1000: u32 = 0x00001000;
pub const UNK_0x2000: u32 = 0x00002000;
pub const UNK_0x4000: u32 = 0x00004000;
pub const UNK_0x8000: u32 = 0x00008000;
}
}
Model Components
Vertices
#![allow(unused)]
fn main() {
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct M2Vertex {
/// Position relative to pivot
pub position: Vec3,
/// Bone weights (0-255)
pub bone_weights: [u8; 4],
/// Bone indices
pub bone_indices: [u8; 4],
/// Normal vector
pub normal: Vec3,
/// Texture coordinates (first set)
pub tex_coords: [f32; 2],
/// Texture coordinates (second set)
pub tex_coords2: [f32; 2],
}
}
Texture Definitions
#![allow(unused)]
fn main() {
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct M2Texture {
/// Texture type
pub texture_type: u32,
/// Flags
pub flags: u32,
/// For non-hardcoded textures, the filename
pub filename: M2Array<u8>,
}
pub mod TextureType {
pub const NONE: u32 = 0;
pub const SKIN: u32 = 1; // Character skin
pub const OBJECT_SKIN: u32 = 2; // Item, Capes ("Item\ObjectComponents\Cape\*.blp")
pub const WEAPON_BLADE: u32 = 3; // Weapon blade
pub const WEAPON_HANDLE: u32 = 4; // Weapon handle
pub const ENVIRONMENT: u32 = 5; // Environment (OBSOLETE)
pub const CHAR_HAIR: u32 = 6; // Character hair
pub const CHAR_FACIAL_HAIR: u32 = 7; // Character facial hair
pub const SKIN_EXTRA: u32 = 8; // Skin extra
pub const UI_SKIN: u32 = 9; // UI Skin (inventory models)
pub const TAUREN_MANE: u32 = 10; // Tauren mane
pub const MONSTER_SKIN_1: u32 = 11; // Monster skin 1
pub const MONSTER_SKIN_2: u32 = 12; // Monster skin 2
pub const MONSTER_SKIN_3: u32 = 13; // Monster skin 3
pub const ITEM_ICON: u32 = 14; // Item icon
pub const GUILD_BACKGROUND: u32 = 15; // Guild background color
pub const GUILD_EMBLEM: u32 = 16; // Guild emblem color
pub const GUILD_BORDER: u32 = 17; // Guild border color
pub const GUILD_EMBLEM_2: u32 = 18; // Guild emblem
}
pub mod TextureFlags {
pub const WRAP_X: u32 = 0x001;
pub const WRAP_Y: u32 = 0x002;
}
}
Materials
#![allow(unused)]
fn main() {
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct M2Material {
/// Flags
pub flags: u16,
/// Blending mode
pub blending_mode: u16,
}
pub mod BlendingMode {
pub const OPAQUE: u16 = 0;
pub const ALPHA_KEY: u16 = 1; // > 0.7 alpha
pub const ALPHA: u16 = 2; // Blended
pub const NO_ALPHA_ADD: u16 = 3; // Additive
pub const ADD: u16 = 4; // Additive
pub const MOD: u16 = 5; // Modulative
pub const MOD2X: u16 = 6; // Modulative 2x
pub const BLEND_ADD: u16 = 7; // Blend Add
}
pub mod MaterialFlags {
pub const UNLIT: u16 = 0x01;
pub const UNFOGGED: u16 = 0x02;
pub const TWO_SIDED: u16 = 0x04;
pub const DEPTH_TEST: u16 = 0x08;
pub const DEPTH_WRITE: u16 = 0x10;
}
}
Bones
#![allow(unused)]
fn main() {
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct M2Bone {
/// Bone type / key bone ID
pub key_bone_id: i32,
/// Flags
pub flags: u32,
/// Parent bone index (-1 for root)
pub parent_bone: i16,
/// Bone submission ID (for mesh submission order)
pub submesh_id: u16,
/// Bone ID for union
pub bone_name_crc: u32,
/// Translation animation
pub translation: M2Track<Vec3>,
/// Rotation animation (quaternion compressed as 4x i16)
pub rotation: M2Track<[i16; 4]>,
/// Scale animation
pub scale: M2Track<Vec3>,
/// Pivot point
pub pivot: Vec3,
}
pub mod BoneFlags {
pub const IGNORE_PARENT_TRANSLATE: u32 = 0x01;
pub const IGNORE_PARENT_SCALE: u32 = 0x02;
pub const IGNORE_PARENT_ROTATION: u32 = 0x04;
pub const SPHERICAL_BILLBOARD: u32 = 0x08;
pub const CYLINDRICAL_BILLBOARD_LOCK_X: u32 = 0x10;
pub const CYLINDRICAL_BILLBOARD_LOCK_Y: u32 = 0x20;
pub const CYLINDRICAL_BILLBOARD_LOCK_Z: u32 = 0x40;
pub const TRANSFORMED: u32 = 0x200;
pub const KINEMATIC_BONE: u32 = 0x400;
pub const HELMET_ANIM_SCALED: u32 = 0x1000;
pub const SEQUENCE_ID: u32 = 0x2000;
}
pub mod KeyBoneId {
pub const ARM_L: i32 = 0;
pub const ARM_R: i32 = 1;
pub const SHOULDER_L: i32 = 2;
pub const SHOULDER_R: i32 = 3;
pub const SPINE_UP: i32 = 4;
pub const NECK: i32 = 5;
pub const HEAD: i32 = 6;
pub const JAW: i32 = 7;
pub const INDEX_FINGER_R: i32 = 8;
pub const MIDDLE_FINGER_R: i32 = 9;
pub const PINKY_FINGER_R: i32 = 10;
pub const RING_FINGER_R: i32 = 11;
pub const THUMB_R: i32 = 12;
pub const INDEX_FINGER_L: i32 = 13;
pub const MIDDLE_FINGER_L: i32 = 14;
pub const PINKY_FINGER_L: i32 = 15;
pub const RING_FINGER_L: i32 = 16;
pub const THUMB_L: i32 = 17;
pub const EVENT: i32 = 26;
pub const CHEST: i32 = 27;
}
}
Animations
#![allow(unused)]
fn main() {
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct M2Animation {
/// Animation ID (AnimationData.dbc)
pub id: u16,
/// Sub-animation ID
pub variation_index: u16,
/// Duration in milliseconds
pub duration: u32,
/// Movement speed
pub move_speed: f32,
/// Flags
pub flags: u32,
/// Probability of playing
pub frequency: i16,
/// Padding
pub _padding: u16,
/// Loop repetitions
pub replay_min: u32,
pub replay_max: u32,
/// Blend time in milliseconds
pub blend_time_in: u16,
pub blend_time_out: u16,
/// Bounds for this animation
pub bounds: M2Bounds,
/// Variation next
pub variation_next: i16,
/// Alias next animation
pub alias_next: u16,
}
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct M2Bounds {
pub extent: BoundingBox,
pub radius: f32,
}
pub mod AnimationFlags {
pub const INIT_BLEND: u32 = 0x00000001; // Sets 0x80 when loaded
pub const UNK_0x2: u32 = 0x00000002;
pub const UNK_0x4: u32 = 0x00000004;
pub const UNK_0x8: u32 = 0x00000008;
pub const LOOPED: u32 = 0x00000020; // Animation looped
pub const IS_ALIAS: u32 = 0x00000040; // Animation is an alias
pub const BLENDED: u32 = 0x00000080; // Animation is blended
pub const SEQUENCE: u32 = 0x00000100; // Animation uses sequence mode
pub const UNK_0x800: u32 = 0x00000800;
}
}
Animation System
Implementation Status: ✅ Format Parsing Complete - Animation data structures fully parsed, interpolation not implemented
Animation IDs
Standard animation IDs from AnimationData.dbc:
#![allow(unused)]
fn main() {
pub mod AnimationId {
pub const STAND: u16 = 0;
pub const DEATH: u16 = 1;
pub const SPELL: u16 = 2;
pub const STOP: u16 = 3;
pub const WALK: u16 = 4;
pub const RUN: u16 = 5;
pub const DEAD: u16 = 6;
pub const RISE: u16 = 7;
pub const STAND_WOUND: u16 = 8;
pub const COMBAT_WOUND: u16 = 9;
pub const COMBAT_CRITICAL: u16 = 10;
pub const SHUFFLE_LEFT: u16 = 11;
pub const SHUFFLE_RIGHT: u16 = 12;
pub const WALK_BACKWARDS: u16 = 13;
pub const STUN: u16 = 14;
pub const HANDS_CLOSED: u16 = 15;
pub const ATTACK_UNARMED: u16 = 16;
pub const ATTACK_1H: u16 = 17;
pub const ATTACK_2H: u16 = 18;
pub const ATTACK_2HL: u16 = 19;
pub const PARRY_UNARMED: u16 = 20;
pub const PARRY_1H: u16 = 21;
pub const PARRY_2H: u16 = 22;
pub const PARRY_2HL: u16 = 23;
pub const SHIELD_BLOCK: u16 = 24;
pub const READY_UNARMED: u16 = 25;
pub const READY_1H: u16 = 26;
pub const READY_2H: u16 = 27;
pub const READY_2HL: u16 = 28;
pub const READY_BOW: u16 = 29;
pub const DODGE: u16 = 30;
pub const SPELL_PRECAST: u16 = 31;
pub const SPELL_CAST: u16 = 32;
pub const SPELL_CAST_AREA: u16 = 33;
pub const NPC_WELCOME: u16 = 34;
pub const NPC_GOODBYE: u16 = 35;
pub const BLOCK: u16 = 36;
pub const JUMP_START: u16 = 37;
pub const JUMP: u16 = 38;
pub const JUMP_END: u16 = 39;
pub const FALL: u16 = 40;
pub const SWIM_IDLE: u16 = 41;
pub const SWIM: u16 = 42;
pub const SWIM_LEFT: u16 = 43;
pub const SWIM_RIGHT: u16 = 44;
pub const SWIM_BACKWARDS: u16 = 45;
pub const ATTACK_BOW: u16 = 46;
pub const FIRE_BOW: u16 = 47;
pub const READY_RIFLE: u16 = 48;
pub const ATTACK_RIFLE: u16 = 49;
pub const LOOT: u16 = 50;
pub const READY_SPELL_DIRECTED: u16 = 51;
pub const READY_SPELL_OMNI: u16 = 52;
pub const SPELL_CAST_DIRECTED: u16 = 53;
pub const SPELL_CAST_OMNI: u16 = 54;
pub const BATTLE_ROAR: u16 = 55;
pub const READY_ABILITY: u16 = 56;
pub const SPECIAL_1H: u16 = 57;
pub const SPECIAL_2H: u16 = 58;
pub const SHIELD_BASH: u16 = 59;
pub const EMOTE_TALK: u16 = 60;
pub const EMOTE_EAT: u16 = 61;
pub const EMOTE_WORK: u16 = 62;
pub const EMOTE_USE_STANDING: u16 = 63;
pub const EMOTE_TALK_EXCLAMATION: u16 = 64;
pub const EMOTE_TALK_QUESTION: u16 = 65;
pub const EMOTE_BOW: u16 = 66;
pub const EMOTE_WAVE: u16 = 67;
pub const EMOTE_CHEER: u16 = 68;
pub const EMOTE_DANCE: u16 = 69;
pub const EMOTE_LAUGH: u16 = 70;
pub const EMOTE_SLEEP: u16 = 71;
pub const EMOTE_SIT_GROUND: u16 = 72;
pub const EMOTE_RUDE: u16 = 73;
pub const EMOTE_ROAR: u16 = 74;
pub const EMOTE_KNEEL: u16 = 75;
pub const EMOTE_KISS: u16 = 76;
pub const EMOTE_CRY: u16 = 77;
pub const EMOTE_CHICKEN: u16 = 78;
pub const EMOTE_BEG: u16 = 79;
pub const EMOTE_APPLAUD: u16 = 80;
pub const EMOTE_SHOUT: u16 = 81;
pub const EMOTE_FLEX: u16 = 82;
pub const EMOTE_SHY: u16 = 83;
pub const EMOTE_POINT: u16 = 84;
// ... continues with many more animation IDs up to 791
// Including druid forms, flying animations, pet battle animations, monk animations, etc.
}
}
Skin Profiles
Skin profiles define levels of detail (LOD) and mesh partitioning:
#![allow(unused)]
fn main() {
/// Skin file header (stored in .skin files)
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct M2SkinHeader {
/// Magic: 'SKIN'
pub magic: [u8; 4],
/// Vertices used by this skin
pub vertices: M2Array<u16>,
/// Triangle indices (3 per face)
pub indices: M2Array<u16>,
/// Bone influences (up to 4 bones per vertex subset)
pub bones: M2Array<[u8; 4]>,
/// Submesh definitions
pub submeshes: M2Array<M2SkinSection>,
/// Texture units (batches)
pub texture_units: M2Array<M2Batch>,
/// Maximum number of bones used
pub bone_count_max: u32,
}
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct M2SkinSection {
/// Skin section ID (for selection)
pub skin_section_id: u16,
/// Starting vertex index
pub vertex_start: u16,
/// Number of vertices
pub vertex_count: u16,
/// Starting index
pub index_start: u16,
/// Number of indices
pub index_count: u16,
/// Number of bones
pub bone_count: u16,
/// Starting bone
pub bone_combo_index: u16,
/// Number of bones
pub bone_influences: u16,
/// Center position
pub center_position: Vec3,
/// Center position (bone weighted)
pub center_bone_weighted: Vec3,
/// Bounding box
pub bounding_box: BoundingBox,
/// Bounding sphere radius
pub bounding_radius: f32,
}
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct M2Batch {
/// Flags
pub flags: u16,
/// Priority plane (for sorting)
pub priority_plane: i16,
/// Shader ID (index into shader table)
pub shader_id: u16,
/// Skin section index
pub skin_section_index: u16,
/// Geoset index (color/alpha)
pub geoset_index: u16,
/// Material index (render flags)
pub material_index: u16,
/// Number of bones from skin section
pub bone_count: u16,
/// Starting bone lookup index
pub bone_combo_index: u16,
/// Texture lookup index
pub texture_lookup: u16,
/// Texture unit (for multitexturing)
pub texture_unit: u16,
/// Transparency lookup index
pub transparency_lookup: u16,
/// Texture animation lookup index
pub texture_anim_lookup: u16,
}
}
Particle Emitters
#![allow(unused)]
fn main() {
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct M2Particle {
/// Particle ID
pub id: u32,
/// Flags
pub flags: u32,
/// Position relative to bone
pub position: Vec3,
/// Bone index
pub bone: u16,
/// Texture index into texture lookup
pub texture: u16,
/// Geometry model filename (M2Array<char>)
pub geometry_model_filename: M2Array<u8>,
/// Recursion model filename (M2Array<char>)
pub recursion_model_filename: M2Array<u8>,
/// Blending type
pub blending_type: u8,
/// Emitter type
pub emitter_type: u8,
/// Particle color index
pub particle_color_index: u16,
/// Multi-texture param
pub multi_texture_param_x: u8,
pub multi_texture_param_y: u8,
/// Texture tile rotation
pub texture_tile_rotation: i16,
/// Texture rows/columns on texture
pub texture_rows: u16,
pub texture_cols: u16,
/// Animated properties
pub emission_speed: M2Track<f32>,
pub speed_variation: M2Track<f32>,
pub vertical_range: M2Track<f32>,
pub horizontal_range: M2Track<f32>,
pub gravity: M2Track<f32>,
pub lifespan: M2Track<f32>,
pub emission_rate: M2Track<f32>,
pub emission_area_length: M2Track<f32>,
pub emission_area_width: M2Track<f32>,
pub z_source: M2Track<f32>,
/// Alpha cutoff values
pub alpha_cutoff: [M2Array<[u16; 2]>; 2],
/// Enabled animation
pub enabled_in: M2Track<u8>,
}
pub mod ParticleFlags {
pub const AFFECTED_BY_LIGHT: u32 = 0x00000001;
pub const SORT_PARTICLES: u32 = 0x00000002;
pub const DO_NOT_TRAIL: u32 = 0x00000004;
pub const TEXTURE_TILE_BLEND: u32 = 0x00000008;
pub const TEXTURE_TILE_BLEND_2: u32 = 0x00000010;
pub const IN_MODEL_SPACE: u32 = 0x00000020;
pub const GRAVITY_SOURCE: u32 = 0x00000040;
pub const DO_NOT_THROTTLE: u32 = 0x00000080;
pub const RANDOM_SPAWN_FLIPBOOK: u32 = 0x00000200;
pub const INHERIT_SCALE: u32 = 0x00000400;
pub const RANDOM_FLIPBOOK_INDEX: u32 = 0x00000800;
pub const COMPRESSED_GRAVITY: u32 = 0x00001000;
pub const BONE_GENERATOR: u32 = 0x00002000;
pub const DO_NOT_THROTTLE_2: u32 = 0x00004000;
pub const MULTI_TEXTURE: u32 = 0x00010000;
pub const CAN_BE_PROJECTED: u32 = 0x00080000;
pub const USE_LOCAL_LIGHTING: u32 = 0x00200000;
}
pub mod EmitterType {
pub const PLANE: u8 = 1;
pub const SPHERE: u8 = 2;
pub const SPLINE: u8 = 3;
pub const BONE: u8 = 4;
}
}
Ribbons
#![allow(unused)]
fn main() {
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct M2Ribbon {
/// Ribbon ID
pub ribbon_id: u32,
/// Bone index
pub bone_index: u32,
/// Position
pub position: Vec3,
/// Texture indices
pub texture_indices: M2Array<u16>,
/// Material indices
pub material_indices: M2Array<u16>,
/// Color animation
pub color_track: M2Track<Vec3>,
/// Alpha animation
pub alpha_track: M2Track<i16>,
/// Height above animation
pub height_above_track: M2Track<f32>,
/// Height below animation
pub height_below_track: M2Track<f32>,
/// Edges per second
pub edges_per_second: f32,
/// Edge lifetime in seconds
pub edge_lifetime: f32,
/// Gravity
pub gravity: f32,
/// Texture rows
pub texture_rows: u16,
/// Texture columns
pub texture_columns: u16,
/// Texture slot animation
pub tex_slot_track: M2Track<u16>,
/// Visibility animation
pub visibility_track: M2Track<u8>,
}
}
Lights
#![allow(unused)]
fn main() {
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct M2Light {
/// Light type
pub light_type: u16,
/// Bone index (-1 for no bone)
pub bone: i16,
/// Position relative to bone
pub position: Vec3,
/// Ambient color animation
pub ambient_color: M2Track<Vec3>,
/// Ambient intensity animation
pub ambient_intensity: M2Track<f32>,
/// Diffuse color animation
pub diffuse_color: M2Track<Vec3>,
/// Diffuse intensity animation
pub diffuse_intensity: M2Track<f32>,
/// Attenuation start animation
pub attenuation_start: M2Track<f32>,
/// Attenuation end animation
pub attenuation_end: M2Track<f32>,
/// Visibility animation
pub visibility: M2Track<u8>,
}
pub mod LightType {
pub const DIRECTIONAL: u16 = 0;
pub const POINT: u16 = 1;
}
}
Cameras
#![allow(unused)]
fn main() {
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct M2Camera {
/// Camera type (usually 0)
pub camera_type: u32,
/// Far clipping plane
pub far_clip: f32,
/// Near clipping plane
pub near_clip: f32,
/// Positions (translational animation)
pub position_track: M2Track<M2SplineKey<Vec3>>,
/// Position base
pub position_base: Vec3,
/// Target positions (translational animation)
pub target_position_track: M2Track<M2SplineKey<Vec3>>,
/// Target position base
pub target_position_base: Vec3,
/// Roll animation
pub roll_track: M2Track<M2SplineKey<f32>>,
/// Field of view animation
pub fov_track: M2Track<M2SplineKey<f32>>,
}
#[repr(C)]
#[derive(Debug, Clone, Copy)]
pub struct M2SplineKey<T> {
pub value: T,
pub in_tan: T,
pub out_tan: T,
}
}
Implementation Examples
Implementation Status: ✅ Production Ready - Chunked format support with validation
Reading an M2 File (Current Implementation)
#![allow(unused)]
fn main() {
use wow_m2::model::M2Model;
use wow_m2::version::M2Version;
// ✅ M2 file parsing (both inline and chunked formats)
let model = M2Model::load("character.m2")?;
// ✅ Access model information and chunks
println!("Model name: {:?}", model.name);
println!("Model version: {:?}", model.header.version());
println!("Vertices: {}", model.vertices.len());
println!("Chunks loaded: {}", model.chunks.len());
// ✅ Access chunked format data
if let Some(texture_ids) = &model.texture_file_ids {
println!("Texture file IDs: {:?}", texture_ids.ids());
}
if let Some(physics_id) = &model.physics_file_id {
println!("Physics file ID: {}", physics_id.id());
}
// ✅ Particle system data access
if let Some(particle_data) = &model.particle_data {
println!("Particle systems: {}", particle_data.systems().len());
}
// ✅ Enhanced rendering data
if let Some(texture_anims) = &model.texture_animation_chunks {
println!("Texture animations: {}", texture_anims.animations().len());
}
// ✅ Version conversion with chunked support
let converter = wow_m2::converter::M2Converter::new();
let converted = converter.convert(&model, M2Version::Legion)?;
## Chunk Processing (Production Implementation)
```rust
use wow_m2::chunks::infrastructure::ChunkReader;
use wow_m2::chunks::file_references::*;
use wow_m2::chunks::rendering_enhancements::*;
// ✅ Advanced chunk processing with validation
impl M2Model {
/// Process all chunks in the M2 file with error handling
pub fn process_chunks<R: Read + Seek>(reader: &mut ChunkReader<R>) -> Result<Self, M2Error> {
let mut model = M2Model::default();
// Process file reference chunks
match reader.magic() {
b"SFID" => {
model.skin_file_ids = Some(SkinFileIds::read(reader)?);
}
b"AFID" => {
model.animation_file_ids = Some(AnimationFileIds::read(reader)?);
}
b"TXID" => {
model.texture_file_ids = Some(TextureFileIds::read(reader)?);
}
b"PFID" => {
model.physics_file_id = Some(PhysicsFileId::read(reader)?);
}
// Particle system chunks
b"PABC" => {
model.particle_bounds_count = Some(ParticleBoundsCount::read(reader)?);
}
b"PCOL" => {
model.particle_colors = Some(ParticleColors::read(reader)?);
}
// Texture animation chunks
b"TXAC" => {
model.texture_animation_chunks = Some(TextureAnimationChunks::read(reader)?);
}
_ => {
log::warn!("Unknown chunk: {:?}", std::str::from_utf8(reader.magic()));
}
}
Ok(model)
}
/// Validate all loaded chunks for consistency
pub fn validate_chunks(&self) -> Result<(), M2Error> {
// Cross-reference validation between chunks
if let (Some(texture_ids), Some(skin_ids)) = (&self.texture_file_ids, &self.skin_file_ids) {
// Validate texture references in skin files
for skin_id in skin_ids.ids() {
// Validation logic for skin-texture relationships
}
}
Ok(())
}
}
}
Animation Data Access (Format Specification)
#![allow(unused)]
fn main() {
// ⚠️ Format Specification Only - Animation interpolation not implemented
// Current implementation supports parsing animation data structures only
use wow_m2::model::M2Model;
// ✅ Access animation structure information
let model = M2Model::load("character.m2")?;
for (i, animation) in model.header.animations().enumerate() {
println!("Animation {}: ID={}, Duration={}ms",
i, animation.id, animation.duration);
// ✅ Access basic animation metadata
if animation.flags & 0x20 != 0 {
println!(" Animation is looped");
}
// ⚠️ Format Specification Only - Interpolation not implemented
// Track interpolation would require additional implementation
}
// ✅ Access animation file references from chunks
if let Some(anim_ids) = &model.animation_file_ids {
println!("External animation files: {:?}", anim_ids.ids());
}
}
Bone Data Access (Format Specification)
#![allow(unused)]
fn main() {
// ⚠️ Format Specification Only - Matrix calculation not implemented
// Current implementation supports parsing bone data structures only
use wow_m2::model::M2Model;
// ✅ Access bone structure information
let model = M2Model::load("character.m2")?;
for (i, bone) in model.header.bones().enumerate() {
println!("Bone {}: Key={}, Parent={}",
i, bone.key_bone_id, bone.parent_bone);
// ✅ Access bone metadata
println!(" Pivot: ({:.2}, {:.2}, {:.2})",
bone.pivot.x, bone.pivot.y, bone.pivot.z);
// ✅ Check bone flags
if bone.flags & 0x200 != 0 {
println!(" Bone is transformed");
}
// ⚠️ Format Specification Only - Animation tracks not processed
// Track processing would require additional animation system
}
// ✅ Access bone file references from chunks
if let Some(bone_id) = &model.bone_file_id {
println!("External bone file ID: {}", bone_id.id());
}
}
Common Patterns
Model Data Management (Current Implementation)
#![allow(unused)]
fn main() {
use wow_m2::model::M2Model;
use std::collections::HashMap;
use std::sync::Arc;
// ✅ Production-ready model loading and caching
struct ModelCache {
models: HashMap<String, Arc<M2Model>>,
}
impl ModelCache {
pub fn new() -> Self {
Self {
models: HashMap::new(),
}
}
// ✅ Load and cache M2 models with chunked format support
pub fn load_model(&mut self, path: &str) -> Result<Arc<M2Model>, M2Error> {
if let Some(model) = self.models.get(path) {
return Ok(Arc::clone(model));
}
let model = Arc::new(M2Model::load(path)?);
// ✅ Log chunk information for debugging
println!("Loaded model '{}' with {} chunks", path, model.chunks.len());
if let Some(texture_ids) = &model.texture_file_ids {
println!(" Texture file IDs: {}", texture_ids.len());
}
if let Some(particle_data) = &model.particle_data {
println!(" Particle systems: {}", particle_data.systems().len());
}
self.models.insert(path.to_string(), Arc::clone(&model));
Ok(model)
}
// ✅ Batch processing for multiple models
pub fn load_batch(&mut self, paths: &[&str]) -> Vec<Result<Arc<M2Model>, M2Error>> {
paths.iter()
.map(|path| self.load_model(path))
.collect()
}
}
}
Skin Profile Selection (Current Implementation)
#![allow(unused)]
fn main() {
// ✅ Access skin profile data from chunks
use wow_m2::model::M2Model;
pub fn get_skin_profiles(model: &M2Model) -> Vec<u32> {
if let Some(skin_ids) = &model.skin_file_ids {
// ✅ Return available skin profile IDs
skin_ids.ids().clone()
} else {
// ✅ Fallback to header skin profile count
(0..model.header.num_skin_profiles).collect()
}
}
// ✅ Skin profile management with chunked format
pub fn select_skin_profile(model: &M2Model, quality_level: u32) -> Option<u32> {
let profiles = get_skin_profiles(model);
if profiles.is_empty() {
return None;
}
// ✅ Select based on available profiles
let index = std::cmp::min(quality_level as usize, profiles.len() - 1);
profiles.get(index).copied()
}
}
Verification and Testing
To verify an M2 implementation, test against known good files:
Test Files
- Character models:
Character\{Race}\{Gender}\{Race}{Gender}.m2 - Creature models:
Creature\{CreatureName}\{CreatureName}.m2 - Simple objects:
World\Expansion02\Doodads\Generic\BloodElf\{Object}.m2
Validation Checks
#![allow(unused)]
fn main() {
// ✅ Production validation with chunk checking
pub fn validate_m2_model(model: &M2Model) -> Vec<ValidationError> {
let mut errors = Vec::new();
// ✅ Version validation with expanded range support
if model.header.version < 256 || model.header.version > 274 {
errors.push(ValidationError::InvalidVersion(model.header.version));
}
// ✅ Chunk consistency validation
if let Some(texture_ids) = &model.texture_file_ids {
if texture_ids.is_empty() && model.header.textures.count > 0 {
errors.push(ValidationError::MissingTextureIds);
}
}
// ✅ Particle system validation
if let (Some(particle_bounds), Some(particle_data)) =
(&model.particle_bounds_count, &model.particle_data) {
if particle_bounds.bounds().len() != particle_data.systems().len() {
errors.push(ValidationError::ParticleDataMismatch);
}
}
// ✅ Physics file reference validation
if let Some(physics_id) = &model.physics_file_id {
if physics_id.id() == 0 && (model.header.global_flags & 0x20) != 0 {
errors.push(ValidationError::InvalidPhysicsReference);
}
}
// ✅ Cross-chunk reference validation
if let (Some(skin_ids), Some(texture_ids)) =
(&model.skin_file_ids, &model.texture_file_ids) {
// Validate that skin profiles reference valid textures
for skin_id in skin_ids.ids() {
if !texture_ids.ids().is_empty() && *skin_id as usize >= texture_ids.len() {
errors.push(ValidationError::InvalidSkinTextureReference(*skin_id));
}
}
}
errors
}
}
Performance Considerations
Current Implementation Optimizations:
- ✅ Efficient chunk parsing with streaming I/O and minimal allocations
- ✅ Lazy loading of optional chunks to reduce memory usage
- ✅ Model caching to avoid re-parsing the same files
- ✅ Validation caching to skip repeated validation checks
- ✅ Zero-copy parsing where possible using byte slices
Recommended Usage Patterns:
- Cache loaded models using
Arc<M2Model>for sharing between systems - Load chunks on demand rather than parsing all chunks upfront
- Use skin profile selection based on distance/quality requirements
- Batch texture file ID lookups when processing multiple models
- Validate models once at load time rather than repeatedly at runtime
Common Issues and Solutions
Chunk Format Detection
✅ Solution: Check for MD21 header chunk to detect chunked vs inline format
#![allow(unused)]
fn main() {
if model.chunks.contains_key("MD21") {
println!("Chunked format detected");
} else {
println!("Inline format (legacy)");
}
}
Missing Texture File IDs
✅ Solution: Use TXID chunk when available, fallback to header texture array
#![allow(unused)]
fn main() {
let texture_count = if let Some(txid) = &model.texture_file_ids {
txid.len() // From chunk
} else {
model.header.textures.count as usize // From header
};
}
Physics Data Access
✅ Solution: Check PFID chunk for external physics file reference
#![allow(unused)]
fn main() {
if let Some(physics_id) = &model.physics_file_id {
println!("External physics file ID: {}", physics_id.id());
// Load separate .phys file using this ID
}
}
Particle System Configuration
✅ Solution: Cross-reference particle chunks for complete data
#![allow(unused)]
fn main() {
if let (Some(bounds), Some(colors), Some(data)) =
(&model.particle_bounds_count, &model.particle_colors, &model.particle_data) {
// All particle data available
for (i, system) in data.systems().enumerate() {
let bounds = bounds.bounds().get(i);
let colors = colors.color_data().get(i);
// Configure particle system with complete data
}
}
}
Version Compatibility
✅ Solution: Handle version differences gracefully
#![allow(unused)]
fn main() {
match model.header.version {
256 | 260 => {
// Legacy inline format only
assert!(model.chunks.is_empty());
}
264..=274 => {
// Chunked format capability, may have inline or chunks
if !model.chunks.is_empty() {
println!("Using chunked format enhancements");
}
}
_ => {
return Err(M2Error::UnsupportedVersion(model.header.version));
}
}
}
References
Implementation Status Summary
✅ Complete Implementation (Production Ready):
- Chunked format parsing (25/25 chunks implemented)
- File reference system (SFID, AFID, TXID, PFID, SKID, BFID)
- Particle system data (PABC, PADC, PSBC, PEDC, PCOL, PFDC)
- Rendering enhancements (TXAC, EDGF, NERF, DETL, RPID, GPID, DBOC)
- Export and processing data (EXPT)
- Animation system chunks (AFRA, DPIV)
- Validation and error handling
- Version compatibility (256-274)
⚠️ Format Specification Only (Not Implemented):
- Animation interpolation and blending
- Bone transformation matrices
- Physics simulation processing
- Advanced rendering pipeline integration
📊 Test Coverage:
- 135+ unit and integration tests
- Validation against original MPQ files
- Cross-platform compatibility testing
- Error condition coverage
See Also
- M2 Skin Format - Mesh and LOD data - ✅ Basic parsing
- M2 Anim Format - External animations - ✅ Basic parsing
- M2 Phys Format - Physics simulation data - ✅ Basic parsing
- BLP Format - Texture format - ✅ Implemented
M2 Anim Format 🎬
M2 .anim files contain external animation sequences that can be loaded on demand for M2 models.
Overview
- Extension:
.anim - Purpose: Store animations separately from main M2 file
- Introduced: Wrath of the Lich King (3.0.2)
- Benefits: Reduced memory usage, faster loading, animation sharing
- Naming:
<ModelName><AnimID>-<SubAnimID>.anim
Structure
File Naming Convention
// Format: ModelName + AnimID + SubAnimID
Character/BloodElf/Female/BloodElfFemale0060-00.anim
^^^^ ^^
| |
| SubAnimID (variation)
AnimID (animation type)
Anim File Structure
#![allow(unused)]
fn main() {
struct AnimFileHeader {
version: u32, // Always 0x100 (version 1.0)
sub_version: u32, // Always 0
}
struct AnimFileData {
global_sequences: Vec<GlobalSequence>,
animations: Vec<M2Animation>,
animation_lookups: Vec<i16>,
play_anim_combos: Vec<PlayAnimCombo>,
rel_anim_combos: Vec<RelAnimCombo>,
bones: Vec<M2Bone>,
key_bone_lookups: Vec<i16>,
vertices: Vec<M2Vertex>,
colors: Vec<M2Color>,
textures: Vec<M2Texture>,
texture_weights: Vec<M2TextureWeight>,
texture_transforms: Vec<M2TextureTransform>,
texture_combos: Vec<u16>,
materials: Vec<M2Material>,
material_combos: Vec<u16>,
texture_coord_combos: Vec<u16>,
fake_anim_ids: Vec<u16>,
attachments: Vec<M2Attachment>,
}
}
Usage Example
#![allow(unused)]
fn main() {
use wow_m2::{M2Model, AnimFile};
// Load base model
let format = M2Model::load("Character/Human/Male/HumanMale.m2")?;
let model = format.model();
// Load external animation file
let anim = AnimFile::load("Character/Human/Male/HumanMale0066-00.anim")?;
println!("Loaded animation with {} sections", anim.sections.len());
// Animation files are discovered by filename convention:
// <ModelName><AnimID>-<SubAnimID>.anim
// e.g., HumanMale0066-00.anim for Dance (AnimID 66)
}
Animation ID Mapping
Common Animation IDs
#![allow(unused)]
fn main() {
// Animation IDs that often have .anim files
const EMOTE_ANIMS: &[u16] = &[
60, // UseStanding
61, // Exclamation
62, // Question
63, // Bow
64, // Wave
65, // Cheer
66, // Dance
67, // Laugh
68, // Sleep
69, // SitGround
// ... more emotes
];
const COMBAT_ANIMS: &[u16] = &[
160, // Attack1H
161, // Attack2H
162, // Attack2HL
163, // AttackUnarmed
164, // AttackBow
165, // AttackRifle
166, // AttackThrown
// ... more combat
];
}
Advanced Features
Animation Sharing
#![allow(unused)]
fn main() {
// Share animations between similar models
struct AnimationCache {
cache: HashMap<String, Arc<AnimationData>>,
}
impl AnimationCache {
fn get_shared_animation(&self, model_type: &str, anim_id: u16) -> Option<Arc<AnimationData>> {
// Try exact match first
let exact_key = format!("{}_{}", model_type, anim_id);
if let Some(anim) = self.cache.get(&exact_key) {
return Some(anim.clone());
}
// Try generic version (e.g., all humans share some animations)
let race = extract_race(model_type);
let generic_key = format!("{}_{}", race, anim_id);
self.cache.get(&generic_key).cloned()
}
}
}
Lazy Loading
#![allow(unused)]
fn main() {
struct LazyAnimationLoader {
model: M2Model,
loaded_anims: HashMap<u16, AnimationData>,
anim_paths: HashMap<u16, String>,
}
impl LazyAnimationLoader {
fn play_animation(&mut self, anim_id: u16) -> Result<()> {
// Load animation on first use
if !self.loaded_anims.contains_key(&anim_id) {
if let Some(path) = self.anim_paths.get(&anim_id) {
let anim = load_anim_file(path)?;
self.loaded_anims.insert(anim_id, anim);
}
}
// Use loaded animation
if let Some(anim) = self.loaded_anims.get(&anim_id) {
self.model.apply_animation(anim);
}
Ok(())
}
}
}
Animation Streaming
#![allow(unused)]
fn main() {
// Stream animations for large cutscenes
struct AnimationStreamer {
current_segment: Option<AnimSegment>,
next_segment: Option<AnimSegment>,
preload_time: u32, // Milliseconds before needed
}
impl AnimationStreamer {
fn update(&mut self, current_time: u32) -> Result<()> {
// Check if we need to preload next segment
if let Some(current) = &self.current_segment {
let time_until_end = current.end_time - current_time;
if time_until_end <= self.preload_time && self.next_segment.is_none() {
// Start async load of next segment
self.start_preload_next_segment()?;
}
}
// Switch to next segment if current is done
if current_time >= self.current_segment.as_ref().unwrap().end_time {
self.current_segment = self.next_segment.take();
}
Ok(())
}
}
}
Common Patterns
Animation Discovery
#![allow(unused)]
fn main() {
fn discover_animations(model_path: &str) -> Vec<AnimationFile> {
let model_name = Path::new(model_path)
.file_stem()
.unwrap()
.to_str()
.unwrap();
let model_dir = Path::new(model_path).parent().unwrap();
let mut animations = Vec::new();
// Search for .anim files matching pattern
for entry in fs::read_dir(model_dir)? {
let path = entry?.path();
if path.extension() == Some(OsStr::new("anim")) {
if let Some(file_name) = path.file_name().and_then(|s| s.to_str()) {
if file_name.starts_with(model_name) {
// Extract animation ID from filename
if let Some(anim_id) = parse_anim_id(&file_name) {
animations.push(AnimationFile {
path,
anim_id,
});
}
}
}
}
}
animations
}
}
Memory Management
#![allow(unused)]
fn main() {
struct AnimationManager {
memory_limit: usize,
loaded_anims: LruCache<(String, u16), AnimationData>,
}
impl AnimationManager {
fn load_animation(&mut self, model: &str, anim_id: u16) -> Result<&AnimationData> {
let key = (model.to_string(), anim_id);
if !self.loaded_anims.contains(&key) {
let anim = load_anim_file(&format!("{}{:04}-00.anim", model, anim_id))?;
// Check memory usage
while self.get_memory_usage() + anim.size() > self.memory_limit {
// Evict least recently used
self.loaded_anims.pop_lru();
}
self.loaded_anims.put(key.clone(), anim);
}
Ok(self.loaded_anims.get(&key).unwrap())
}
}
}
Performance Tips
- Load animations asynchronously during loading screens
- Cache frequently used animations (idle, walk, run)
- Unload rarely used animations (emotes, special attacks)
- Consider animation LOD for distant models
Common Issues
Missing Animations
- Not all animations are externalized
- Some remain embedded in M2 file
- Check both internal and external sources
Version Compatibility
- .anim format introduced in WotLK (3.0.2)
- Earlier clients use embedded animations only
- Format unchanged through 5.4.8
File Discovery
- Animation files follow strict naming
- Sub-animations use different SubAnimID
- Some animations have multiple variations
References
See Also
- M2 Format - Main model format
- Animation System Guide
M2 Skin Format 🎨
M2 .skin files contain mesh data and level-of-detail (LOD) information for M2 models.
Overview
- Extension:
.skin - Purpose: Store mesh topology and LOD variants
- Naming:
<ModelName>00.skin,<ModelName>01.skin, etc. - LOD Levels: Usually 0-3 (0 = highest detail)
- Benefits: Reduced GPU memory, better performance at distance
Structure
Skin File Header
#![allow(unused)]
fn main() {
struct M2SkinHeader {
magic: [u8; 4], // "SKIN"
vertices: M2Array<u16>, // Vertex indices into M2 vertex list
indices: M2Array<u16>, // Triangle indices (3 per face)
bones: M2Array<M2SkinBone>, // Bone influences
submeshes: M2Array<M2SkinSubmesh>, // Mesh sections
texture_units: M2Array<M2SkinTextureUnit>, // Texture assignments
bone_count_max: u32, // Max bones per draw call
}
struct M2SkinSubmesh {
mesh_part_id: u16, // Mesh part identifier
starting_vertex: u16, // First vertex in vertex list
vertex_count: u16, // Number of vertices
starting_triangle: u16, // First triangle index
triangle_count: u16, // Number of triangle indices
bone_count: u16, // Number of bones
bone_combo_index: u16, // Index into bone combination
bone_influences: u16, // Max bone influences used
center_bone_index: u16, // Bone for bounding calculations
center_position: Vec3, // Bounding center
sort_center: Vec3, // Sort center for alpha
sort_radius: f32, // Sort radius
}
}
Geoset Types
#![allow(unused)]
fn main() {
// Standard geoset IDs for character models
enum GeosetType {
Skin = 0,
Hair = 1,
Facial1 = 2,
Facial2 = 3,
Facial3 = 4,
Gloves = 5,
Boots = 6,
Tail = 7,
Ears = 8,
Wristbands = 9,
Kneepads = 10,
Chest = 11,
Pants = 12,
Tabard = 13,
Trousers = 14,
Tabard2 = 15,
Cape = 16,
Feet = 17,
Eyeglow = 18,
Belt = 19,
Tail2 = 20,
// ... more
}
}
Usage Example
#![allow(unused)]
fn main() {
use wow_m2::{M2Model, parse_m2, load_skin};
// Load model and skin
let format = M2Model::load("Character/Human/Female/HumanFemale.m2")?;
let model = format.model();
let skin = load_skin("Character/Human/Female/HumanFemale00.skin")?;
// Get mesh data
let mesh_data = model.build_mesh_data()?;
println!("Vertices: {}", mesh_data.vertices.len());
println!("Indices: {}", mesh_data.indices.len());
println!("Submeshes: {}", mesh_data.submeshes.len());
// Render each submesh
for submesh in &mesh_data.submeshes {
let material = model.get_material(submesh.material_id);
renderer.set_material(material);
renderer.draw_indexed(
&mesh_data.vertices,
&mesh_data.indices[submesh.index_start..submesh.index_end],
);
}
// Load different LOD
let lod1_skin = load_skin("Character/Human/Female/HumanFemale01.skin")?;
}
LOD Management
Selecting Appropriate LOD
#![allow(unused)]
fn main() {
struct LodSelector {
lod_distances: [f32; 4], // Distance thresholds
}
impl LodSelector {
fn select_lod(&self, view_distance: f32, importance: f32) -> u8 {
let adjusted_distance = view_distance / importance;
for (lod, threshold) in self.lod_distances.iter().enumerate() {
if adjusted_distance < *threshold {
return lod as u8;
}
}
3 // Lowest detail
}
}
// Usage
let selector = LodSelector {
lod_distances: [30.0, 60.0, 120.0, 250.0],
};
let lod = selector.select_lod(player_distance, 1.0);
let skin_file = format!("{}{:02}.skin", model_name, lod);
}
Dynamic LOD Loading
#![allow(unused)]
fn main() {
struct DynamicLodModel {
base_model: M2Model,
skins: [Option<M2Skin>; 4],
current_lod: u8,
}
impl DynamicLodModel {
fn update_lod(&mut self, new_lod: u8) -> Result<()> {
if new_lod != self.current_lod {
// Load skin if not cached
if self.skins[new_lod as usize].is_none() {
let skin_path = format!("{}{:02}.skin",
self.base_model.base_name(), new_lod);
self.skins[new_lod as usize] = Some(load_skin(&skin_path)?);
}
// Apply skin
if let Some(skin) = &self.skins[new_lod as usize] {
self.base_model.set_skin(skin.clone());
self.current_lod = new_lod;
}
}
Ok(())
}
}
}
Advanced Features
Geoset Visibility
#![allow(unused)]
fn main() {
// Character customization through geoset control
struct CharacterCustomizer {
model: M2Model,
visible_geosets: HashSet<u16>,
}
impl CharacterCustomizer {
fn set_armor_piece(&mut self, slot: ArmorSlot, item_id: u32) {
// Hide conflicting geosets
match slot {
ArmorSlot::Chest => {
self.hide_geoset(GeosetType::Chest);
self.hide_geoset(GeosetType::Wristbands);
}
ArmorSlot::Legs => {
self.hide_geoset(GeosetType::Pants);
self.hide_geoset(GeosetType::Kneepads);
}
ArmorSlot::Boots => {
self.hide_geoset(GeosetType::Boots);
self.hide_geoset(GeosetType::Feet);
}
_ => {}
}
// Show item geosets
let item_geosets = get_item_geosets(item_id);
for geoset in item_geosets {
self.show_geoset(geoset);
}
}
fn render(&self) {
for submesh in self.model.submeshes() {
if self.visible_geosets.contains(&submesh.mesh_part_id) {
renderer.draw_submesh(submesh);
}
}
}
}
}
Mesh Optimization
#![allow(unused)]
fn main() {
// Optimize skin data for GPU
fn optimize_skin(skin: &M2Skin) -> OptimizedSkin {
let mut optimizer = MeshOptimizer::new();
// Optimize vertex cache
let optimized_indices = optimizer.optimize_vertex_cache(&skin.indices);
// Remove duplicate vertices
let (unique_vertices, index_map) = optimizer.remove_duplicates(&skin.vertices);
// Generate vertex buffer regions for instancing
let buffer_regions = optimizer.create_buffer_regions(&skin.submeshes);
OptimizedSkin {
vertices: unique_vertices,
indices: optimized_indices,
buffer_regions,
}
}
}
Bone Batching
#![allow(unused)]
fn main() {
// Minimize draw calls by batching submeshes with same bones
fn batch_submeshes(skin: &M2Skin) -> Vec<DrawBatch> {
let mut batches: HashMap<Vec<u16>, DrawBatch> = HashMap::new();
for submesh in &skin.submeshes {
let bone_combo = skin.get_bone_combo(submesh.bone_combo_index);
batches.entry(bone_combo.to_vec())
.or_insert_with(|| DrawBatch::new(bone_combo))
.add_submesh(submesh);
}
batches.into_values().collect()
}
}
Common Patterns
Multi-Resolution Rendering
#![allow(unused)]
fn main() {
struct MultiResRenderer {
models: Vec<(M2Model, f32)>, // Model and distance
lod_bias: f32,
}
impl MultiResRenderer {
fn render(&mut self, camera: &Camera) {
// Sort by distance
self.models.sort_by(|a, b|
b.1.partial_cmp(&a.1).unwrap()
);
for (model, distance) in &mut self.models {
// Select LOD based on distance and settings
let lod = self.calculate_lod(*distance);
model.set_lod(lod);
// Skip if too far
if lod > 3 {
continue;
}
// Render with appropriate detail
model.render();
}
}
}
}
Skin Streaming
#![allow(unused)]
fn main() {
struct SkinStreamer {
cache: LruCache<String, M2Skin>,
loading: HashSet<String>,
}
impl SkinStreamer {
async fn get_skin(&mut self, path: &str) -> Result<&M2Skin> {
if !self.cache.contains(path) && !self.loading.contains(path) {
self.loading.insert(path.to_string());
// Async load
let skin = tokio::spawn(async move {
load_skin(path)
});
let loaded_skin = skin.await??;
self.cache.put(path.to_string(), loaded_skin);
self.loading.remove(path);
}
Ok(self.cache.get(path).unwrap())
}
}
}
Performance Considerations
- LOD 0 can have 10x more triangles than LOD 3
- Batch submeshes with same material/bones
- Use index buffers for efficient GPU usage
- Consider view frustum culling per submesh
- Preload next LOD during idle time
Common Issues
Missing LODs
- Not all models have all 4 LOD levels
- Fallback to nearest available LOD
- LOD 0 always exists
Geoset IDs
- IDs not standardized across all models
- Character models follow convention
- Creature models may use custom IDs
Bone Limits
- GPU has max bones per batch (usually 64-256)
- Split submeshes if exceeding limit
- Check
bone_count_maxfield
References
See Also
- M2 Format - Main model format
- LOD System Guide
M2 Phys Format 🌊
M2 .phys files contain physics simulation data for cloth, hair, and other dynamic elements in M2 models.
Overview
- Extension:
.phys - Purpose: Define physics constraints and properties for dynamic bones
- Introduced: Cataclysm (4.0.0)
- Use Cases: Cloaks, hair, tabards, loose armor pieces
- Engine: Based on simplified Havok cloth simulation
Structure
Physics File Header
#![allow(unused)]
fn main() {
struct M2PhysHeader {
version: u32, // Version (usually 0)
chunks: Vec<PhysChunk>, // Variable chunks
}
enum PhysChunk {
PHYS(PhysicsData), // Main physics data
BODY(PhysicsBody), // Rigid body definitions
BDY2(PhysicsBodyV2), // Version 2 body data
SHAP(PhysicsShapes), // Collision shapes
JOIN(PhysicsJoints), // Joint constraints
WELJ(WeldJoints), // Welded joints
SHP2(PhysicsShapesV2), // Version 2 shapes
PHYV(PhysicsVersion), // Physics version info
}
}
Physics Bone Data
#![allow(unused)]
fn main() {
struct PhysicsBone {
bone_index: u16, // Index in M2 bone array
flags: u16, // Physics flags
mass: f32, // Bone mass
wind_resistance: f32, // Wind interaction strength
damping: f32, // Motion damping
max_distance: f32, // Max distance from rest position
stiffness: f32, // Spring stiffness
thickness: f32, // Collision thickness
gravity_scale: f32, // Gravity multiplier
}
struct PhysicsConstraint {
bone_a: u16, // First bone index
bone_b: u16, // Second bone index
distance: f32, // Rest distance
stretch_resistance: f32, // Stretch stiffness
compress_resistance: f32, // Compression stiffness
}
}
Usage Example
#![allow(unused)]
fn main() {
use wow_m2::M2Model;
// Load model
let format = M2Model::load("Character/Human/Female/HumanFemale.m2")?;
let model = format.model();
// Physics data (.phys files) contains bone simulation parameters.
// The wow-m2 crate parses the M2 model and its bone hierarchy.
// Physics simulation (cloth, hair) would be implemented in your
// rendering engine using the bone data and .phys file constraints.
//
// The .phys file is a separate binary file with the same base name:
// Character/Human/Female/HumanFemale.phys
//
// It contains rigid body definitions, collision shapes, and joint
// constraints that drive the physics simulation for dynamic bones.
}
Physics Systems
Cloth Simulation
#![allow(unused)]
fn main() {
struct ClothSimulator {
particles: Vec<ClothParticle>,
constraints: Vec<ClothConstraint>,
collision_shapes: Vec<CollisionShape>,
}
impl ClothSimulator {
fn simulate_step(&mut self, dt: f32) {
// Apply forces
for particle in &mut self.particles {
if !particle.is_fixed {
// Gravity
particle.velocity += Vec3::new(0.0, -9.81, 0.0) * dt;
// Wind
let wind_force = self.calculate_wind_force(&particle);
particle.velocity += wind_force * dt;
// Damping
particle.velocity *= 0.99;
}
}
// Update positions
for particle in &mut self.particles {
particle.predicted_pos = particle.position + particle.velocity * dt;
}
// Solve constraints
for _ in 0..4 { // Multiple iterations for stability
self.solve_distance_constraints();
self.solve_collision_constraints();
}
// Update velocities and positions
for particle in &mut self.particles {
particle.velocity = (particle.predicted_pos - particle.position) / dt;
particle.position = particle.predicted_pos;
}
}
}
}
Hair Physics
#![allow(unused)]
fn main() {
struct HairStrand {
segments: Vec<HairSegment>,
root_bone: u16,
stiffness: f32,
damping: f32,
}
impl HairStrand {
fn update(&mut self, head_transform: &Matrix4, dt: f32) {
// Fix root to head
self.segments[0].position = head_transform * self.segments[0].rest_position;
// Simulate each segment
for i in 1..self.segments.len() {
let parent = &self.segments[i-1];
let segment = &mut self.segments[i];
// Spring force to maintain length
let to_parent = parent.position - segment.position;
let current_length = to_parent.length();
let rest_length = segment.rest_length;
let spring_force = self.stiffness * (current_length - rest_length)
* to_parent.normalize();
// Apply forces
segment.velocity += spring_force * dt;
segment.velocity *= self.damping;
segment.position += segment.velocity * dt;
// Length constraint
let dir = (segment.position - parent.position).normalize();
segment.position = parent.position + dir * rest_length;
}
}
}
}
Advanced Features
Collision Detection
#![allow(unused)]
fn main() {
enum CollisionShape {
Sphere { center: Vec3, radius: f32 },
Capsule { start: Vec3, end: Vec3, radius: f32 },
Box { min: Vec3, max: Vec3 },
}
fn resolve_collision(particle: &mut ClothParticle, shape: &CollisionShape) {
match shape {
CollisionShape::Sphere { center, radius } => {
let to_particle = particle.position - center;
let distance = to_particle.length();
if distance < *radius {
// Push particle outside sphere
particle.position = center + to_particle.normalize() * radius;
}
}
CollisionShape::Capsule { start, end, radius } => {
// Find closest point on line segment
let closest = closest_point_on_segment(&particle.position, start, end);
let to_particle = particle.position - closest;
let distance = to_particle.length();
if distance < *radius {
particle.position = closest + to_particle.normalize() * radius;
}
}
_ => {}
}
}
}
Wind Interaction
#![allow(unused)]
fn main() {
struct WindSystem {
base_direction: Vec3,
turbulence_scale: f32,
gust_frequency: f32,
time: f32,
}
impl WindSystem {
fn get_wind_at(&self, position: Vec3) -> Vec3 {
// Base wind
let mut wind = self.base_direction;
// Add turbulence
let turb_x = noise_3d(position * self.turbulence_scale + self.time);
let turb_y = noise_3d(position * self.turbulence_scale + self.time + 100.0);
let turb_z = noise_3d(position * self.turbulence_scale + self.time + 200.0);
wind += Vec3::new(turb_x, turb_y, turb_z) * 2.0;
// Add gusts
let gust_strength = (self.time * self.gust_frequency).sin().max(0.0);
wind *= 1.0 + gust_strength * 3.0;
wind
}
}
}
Performance Optimization
#![allow(unused)]
fn main() {
struct OptimizedPhysics {
lod_distances: [f32; 3],
simulation_rates: [u32; 3], // Simulation steps per frame
}
impl OptimizedPhysics {
fn update(&mut self, models: &mut [PhysicsModel], camera: &Camera) {
for model in models {
let distance = (model.position - camera.position).length();
// Determine LOD
let lod = if distance < self.lod_distances[0] {
0 // Full simulation
} else if distance < self.lod_distances[1] {
1 // Reduced simulation
} else if distance < self.lod_distances[2] {
2 // Minimal simulation
} else {
continue; // Skip physics
};
// Simulate at appropriate rate
let steps = self.simulation_rates[lod];
for _ in 0..steps {
model.physics.step(0.016 / steps as f32);
}
}
}
}
}
Common Patterns
Physics Asset Pipeline
#![allow(unused)]
fn main() {
struct PhysicsAssetLoader {
cache: HashMap<String, Arc<M2Physics>>,
}
impl PhysicsAssetLoader {
fn load_with_fallback(&mut self, model_path: &str) -> Option<Arc<M2Physics>> {
// Try exact match
let phys_path = model_path.replace(".m2", ".phys");
if let Ok(physics) = load_phys_file(&phys_path) {
return Some(Arc::new(physics));
}
// Try shared physics (e.g., all human females share cape physics)
let model_type = extract_model_type(model_path);
let shared_path = format!("Physics/Shared/{}.phys", model_type);
if let Ok(physics) = M2Physics::open(&shared_path) {
return Some(Arc::new(physics));
}
None // No physics data
}
}
}
Dynamic Quality Settings
#![allow(unused)]
fn main() {
struct PhysicsQualitySettings {
enable_cloth: bool,
enable_hair: bool,
max_simulated_models: u32,
collision_iterations: u32,
}
impl PhysicsQualitySettings {
fn apply(&self, simulator: &mut PhysicsSimulator) {
simulator.set_enabled(PhysicsType::Cloth, self.enable_cloth);
simulator.set_enabled(PhysicsType::Hair, self.enable_hair);
simulator.set_iterations(self.collision_iterations);
}
fn from_preset(preset: QualityPreset) -> Self {
match preset {
QualityPreset::Low => Self {
enable_cloth: false,
enable_hair: false,
max_simulated_models: 5,
collision_iterations: 1,
},
QualityPreset::High => Self {
enable_cloth: true,
enable_hair: true,
max_simulated_models: 50,
collision_iterations: 4,
},
_ => Self::default(),
}
}
}
}
Performance Tips
- Use LOD system for physics simulation
- Disable physics for off-screen models
- Reduce iterations for distant objects
- Cache physics data - don’t reload per instance
- Use spatial partitioning for collision detection
Common Issues
Stability Problems
- Too large time steps cause explosions
- Use fixed timestep with interpolation
- Multiple constraint iterations improve stability
Version Compatibility
- .phys introduced in Cataclysm (4.0.0)
- Format evolved through expansions
- Not all models have physics data
Performance Impact
- Physics can be CPU intensive
- Limit number of simulated models
- Consider GPU physics for crowds
References
See Also
- M2 Format - Main model format
ADT File References
This document details all file references found in ADT (terrain) files and how they reference external assets.
Overview
ADT files contain references to various external file formats used to construct the game world. These references are stored in specific chunks and use different methods to identify the external files.
File Reference Chunks
1. MTEX - Texture References
- File Format Referenced: BLP (texture files)
- Storage Method: Null-terminated filename strings
- Structure: Sequential list of texture filenames
- Example:
Tileset\Elwynn\ElwynnGrass01.blp
2. MMDX - Model References
- File Format Referenced: M2 (model files)
- Storage Method: Null-terminated filename strings
- Structure: Sequential list of model filenames
- Example:
World\Azeroth\Elwynn\PassiveDoodads\Trees\ElwynnTree01.m2
3. MMID - Model ID Mapping
- Purpose: Maps model instances to filenames in MMDX
- Storage Method: 32-bit offsets into the MMDX chunk
- Usage: Each offset points to the start of a filename string in MMDX
4. MWMO - WMO References
- File Format Referenced: WMO (World Map Object files)
- Storage Method: Null-terminated filename strings
- Structure: Sequential list of WMO filenames
- Example:
World\wmo\Azeroth\Buildings\Stormwind\Stormwind.wmo
5. MWID - WMO ID Mapping
- Purpose: Maps WMO instances to filenames in MWMO
- Storage Method: 32-bit offsets into the MWMO chunk
- Usage: Each offset points to the start of a filename string in MWMO
6. MDDF - Doodad (M2) Placement
- Purpose: Places M2 models in the world
- Reference Method: Uses
name_idfield which is an index into MMID - Additional Data: Position, rotation, scale, flags, unique ID
- Structure:
#![allow(unused)] fn main() { pub struct DoodadPlacement { pub name_id: u32, // Index into MMID list pub unique_id: u32, // Unique instance identifier pub position: [f32; 3], pub rotation: [f32; 3], pub scale: f32, pub flags: u16, } }
7. MODF - WMO Placement
- Purpose: Places WMO objects in the world
- Reference Method: Uses
name_idfield which is an index into MWID - Additional Data: Position, rotation, bounding box, flags, doodad set, name set
- Structure:
#![allow(unused)] fn main() { pub struct ModelPlacement { pub name_id: u32, // Index into MWID list pub unique_id: u32, // Unique instance identifier pub position: [f32; 3], pub rotation: [f32; 3], pub bounds_min: [f32; 3], pub bounds_max: [f32; 3], pub flags: u16, pub doodad_set: u16, pub name_set: u16, pub padding: u16, } }
8. MCNK Texture Layers
- Purpose: References textures for terrain painting
- Reference Method: Uses
texture_idfield which is an index into MTEX - Location: Within MCNK chunks in the MCLY subchunk
- Structure:
#![allow(unused)] fn main() { pub struct McnkTextureLayer { pub texture_id: u32, // Index into MTEX list pub flags: u32, pub alpha_map_offset: u32, // Offset within MCAL chunk pub effect_id: u32, // Reference to texture effect } }
9. MCNK Object References
- Doodad References:
- Stored in MCRF subchunk
- Contains indices into MMID (which map to MMDX filenames)
- WMO References:
- Also stored in MCRF subchunk after doodad references
- Contains indices into MWID (which map to MWMO filenames)
10. MTFX - Texture Effects (Cataclysm+)
- Purpose: Defines special effects for textures (e.g., lava, water shaders)
- Reference Method: Effect IDs that correspond to shader effects
- Structure: List of effect IDs corresponding to each texture in MTEX
Reference Resolution Process
-
Texture Resolution:
- MCNK chunk contains texture layers with
texture_id texture_idis used as an index into MTEX chunk- MTEX contains the actual BLP filename
- MCNK chunk contains texture layers with
-
M2 Model Resolution:
- MDDF chunk contains doodad placements with
name_id name_idis used as an index into MMID chunk- MMID contains an offset into MMDX chunk
- MMDX contains the actual M2 filename at that offset
- MDDF chunk contains doodad placements with
-
WMO Resolution:
- MODF chunk contains WMO placements with
name_id name_idis used as an index into MWID chunk- MWID contains an offset into MWMO chunk
- MWMO contains the actual WMO filename at that offset
- MODF chunk contains WMO placements with
Version-Specific Considerations
- Vanilla/TBC: Basic reference system as described above
- WotLK: Added MH2O for water (replaces MCLQ in MCNK chunks)
- Cataclysm+:
- Added MTFX for texture effects
- Split ADT files may separate object references (_obj0, _obj1) from texture references (_tex0, _tex1)
File Path Conventions
All file paths in ADT files follow these conventions:
- Use backslashes (
\) as path separators - Are relative to the game’s data directory
- Do not include file extensions in some cases (game adds .blp for textures automatically)
- Are case-insensitive (game converts to lowercase internally)
Usage in Code
To access referenced files from an ADT:
#![allow(unused)]
fn main() {
// Get texture filename
let texture_filename = &adt.mtex.as_ref().unwrap().filenames[texture_id as usize];
// Get M2 model filename
let mmid_offset = adt.mmid.as_ref().unwrap().offsets[name_id as usize];
let model_filename = &adt.mmdx.as_ref().unwrap().filenames
.iter()
.find(|f| /* find string at mmid_offset */)
.unwrap();
// Get WMO filename
let mwid_offset = adt.mwid.as_ref().unwrap().offsets[name_id as usize];
let wmo_filename = &adt.mwmo.as_ref().unwrap().filenames
.iter()
.find(|f| /* find string at mwid_offset */)
.unwrap();
}
WMO Format 🏰
WMO (World Map Object) files are used in World of Warcraft to represent large static objects such as buildings, caves, and other structures that are too complex to be represented as M2 (doodad) models. WMOs consist of a root file and zero or more group files that contain geometry data.
Overview
- Extension:
.wmo(root file),_000.wmoto_999.wmo(group files) - Magic: Chunk-based format with 4-character identifiers (reversed in file)
- Purpose: Large static world geometry with interior/exterior areas
- Components: Root file + multiple group files
- Features: Portal data ⚠️ Parsing Only, BSP trees ⚠️ Parsing Only, lighting data ⚠️ Parsing Only, multiple LODs ⚠️ Format Specification
- Use Cases: Buildings, dungeons, caves, large structures, instances
Key Characteristics
- Chunk-based format: Similar to other Blizzard formats, using 4-character chunk identifiers
- Multi-file structure: Root file + group files (numbered _000.wmo to_999.wmo)
- Portal data: ⚠️ Format Specification Only - Portal vertices and normals parsed, visibility culling not implemented
- BSP trees: ⚠️ Format Specification Only - Node structure parsed, collision detection not implemented
- Multiple LOD support: ⚠️ Format Specification Only - LOD data parsed, rendering optimization not implemented
- Lighting data: ⚠️ Format Specification Only - Light parameters parsed, lighting calculations not implemented
Version History
Based on empirical analysis of WMO files from original MPQ archives:
| Version | Expansion | Core Chunks | Notable Changes |
|---|---|---|---|
| 17 | Vanilla WoW (1.12.1) | MVER, MOHD, MOTX, MOMT, MOGN, MOGI, MOSB, MOPV, MOPT, MOPR, MOVV, MOVB, MOLT, MODS, MODN, MODD, MFOG | Original format with 17 core chunks |
| 17 | The Burning Crusade (2.4.3) | Same as 1.12.1 | No new chunks detected in samples |
| 17 | Wrath of the Lich King (3.3.5a) | Same as 1.12.1 | No new chunks detected in samples |
| 17 | Cataclysm (4.3.4) | Core + MCVP | Added MCVP (Convex Volume Planes, 496 bytes in transport WMOs) |
| 17 | Mists of Pandaria (5.4.8) | Core + MCVP | No additional chunks detected |
| 17 | Warlords of Draenor (6.x) | Core + GFID | Added GFID chunk for file IDs |
| 17 | Legion (7.x) | Core + MOP2, MPVD | Added MOP2 (Portal Info 2), MPVD (particle volumes) |
| 17 | Battle for Azeroth (8.x) | Core + shadow chunks | Enhanced shadow mapping (MLSP, MLSS, MLSK) |
| 17 | Shadowlands (9.x) | Core + volume chunks | Additional volume data types (MAVD, MBVD) |
Note: Version number (17) remained constant from Vanilla through later WoW, with functionality added through new optional chunks rather than version changes.
File Structure Overview
WMO files follow a chunk-based format where each chunk has:
- 4-byte chunk identifier (reversed in file, e.g., “REVM” for MVER)
- 4-byte chunk size (not including the 8-byte header)
- Chunk data
#![allow(unused)]
fn main() {
use wow_wmo::{ChunkHeader, ChunkId};
// Example of reading a chunk header
let header = ChunkHeader {
id: ChunkId::from_str("MVER"),
size: 4,
};
println!("Chunk ID: {}", header.id);
println!("Size: {}", header.size);
}
Empirical Analysis Results
Based on analysis of WMO files from WoW versions 1.12.1 through 3.3.5a:
Core Chunk Structure (All Versions)
All analyzed WMO root files consistently contain these 17 chunks in order:
- MVER (4 bytes) - Version, always value 17
- MOHD (64 bytes) - Header with counts and flags
- MOTX (variable) - Texture filenames, null-terminated strings
- MOMT (variable) - Materials, 64 bytes per material
- MOGN (variable) - Group names, null-terminated strings
- MOGI (variable) - Group information, 32 bytes per group
- MOSB (4 bytes) - Skybox filename offset or empty
- MOPV (variable) - Portal vertices, 12 bytes per vertex
- MOPT (variable) - Portal information, 20 bytes per portal
- MOPR (variable) - Portal references, 8 bytes per reference
- MOVV (0 bytes typically) - Visible block vertices (often empty)
- MOVB (0 bytes typically) - Visible block list (often empty)
- MOLT (variable) - Lighting, 48 bytes per light
- MODS (32 bytes typically) - Doodad sets, single default set common
- MODN (variable) - Doodad names, null-terminated M2 filenames
- MODD (variable) - Doodad definitions, 40 bytes per doodad
- MFOG (variable) - Fog parameters, 48 bytes per fog entry
Group File Structure
Group files (e.g., *_000.wmo) contain a single large MOGP chunk:
- MVER (4 bytes) - Version 17
- MOGP (entire remaining file) - Contains all group geometry sub-chunks
Chunk Specifications
Root File Chunks
The root WMO file contains global information about the entire model.
MVER - Version
Always the first chunk in the file. ✅ Implemented
#![allow(unused)]
fn main() {
// Version information is part of WmoRoot
use wow_wmo::{WmoVersion, WmoRoot};
// Example accessing version from parsed WMO
fn check_version(wmo: &WmoRoot) {
println!("WMO Version: {}", wmo.version.to_raw());
println!("Expansion: {}", wmo.version.expansion_name());
}
}
MOHD - Header
Contains general information about the WMO. ✅ Implemented
#![allow(unused)]
fn main() {
use wow_wmo::{WmoHeader, WmoFlags, Color};
// Example accessing WMO header information
fn analyze_wmo_header(header: &WmoHeader) {
println!("Materials: {}", header.n_materials);
println!("Groups: {}", header.n_groups);
println!("Portals: {}", header.n_portals);
println!("Lights: {}", header.n_lights);
println!("Doodad Defs: {}", header.n_doodad_defs);
println!("Doodad Sets: {}", header.n_doodad_sets);
if header.flags.contains(WmoFlags::HAS_SKYBOX) {
println!("WMO has skybox");
}
if header.flags.contains(WmoFlags::INDOOR_MAP) {
println!("WMO is an indoor map");
}
}
}
MOTX - Textures
Null-terminated texture filenames used by this WMO. ✅ Implemented
#![allow(unused)]
fn main() {
// Textures are automatically parsed and available in WmoRoot
use wow_wmo::WmoRoot;
fn list_textures(wmo: &WmoRoot) {
for (i, texture) in wmo.textures.iter().enumerate() {
println!("Texture {}: {}", i, texture);
}
}
}
MOMT - Materials
Material definitions for all textures. ✅ Implemented
#![allow(unused)]
fn main() {
use wow_wmo::{WmoMaterial, WmoMaterialFlags};
// Example analyzing WMO materials
fn analyze_material(material: &WmoMaterial) {
if material.flags.contains(WmoMaterialFlags::UNLIT) {
println!("Material is unlit");
}
if material.flags.contains(WmoMaterialFlags::TWO_SIDED) {
println!("Material is two-sided");
}
if material.flags.contains(WmoMaterialFlags::UNFOGGED) {
println!("Material is unfogged");
}
println!("Texture 1 index: {}", material.texture1);
println!("Texture 2 index: {}", material.texture2);
println!("Blend mode: {}", material.blend_mode);
println!("Ground type: {}", material.ground_type);
}
}
MOGN - Group Names
Null-terminated strings for group names (primarily for debugging).
#![allow(unused)]
fn main() {
// Group names are automatically parsed and available in WmoGroupInfo
use wow_wmo::{WmoRoot, WmoGroupInfo};
fn list_group_names(wmo: &WmoRoot) {
for (i, group_info) in wmo.groups.iter().enumerate() {
println!("Group {}: {}", i, group_info.name);
}
}
}
MOGI - Group Information
Information about each group in the WMO.
#![allow(unused)]
fn main() {
use wow_wmo::{WmoGroupInfo, WmoGroupFlags};
// Example analyzing group information
fn analyze_group_info(group_info: &WmoGroupInfo) {
println!("Group name: {}", group_info.name);
println!("Bounding box: {:?}", group_info.bounding_box);
if group_info.flags.contains(WmoGroupFlags::INDOOR) {
println!("Group is indoor");
}
if group_info.flags.contains(WmoGroupFlags::HAS_VERTEX_COLORS) {
println!("Group has vertex colors");
}
if group_info.flags.contains(WmoGroupFlags::HAS_DOODADS) {
println!("Group has doodads");
}
if group_info.flags.contains(WmoGroupFlags::HAS_WATER) {
println!("Group has liquid");
}
}
}
MOSB - Skybox
Skybox model filename (if present).
#![allow(unused)]
fn main() {
// Skybox information is available in WmoRoot
use wow_wmo::WmoRoot;
fn check_skybox(wmo: &WmoRoot) {
if let Some(skybox) = &wmo.skybox {
println!("Skybox model: {}", skybox);
} else {
println!("No skybox");
}
}
}
MOPV - Portal Vertices
Vertices used to define portal geometry.
#![allow(unused)]
fn main() {
use wow_wmo::{WmoPortal, Vec3};
// Portal vertices are part of WmoPortal structure
fn analyze_portal(portal: &WmoPortal) {
println!("Portal has {} vertices", portal.vertices.len());
println!("Portal normal: {:?}", portal.normal);
for (i, vertex) in portal.vertices.iter().enumerate() {
println!("Vertex {}: ({}, {}, {})", i, vertex.x, vertex.y, vertex.z);
}
}
}
MOPT - Portal Information
Portal definitions connecting groups.
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct MOPTEntry {
/// Index into MOPV for start of vertices
start_vertex: u16,
/// Number of vertices in this portal
vertex_count: u16,
/// Portal plane (normal xyz, distance w)
plane: [f32; 4],
}
}
MOPR - Portal References
Links portals to groups.
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct MOPREntry {
/// Portal index
portal_index: u16,
/// Group index
group_index: u16,
/// 1 = portal is on the interior side of the group
side: i16,
/// Padding
_padding: u16,
}
}
MOVV - Visible Block Vertices
Vertices for visibility blocking volumes.
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct MOVVEntry {
position: [f32; 3],
}
}
MOVB - Visible Block List
Defines visibility blocking volumes.
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct MOVBEntry {
/// Index into MOVV
start_vertex: u16,
/// Number of vertices
vertex_count: u16,
}
}
MOLT - Lighting
Light definitions for the WMO.
#![allow(unused)]
fn main() {
use wow_wmo::{WmoLight, WmoLightType, WmoLightProperties};
// Example analyzing WMO lights
fn analyze_light(light: &WmoLight) {
println!("Light type: {:?}", light.light_type);
println!("Position: ({}, {}, {})", light.position.x, light.position.y, light.position.z);
println!("Color: {:?}", light.color);
println!("Intensity: {}", light.intensity);
if light.use_attenuation {
println!("Attenuation: {} to {}", light.attenuation_start, light.attenuation_end);
}
match &light.properties {
WmoLightProperties::Spot { direction, hotspot, falloff } => {
println!("Spot light: direction {:?}, hotspot {}, falloff {}", direction, hotspot, falloff);
}
WmoLightProperties::Directional { direction } => {
println!("Directional light: direction {:?}", direction);
}
WmoLightProperties::Omni => {
println!("Omni light");
}
WmoLightProperties::Ambient => {
println!("Ambient light");
}
}
}
}
MODS - Doodad Sets
Doodad set definitions (e.g., “furniture”, “decorations”).
#![allow(unused)]
fn main() {
use wow_wmo::WmoDoodadSet;
// Example analyzing doodad sets
fn analyze_doodad_set(doodad_set: &WmoDoodadSet) {
println!("Doodad set: {}", doodad_set.name);
println!("Start doodad: {}", doodad_set.start_doodad);
println!("Number of doodads: {}", doodad_set.n_doodads);
}
}
MODN - Doodad Names
List of null-terminated doodad filenames (M2 models).
#![allow(unused)]
fn main() {
// Doodad names are automatically parsed and available
// They would typically be referenced by doodad definitions
use wow_wmo::WmoRoot;
fn show_doodad_info(wmo: &WmoRoot) {
for (i, doodad_def) in wmo.doodad_defs.iter().enumerate() {
println!("Doodad {}: position ({}, {}, {})",
i, doodad_def.position.x, doodad_def.position.y, doodad_def.position.z);
println!(" Scale: {}", doodad_def.scale);
println!(" Color: {:?}", doodad_def.color);
}
}
}
MODD - Doodad Definitions
Placement information for doodads.
#![allow(unused)]
fn main() {
use wow_wmo::WmoDoodadDef;
// Example analyzing doodad definitions
fn analyze_doodad_def(doodad_def: &WmoDoodadDef) {
println!("Name offset: {}", doodad_def.name_offset);
println!("Position: ({}, {}, {})",
doodad_def.position.x, doodad_def.position.y, doodad_def.position.z);
println!("Orientation: [{}, {}, {}, {}]",
doodad_def.orientation[0], doodad_def.orientation[1],
doodad_def.orientation[2], doodad_def.orientation[3]);
println!("Scale: {}", doodad_def.scale);
println!("Color: {:?}", doodad_def.color);
println!("Set index: {}", doodad_def.set_index);
}
}
MFOG - Fog
Fog settings for groups.
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct MFOGEntry {
/// Flags
flags: u32,
/// Position
position: [f32; 3],
/// Small radius
radius_small: f32,
/// Large radius
radius_large: f32,
/// Fog end distance
fog_end: f32,
/// Fog start multiplier
fog_start_multiplier: f32,
/// Fog color (BGRA)
color: u32,
/// Underwater fog end
underwater_end: f32,
/// Underwater fog start multiplier
underwater_start_multiplier: f32,
/// Underwater color (BGRA)
underwater_color: u32,
}
}
MCVP - Convex Volume Planes
Convex volume planes for advanced collision or effects.
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct MCVPEntry {
/// Plane equation (normal xyz, distance w)
plane: [f32; 4],
}
}
MOUV - UV Transformations
UV transformations for animated textures (Legion+). ✅ Implemented
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct MOUVEntry {
translation_speed: [[f32; 2]; 2], // 2 C2Vectors per material
}
}
MOPE - Portal Extra Information
Additional portal information (WarWithin+). ✅ Implemented
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct MOPEEntry {
portal_index: u32, // index into MOPT
unk1: u32,
unk2: u32,
unk3: u32,
}
}
MOLV - Light Extensions
Extended light information (Shadowlands+). ✅ Implemented
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct MOLVEntry {
directions: [[f32; 4]; 6], // 6 sets of C3Vector + float value
unknown: [u8; 3],
molt_index: u8,
}
}
MODI - Doodad File IDs
Doodad file IDs for file reference system (Battle for Azeroth+). ✅ Implemented
#![allow(unused)]
fn main() {
/// MODI contains an array of u32 doodad IDs, same count as SMOHeader.nDoodadNames
fn parse_modi(data: &[u8]) -> Vec<u32> {
data.chunks_exact(4)
.map(|chunk| u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]))
.collect()
}
}
MOM3 - New Materials
New material system for later WoW versions (WarWithin+). ✅ Implemented
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct MOM3Entry {
// m3SI structure - defines new materials
// Structure details may vary, treated as opaque data
data: Vec<u8>,
}
}
MOMO - Alpha Version Container
Container chunk for alpha WoW versions (version 14 only). ✅ Implemented
#![allow(unused)]
fn main() {
// MOMO is a container chunk with no additional data
// It wraps other chunks in early WoW alpha versions
}
GFID - Group File IDs
File IDs for group files (later WoW versions). ⚠️ Format Specification Only
#![allow(unused)]
fn main() {
/// GFID contains an array of u32 file IDs, one per group
fn parse_gfid(data: &[u8]) -> Vec<u32> {
data.chunks_exact(4)
.map(|chunk| u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]))
.collect()
}
}
Group File Chunks
Each group file contains the geometry and rendering data for a portion of the WMO.
MOGP - Group Header
The main header for a group file, contains all other chunks.
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct MOGPHeader {
/// Group name offset in MOGN
group_name: u32,
/// Descriptive name offset in MOGN
descriptive_name: u32,
/// Flags (same as MOGI flags)
flags: u32,
/// Bounding box
bounding_box_min: [f32; 3],
bounding_box_max: [f32; 3],
/// Portal index offset
portal_start: u16,
/// Number of portals
portal_count: u16,
/// Number of batches A
batch_count_a: u16,
/// Number of batches B
batch_count_b: u16,
/// Number of batches C
batch_count_c: u16,
/// Number of batches D
batch_count_d: u16,
/// Fog indices
fog_indices: [u8; 4],
/// Liquid type
liquid_type: u32,
/// Group ID
group_id: u32,
/// Unknown fields
unknown_1: u32,
unknown_2: u32,
}
}
MOPY - Material Info
Material information for each triangle. ✅ Implemented
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct MOPYEntry {
/// Flags
flags: u8,
/// Material ID
material_id: u8,
}
impl MOPYEntry {
// Triangle flags
pub const FLAG_UNK_0X01: u8 = 0x01;
pub const FLAG_NO_COLLISION: u8 = 0x02;
pub const FLAG_NO_CAMERA_COLLISION: u8 = 0x04;
pub const FLAG_NO_RENDER: u8 = 0x08;
pub const FLAG_IS_WATER: u8 = 0x10;
}
}
MOVI - Vertex Indices
Triangle vertex indices.
#![allow(unused)]
fn main() {
/// MOVI contains u16 indices, 3 per triangle
fn parse_movi(data: &[u8]) -> Vec<[u16; 3]> {
data.chunks_exact(6)
.map(|chunk| {
[
u16::from_le_bytes([chunk[0], chunk[1]]),
u16::from_le_bytes([chunk[2], chunk[3]]),
u16::from_le_bytes([chunk[4], chunk[5]]),
]
})
.collect()
}
}
MOVT - Vertices
Vertex positions. ✅ Implemented
Vertices chunk with count = size / (sizeof(float) * 3). 3 floats per vertex. Important: Coordinates are in (X,Z,-Y) order as WMOs use a coordinate system with Z-up and Y into screen, while OpenGL uses Z toward viewer and Y up.
#![allow(unused)]
fn main() {
#[repr(C, packed)]
#[derive(Debug, Clone, Copy)]
struct MOVTEntry {
x: f32, // X coordinate
y: f32, // Z coordinate (in WMO space)
z: f32, // -Y coordinate (in WMO space)
}
}
MONR - Normals
Vertex normals. ✅ Implemented
#![allow(unused)]
fn main() {
#[repr(C, packed)]
#[derive(Debug, Clone, Copy)]
struct MONREntry {
x: f32,
y: f32,
z: f32,
}
}
MOTV - Texture Coordinates
Texture coordinates (can have up to 3 sets). ✅ Implemented
#![allow(unused)]
fn main() {
#[repr(C, packed)]
#[derive(Debug, Clone, Copy)]
struct MOTVEntry {
u: f32,
v: f32,
}
}
MOBA - Render Batches
Defines how triangles are grouped for rendering. ✅ Implemented
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct MOBAEntry {
/// Bounding box for culling (bx, by, bz)
bounding_box_min: [i16; 3],
/// Bounding box for culling (tx, ty, tz)
bounding_box_max: [i16; 3],
/// Index of the first face index used in MOVI
start_index: u32,
/// Number of MOVI indices used
count: u16,
/// Index of the first vertex used in MOVT
min_index: u16,
/// Index of the last vertex used (batch includes this one)
max_index: u16,
/// Batch flags
flags: u8,
/// Material index in MOMT
material_id: u8,
}
}
MOLR - Light References
References to lights that affect this group.
#![allow(unused)]
fn main() {
/// MOLR contains u16 light indices
fn parse_molr(data: &[u8]) -> Vec<u16> {
data.chunks_exact(2)
.map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
.collect()
}
}
MODR - Doodad References
References to doodads in this group.
#![allow(unused)]
fn main() {
/// MODR contains u16 doodad indices
fn parse_modr(data: &[u8]) -> Vec<u16> {
data.chunks_exact(2)
.map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
.collect()
}
}
MOBN - BSP Nodes
Binary Space Partition tree nodes for collision detection.
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct MOBNNode {
/// Flags
flags: u16,
/// Negative child index
neg_child: i16,
/// Positive child index
pos_child: i16,
/// Number of faces
face_count: u16,
/// Index of first face
face_start: u32,
/// Plane distance
plane_dist: f32,
}
impl MOBNNode {
pub const FLAG_AXIS_X: u16 = 0x00;
pub const FLAG_AXIS_Y: u16 = 0x01;
pub const FLAG_AXIS_Z: u16 = 0x02;
pub const FLAG_AXIS_MASK: u16 = 0x03;
pub const FLAG_LEAF: u16 = 0x04;
}
}
MOBR - BSP Face Indices
Face indices referenced by BSP leaf nodes.
#![allow(unused)]
fn main() {
/// MOBR contains u16 face indices
fn parse_mobr(data: &[u8]) -> Vec<u16> {
data.chunks_exact(2)
.map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
.collect()
}
}
MOCV - Vertex Colors
Vertex colors for lighting.
#![allow(unused)]
fn main() {
#[repr(C, packed)]
#[derive(Debug, Clone, Copy)]
struct MOCVEntry {
/// BGRA color
color: u32,
}
impl MOCVEntry {
pub fn from_bgra(b: u8, g: u8, r: u8, a: u8) -> Self {
Self {
color: (a as u32) << 24 | (r as u32) << 16 | (g as u32) << 8 | (b as u32)
}
}
pub fn to_rgba_f32(&self) -> [f32; 4] {
[
((self.color >> 16) & 0xFF) as f32 / 255.0, // R
((self.color >> 8) & 0xFF) as f32 / 255.0, // G
(self.color & 0xFF) as f32 / 255.0, // B
((self.color >> 24) & 0xFF) as f32 / 255.0, // A
]
}
}
}
MLIQ - Liquids
Liquid (water/lava/slime) data for this group.
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct MLIQHeader {
/// Number of vertices in X direction
x_verts: u32,
/// Number of vertices in Y direction
y_verts: u32,
/// Number of tiles in X direction
x_tiles: u32,
/// Number of tiles in Y direction
y_tiles: u32,
/// Base coordinates
base_coords: [f32; 3],
/// Material ID (0 = water, 1 = ocean, 2 = magma, 3 = slime)
material_id: u16,
}
#[repr(C, packed)]
struct MLIQVertex {
/// Height or depth value
height: f32,
}
#[repr(C, packed)]
struct MLIQTile {
/// 0 = no liquid, 1 = has liquid
liquid: u8,
}
}
MORI - Triangle Strip Indices
Triangle strip indices for optimized rendering.
#![allow(unused)]
fn main() {
/// MORI contains u16 indices for triangle strips
fn parse_mori(data: &[u8]) -> Vec<u16> {
data.chunks_exact(2)
.map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
.collect()
}
}
MORB - Additional Render Batches
Additional render batch information.
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct MORBEntry {
start_index: u16,
index_count: u16,
min_index: u16,
max_index: u16,
flags: u8,
material_id: u8,
}
}
MOTA - Map Object Tangent Array
Tangent data for normal mapping.
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct MOTAEntry {
/// Tangent vector
tangent: [i16; 4], // Packed as 16-bit signed integers
}
impl MOTAEntry {
/// Convert packed tangent to normalized float vector
pub fn to_float_tangent(&self) -> [f32; 4] {
[
self.tangent[0] as f32 / 32767.0,
self.tangent[1] as f32 / 32767.0,
self.tangent[2] as f32 / 32767.0,
self.tangent[3] as f32 / 32767.0,
]
}
}
}
MOGX - Query Face Start
Query face start index for collision (Dragonflight+). ✅ Implemented
#![allow(unused)]
fn main() {
/// MOGX contains a single u32 query face start index
fn parse_mogx(data: &[u8]) -> u32 {
u32::from_le_bytes([data[0], data[1], data[2], data[3]])
}
}
MPY2 - Extended Material Info
Extended material information for rendering (Dragonflight+). ✅ Implemented
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct MPY2Entry {
flags: u16,
material_id: u16,
}
}
MOVX - Extended Vertex Indices
Extended vertex indices allowing larger index values (Shadowlands+). ✅ Implemented
#![allow(unused)]
fn main() {
/// MOVX contains u32 indices instead of u16, allowing larger meshes
fn parse_movx(data: &[u8]) -> Vec<u32> {
data.chunks_exact(4)
.map(|chunk| u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]))
.collect()
}
}
MOQG - Query Faces
Query face ground type values for collision detection (Dragonflight+). ✅ Implemented
#![allow(unused)]
fn main() {
/// MOQG contains an array of u32 ground type values
fn parse_moqg(data: &[u8]) -> Vec<u32> {
data.chunks_exact(4)
.map(|chunk| u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]))
.collect()
}
}
MOBS - Map Object Shadow Batches
Shadow batch information for shadow rendering. ⚠️ Format Specification Only
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct MOBSEntry {
/// Same structure as MOBA
start_index: u16,
index_count: u16,
min_index: u16,
max_index: u16,
flags: u8,
material_id: u8,
}
}
Additional Group Chunks (Later Versions)
- MDAL - Unknown chunk
- MOPL - Terrain Cutting Planes (4.x+)
- MOPB - Prepass Batches
- MOLS - Spot Lights
- MOLP - Light Page
- MLSP - Shadowmap LSP
- MLSS - Shadowmap Shadows
- MLSK - Shadowmap LSSK
- MOP2 - Portal Information 2 (7.x+)
- MOS2 - Skybox 2
- MPVD - Particle Volume Data (7.x+)
- MAVD - Ambient Volume Data
- MBVD - Baked Volume Data
Coordinate System
World of Warcraft uses a right-handed coordinate system:
- X-axis: North (positive) to South (negative)
- Y-axis: West (positive) to East (negative)
- Z-axis: Up (positive) to Down (negative)
WMO local coordinates are transformed to world coordinates using placement information from ADT files.
Material System
Materials in WMOs control how surfaces are rendered:
#![allow(unused)]
fn main() {
pub enum BlendMode {
Opaque = 0,
AlphaKey = 1,
Alpha = 2,
NoAlphaAdd = 3,
Add = 4,
Mod = 5,
Mod2x = 6,
ModAdd = 7,
InvSrcAlphaAdd = 8,
InvSrcAlphaOpaque = 9,
SrcAlphaOpaque = 10,
NoAlphaAddAlpha = 11,
ConstantAlpha = 12,
}
pub fn apply_blend_mode(blend_mode: BlendMode) {
match blend_mode {
BlendMode::Opaque => {
// src = 1, dst = 0
}
BlendMode::AlphaKey => {
// src = 1, dst = 0 (with alpha test)
}
BlendMode::Alpha => {
// src = srcAlpha, dst = invSrcAlpha
}
BlendMode::Add => {
// src = 1, dst = 1
}
// ... etc
}
}
}
Portal System
Portals connect indoor groups for visibility culling:
#![allow(unused)]
fn main() {
pub struct Portal {
pub vertices: Vec<[f32; 3]>,
pub plane: [f32; 4],
pub groups: [u16; 2], // Groups on each side
}
impl Portal {
/// Check if a point is on the positive side of the portal
pub fn is_point_on_positive_side(&self, point: &[f32; 3]) -> bool {
let dot = point[0] * self.plane[0]
+ point[1] * self.plane[1]
+ point[2] * self.plane[2];
dot >= self.plane[3]
}
/// Check if portal is visible from a viewpoint
pub fn is_visible_from(&self, viewpoint: &[f32; 3], view_dir: &[f32; 3]) -> bool {
// Check if viewpoint is on positive side
if !self.is_point_on_positive_side(viewpoint) {
return false;
}
// Check if portal faces viewpoint
let normal = [self.plane[0], self.plane[1], self.plane[2]];
let dot = normal[0] * view_dir[0]
+ normal[1] * view_dir[1]
+ normal[2] * view_dir[2];
dot < 0.0
}
}
}
Lighting System
WMO lighting combines several elements:
- Vertex Colors: Baked lighting stored per-vertex
- Dynamic Lights: Point and spot lights that affect nearby geometry
- Ambient Color: Global ambient light color
#![allow(unused)]
fn main() {
pub fn calculate_vertex_lighting(
vertex_pos: &[f32; 3],
vertex_normal: &[f32; 3],
vertex_color: &[f32; 4],
lights: &[MOLTEntry],
ambient: &[f32; 3],
) -> [f32; 3] {
let mut final_color = [
ambient[0] * vertex_color[0],
ambient[1] * vertex_color[1],
ambient[2] * vertex_color[2],
];
for light in lights {
match light.light_type {
0 => {
// Ambient light
final_color[0] += light.intensity * ((light.color >> 16) & 0xFF) as f32 / 255.0;
final_color[1] += light.intensity * ((light.color >> 8) & 0xFF) as f32 / 255.0;
final_color[2] += light.intensity * (light.color & 0xFF) as f32 / 255.0;
}
1 => {
// Directional light
let light_dir = normalize(&light.position);
let n_dot_l = dot_product(vertex_normal, &light_dir).max(0.0);
final_color[0] += n_dot_l * light.intensity * ((light.color >> 16) & 0xFF) as f32 / 255.0;
final_color[1] += n_dot_l * light.intensity * ((light.color >> 8) & 0xFF) as f32 / 255.0;
final_color[2] += n_dot_l * light.intensity * (light.color & 0xFF) as f32 / 255.0;
}
2 | 3 => {
// Point or spot light
let light_vec = sub_vec3(&light.position, vertex_pos);
let dist = length(&light_vec);
if dist < light.attenuation_end {
let light_dir = normalize(&light_vec);
let n_dot_l = dot_product(vertex_normal, &light_dir).max(0.0);
let attenuation = if dist < light.attenuation_start {
1.0
} else {
1.0 - (dist - light.attenuation_start)
/ (light.attenuation_end - light.attenuation_start)
};
final_color[0] += attenuation * n_dot_l * light.intensity
* ((light.color >> 16) & 0xFF) as f32 / 255.0;
final_color[1] += attenuation * n_dot_l * light.intensity
* ((light.color >> 8) & 0xFF) as f32 / 255.0;
final_color[2] += attenuation * n_dot_l * light.intensity
* (light.color & 0xFF) as f32 / 255.0;
}
}
_ => {}
}
}
final_color
}
}
References
- WoWDev Wiki - WMO Format
- WoWDev Wiki - WMO/v17
- Ladislav Zezula’s WMO Documentation
- libwarcraft WMO Implementation
- Neo (WoW Model Viewer) Source
- WoWMapViewer Source Code
- PyWoW WMO Module
Key Findings from Empirical Analysis
Format Stability
- Version Consistency: All WMO files from 1.12.1 through 3.3.5a use version 17
- Chunk Order: The 17 core chunks always appear in the same order
- Backward Compatibility: No breaking changes detected between versions
- Extension Model: New features added via optional chunks, not format changes
Common Patterns
- Empty Chunks: MOVV and MOVB frequently have 0 size (no visibility blocking)
- Single Doodad Set: Most WMOs have just one doodad set (32 bytes)
- Skybox: Usually empty (4 bytes of zeros) for indoor WMOs
- Consistent Sizes: MOHD always 64 bytes, MVER always 4 bytes
Implementation Priority
Based on chunk frequency and importance:
- Essential: MVER, MOHD, MOTX, MOMT, MOGI, MOGN (basic structure)
- Important: MODD, MODN, MODS (doodad placement)
- Lighting: MOLT, MFOG (visual quality)
- Advanced: MOPV, MOPT, MOPR (portal culling)
- Optional: MOVV, MOVB (rarely used)
File Size Distribution
- Root files: Typically 50KB - 500KB
- Group files: Typically 100KB - 2MB per group
- Texture paths: Average 500-5000 bytes
- Doodad data: Can be 80KB+ for complex WMOs
See Also
-
BLP Format - Texture format used by WMO
-
M2 Format - Doodads placed in WMO
-
ADT Format - Terrain that WMOs sit on
-
Ok(wmo)} }
pub struct WMOGroup {
pub header: MOGPHeader,
pub vertices: Vec<[f32; 3]>,
pub normals: Vec<[f32; 3]>,
pub tex_coords: Vec<Vec<[f32; 2]>>,
pub vertex_colors: Option<Vec<[f32; 4]>>,
pub triangles: Vec<[u16; 3]>,
pub materials: Vec
impl WMOGroup {
pub fn read<R: Read + Seek>(reader: &mut R) -> io::Result
let mut group_reader = io::Cursor::new(chunk.data);
let header: MOGPHeader = read_struct(&mut group_reader)?;
let mut group = WMOGroup {
header,
vertices: Vec::new(),
normals: Vec::new(),
tex_coords: Vec::new(),
vertex_colors: None,
triangles: Vec::new(),
materials: Vec::new(),
render_batches: Vec::new(),
bsp_tree: None,
liquid: None,
};
// Read sub-chunks
while group_reader.position() < group_reader.get_ref().len() as u64 {
let sub_chunk = Chunk::read(&mut group_reader)?;
match sub_chunk.header.id_string().as_str() {
"MOVT" => {
let verts: Vec<MOVTEntry> = read_array(&sub_chunk.data)?;
group.vertices = verts.iter().map(|v| v.position).collect();
}
"MONR" => {
let norms: Vec<MONREntry> = read_array(&sub_chunk.data)?;
group.normals = norms.iter().map(|n| n.normal).collect();
}
"MOTV" => {
let coords: Vec<MOTVEntry> = read_array(&sub_chunk.data)?;
let tex_coords = coords.iter().map(|tc| [tc.u, tc.v]).collect();
group.tex_coords.push(tex_coords);
}
"MOVI" => {
group.triangles = parse_movi(&sub_chunk.data);
}
"MOPY" => {
let mopy: Vec<MOPYEntry> = read_array(&sub_chunk.data)?;
group.materials = mopy.iter().map(|m| m.material_id).collect();
}
"MOBA" => {
group.render_batches = read_array(&sub_chunk.data)?;
}
"MOCV" => {
let colors: Vec<MOCVEntry> = read_array(&sub_chunk.data)?;
group.vertex_colors = Some(
colors.iter().map(|c| c.to_rgba_f32()).collect()
);
}
_ => {
// Unknown sub-chunk
}
}
}
Ok(group)
}
}
/// Helper function to read a struct from bytes
fn read_struct
unsafe {
Ok(std::ptr::read_unaligned(data.as_ptr() as *const T))
}
}
/// Helper function to read an array of structs
fn read_array
let mut result = Vec::with_capacity(count);
for i in 0..count {
let start = i * item_size;
let item = read_struct(&data[start..start + item_size])?;
result.push(item);
}
Ok(result)
}
### Render Batch Processing
```rust
pub fn process_render_batches(
group: &WMOGroup,
materials: &[MOMTEntry],
) -> Vec<RenderBatch> {
let mut batches = Vec::new();
for batch in &group.render_batches {
let material = &materials[batch.material_id as usize];
let indices: Vec<u32> = (batch.start_index..batch.start_index + batch.count as u32)
.map(|i| i as u32)
.collect();
let render_batch = RenderBatch {
indices,
material_id: batch.material_id,
blend_mode: BlendMode::from_u32(material.blend_mode),
texture_ids: [material.texture_1, material.texture_2, material.texture_3],
shader_flags: material.flags,
};
batches.push(render_batch);
}
batches
}
Test Vectors
Chunk Header Parsing
#![allow(unused)]
fn main() {
#[test]
fn test_chunk_header_parsing() {
// MVER chunk header (reversed in file)
let data = vec![0x52, 0x45, 0x56, 0x4D, 0x04, 0x00, 0x00, 0x00];
let header = ChunkHeader {
id: [data[0], data[1], data[2], data[3]],
size: u32::from_le_bytes([data[4], data[5], data[6], data[7]]),
};
assert_eq!(header.id_string(), "MVER");
assert_eq!(header.size, 4);
}
}
Material Flag Tests
#![allow(unused)]
fn main() {
#[test]
fn test_material_flags() {
let material = MOMTEntry {
flags: MOMTEntry::SHADER_TWO_SIDED | MOMTEntry::SHADER_UNFOGGED,
// ... other fields
};
assert!(material.flags & MOMTEntry::SHADER_TWO_SIDED != 0);
assert!(material.flags & MOMTEntry::SHADER_UNFOGGED != 0);
assert!(material.flags & MOMTEntry::SHADER_METAL == 0);
}
}
BSP Tree Traversal
#![allow(unused)]
fn main() {
#[test]
fn test_bsp_ray_intersection() {
let vertices = vec![
[0.0, 0.0, 0.0],
[1.0, 0.0, 0.0],
[0.0, 1.0, 0.0],
];
let faces = vec![[0, 1, 2]];
let nodes = vec![
MOBNNode {
flags: MOBNNode::FLAG_LEAF,
neg_child: -1,
pos_child: -1,
face_count: 1,
face_start: 0,
plane_dist: 0.0,
},
];
let face_indices = vec![0];
let bsp = BSPTree { nodes, face_indices };
// Ray pointing at triangle
let t = bsp.ray_intersect(
&[0.25, 0.25, 1.0],
&[0.0, 0.0, -1.0],
&faces,
&vertices,
);
assert!(t.is_some());
assert!((t.unwrap() - 1.0).abs() < 0.001);
// Ray missing triangle
let t = bsp.ray_intersect(
&[2.0, 2.0, 1.0],
&[0.0, 0.0, -1.0],
&faces,
&vertices,
);
assert!(t.is_none());
}
}
Portal Visibility
#![allow(unused)]
fn main() {
#[test]
fn test_portal_visibility() {
let portal = Portal {
vertices: vec![
[-1.0, -1.0, 0.0],
[1.0, -1.0, 0.0],
[1.0, 1.0, 0.0],
[-1.0, 1.0, 0.0],
],
plane: [0.0, 0.0, 1.0, 0.0], // Facing +Z
groups: [0, 1],
};
// Viewpoint on positive side, looking at portal
assert!(portal.is_visible_from(&[0.0, 0.0, 1.0], &[0.0, 0.0, -1.0]));
// Viewpoint on negative side
assert!(!portal.is_visible_from(&[0.0, 0.0, -1.0], &[0.0, 0.0, 1.0]));
// Viewpoint on positive side but looking away
assert!(!portal.is_visible_from(&[0.0, 0.0, 1.0], &[0.0, 0.0, 1.0]));
}
}
Common Pitfalls
- Byte Order: All multi-byte values are little-endian
- Chunk Alignment: Some chunks may have padding to align to 4-byte boundaries
- String Parsing: Strings in MOTX, MOGN, MODN are null-terminated and can be empty
- Group Numbering: Group files are numbered from 000, not 001
- Coordinate System: Remember WoW uses a right-handed system with Y pointing north
- Material IDs: Material IDs in groups index into the root file’s MOMT chunk
- BSP Face Indices: BSP face indices refer to triangles, not vertices
- Portal Normals: Portal plane normals point toward the positive side
- Vertex Colors: MOCV can have 1 or 2 sets of colors (check MOGI flags)
- Texture Coordinates: Groups can have up to 3 sets of texture coordinates
References
- WoWDev Wiki - WMO Format
- WoWDev Wiki - WMO/v17
- Ladislav Zezula’s WMO Documentation
- libwarcraft WMO Implementation
- Neo (WoW Model Viewer) Source
- WoWMapViewer Source Code
- PyWoW WMO Module
Implementation References
- C++: StormLib for MPQ reading
- C#: libwarcraft for complete WMO support
- Python: pywow for WMO parsing
- JavaScript: tswow for modding framework
This documentation is based on reverse engineering efforts by the WoW modding community and may contain inaccuracies. Always verify against known working implementations when developing WMO parsing code.
Key Findings from Empirical Analysis
Format Stability
- Version Consistency: All WMO files from 1.12.1 through 3.3.5a use version 17
- Chunk Order: The 17 core chunks always appear in the same order
- Backward Compatibility: No breaking changes detected between versions
- Extension Model: New features added via optional chunks, not format changes
Common Patterns
- Empty Chunks: MOVV and MOVB frequently have 0 size (no visibility blocking)
- Single Doodad Set: Most WMOs have just one doodad set (32 bytes)
- Skybox: Usually empty (4 bytes of zeros) for indoor WMOs
- Consistent Sizes: MOHD always 64 bytes, MVER always 4 bytes
Implementation Priority
Based on chunk frequency and importance:
- Essential: MVER, MOHD, MOTX, MOMT, MOGI, MOGN (basic structure)
- Important: MODD, MODN, MODS (doodad placement)
- Lighting: MOLT, MFOG (visual quality)
- Advanced: MOPV, MOPT, MOPR (portal culling)
- Optional: MOVV, MOVB (rarely used)
File Size Distribution
- Root files: Typically 50KB - 500KB
- Group files: Typically 100KB - 2MB per group
- Texture paths: Average 500-5000 bytes
- Doodad data: Can be 80KB+ for complex WMOs
See Also
- BLP Format - Texture format used by WMO
- M2 Format - Doodads placed in WMO
- ADT Format - Terrain that WMOs sit on
- WMO Rendering Guide
Database Formats
Database formats store game data in structured, tabular formats.
Supported Formats
DBC Format
DataBase Client - Client-side database files containing game data.
- Fixed-size records
- String block for text data
- Indexed by ID
- Localization support
DBC Structure
DBCs are binary tabular files with a header:
Header (20 bytes)
Records (n × record_size)
String Block (variable)
Common DBC Files
Core Game Data
Item.dbc- Item definitionsSpell.dbc- Spell dataCreatureDisplayInfo.dbc- Creature modelsMap.dbc- Map definitions
Display Data
ItemDisplayInfo.dbc- Item visualsCharSections.dbc- Character customizationCreatureModelData.dbc- Model parameters
Game Mechanics
Talent.dbc- Talent treesSkillLine.dbc- Skills and professionsAchievement.dbc- Achievement data
Usage Patterns
Reading DBC Files
#![allow(unused)]
fn main() {
use wow_cdbc::DbcParser;
use std::io::BufReader;
use std::fs::File;
let file = File::open("DBFilesClient/Item.dbc")?;
let mut reader = BufReader::new(file);
let parser = DbcParser::parse(&mut reader)?;
let header = parser.header();
println!("Records: {}", header.record_count);
println!("Fields per record: {}", header.field_count);
}
Schema-Based Parsing
#![allow(unused)]
fn main() {
use wow_cdbc::{DbcParser, Schema, SchemaField, FieldType};
let parser = DbcParser::parse(&mut reader)?;
// Define a schema for the DBC file
let mut schema = Schema::new("SpellItemEnchantment");
schema.add_field(SchemaField::new("ID", FieldType::UInt32));
schema.add_field(SchemaField::new("Charges", FieldType::UInt32));
schema.add_field(SchemaField::new("Description", FieldType::String));
schema.set_key_field("ID");
// Apply the schema and parse records
let parser = parser.with_schema(schema)?;
let record_set = parser.parse_records()?;
for record in record_set.iter() {
println!("{:?}", record);
}
}
Localization
WoW supports 16 locales in DBC files:
| ID | Locale | Description |
|---|---|---|
| 0 | enUS | English (US) |
| 1 | koKR | Korean |
| 2 | frFR | French |
| 3 | deDE | German |
| 4 | zhCN | Chinese (Simplified) |
| 5 | zhTW | Chinese (Traditional) |
| 6 | esES | Spanish (Spain) |
| 7 | esMX | Spanish (Mexico) |
| 8 | ruRU | Russian |
Tools
The CLI provides DBC operations:
warcraft-rs dbc info Item.dbc
warcraft-rs dbc export Item.dbc --format csv
See Also
DBC Format 📊
DBC (DataBase Client) files are binary data files used by World of Warcraft to store game data that needs to be accessible by the client. These files contain structured records with information about spells, items, zones, creatures, and many other game elements.
Overview
- Extension:
.dbc - Magic:
WDBC(0x43424457 in little-endian) - ✅ Implemented - Purpose: Client-side database tables for game data
- Structure: Fixed-size records with defined schemas - ✅ Implemented
- Encoding: Little-endian binary format - ✅ Implemented
- String Storage: Separate string block with offset references - ✅ Implemented
- Localization: Built-in support for multiple languages - ⚠️ Partial - Multiple language support available but not all localizations tested
Key Characteristics
- Binary Format: Optimized for fast loading and minimal storage
- Fixed-Size Records: Each record in a DBC file has the same size
- String Pooling: Strings are stored in a shared pool at the end of the file
- Version Stability: Format remained largely unchanged from Vanilla through WotLK
Version History
| WoW Version | DBC Format | Notable Changes |
|---|---|---|
| Classic (1.12.x) | WDBC v1 | Original format |
| TBC (2.4.3) | WDBC v1 | No format changes, new files |
| WotLK (3.3.5) | WDBC v1 | No format changes, new fields |
| Cataclysm (4.x) | DB2 | New format with variable record sizes |
| MoP+ (5.x+) | DB2/DB5/DB6 | Progressive format enhancements |
This documentation focuses on the WDBC v1 format used in Classic through WotLK.
File Structure
A DBC file consists of three main sections:
+----------------+
| Header | 20 bytes
+----------------+
| |
| Records | record_count * record_size bytes
| |
+----------------+
| String Block | string_block_size bytes
+----------------+
DBC Header
The DBC header is always 20 bytes and contains essential metadata:
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct DbcHeader {
/// Magic signature: "WDBC" (0x43424457 in little-endian)
magic: [u8; 4],
/// Number of records in the file
record_count: u32,
/// Number of fields per record
field_count: u32,
/// Size of each record in bytes
record_size: u32,
/// Size of the string block
string_block_size: u32,
}
impl DbcHeader {
const MAGIC: &'static [u8; 4] = b"WDBC";
const HEADER_SIZE: usize = 20;
/// Verify header validity
pub fn is_valid(&self) -> bool {
self.magic == Self::MAGIC &&
self.record_size == self.field_count * 4 && // Each field is 4 bytes
self.record_count > 0
}
/// Calculate total file size
pub fn file_size(&self) -> usize {
Self::HEADER_SIZE +
(self.record_count as usize * self.record_size as usize) +
self.string_block_size as usize
}
}
}
Record Structure
Records immediately follow the header. Each record:
- Has a fixed size (specified in header)
- Contains
field_countfields - Each field is 4 bytes (can be interpreted as different types)
#![allow(unused)]
fn main() {
/// Generic DBC record representation
struct DbcRecord {
/// Record data as raw bytes
data: Vec<u8>,
}
impl DbcRecord {
/// Read a u32 field
pub fn get_u32(&self, field_index: usize) -> u32 {
let offset = field_index * 4;
u32::from_le_bytes([
self.data[offset],
self.data[offset + 1],
self.data[offset + 2],
self.data[offset + 3],
])
}
/// Read an i32 field
pub fn get_i32(&self, field_index: usize) -> i32 {
self.get_u32(field_index) as i32
}
/// Read a f32 field
pub fn get_f32(&self, field_index: usize) -> f32 {
f32::from_bits(self.get_u32(field_index))
}
}
}
String Block
The string block contains null-terminated UTF-8 strings referenced by offset:
Offset Content
0x0000 \0 (empty string)
0x0001 "Fireball\0"
0x000A "Frost Bolt\0"
0x0015 "Healing Touch\0"
Data Types
Basic Types
All fields in DBC files are 4 bytes, interpreted as:
| Type | Size | Description |
|---|---|---|
u32 | 4 bytes | Unsigned integer |
i32 | 4 bytes | Signed integer |
f32 | 4 bytes | IEEE 754 single-precision float |
StringRef | 4 bytes | Offset into string block |
Special Types
#![allow(unused)]
fn main() {
/// Reference to another DBC record
type RecordId = u32;
/// Bit flags
type Flags = u32;
/// String reference (offset into string block)
type StringRef = u32;
/// Unused/padding field
type Padding = u32;
}
Localized Strings
Localized strings use a special pattern of 16 consecutive fields plus a flags field:
#![allow(unused)]
fn main() {
#[repr(C)]
struct LocalizedString {
/// String references for each locale (16 locales)
locale_strings: [StringRef; 16],
/// Bitmask of locales present
locale_mask: u32,
}
/// Locale indices
const LOCALE_EN_US: usize = 0; // English (US)
const LOCALE_KO_KR: usize = 1; // Korean
const LOCALE_FR_FR: usize = 2; // French
const LOCALE_DE_DE: usize = 3; // German
const LOCALE_EN_CN: usize = 4; // English (China)
const LOCALE_EN_TW: usize = 5; // English (Taiwan)
const LOCALE_ES_ES: usize = 6; // Spanish (Spain)
const LOCALE_ES_MX: usize = 7; // Spanish (Mexico)
const LOCALE_RU_RU: usize = 8; // Russian
const LOCALE_JA_JP: usize = 9; // Japanese
const LOCALE_PT_PT: usize = 10; // Portuguese
const LOCALE_IT_IT: usize = 11; // Italian
const LOCALE_UNKNOWN_12: usize = 12;
const LOCALE_UNKNOWN_13: usize = 13;
const LOCALE_UNKNOWN_14: usize = 14;
const LOCALE_UNKNOWN_15: usize = 15;
}
String Handling
String References
Strings are referenced by their byte offset in the string block:
#![allow(unused)]
fn main() {
/// Resolve a string reference
fn get_string(string_block: &[u8], string_ref: StringRef) -> Result<&str, DbcError> {
if string_ref == 0 {
return Ok(""); // Null reference
}
let offset = string_ref as usize;
if offset >= string_block.len() {
return Err(DbcError::InvalidStringRef(string_ref));
}
// Find null terminator
let string_data = &string_block[offset..];
let null_pos = string_data.iter()
.position(|&b| b == 0)
.ok_or(DbcError::UnterminatedString)?;
// Convert to UTF-8
std::str::from_utf8(&string_data[..null_pos])
.map_err(|_| DbcError::InvalidUtf8)
}
}
Localization
The locale mask indicates which locales have valid strings:
#![allow(unused)]
fn main() {
/// Check if a locale has a valid string
fn has_locale(locale_mask: u32, locale_index: usize) -> bool {
if locale_index >= 16 {
return false;
}
(locale_mask & (1 << locale_index)) != 0
}
/// Get the best available locale string
fn get_best_locale_string(
loc_string: &LocalizedString,
preferred_locale: usize,
string_block: &[u8]
) -> Result<String, DbcError> {
// Try preferred locale first
if loc_string.locale_strings[preferred_locale] != 0 {
return get_string(string_block, loc_string.locale_strings[preferred_locale])
.map(|s| s.to_string());
}
// Fall back to enUS
if loc_string.locale_strings[LOCALE_EN_US] != 0 {
return get_string(string_block, loc_string.locale_strings[LOCALE_EN_US])
.map(|s| s.to_string());
}
// Try any available locale
for i in 0..16 {
if loc_string.locale_strings[i] != 0 {
return get_string(string_block, loc_string.locale_strings[i])
.map(|s| s.to_string());
}
}
Ok(String::new())
}
}
Common DBC Files
Spell.dbc
Contains spell definitions (very large structure with ~240 fields in WotLK):
#![allow(unused)]
fn main() {
#[repr(C)]
struct SpellRecord {
id: u32, // Spell ID
category: u32, // Spell category
dispel_type: u32, // Dispel type (magic, curse, etc.)
mechanic: u32, // Spell mechanic
attributes: [u32; 7], // Spell attributes (7 fields)
stances: u32, // Required stances
stances_not: u32, // Excluded stances
targets: u32, // Valid targets
target_creature_type: u32, // Target creature type mask
requires_spell_focus: u32, // Required spell focus object
facing_caster_flags: u32, // Facing requirements
caster_aura_state: u32, // Required aura state
target_aura_state: u32, // Target aura state
caster_aura_state_not: u32, // Excluded caster aura state
target_aura_state_not: u32, // Excluded target aura state
caster_aura_spell: u32, // Required aura spell
target_aura_spell: u32, // Target aura spell
exclude_caster_aura_spell: u32, // Excluded caster aura
exclude_target_aura_spell: u32, // Excluded target aura
casting_time_index: u32, // Index into CastingTime.dbc
recovery_time: u32, // Recovery time
category_recovery_time: u32, // Category recovery time
interrupt_flags: u32, // Interrupt flags
aura_interrupt_flags: u32, // Aura interrupt flags
channel_interrupt_flags: u32, // Channel interrupt flags
proc_flags: u32, // Proc event flags
proc_chance: u32, // Proc chance
proc_charges: u32, // Proc charges
max_level: u32, // Maximum level
base_level: u32, // Base level
spell_level: u32, // Spell level
duration_index: u32, // Index into Duration.dbc
power_type: u32, // Power type (mana, rage, etc.)
mana_cost: u32, // Mana cost
mana_cost_perlevel: u32, // Mana cost per level
mana_per_second: u32, // Mana per second
mana_per_second_per_level: u32, // Mana per second per level
range_index: u32, // Index into Range.dbc
speed: f32, // Projectile speed
modal_next_spell: u32, // Next spell in sequence
stack_amount: u32, // Stack amount
totem: [u32; 2], // Required totems
reagent: [i32; 8], // Required reagents
reagent_count: [u32; 8], // Reagent counts
equipped_item_class: i32, // Required item class
equipped_item_subclass_mask: i32, // Required item subclass
equipped_item_inventory_type_mask: i32, // Required inventory type
effect: [u32; 3], // Spell effects
effect_die_sides: [i32; 3], // Effect die sides
effect_real_points_per_level: [f32; 3], // Points per level
effect_base_points: [i32; 3], // Base points
effect_mechanic: [u32; 3], // Effect mechanics
effect_implicit_target_a: [u32; 3], // Implicit targets A
effect_implicit_target_b: [u32; 3], // Implicit targets B
effect_radius_index: [u32; 3], // Radius indices
effect_apply_aura_name: [u32; 3], // Aura types
effect_amplitude: [u32; 3], // Effect amplitude
effect_multiple_value: [f32; 3], // Multiple value
effect_chain_target: [u32; 3], // Chain targets
effect_item_type: [u32; 3], // Created items
effect_misc_value: [i32; 3], // Misc values
effect_misc_value_b: [i32; 3], // Misc values B
effect_trigger_spell: [u32; 3], // Triggered spells
effect_points_per_combo_point: [f32; 3], // Points per combo
effect_spell_class_mask_a: [u32; 3], // Class mask A
effect_spell_class_mask_b: [u32; 3], // Class mask B
effect_spell_class_mask_c: [u32; 3], // Class mask C
spell_visual: [u32; 2], // Visual effects
spell_icon_id: u32, // Icon ID
active_icon_id: u32, // Active icon ID
spell_priority: u32, // Priority
spell_name: LocalizedString, // Spell name (17 fields)
spell_rank: LocalizedString, // Spell rank (17 fields)
spell_description: LocalizedString, // Description (17 fields)
spell_tooltip: LocalizedString, // Tooltip (17 fields)
mana_cost_percentage: u32, // Mana cost percentage
start_recovery_category: u32, // Recovery category
start_recovery_time: u32, // Recovery time
max_target_level: u32, // Max target level
spell_family_name: u32, // Spell family
spell_family_flags: [u32; 3], // Family flags
max_affected_targets: u32, // Max targets
dmg_class: u32, // Damage class
prevention_type: u32, // Prevention type
stance_bar_order: u32, // Stance bar position
dmg_multiplier: [f32; 3], // Damage multipliers
min_faction_id: u32, // Min faction ID
min_reputation: u32, // Min reputation
required_aura_vision: u32, // Required aura vision
totem_category: [u32; 2], // Totem categories
area_group_id: u32, // Area group
school_mask: u32, // School mask
rune_cost_id: u32, // Rune cost ID
spell_missile_id: u32, // Missile ID
power_display_id: u32, // Power display
effect_bonus_multiplier: [f32; 3], // Bonus multipliers
spell_description_variable_id: u32, // Description variable
spell_difficulty_id: u32, // Difficulty ID
}
}
Item.dbc
Contains item template data:
#![allow(unused)]
fn main() {
#[repr(C)]
struct ItemRecord {
id: u32, // Item ID
class: u32, // Item class (weapon, armor, etc.)
subclass: u32, // Item subclass
sound_override_subclass: i32, // Sound override
material: u32, // Material type
display_id: u32, // Display info ID
inventory_type: u32, // Equipment slot
sheath_type: u32, // Sheath animation type
}
enum ItemClass {
Consumable = 0,
Container = 1,
Weapon = 2,
Gem = 3,
Armor = 4,
Reagent = 5,
Projectile = 6,
TradeGoods = 7,
Generic = 8,
Recipe = 9,
Money = 10,
Quiver = 11,
Quest = 12,
Key = 13,
Permanent = 14,
Misc = 15,
Glyph = 16,
}
}
Map.dbc
Contains map/instance information:
#![allow(unused)]
fn main() {
#[repr(C)]
struct MapRecord {
id: u32, // Map ID
directory: StringRef, // Map directory name
instance_type: u32, // Instance type (world, dungeon, raid, etc.)
flags: u32, // Map flags
pvp: u32, // PvP type
map_name: LocalizedString, // Map name (17 fields)
area_table_id: u32, // Link to AreaTable.dbc
map_description0: LocalizedString, // Description (17 fields)
map_description1: LocalizedString, // Description (17 fields)
loading_screen: u32, // Loading screen ID
minimap_icon_scale: f32, // Minimap icon scale
corpse_map_id: u32, // Corpse location map
corpse_x: f32, // Corpse X coordinate
corpse_y: f32, // Corpse Y coordinate
time_of_day_override: u32, // Time override
expansion_id: u32, // Required expansion
raid_offset: u32, // Raid reset offset
max_players: u32, // Maximum players
}
}
AreaTable.dbc
Contains zone/area information:
#![allow(unused)]
fn main() {
#[repr(C)]
struct AreaTableRecord {
id: u32, // Area ID
map_id: u32, // Map ID
parent_area_id: u32, // Parent area ID
area_bit: u32, // Area bit for exploration
flags: u32, // Area flags
sound_preferences: u32, // Sound preferences
sound_preferences_underwater: u32, // Underwater sound
sound_ambience: u32, // Ambience sound
zone_music: u32, // Zone music
zone_intro_music: u32, // Intro music
exploration_level: u32, // Min level for exploration XP
area_name: LocalizedString, // Area name (17 fields)
faction_group_mask: u32, // Faction group
liquid_type_id: [u32; 4], // Liquid types
min_elevation: f32, // Minimum elevation
ambient_multiplier: f32, // Ambient light multiplier
light_id: u32, // Light parameters
}
}
Reading DBC Files
Algorithm
- Read and validate header
- Allocate memory for records
- Read all records sequentially
- Read string block
- Build any necessary indices
Implementation Example - ✅ Implemented
#![allow(unused)]
fn main() {
use std::fs::File;
use std::io::BufReader;
use wow_cdbc::{DbcParser, FieldType, Schema, SchemaField};
// Open a DBC file
let file = File::open("SpellItemEnchantment.dbc")?;
let mut reader = BufReader::new(file);
// Parse the DBC file
let parser = DbcParser::parse(&mut reader)?;
// Print header information
let header = parser.header();
println!("Record Count: {}", header.record_count);
println!("Field Count: {}", header.field_count);
// Define a schema for SpellItemEnchantment.dbc
let mut schema = Schema::new("SpellItemEnchantment");
schema.add_field(SchemaField::new("ID", FieldType::UInt32));
schema.add_field(SchemaField::new("Charges", FieldType::UInt32));
schema.add_field(SchemaField::new("Description", FieldType::String));
schema.set_key_field("ID");
// Apply the schema and parse records
let parser = parser.with_schema(schema)?;
let record_set = parser.parse_records()?;
}
Writing DBC Files - ❌ Not Implemented
Algorithm
- Build string block with deduplication
- Calculate header values
- Write header
- Write records with updated string references
- Write string block
DBC writing functionality is not currently implemented in the wow-cdbc crate.
Performance Considerations - ✅ Implemented
Memory Mapping
For large DBC files, consider memory mapping: /// Create a new DBC builder pub fn new(field_count: u32) -> Self { let mut builder = DbcBuilder { field_count, records: Vec::new(), strings: HashMap::new(), string_data: vec![0], // Start with null string };
// Add empty string at offset 0
builder.strings.insert(String::new(), 0);
builder
}
/// Add a string to the string block
pub fn add_string(&mut self, string: &str) -> u32 {
if string.is_empty() {
return 0;
}
// Check if string already exists
if let Some(&offset) = self.strings.get(string) {
return offset;
}
// Add new string
let offset = self.string_data.len() as u32;
self.string_data.extend_from_slice(string.as_bytes());
self.string_data.push(0); // Null terminator
self.strings.insert(string.to_string(), offset);
offset
}
/// Add a record
pub fn add_record(&mut self, fields: Vec<u32>) -> Result<(), DbcError> {
if fields.len() != self.field_count as usize {
return Err(DbcError::InvalidHeader);
}
self.records.push(fields);
Ok(())
}
/// Write the DBC file
pub fn write_file(&self, path: &str) -> Result<(), DbcError> {
let file = File::create(path).map_err(DbcError::Io)?;
let mut writer = BufWriter::new(file);
// Build header
let header = DbcHeader {
magic: *b"WDBC",
record_count: self.records.len() as u32,
field_count: self.field_count,
record_size: self.field_count * 4,
string_block_size: self.string_data.len() as u32,
};
// Write header
writer.write_all(&header.magic).map_err(DbcError::Io)?;
writer.write_all(&header.record_count.to_le_bytes()).map_err(DbcError::Io)?;
writer.write_all(&header.field_count.to_le_bytes()).map_err(DbcError::Io)?;
writer.write_all(&header.record_size.to_le_bytes()).map_err(DbcError::Io)?;
writer.write_all(&header.string_block_size.to_le_bytes()).map_err(DbcError::Io)?;
// Write records
for record in &self.records {
for &field in record {
writer.write_all(&field.to_le_bytes()).map_err(DbcError::Io)?;
}
}
// Write string block
writer.write_all(&self.string_data).map_err(DbcError::Io)?;
writer.flush().map_err(DbcError::Io)?;
Ok(())
}
}
/// Example: Create a simple Item.dbc fn create_item_dbc() -> Result<(), DbcError> { let mut builder = DbcBuilder::new(8); // Item.dbc has 8 fields
// Add Hearthstone
builder.add_record(vec![
6948, // ID
15, // Class (Miscellaneous)
0, // Subclass
0, // Sound override
0, // Material
6418, // Display ID
0, // Inventory type
0, // Sheath type
])?;
// Add Thunderfury
builder.add_record(vec![
19019, // ID
2, // Class (Weapon)
7, // Subclass (1H Sword)
1, // Sound override
1, // Material (Metal)
30606, // Display ID
13, // Inventory type (1H Weapon)
3, // Sheath type
])?;
builder.write_file("Item.dbc")?;
Ok(())
}
## Performance Considerations
### Memory Mapping
For large DBC files, consider memory mapping:
```rust
use memmap2::MmapOptions;
/// Memory-mapped DBC reader
pub struct MappedDbc {
mmap: memmap2::Mmap,
header: DbcHeader,
}
impl MappedDbc {
pub fn open(path: &str) -> Result<Self, DbcError> {
let file = File::open(path).map_err(DbcError::Io)?;
let mmap = unsafe {
MmapOptions::new()
.map(&file)
.map_err(DbcError::Io)?
};
// Parse header from mmap
let header = Self::parse_header(&mmap)?;
Ok(MappedDbc { mmap, header })
}
fn parse_header(data: &[u8]) -> Result<DbcHeader, DbcError> {
if data.len() < 20 {
return Err(DbcError::InvalidHeader);
}
Ok(DbcHeader {
magic: [data[0], data[1], data[2], data[3]],
record_count: u32::from_le_bytes([data[4], data[5], data[6], data[7]]),
field_count: u32::from_le_bytes([data[8], data[9], data[10], data[11]]),
record_size: u32::from_le_bytes([data[12], data[13], data[14], data[15]]),
string_block_size: u32::from_le_bytes([data[16], data[17], data[18], data[19]]),
})
}
}
Indexing and Caching
Build indices for frequently accessed fields:
#![allow(unused)]
fn main() {
struct DbcIndex<T> {
by_id: HashMap<u32, usize>,
by_name: HashMap<String, Vec<usize>>,
records: Vec<T>,
}
impl<T: DbcRecord> DbcIndex<T> {
fn build(dbc: DbcFile<T>) -> Self {
let mut by_id = HashMap::new();
let mut by_name = HashMap::new();
for (idx, record) in dbc.records.iter().enumerate() {
by_id.insert(record.id(), idx);
if let Some(name) = record.name() {
by_name.entry(name.to_lowercase())
.or_insert_with(Vec::new)
.push(idx);
}
}
DbcIndex {
by_id,
by_name,
records: dbc.records,
}
}
}
}
Implementation Notes
Memory Alignment
DBC files use packed structures with no padding:
#![allow(unused)]
fn main() {
#[repr(C, packed)]
struct PackedRecord {
// Fields are tightly packed
}
}
Endianness
All multi-byte values are little-endian:
#![allow(unused)]
fn main() {
fn read_u32_le(data: &[u8]) -> u32 {
u32::from_le_bytes([data[0], data[1], data[2], data[3]])
}
}
String Block Organization
The string block is typically organized as:
- Empty string at offset 0 (for null references)
- Strings in order of first reference
- No duplicate strings (string pooling)
Common Patterns
DBC Validation
#![allow(unused)]
fn main() {
struct DbcValidator {
errors: Vec<ValidationError>,
}
impl DbcValidator {
fn validate_spell(&mut self, spell: &SpellRecord, db: &DbcDatabase) {
// Check foreign key references
for &reagent_id in &spell.reagent {
if reagent_id > 0 && !db.items.by_id.contains_key(&(reagent_id as u32)) {
self.errors.push(ValidationError::InvalidReference {
table: "Spell",
field: "reagent",
id: spell.id,
ref_id: reagent_id as u32,
});
}
}
// Validate spell schools
if spell.school_mask == 0 {
self.errors.push(ValidationError::InvalidValue {
table: "Spell",
field: "school_mask",
id: spell.id,
reason: "No spell school defined",
});
}
}
}
}
Cross-Reference Resolution
#![allow(unused)]
fn main() {
struct DbcDatabase {
items: DbcIndex<ItemRecord>,
spells: DbcIndex<SpellRecord>,
item_display: DbcIndex<ItemDisplayInfoRecord>,
// ... more tables
}
impl DbcDatabase {
fn resolve_item_display(&self, item: &ItemRecord) -> Option<&ItemDisplayInfoRecord> {
self.item_display.by_id.get(&item.display_info_id)
.map(|&idx| &self.item_display.records[idx])
}
fn get_item_spells(&self, item: &ItemRecord) -> Vec<&SpellRecord> {
item.spell_trigger.iter()
.filter_map(|&spell_id| {
if spell_id > 0 {
self.spells.by_id.get(&(spell_id as u32))
.map(|&idx| &self.spells.records[idx])
} else {
None
}
})
.collect()
}
}
}
Test Vectors
Header Verification
Valid DBC header bytes:
57 44 42 43 // "WDBC"
0A 00 00 00 // 10 records
05 00 00 00 // 5 fields
14 00 00 00 // 20 bytes per record
64 00 00 00 // 100 bytes string block
String Reference Tests
String block with test data:
Offset Hex ASCII
0x0000 00 . (null string)
0x0001 48 65 6C 6C 6F 00 Hello.
0x0007 57 6F 72 6C 64 00 World.
0x000D 54 65 73 74 20 31 32 33 00 Test 123.
Test cases:
- StringRef(0) → “”
- StringRef(1) → “Hello”
- StringRef(7) → “World”
- StringRef(13) → “Test 123”
Localization Tests
LocalizedString test data (17 fields):
Field Value Description
0-15 StringRef Locale strings
16 0x0009 Locale mask (enUS and deDE present)
Common Issues
String Encoding
- Strings are null-terminated UTF-8
- Check string block bounds
- Handle missing translations
- Validate UTF-8 encoding
Schema Changes
- Field counts vary by version
- New fields often added at end
- Some fields repurposed between versions
- Use version-specific schemas
Data Integrity
- Validate foreign key references
- Check for orphaned records
- Verify enum values are valid
- Handle circular references
Version Differences
Classic (1.12.x)
- Original WDBC format
- 16 locale fields in LocalizedString
- Basic spell, item, and zone data
The Burning Crusade (2.4.3)
- Same file format as Classic
- New DBC files for:
- Flying mounts
- Heroic dungeons
- Arena data
- Jewelcrafting
- Extended spell attributes (2 more attribute fields)
Wrath of the Lich King (3.3.5)
- Still uses WDBC format
- New DBC files for:
- Achievement data
- Vehicle data
- Glyphs
- Extended quest data
- Spell.dbc grew to ~240 fields
Cataclysm+ (4.x+)
- Introduced DB2 format
- Variable record sizes
- Field types in header
- Inline strings
- Relationship data
References
See Also
📦 Working with MPQ Archives
Overview
MPQ (Mo’PaQ) archives are Blizzard’s archive format used
in World of Warcraft to store game assets. This guide covers working with
MPQ archives using warcraft-rs.
Key Features:
- ✅ StormLib Compatibility - Cross-implementation support
- ✅ Blizzard Archive Support - Handles official WoW archives (1.12.1 - 5.4.8)
- ✅ Bidirectional Compatibility - Archives work with both implementations
- ✅ Path Conversion - Forward slashes converted to backslashes
Prerequisites
Prerequisites:
- Rust programming knowledge
warcraft-rsinstalled with thempqfeature enabled- Access to World of Warcraft MPQ files (from game installation)
- File I/O knowledge in Rust
Understanding MPQ Archives
MPQ Archives
MPQ archives are file containers that store:
- Game textures (BLP files)
- Models (M2, WMO files)
- Database files (DBC)
- Audio files
- UI resources
- Scripts and configuration files
Key Features
- Compression: Multiple compression algorithms (PKWARE, zlib, bzip2)
- Encryption: Optional file encryption
- Listfiles: Internal file listings (not always present)
- Patches: Incremental updates
- Multi-locale: Language-specific file variations
Instructions
1. Opening an MPQ Archive
#![allow(unused)]
fn main() {
use wow_mpq::{Archive, OpenOptions};
fn open_mpq_archive() -> Result<Archive, Box<dyn std::error::Error>> {
// Open an MPQ archive for reading
let mut archive = Archive::open("Data/common.MPQ")?;
// Open with specific options
let options = OpenOptions::new()
.load_tables(true); // Load hash and block tables
let archive = Archive::open_with_options("Data/patch.MPQ", options)?;
Ok(archive)
}
}
2. Listing Files in an Archive
#![allow(unused)]
fn main() {
use wow_mpq::Archive;
fn list_archive_contents(archive: &mut Archive) -> Result<(), Box<dyn std::error::Error>> {
// List files (requires listfile to be present)
match archive.list() {
Ok(entries) => {
println!("Archive contains {} files:", entries.len());
for entry in entries {
println!(" - {} ({} bytes)", entry.name, entry.size);
// Check file attributes using the flags field
if entry.compressed_size < entry.size {
println!(" Compressed: {} -> {} bytes",
entry.compressed_size, entry.size);
}
if entry.flags != 0 {
println!(" Flags: 0x{:08X}", entry.flags);
}
}
}
Err(_) => {
println!("No listfile found in archive");
// Need exact filenames without a listfile
}
}
Ok(())
}
}
3. Extracting Files
#![allow(unused)]
fn main() {
use wow_mpq::Archive;
use std::fs::File;
use std::io::Write;
fn extract_file(archive: &mut Archive, filename: &str) -> Result<(), Box<dyn std::error::Error>> {
// Extract a single file
let data = archive.read_file(filename)?;
// Save to disk
let mut file = File::create(filename)?;
file.write_all(&data)?;
println!("Extracted {} ({} bytes)", filename, data.len());
Ok(())
}
fn extract_all_files(archive: &mut Archive, output_dir: &str) -> Result<(), Box<dyn std::error::Error>> {
use std::fs;
use std::path::Path;
// Create output directory
fs::create_dir_all(output_dir)?;
// Get file list from listfile (or use list_all() to include all files)
let entries = archive.list()?;
for entry in entries {
let filename = &entry.name;
let output_path = Path::new(output_dir).join(filename);
// Create subdirectories if needed
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent)?;
}
// Extract and save
match archive.read_file(filename) {
Ok(data) => {
let mut file = File::create(output_path)?;
file.write_all(&data)?;
println!("Extracted: {}", filename);
}
Err(e) => {
eprintln!("Failed to extract {}: {}", filename, e);
}
}
}
Ok(())
}
}
4. Working with Multiple Archives using PatchChain
The PatchChain struct provides priority-based file resolution across
multiple MPQ archives.
#![allow(unused)]
fn main() {
use wow_mpq::{PatchChain, Archive};
use std::path::PathBuf;
fn work_with_patch_chain() -> Result<(), Box<dyn std::error::Error>> {
// Create a patch chain
let mut chain = PatchChain::new();
// Add archives with priority (higher numbers override lower)
chain.add_archive(PathBuf::from("Data/common.MPQ"), 0)?; // Base content
chain.add_archive(PathBuf::from("Data/expansion.MPQ"), 100)?; // Expansion content
chain.add_archive(PathBuf::from("Data/patch.MPQ"), 200)?; // Patch 1
chain.add_archive(PathBuf::from("Data/patch-2.MPQ"), 300)?; // Patch 2
chain.add_archive(PathBuf::from("Data/patch-3.MPQ"), 400)?; // Patch 3 (highest priority)
// Extract a file - uses the highest priority version
let filename = "Interface/Icons/INV_Misc_QuestionMark.blp";
let data = chain.read_file(filename)?;
println!("Extracted {} ({} bytes)", filename, data.len());
// Find which archive contains a file
if let Some(archive_path) = chain.find_file_archive(filename) {
println!("File found in: {}", archive_path.display());
}
// List unique files across archives
let all_files = chain.list()?;
println!("Total unique files: {}", all_files.len());
// Get information about archives
let chain_info = chain.get_chain_info();
for info in &chain_info {
println!("{} (priority {}): {} files",
info.path.display(),
info.priority,
info.file_count
);
}
Ok(())
}
}
Manual Archive Management (Legacy Approach)
#![allow(unused)]
fn main() {
use wow_mpq::Archive;
fn work_with_multiple_archives_manual() -> Result<(), Box<dyn std::error::Error>> {
// Open archives individually
let mut base = Archive::open("Data/common.MPQ")?;
let mut patch = Archive::open("Data/patch.MPQ")?;
let mut patch2 = Archive::open("Data/patch-2.MPQ")?;
// Search for a file across archives (manual priority handling)
let filename = "Interface/Icons/INV_Misc_QuestionMark.blp";
// Try patch archives first (highest priority)
let data = if let Ok(data) = patch2.read_file(filename) {
println!("Found {} in patch-2.MPQ", filename);
data
} else if let Ok(data) = patch.read_file(filename) {
println!("Found {} in patch.MPQ", filename);
data
} else {
println!("Found {} in common.MPQ", filename);
base.read_file(filename)?
};
println!("Extracted {} ({} bytes)", filename, data.len());
Ok(())
}
}
5. Creating New Archives
#![allow(unused)]
fn main() {
use wow_mpq::{ArchiveBuilder, FormatVersion, ListfileOption};
fn create_simple_archive() -> Result<(), Box<dyn std::error::Error>> {
// Create archive
ArchiveBuilder::new()
.add_file("readme.txt", "README.txt")
.add_file_data(b"Hello World".to_vec(), "hello.txt")
.build("simple.mpq")?;
Ok(())
}
fn create_advanced_archive() -> Result<(), Box<dyn std::error::Error>> {
use wow_mpq::compression::flags;
ArchiveBuilder::new()
// Configure archive settings
.version(FormatVersion::V2)
.block_size(7) // 64KB sectors
.default_compression(flags::ZLIB)
.listfile_option(ListfileOption::Generate)
// Add files with different options
.add_file("data/texture.blp", "Textures/MyTexture.blp")
.add_file_data_with_options(
b"Important data".to_vec(),
"Data/config.ini",
flags::BZIP2, // Better compression
false, // no encryption
0, // default locale
)
.add_file_data_with_options(
b"Secret data".to_vec(),
"Keys/secret.key",
flags::ZLIB,
true, // encrypt
0, // locale
)
// Build the archive
.build("advanced.mpq")?;
Ok(())
}
}
5a. Modifying Existing Archives
The MutableArchive type allows you to modify existing MPQ archives:
#![allow(unused)]
fn main() {
use wow_mpq::{MutableArchive, AddFileOptions, compression::CompressionMethod};
fn modify_archive_example() -> Result<(), Box<dyn std::error::Error>> {
// Open an archive for modification
let mut archive = MutableArchive::open("my_archive.mpq")?;
// Add a file from disk with default options (zlib compression)
archive.add_file("new_file.txt", "data/new_file.txt", AddFileOptions::new())?;
// Add file data directly with custom compression
let options = AddFileOptions::new()
.compression(CompressionMethod::BZip2)
.encrypt()
.replace_existing(true);
archive.add_file_data(
b"Secret content".to_vec(),
"encrypted.dat",
options
)?;
// Remove a file
archive.remove_file("old_file.txt")?;
// Rename a file
archive.rename_file("readme.txt", "README.TXT")?;
// Read files from a mutable archive (convenience method)
let data = archive.read_file("some_file.txt")?;
println!("File content: {} bytes", data.len());
// List files (convenience method)
let files = archive.list()?;
for entry in files {
println!("{}: {} bytes", entry.name, entry.size);
}
// Verify signature (if present)
match archive.verify_signature() {
Ok(status) => println!("Signature status: {:?}", status),
Err(_) => println!("No signature found"),
}
// Flush changes to disk
archive.flush()?;
// Compact the archive to remove deleted files and reclaim space
// This creates a new archive without gaps from deleted files
archive.compact()?;
Ok(())
}
fn batch_modifications() -> Result<(), Box<dyn std::error::Error>> {
let mut archive = MutableArchive::open("patch.mpq")?;
// Add multiple files with different settings
let files_to_add = vec![
("assets/icon1.blp", "Interface/Icons/Icon1.blp", CompressionMethod::Zlib),
("assets/icon2.blp", "Interface/Icons/Icon2.blp", CompressionMethod::BZip2),
("assets/model.m2", "Models/Creature/Model.m2", CompressionMethod::None),
];
for (source, archived_name, compression) in files_to_add {
let options = AddFileOptions::new()
.compression(compression)
.replace_existing(true);
archive.add_file(source, archived_name, options)?;
}
// Remove old versions
let files_to_remove = vec![
"Interface/Icons/OldIcon.blp",
"Models/Deprecated/OldModel.m2",
];
for filename in files_to_remove {
if let Err(_) = archive.remove_file(filename) {
println!("File {} not found, skipping", filename);
}
}
// Batch rename for consistency
let renames = vec![
("interface/icons/spell_fire_01.blp", "Interface/Icons/Spell_Fire_01.blp"),
("interface/icons/spell_frost_01.blp", "Interface/Icons/Spell_Frost_01.blp"),
];
for (old_name, new_name) in renames {
if let Err(_) = archive.rename_file(old_name, new_name) {
println!("Could not rename {} to {}", old_name, new_name);
}
}
// Save all changes
archive.flush()?;
Ok(())
}
}
6. Rebuilding and Comparing Archives
The warcraft-rs CLI provides tools for rebuilding MPQ archives and
comparing them.
Rebuilding Archives
Archive rebuilding allows you to recreate MPQ archives 1:1 while optionally upgrading formats or changing compression:
#![allow(unused)]
fn main() {
use wow_mpq::{rebuild_archive, RebuildOptions, FormatVersion};
fn rebuild_archive_example() -> Result<(), Box<dyn std::error::Error>> {
// Basic rebuild with format preservation
let options = RebuildOptions {
preserve_format: true,
target_format: None,
preserve_order: true,
skip_encrypted: false,
skip_signatures: true,
verify: false,
override_compression: None,
override_block_size: None,
list_only: false,
};
rebuild_archive(
"original.mpq",
"rebuilt.mpq",
options,
None // No progress callback
)?;
println!("Archive rebuilt successfully");
Ok(())
}
fn rebuild_with_upgrade() -> Result<(), Box<dyn std::error::Error>> {
// Rebuild with format upgrade and verification
let options = RebuildOptions {
preserve_format: false,
target_format: Some(FormatVersion::V4),
preserve_order: true,
skip_encrypted: false,
skip_signatures: true,
verify: true,
override_compression: Some(wow_mpq::compression::flags::LZMA),
override_block_size: Some(6), // 32KB sectors
list_only: false,
};
let summary = rebuild_archive(
"old_v1.mpq",
"modern_v4.mpq",
options,
Some(&|current, total, file| {
if current % 100 == 0 {
println!("Processing [{}/{}]: {}", current, total, file);
}
})
)?;
println!("Rebuild completed:");
println!(" Source files: {}", summary.source_files);
println!(" Extracted files: {}", summary.extracted_files);
println!(" Skipped files: {}", summary.skipped_files);
println!(" Target format: {:?}", summary.target_format);
println!(" Verified: {}", summary.verified);
Ok(())
}
}
Comparing Archives
Archive comparison helps verify rebuilds and analyze differences between archives:
#![allow(unused)]
fn main() {
use wow_mpq::{compare_archives, CompareOptions};
fn compare_archives_example() -> Result<(), Box<dyn std::error::Error>> {
// Basic comparison
let result = compare_archives(
"original.mpq",
"rebuilt.mpq",
false, // not detailed
false, // no content check
false, // not metadata only
true, // ignore order
None // no filter
)?;
if result.identical {
println!("✓ Archives are identical");
} else {
println!("✗ Archives differ");
// Show metadata differences
if !result.metadata.matches {
println!("Metadata differences:");
if result.metadata.format_version.0 != result.metadata.format_version.1 {
println!(" Format: {:?} → {:?}",
result.metadata.format_version.0,
result.metadata.format_version.1);
}
if result.metadata.file_count.0 != result.metadata.file_count.1 {
println!(" File count: {} → {}",
result.metadata.file_count.0,
result.metadata.file_count.1);
}
}
// Show file differences
if let Some(files) = &result.files {
if !files.source_only.is_empty() {
println!("Files only in source ({}): {:?}",
files.source_only.len(),
&files.source_only[..files.source_only.len().min(5)]);
}
if !files.target_only.is_empty() {
println!("Files only in target ({}): {:?}",
files.target_only.len(),
&files.target_only[..files.target_only.len().min(5)]);
}
if !files.size_differences.is_empty() {
println!("Files with size differences: {}", files.size_differences.len());
}
}
}
Ok(())
}
fn compare_with_content_verification() -> Result<(), Box<dyn std::error::Error>> {
// Thorough comparison with content verification
let result = compare_archives(
"original.mpq",
"rebuilt.mpq",
true, // detailed
true, // content check
false, // not metadata only
true, // ignore order
Some("*.dbc".to_string()) // only compare DBC files
)?;
if let Some(files) = &result.files {
if !files.content_differences.is_empty() {
println!("⚠ Content differences found:");
for file in &files.content_differences {
println!(" - {}", file);
}
} else {
println!("✓ All file contents match");
}
}
Ok(())
}
}
CLI Workflow Examples
# Complete rebuild and verification workflow
echo "=== Archive Rebuild and Verification ==="
# 1. Analyze original archive
warcraft-rs mpq info original.mpq
warcraft-rs mpq list original.mpq --long | head -10
# 2. Rebuild archive preserving format
warcraft-rs mpq rebuild original.mpq rebuilt.mpq
# 3. Compare archives
warcraft-rs mpq compare original.mpq rebuilt.mpq --output summary
# 4. Verify content integrity
warcraft-rs mpq compare original.mpq rebuilt.mpq --content-check
# 5. Upgrade to modern format
warcraft-rs mpq rebuild original.mpq modern.mpq --upgrade-to v4 --compression lzma
# 6. Compare format differences
warcraft-rs mpq compare original.mpq modern.mpq --metadata-only
7. Searching for Files
#![allow(unused)]
fn main() {
use wow_mpq::Archive;
use regex::Regex;
fn search_files(archive: &mut Archive, pattern: &str) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let re = Regex::new(pattern)?;
// Get listfile (required for file enumeration)
let entries = archive.list()?;
let matches: Vec<String> = entries
.iter()
.filter(|entry| re.is_match(&entry.name))
.map(|entry| entry.name.clone())
.collect();
println!("Found {} files matching '{}':", matches.len(), pattern);
for filename in &matches {
println!(" - {}", filename);
}
Ok(matches)
}
// Example: Find all BLP textures
fn find_textures(archive: &mut Archive) -> Result<Vec<String>, Box<dyn std::error::Error>> {
search_files(archive, r"\.blp$")
}
// Example: Find a specific file if you know part of the name
fn find_specific_file(archive: &mut Archive, partial_name: &str) -> Result<Option<String>, Box<dyn std::error::Error>> {
let entries = archive.list()?;
for entry in entries {
if entry.name.contains(partial_name) {
return Ok(Some(entry.name));
}
}
Ok(None)
}
}
Code Examples
Complete Example: MPQ Explorer
use wow_mpq::Archive;
use std::io::{self, Write};
struct MpqExplorer {
archive: Archive,
}
impl MpqExplorer {
fn new(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
let archive = Archive::open(path)?;
Ok(Self { archive })
}
fn info(&self) -> Result<(), Box<dyn std::error::Error>> {
let info = self.archive.get_info()?;
println!("Archive Information:");
println!(" Path: {}", info.path.display());
println!(" Format Version: {:?}", info.format_version);
println!(" File Count: {}", info.file_count);
println!(" Archive Size: {:.2} MB", info.file_size as f64 / 1024.0 / 1024.0);
println!(" Sector Size: {} bytes", info.sector_size);
Ok(())
}
fn list(&mut self, filter: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let entries = self.archive.list()?;
for entry in entries {
let filename = &entry.name;
if let Some(filter) = filter {
if !filename.contains(filter) {
continue;
}
}
println!("{} ({} bytes)", filename, entry.size);
}
Ok(())
}
fn extract(&mut self, filename: &str, output: Option<&str>) -> Result<(), Box<dyn std::error::Error>> {
let data = self.archive.read_file(filename)?;
let output_path = output.unwrap_or(filename);
use std::fs::File;
let mut file = File::create(output_path)?;
file.write_all(&data)?;
println!("Extracted {} to {} ({} bytes)", filename, output_path, data.len());
Ok(())
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let mut explorer = MpqExplorer::new("Data/common.MPQ")?;
loop {
print!("> ");
io::stdout().flush()?;
let mut input = String::new();
io::stdin().read_line(&mut input)?;
let parts: Vec<&str> = input.trim().split_whitespace().collect();
if parts.is_empty() {
continue;
}
match parts[0] {
"info" => explorer.info()?,
"list" => explorer.list(parts.get(1).copied())?,
"extract" => {
if let Some(filename) = parts.get(1) {
explorer.extract(filename, parts.get(2).copied())?;
} else {
println!("Usage: extract <filename> [output]");
}
}
"quit" => break,
_ => println!("Unknown command. Available: info, list, extract, quit"),
}
}
Ok(())
}
Best Practices
1. Memory Management
#![allow(unused)]
fn main() {
// For large files, consider the file size before extraction
use wow_mpq::Archive;
fn extract_with_size_check(archive: &mut Archive, filename: &str) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
// Get file list and check size
let entries = archive.list()?;
for entry in &entries {
if entry.name == filename {
if entry.size > 100 * 1024 * 1024 { // 100MB
println!("Warning: File {} is large ({} bytes)", filename, entry.size);
}
break;
}
}
// Extract the file
archive.read_file(filename)
}
}
2. Error Handling
#![allow(unused)]
fn main() {
use wow_mpq::{Archive, Error};
fn safe_extract(archive: &mut Archive, filename: &str) -> Result<Vec<u8>, String> {
match archive.read_file(filename) {
Ok(data) => Ok(data),
Err(Error::FileNotFound(_)) => {
Err(format!("File '{}' not found in archive", filename))
}
Err(Error::InvalidFormat(msg)) => {
Err(format!("File '{}' has invalid format: {}", filename, msg))
}
Err(Error::Io(e)) => {
Err(format!("I/O error reading '{}': {}", filename, e))
}
Err(e) => Err(format!("Error reading '{}': {}", filename, e)),
}
}
}
3. Caching Extracted Files
#![allow(unused)]
fn main() {
use std::collections::HashMap;
use wow_mpq::Archive;
struct CachedArchive {
archive: Archive,
cache: HashMap<String, Vec<u8>>,
}
impl CachedArchive {
fn new(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
Ok(Self {
archive: Archive::open(path)?,
cache: HashMap::new(),
})
}
fn get(&mut self, filename: &str) -> Result<&[u8], Box<dyn std::error::Error>> {
if !self.cache.contains_key(filename) {
let data = self.archive.read_file(filename)?;
self.cache.insert(filename.to_string(), data);
}
Ok(&self.cache[filename])
}
}
}
Common Issues and Solutions
Issue: File Not Found
Problem: archive.read_file() returns FileNotFound error.
Solutions:
- Check the exact filename (case-sensitive)
- Check if archive has a listfile (use
archive.list()) - Check if using correct patch archive
- For archives without listfiles, you need to know exact filenames
#![allow(unused)]
fn main() {
// Debug file lookup
fn debug_file_lookup(archive: &mut Archive, partial_name: &str) -> Result<(), Box<dyn std::error::Error>> {
let entries = archive.list()?;
println!("Files containing '{}':", partial_name);
for entry in entries {
if entry.name.contains(partial_name) {
println!(" - {}", entry.name);
}
}
Ok(())
}
// Check if file exists before extraction
fn safe_file_check(archive: &mut Archive, filename: &str) -> Result<bool, Box<dyn std::error::Error>> {
let entries = archive.list()?;
for entry in &entries {
if entry.name == filename {
return Ok(true);
}
}
Ok(false)
}
}
Issue: Missing Listfile
Problem: Cannot enumerate files without listfile.
Solutions:
- Add a listfile to the archive manually
- Extract files by their exact known names
- Some archives don’t have listfiles - this is normal
#![allow(unused)]
fn main() {
fn handle_missing_listfile(archive: &mut Archive) -> Result<(), Box<dyn std::error::Error>> {
match archive.list() {
Ok(entries) => {
println!("Found {} files with listfile:", entries.len());
for entry in entries.iter().take(10) {
println!(" - {}", entry.name);
}
}
Err(_) => {
println!("No listfile found in archive");
println!("You can:");
println!("1. Add a listfile to the archive");
println!("2. Extract specific files by exact name");
println!("3. Use external listfile references");
}
}
Ok(())
}
}
Issue: Archive Integrity
Problem: Want to verify archive is not corrupted.
Solutions:
- Check archive information
- Try to read a few files to test basic functionality
#![allow(unused)]
fn main() {
fn basic_archive_test(archive: &mut Archive) -> Result<(), Box<dyn std::error::Error>> {
let info = archive.get_info()?;
println!("Archive format: {:?}", info.format_version);
println!("File count: {}", info.file_count);
// Try to list files as a basic integrity test
match archive.list() {
Ok(entries) => println!("Listfile found with {} entries", entries.len()),
Err(_) => println!("No listfile found - cannot enumerate files without exact names"),
}
Ok(())
}
}
Issue: Blizzard Archive Warnings
Problem: Getting “-28 byte attributes file size mismatch” warnings with official WoW archives.
Solution: This is normal and expected behavior. All Blizzard MPQ archives have exactly 28 extra zero bytes at the end of their attributes files. The warning is informational only - the archives work perfectly.
#![allow(unused)]
fn main() {
// The warning looks like:
// "Attributes file size mismatch: actual=X, expected=Y, difference=-28 (tolerating for compatibility)"
// This is handled automatically by wow-mpq and doesn't affect functionality
let archive = Archive::open("Data/patch.mpq")?; // Works despite warning
}
Patch Chain Management
Understanding Patch Chains
World of Warcraft uses a patch chain system where newer patches override files
in older archives. The PatchChain struct automates this process, returning
the highest priority version of a file.
Critical Loading Order Rules
Based on TrinityCore’s implementation and the official WoW client behavior, archives must be loaded in a specific order:
- Base Archives First: Common game data (common.MPQ, common-2.MPQ)
- Expansion Archives: Each expansion adds its archives (expansion.MPQ, lichking.MPQ)
- Locale Archives: Language-specific content that overrides base content
- General Patches: Numbered patches (patch.MPQ, patch-2.MPQ, patch-3.MPQ)
- Locale Patches: Language-specific patches (patch-enUS.MPQ, patch-enUS-2.MPQ)
Important principles:
- Files in later-loaded archives override files with the same path in earlier archives
- Locale-specific files always override their generic counterparts
- Patches are loaded in numerical order (patch-2 overrides patch)
- Custom patches should use higher numbers (patch-4.MPQ+) or letters (patch-x.MPQ)
Advanced PatchChain Usage
#![allow(unused)]
fn main() {
use wow_mpq::{PatchChain, ChainInfo};
use std::path::PathBuf;
fn advanced_patch_chain_example() -> Result<(), Box<dyn std::error::Error>> {
let mut chain = PatchChain::new();
// Add archives with descriptive priorities
const BASE_PRIORITY: i32 = 0;
const EXPANSION_PRIORITY: i32 = 1000;
const PATCH_PRIORITY_BASE: i32 = 2000;
chain.add_archive(PathBuf::from("Data/common.MPQ"), BASE_PRIORITY)?;
chain.add_archive(PathBuf::from("Data/expansion.MPQ"), EXPANSION_PRIORITY)?;
// Add patches in order
for (i, patch_file) in vec!["patch.MPQ", "patch-2.MPQ", "patch-3.MPQ"].iter().enumerate() {
let path = PathBuf::from(format!("Data/{}", patch_file));
let priority = PATCH_PRIORITY_BASE + (i as i32 * 100);
chain.add_archive(path, priority)?;
}
// Extract multiple files efficiently
let files_to_extract = vec![
"Interface/Icons/INV_Misc_QuestionMark.blp",
"DBFilesClient/Item.dbc",
"DBFilesClient/Spell.dbc",
];
for filename in &files_to_extract {
match chain.read_file(filename) {
Ok(data) => println!("Extracted {}: {} bytes", filename, data.len()),
Err(e) => eprintln!("Failed to extract {}: {}", filename, e),
}
}
// Get chain information
let chain_info = chain.get_chain_info();
for info in &chain_info {
println!("Archive: {} (priority: {})", info.path.display(), info.priority);
}
Ok(())
}
}
Patch Chain for Different WoW Versions
⚠️ Important: The loading order below matches the exact order used by the WoW client, as documented by TrinityCore. Archives must be loaded in this specific order for correct file resolution.
#![allow(unused)]
fn main() {
use wow_mpq::PatchChain;
use std::path::Path;
/// Setup patch chain for WoW 3.3.5a following TrinityCore's definitive loading order
fn setup_wotlk_3_3_5a(data_path: &Path, locale: &str) -> Result<PatchChain, Box<dyn std::error::Error>> {
let mut chain = PatchChain::new();
// The exact loading order from TrinityCore:
// 1-4: Base and expansion archives
chain.add_archive(data_path.join("common.MPQ").to_path_buf(), 0)?;
chain.add_archive(data_path.join("common-2.MPQ").to_path_buf(), 1)?;
chain.add_archive(data_path.join("expansion.MPQ").to_path_buf(), 2)?;
chain.add_archive(data_path.join("lichking.MPQ").to_path_buf(), 3)?;
// 5-10: Locale and speech archives
chain.add_archive(data_path.join(format!("locale-{}.MPQ", locale)).to_path_buf(), 4)?;
chain.add_archive(data_path.join(format!("speech-{}.MPQ", locale)).to_path_buf(), 5)?;
chain.add_archive(data_path.join(format!("expansion-locale-{}.MPQ", locale)).to_path_buf(), 6)?;
chain.add_archive(data_path.join(format!("lichking-locale-{}.MPQ", locale)).to_path_buf(), 7)?;
chain.add_archive(data_path.join(format!("expansion-speech-{}.MPQ", locale)).to_path_buf(), 8)?;
chain.add_archive(data_path.join(format!("lichking-speech-{}.MPQ", locale)).to_path_buf(), 9)?;
// 11-13: General patches
chain.add_archive(data_path.join("patch.MPQ").to_path_buf(), 10)?;
chain.add_archive(data_path.join("patch-2.MPQ").to_path_buf(), 11)?;
chain.add_archive(data_path.join("patch-3.MPQ").to_path_buf(), 12)?;
// 14-16: Locale patches (in locale subdirectory)
let locale_path = data_path.join(locale);
chain.add_archive(locale_path.join(format!("patch-{}.MPQ", locale)).to_path_buf(), 13)?;
chain.add_archive(locale_path.join(format!("patch-{}-2.MPQ", locale)).to_path_buf(), 14)?;
chain.add_archive(locale_path.join(format!("patch-{}-3.MPQ", locale)).to_path_buf(), 15)?;
Ok(chain)
}
/// Setup patch chain for different WoW versions
fn setup_wow_patch_chain(wow_path: &Path, version: &str, locale: &str) -> Result<PatchChain, Box<dyn std::error::Error>> {
let mut chain = PatchChain::new();
let data_path = wow_path.join("Data");
match version {
"1.12.1" => {
// Vanilla WoW uses categorized archives
let base_priority = 0;
chain.add_archive(data_path.join("dbc.MPQ"), base_priority)?;
chain.add_archive(data_path.join("fonts.MPQ"), base_priority)?;
chain.add_archive(data_path.join("interface.MPQ"), base_priority)?;
chain.add_archive(data_path.join("misc.MPQ"), base_priority)?;
chain.add_archive(data_path.join("model.MPQ"), base_priority)?;
chain.add_archive(data_path.join("sound.MPQ"), base_priority)?;
chain.add_archive(data_path.join("speech.MPQ"), base_priority)?;
chain.add_archive(data_path.join("terrain.MPQ"), base_priority)?;
chain.add_archive(data_path.join("texture.MPQ"), base_priority)?;
chain.add_archive(data_path.join("wmo.MPQ"), base_priority)?;
// Patches override everything
chain.add_archive(data_path.join("patch.MPQ"), 1000)?;
chain.add_archive(data_path.join("patch-2.MPQ"), 1001)?;
}
"2.4.3" => {
// TBC introduced common.MPQ structure
// Base archives
chain.add_archive(data_path.join("common.MPQ"), 0)?;
chain.add_archive(data_path.join("common-2.MPQ"), 1)?;
chain.add_archive(data_path.join("expansion.MPQ"), 2)?;
// Locale archives (override base)
let locale_path = data_path.join(locale);
chain.add_archive(locale_path.join(format!("locale-{}.MPQ", locale)), 100)?;
chain.add_archive(locale_path.join(format!("speech-{}.MPQ", locale)), 101)?;
chain.add_archive(locale_path.join(format!("expansion-locale-{}.MPQ", locale)), 102)?;
chain.add_archive(locale_path.join(format!("expansion-speech-{}.MPQ", locale)), 103)?;
// General patches
chain.add_archive(data_path.join("patch.MPQ"), 1000)?;
chain.add_archive(data_path.join("patch-2.MPQ"), 1001)?;
// Locale patches (highest priority)
chain.add_archive(locale_path.join(format!("patch-{}.MPQ", locale)), 2000)?;
chain.add_archive(locale_path.join(format!("patch-{}-2.MPQ", locale)), 2001)?;
}
"3.3.5a" => {
// Use the definitive loading order function
return setup_wotlk_3_3_5a(&data_path, locale);
}
_ => {
return Err(format!("Unsupported WoW version: {}", version).into());
}
}
Ok(chain)
}
}
Searching Across Patch Chains
#![allow(unused)]
fn main() {
use wow_mpq::PatchChain;
use regex::Regex;
fn search_patch_chain(chain: &mut PatchChain, pattern: &str) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let re = Regex::new(pattern)?;
let all_files = chain.list()?;
let matches: Vec<String> = all_files
.into_iter()
.filter(|entry| re.is_match(&entry.name))
.map(|entry| entry.name)
.collect();
Ok(matches)
}
// Example: Find all spell icons
fn find_spell_icons(chain: &mut PatchChain) -> Result<Vec<String>, Box<dyn std::error::Error>> {
search_patch_chain(chain, r"Interface/Icons/Spell_.*\.blp")
}
}
Performance Tips
1. Batch Operations
#![allow(unused)]
fn main() {
// Extract multiple files efficiently
fn batch_extract(archive: &Archive, filenames: &[&str]) -> Result<Vec<(String, Vec<u8>)>, Box<dyn std::error::Error>> {
let mut results = Vec::with_capacity(filenames.len());
for &filename in filenames {
match archive.read_file(filename) {
Ok(data) => results.push((filename.to_string(), data)),
Err(e) => eprintln!("Failed to extract {}: {}", filename, e),
}
}
Ok(results)
}
}
2. Efficient File Listing
#![allow(unused)]
fn main() {
use wow_mpq::Archive;
fn efficient_file_search(archive: &mut Archive, pattern: &str) -> Result<Vec<String>, Box<dyn std::error::Error>> {
// Get file list once and reuse it
let entries = archive.list()?;
let matches: Vec<String> = entries
.into_iter()
.filter(|entry| entry.name.contains(pattern))
.map(|entry| entry.name)
.collect();
Ok(matches)
}
}
3. Reuse Archive Objects
#![allow(unused)]
fn main() {
use wow_mpq::Archive;
struct ArchivePool {
archives: Vec<Archive>,
}
impl ArchivePool {
fn new(paths: &[&str]) -> Result<Self, Box<dyn std::error::Error>> {
let archives = paths.iter()
.map(|path| Archive::open(path))
.collect::<Result<Vec<_>, _>>()?;
Ok(Self { archives })
}
fn find_and_extract(&mut self, filename: &str) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
for archive in &mut self.archives {
if let Ok(data) = archive.read_file(filename) {
return Ok(data);
}
}
Err(format!("File '{}' not found in any archive", filename).into())
}
}
}
4. Check File Existence Before Extraction
#![allow(unused)]
fn main() {
use wow_mpq::Archive;
fn smart_extract(archive: &mut Archive, filename: &str) -> Result<Option<Vec<u8>>, Box<dyn std::error::Error>> {
// Check if file exists first (cheaper than attempting extraction)
if let Some(file_info) = archive.find_file(filename)? {
Some(file_info) => {
println!("File {} exists ({} bytes), extracting...", filename, file_info.file_size);
Ok(Some(archive.read_file(filename)?))
}
None => {
println!("File {} not found", filename);
Ok(None)
}
}
}
}
Related Guides
- 📝 DBC Data Extraction - Extract and parse DBC files from MPQ archives
- 🖼️ Texture Loading Guide - Load BLP textures from MPQ archives
- 🎭 Loading M2 Models - Extract and load M2 model files
- 🏛️ WMO Rendering Guide - Extract and render WMO files
- 📦 WoW Patch Chain Summary - Guide to patch chaining across all WoW versions
References
- MPQ Format Documentation - Detailed MPQ format specification
- StormLib - Reference C++ implementation
- Blizzard Archive Formats - Technical details
- WoW File Formats - Complete WoW file format documentation
MPQ CLI Usage Guide
The warcraft-rs command-line tool provides MPQ archive operations through the
mpq subcommand with full compatibility for all World of Warcraft MPQ archives.
Key Features:
- ✅ 100% StormLib Compatibility - Works with archives from both implementations
- ✅ Full Blizzard Support - Handles all official WoW archives (1.12.1 - 5.4.8)
- ✅ Automatic Path Conversion - Cross-platform path handling
- ✅ Archive Rebuild & Compare - Advanced archive management capabilities
Installation
# Build from source
cd warcraft-rs
cargo build --release
# Or install globally
cargo install --path .
# The binary will be available as 'warcraft-rs'
Basic Commands
List Archive Contents
# Simple listing
warcraft-rs mpq list archive.mpq
# Detailed listing with sizes and compression ratios
warcraft-rs mpq list archive.mpq --long
# Filter by pattern (supports wildcards)
warcraft-rs mpq list archive.mpq --filter "*.m2"
warcraft-rs mpq list archive.mpq --filter "*Interface*" --long
Extract Files
# Extract all files
warcraft-rs mpq extract archive.mpq
# Extract to specific directory
warcraft-rs mpq extract archive.mpq --output ./extracted
# Extract specific files
warcraft-rs mpq extract archive.mpq file1.txt file2.dat
# Preserve directory structure
warcraft-rs mpq extract archive.mpq --preserve-paths
Archive Information
# Basic information
warcraft-rs mpq info archive.mpq
# Show hash table details
warcraft-rs mpq info archive.mpq --show-hash-table
# Show block table details
warcraft-rs mpq info archive.mpq --show-block-table
Validate Archives
# Basic validation
warcraft-rs mpq validate archive.mpq
Create Archives
# Create new archive with files
warcraft-rs mpq create new.mpq --add file1.txt --add file2.dat
# Specify format version
warcraft-rs mpq create new.mpq --version v2 --add "data/*"
# Choose compression method
warcraft-rs mpq create new.mpq --compression zlib --add file1.txt
warcraft-rs mpq create new.mpq --compression bzip2 --add largefile.dat
warcraft-rs mpq create new.mpq --compression none --add already_compressed.zip
# Include (listfile) for better compatibility
warcraft-rs mpq create new.mpq --with-listfile --add file1.txt --add file2.txt
Rebuild Archives
Rebuild MPQ archives 1:1 while preserving original structure and optionally upgrading format:
# Basic rebuild (preserves original format)
warcraft-rs mpq rebuild source.mpq target.mpq
# Upgrade to specific format version
warcraft-rs mpq rebuild old.mpq new.mpq --upgrade-to v4
# Skip encrypted files during rebuild
warcraft-rs mpq rebuild source.mpq target.mpq --skip-encrypted
# Skip digital signatures (default: true)
warcraft-rs mpq rebuild source.mpq target.mpq --skip-signatures
# Verify rebuilt archive matches original
warcraft-rs mpq rebuild source.mpq target.mpq --verify
# Override compression method for all files
warcraft-rs mpq rebuild source.mpq target.mpq --compression zlib
# Override block size
warcraft-rs mpq rebuild source.mpq target.mpq --block-size 4
# Dry run - list files that would be processed
warcraft-rs mpq rebuild source.mpq target.mpq --list-only
Compare Archives
Compare two MPQ archives to identify differences in metadata, file lists, and content:
# Basic comparison with table output
warcraft-rs mpq compare source.mpq target.mpq
# Summary output format
warcraft-rs mpq compare source.mpq target.mpq --output summary
# Detailed file-by-file comparison
warcraft-rs mpq compare source.mpq target.mpq --detailed
# Compare actual file contents (slower but thorough)
warcraft-rs mpq compare source.mpq target.mpq --content-check
# Only compare archive metadata
warcraft-rs mpq compare source.mpq target.mpq --metadata-only
# Filter comparison to specific files
warcraft-rs mpq compare source.mpq target.mpq --filter "*.dbc"
warcraft-rs mpq compare source.mpq target.mpq --filter "*Interface*"
# JSON output for scripting
warcraft-rs mpq compare source.mpq target.mpq --output json
Note: Archive modification features (add/remove files to existing archives) are planned for future releases.
Advanced Usage
Working with World of Warcraft Archives
# List models in a patch archive
warcraft-rs mpq list "patch-4.mpq" --filter "*.m2" --long
# Extract specific files with preserved paths
warcraft-rs mpq extract patch.mpq "Interface/Icons/INV_Misc_QuestionMark.blp" --preserve-paths --output ./extracted
# Extract multiple related files
warcraft-rs mpq extract common.mpq "DBFilesClient/ItemDisplayInfo.dbc" "DBFilesClient/Item.dbc"
# Note: Forward slashes in paths are automatically converted to backslashes for MPQ compatibility
# Both of these work identically:
warcraft-rs mpq extract patch.mpq "Units/Human/Footman.mdx"
warcraft-rs mpq extract patch.mpq "Units\\Human\\Footman.mdx"
Batch Operations
# Extract from multiple archives
for archive in *.mpq; do
echo "Processing $archive..."
warcraft-rs mpq extract "$archive" --output "./${archive%.mpq}_extracted"
done
# List all textures across archives
for archive in *.mpq; do
echo "=== $archive ==="
warcraft-rs mpq list "$archive" --filter "*.blp" | head -10
done
# Get archive statistics
for archive in *.mpq; do
echo "=== $archive ==="
warcraft-rs mpq info "$archive"
echo
done
Analysis Tasks
# Find all model files
warcraft-rs mpq list archive.mpq --filter "*.m2" --long
# Look for specific content
warcraft-rs mpq list archive.mpq --filter "*Stormwind*"
# Extract database files for analysis
warcraft-rs mpq extract common.mpq --filter "*.dbc" --output ./dbc_files
Global Options
Verbosity Control
# Quiet mode (errors only)
warcraft-rs mpq --quiet list archive.mpq
# Verbose output
warcraft-rs mpq -v list archive.mpq
# Very verbose (debug output)
warcraft-rs mpq -vv info archive.mpq
# Maximum verbosity (trace output)
warcraft-rs mpq -vvv info archive.mpq
Environment Variables
# Set default log level
export RUST_LOG=info
warcraft-rs mpq list archive.mpq
# For debug logging
export RUST_LOG=debug
warcraft-rs mpq extract archive.mpq
Common Workflows
Archive Analysis
# Get archive statistics
warcraft-rs mpq info archive.mpq
# List all files with details
warcraft-rs mpq list archive.mpq --long
# Find largest files (using external tools)
warcraft-rs mpq list archive.mpq --long | sort -k3 -n -r | head -20
# Find specific file types
warcraft-rs mpq list archive.mpq --filter "*.m2"
warcraft-rs mpq list archive.mpq --filter "*.dbc"
warcraft-rs mpq list archive.mpq --filter "*Interface*"
Archive Exploration
# Validate archive integrity
warcraft-rs mpq validate archive.mpq
# Extract specific content for analysis
warcraft-rs mpq extract archive.mpq --filter "DBFilesClient/*" --output ./database_files --preserve-paths
warcraft-rs mpq extract archive.mpq --filter "Interface/Icons/*" --output ./icons --preserve-paths
Tree Visualization
Visualize the structure of MPQ archives using the tree command:
# Basic tree view
warcraft-rs mpq tree archive.mpq
# Limit depth for large archives
warcraft-rs mpq tree archive.mpq --depth 3
# Compact mode without metadata
warcraft-rs mpq tree archive.mpq --compact
# Show external file references
warcraft-rs mpq tree archive.mpq --show-refs
# No color output for piping
warcraft-rs mpq tree archive.mpq --no-color
# Hide file sizes and metadata
warcraft-rs mpq tree archive.mpq --no-metadata
The tree view shows:
- 📦 Archive structure with header, tables, and files
- 📁 Directory hierarchy
- 📄 Individual files with sizes
- 🔗 External file references (e.g., M2 models referencing .skin files)
- 🎨 Color-coded output for better readability
Data Extraction Workflow
# Extract database files
warcraft-rs mpq extract common.mpq --filter "*.dbc" --output ./dbc_analysis --preserve-paths
# Extract models and textures
warcraft-rs mpq extract model.mpq --filter "*.m2" --output ./models --preserve-paths
warcraft-rs mpq extract texture.mpq --filter "*.blp" --output ./textures --preserve-paths
# Extract UI resources
warcraft-rs mpq extract interface.mpq --filter "Interface/*" --output ./ui_resources --preserve-paths
Archive Rebuild and Verification Workflow
Complete workflow for rebuilding and verifying MPQ archives:
# 1. Analyze original archive
warcraft-rs mpq info original.mpq
warcraft-rs mpq list original.mpq --long | head -20
# 2. Rebuild with format preservation
warcraft-rs mpq rebuild original.mpq rebuilt.mpq
# 3. Verify rebuild accuracy
warcraft-rs mpq compare original.mpq rebuilt.mpq --content-check
# 4. Upgrade to modern format
warcraft-rs mpq rebuild original.mpq modern.mpq --upgrade-to v4
# 5. Compare format differences
warcraft-rs mpq compare original.mpq modern.mpq --metadata-only
# 6. Verify file integrity after upgrade
warcraft-rs mpq compare original.mpq modern.mpq --content-check --filter "*.dbc"
Archive Migration and Optimization
# Upgrade old archives to V4 format with better compression
warcraft-rs mpq rebuild old_v1.mpq optimized.mpq \
--upgrade-to v4 \
--compression lzma \
--block-size 6 \
--verify
# Compare before and after optimization
warcraft-rs mpq compare old_v1.mpq optimized.mpq --output summary
# Batch upgrade multiple archives
for archive in *.mpq; do
echo "Upgrading $archive..."
warcraft-rs mpq rebuild "$archive" "v4_${archive}" --upgrade-to v4
warcraft-rs mpq compare "$archive" "v4_${archive}" --metadata-only
done
WDL Subcommand Usage
Note: WDL support requires enabling the WDL feature when building or running the CLI:
# Build with WDL support cargo build --features wdl cargo build --features full # Includes all format features # Run with WDL support cargo run --features wdl -- wdl validate Azeroth.wdl cargo run --features full -- wdl validate Azeroth.wdl
The warcraft-rs tool also provides WDL (World of Warcraft Low-resolution terrain) file operations:
WDL Validation
# Validate WDL file structure
warcraft-rs wdl validate Azeroth.wdl
# Validate with verbose output
warcraft-rs wdl validate Azeroth.wdl --verbose
# Validate multiple files
warcraft-rs wdl validate *.wdl
WDL Information
# Show basic WDL information
warcraft-rs wdl info Azeroth.wdl
# Detailed information with tile data
warcraft-rs wdl info Azeroth.wdl --detailed
# Show version and format details
warcraft-rs wdl info Azeroth.wdl --show-version
WDL Conversion
# Convert WDL to heightmap image
warcraft-rs wdl convert Azeroth.wdl --output azeroth_heightmap.png
# Convert to grayscale heightmap
warcraft-rs wdl convert Azeroth.wdl --format grayscale --output heightmap.png
# Convert to colorized heightmap
warcraft-rs wdl convert Azeroth.wdl --format color --output colorized.png
# Specify custom output directory
warcraft-rs wdl convert Azeroth.wdl --output-dir ./heightmaps/
WDL Tree Visualization
Visualize the structure of WDL files:
# Basic tree view showing chunk structure
warcraft-rs wdl tree Azeroth.wdl
# Limit depth for focused view
warcraft-rs wdl tree Azeroth.wdl --depth 2
# Show external file references (WMO models)
warcraft-rs wdl tree Azeroth.wdl --show-refs
# Compact mode for quick overview
warcraft-rs wdl tree Azeroth.wdl --compact
# No color output (for piping)
warcraft-rs wdl tree Azeroth.wdl --no-color
The tree view shows:
- 📦 WDL file structure with all chunks
- 🗂️ Chunk hierarchy (MVER, MAOF, MARE, etc.)
- 📊 Chunk sizes and metadata
- 🏛️ WMO model references if present
- 🎨 Color-coded output for readability
WDL Batch Operations
# Convert all WDL files in a directory
for wdl in *.wdl; do
echo "Converting $wdl..."
warcraft-rs wdl convert "$wdl" --output "${wdl%.wdl}_heightmap.png"
done
# Validate all WDL files and generate report
echo "WDL Validation Report" > wdl_report.txt
for wdl in *.wdl; do
echo "=== $wdl ===" >> wdl_report.txt
warcraft-rs wdl validate "$wdl" >> wdl_report.txt 2>&1
echo >> wdl_report.txt
done
# Extract WDL files from MPQ and convert
warcraft-rs mpq extract world.mpq --filter "*.wdl" --output ./extracted_wdl/
for wdl in ./extracted_wdl/*.wdl; do
warcraft-rs wdl convert "$wdl" --output "${wdl%.wdl}_heightmap.png"
done
Archive Comparison and Analysis
# Compare different game versions
warcraft-rs mpq compare wow_1.12.1_dbc.mpq wow_2.4.3_dbc.mpq --detailed
# Find differences in specific content
warcraft-rs mpq compare original.mpq modified.mpq --filter "*.dbc" --content-check
# Quick metadata comparison for multiple archives
for target in rebuilt_*.mpq; do
original="${target/rebuilt_/}"
if [[ -f "$original" ]]; then
echo "=== Comparing $original vs $target ==="
warcraft-rs mpq compare "$original" "$target" --metadata-only
fi
done
Quality Assurance Workflow
# Complete QA workflow for archive processing
original="source.mpq"
rebuilt="rebuilt.mpq"
echo "=== Quality Assurance Workflow ==="
# 1. Validate original archive integrity
echo "1. Validating original archive..."
warcraft-rs mpq validate "$original"
# 2. Rebuild archive
echo "2. Rebuilding archive..."
warcraft-rs mpq rebuild "$original" "$rebuilt" --verify
# 3. Compare archives thoroughly
echo "3. Comparing archives..."
warcraft-rs mpq compare "$original" "$rebuilt" --content-check --output summary
# 4. Spot check random files
echo "4. Spot checking files..."
files=$(warcraft-rs mpq list "$original" | shuf | head -5)
for file in $files; do
echo "Checking: $file"
# Extract and compare individual files if needed
done
echo "=== QA Complete ==="
Error Handling
Common errors and solutions:
# File not found
Error: Failed to open archive: nonexistent.mpq
# Solution: Check file path and permissions
# No listfile warning
Warning: No (listfile) found - cannot enumerate files
# Note: You can still extract specific files if you know their names
# Archive format errors
Error: Invalid MPQ header
# Solution: Check if file is actually an MPQ archive
# Blizzard archive warnings (informational only)
Warning: Attributes file size mismatch: ... difference=-28 (tolerating for compatibility)
# Note: This is normal for all official WoW archives - they work perfectly
Performance Tips
- Use filters to reduce processing time when working with large archives
- Enable quiet mode (
-q) for scripting to reduce output overhead - Extract to SSD for better performance with many small files
- Use specific file names when possible instead of extracting everything
- Enable verbose mode (
-v) for debugging when commands fail
MPQ Digital Signatures Guide
This guide covers the digital signature functionality in MPQ archives, including verification and generation of signatures for archive integrity protection.
Overview
MPQ archives support digital signatures to ensure file integrity and authenticity. There are two types of signatures:
Weak Signatures (MPQ v1+)
- Algorithm: 512-bit RSA with MD5 hash
- Storage: Internal
(signature)file within the archive - Size: 72 bytes (8-byte header + 64-byte signature)
- Support: Full verification and generation support
Strong Signatures (MPQ v2+)
- Algorithm: 2048-bit RSA with SHA-1 hash
- Storage: Appended after archive data with “NGIS” header
- Size: 260 bytes (4-byte header + 256-byte signature)
- Support: Verification only (generation requires private key)
Signature Verification
Checking Archive Signatures
#![allow(unused)]
fn main() {
use wow_mpq::Archive;
// Open an archive and check its signature
let archive = Archive::open("signed.mpq")?;
match archive.verify_signature()? {
SignatureStatus::None => println!("No signature present"),
SignatureStatus::Weak => println!("Valid weak signature (512-bit RSA)"),
SignatureStatus::Strong => println!("Valid strong signature (2048-bit RSA)"),
SignatureStatus::Invalid => println!("WARNING: Invalid signature detected!"),
}
}
Manual Signature Verification
For more control over the verification process:
#![allow(unused)]
fn main() {
use wow_mpq::crypto::{verify_weak_signature_stormlib, parse_weak_signature, SignatureInfo};
use std::io::Cursor;
// Read the (signature) file from the archive
let signature_data = archive.read_file("(signature)")?;
let signature = parse_weak_signature(&signature_data)?;
// Create signature info
let sig_info = SignatureInfo::new_weak(
0, // Archive start offset
archive_size, // Archive size (excluding signature)
signature_file_pos, // Position of (signature) file
signature_data.len() as u64,// Size of (signature) file
signature_data.clone(),
);
// Verify the signature
let archive_data = std::fs::read("signed.mpq")?;
let valid = verify_weak_signature_stormlib(
Cursor::new(&archive_data),
&signature,
&sig_info
)?;
if valid {
println!("Signature is valid!");
} else {
println!("Signature verification failed!");
}
}
Signature Generation
Generating Weak Signatures
Weak signatures can be generated using the well-known Blizzard private key:
#![allow(unused)]
fn main() {
use wow_mpq::crypto::{generate_weak_signature, SignatureInfo, WEAK_SIGNATURE_FILE_SIZE};
use wow_mpq::ArchiveBuilder;
use std::io::Cursor;
// Create an archive
let mut builder = ArchiveBuilder::new();
builder
.add_file("readme.txt", b"Hello, World!")?
.add_file("data.bin", b"Binary data here")?;
// Build the archive to memory
let archive_data = builder.build_to_vec()?;
let archive_size = archive_data.len() as u64;
// Create signature info
let sig_info = SignatureInfo::new_weak(
0, // Archive start
archive_size, // Archive size
archive_size, // Signature position (at end)
WEAK_SIGNATURE_FILE_SIZE as u64, // Signature file size (72 bytes)
vec![],
);
// Generate the signature
let signature_file = generate_weak_signature(
Cursor::new(&archive_data),
&sig_info
)?;
// Now you can append the signature to the archive
// or add it as a "(signature)" file when rebuilding
}
Adding Signatures During Archive Creation
The recommended approach is to add signatures during archive creation:
#![allow(unused)]
fn main() {
use wow_mpq::{ArchiveBuilder, RebuildOptions, rebuild_archive};
// Method 1: Using rebuild with signature generation
let options = RebuildOptions::new()
.add_weak_signature(true); // Enable weak signature generation
rebuild_archive("unsigned.mpq", "signed.mpq", &options)?;
// Method 2: Manual signature addition
let mut builder = ArchiveBuilder::new();
// Add your files
builder.add_file("content.txt", b"Important data")?;
// The signature will be generated and added during build
// when the appropriate flag is set
}
Technical Details
Hash Calculation
Signatures are calculated over the entire archive data, excluding the signature area itself:
- Weak Signatures: Use MD5 hash over 64KB chunks
- Strong Signatures: Use SHA-1 hash over 64KB chunks
- Exclusion Area: The signature file area is zeroed during hash calculation
StormLib Compatibility
The implementation maintains full compatibility with StormLib’s signature format:
#![allow(unused)]
fn main() {
// Hash calculation uses 64KB chunks (DIGEST_UNIT_SIZE)
const DIGEST_UNIT_SIZE: usize = 0x10000;
// Signature areas are zeroed, not skipped
// Multi-byte values use little-endian byte order
// RSA signatures are stored in little-endian format
}
Signature File Format
Weak Signature File (72 bytes)
Offset Size Description
------ ---- -----------
0x00 8 Unknown header (usually zeros)
0x08 64 RSA signature (little-endian)
Strong Signature Block (260 bytes)
Offset Size Description
------ ---- -----------
0x00 4 "NGIS" header (0x5349474E reversed)
0x04 256 RSA signature (little-endian)
Common Use Cases
Verifying Downloaded Archives
#![allow(unused)]
fn main() {
use wow_mpq::Archive;
fn verify_download(path: &str) -> Result<bool, Box<dyn std::error::Error>> {
let archive = Archive::open(path)?;
match archive.verify_signature()? {
SignatureStatus::None => {
println!("Warning: Archive has no signature");
Ok(false)
}
SignatureStatus::Invalid => {
println!("ERROR: Archive signature is invalid!");
Ok(false)
}
_ => {
println!("Archive signature verified successfully");
Ok(true)
}
}
}
}
Creating Signed Patches
#![allow(unused)]
fn main() {
use wow_mpq::{ArchiveBuilder, RebuildOptions};
fn create_signed_patch(files: Vec<(&str, Vec<u8>)>) -> Result<(), Box<dyn std::error::Error>> {
let mut builder = ArchiveBuilder::new();
// Add patch files
for (name, data) in files {
builder.add_file(name, data)?;
}
// Build with signature
let options = RebuildOptions::new()
.add_weak_signature(true);
builder.build_with_options("patch.mpq", &options)?;
Ok(())
}
}
Batch Signature Verification
#![allow(unused)]
fn main() {
use wow_mpq::Archive;
use std::path::Path;
fn verify_all_archives(directory: &Path) -> Result<(), Box<dyn std::error::Error>> {
for entry in std::fs::read_dir(directory)? {
let entry = entry?;
let path = entry.path();
if path.extension() == Some("mpq".as_ref()) {
print!("Checking {:?}... ", path.file_name().unwrap());
match Archive::open(&path) {
Ok(archive) => {
match archive.verify_signature()? {
SignatureStatus::None => println!("no signature"),
SignatureStatus::Weak => println!("weak signature OK"),
SignatureStatus::Strong => println!("strong signature OK"),
SignatureStatus::Invalid => println!("INVALID SIGNATURE!"),
}
}
Err(e) => println!("failed to open: {}", e),
}
}
}
Ok(())
}
}
Best Practices
- Always Verify Signatures: Check signatures on downloaded or received archives
- Sign Distribution Archives: Add signatures to archives you distribute
- Use Appropriate Signature Type:
- Weak signatures for compatibility with older tools
- Strong signatures for maximum security (when possible)
- Handle Missing Signatures Gracefully: Not all archives have signatures
- Log Verification Results: Keep audit trails of signature checks
Limitations
- Strong Signature Generation: Requires Blizzard’s private key (not publicly available)
- Signature Modification: Cannot modify signatures on existing archives without rebuilding
- Performance: Signature verification requires reading the entire archive
CLI Usage
The warcraft-rs CLI supports signature operations:
# Validate archive signature
warcraft-rs mpq validate archive.mpq --check-checksums
# Rebuild with signature
warcraft-rs mpq rebuild input.mpq output.mpq --add-signature
# Show signature information
warcraft-rs mpq info archive.mpq --show-signature
References
StormLib vs wow-mpq: Technical Differences Guide
This guide outlines the technical differences between StormLib (the reference C++ implementation) and wow-mpq (our Rust implementation). Understanding these differences helps users migrate from StormLib or implement missing features.
Overview
StormLib has evolved over 20+ years to handle edge cases, game-specific quirks, and optimizations. The wow-mpq implementation provides a Rust alternative focusing on core MPQ functionality with compatibility with StormLib.
Key Achievements
- ✅ Bidirectional Archive Compatibility: StormLib can read wow-mpq archives and vice versa
- ✅ Full V1-V4 Format Support: All MPQ versions work seamlessly between implementations
- ✅ HET/BET Table Compatibility: V3+ archives with extended tables work perfectly
- ✅ Attributes File Support: Both 120-byte (wow-mpq) and 149-byte (StormLib) formats
- ✅ Blizzard Archive Support: Handles all official WoW archives (1.12.1 - 5.4.8)
1. Header Structure and Parsing
StormLib Approach
- Unified structure: Single header struct containing all fields for all versions
- 64-bit fields: Uses 64-bit fields from the start for forward compatibility
- Platform handling: Special logic for different platforms and file types
- Fields include:
file_offset_maskfor version-specific offset handlingraw_chunk_sizefor MD5 calculation- Separate 64-bit size fields for all tables
wow-mpq Approach
- Version-specific: Optional fields added progressively based on version
- Modular design: V4 data wrapped in separate struct
- Simpler parsing: Focus on standard MPQ format without platform quirks
#![allow(unused)]
fn main() {
// StormLib style (conceptual)
struct MpqHeader {
signature: u32,
header_size: u32,
archive_size: u32,
format_version: u16,
sector_size: u16,
// ... all fields for all versions ...
raw_chunk_size: u32, // Always present
file_offset_mask: u64, // Calculated based on version
}
// wow-mpq style
struct MpqHeader {
// Core fields always present
header_size: u32,
archive_size: u32,
format_version: FormatVersion,
// Version-specific fields as Options
v4_data: Option<MpqHeaderV4Data>,
}
}
2. Archive Opening Workflow
StormLib: Complex 9-Step Process
- Parameter validation with checks
- File size validation with platform-specific handling
- Header search with game type detection:
- AVI file detection for Warcraft III cinematics
- PE header analysis for DLL files
- Map type detection (Starcraft, Warcraft III, etc.)
- Header processing with compatibility quirks
- Cryptography initialization including storm buffer
- Table loading with defragmentation
- File table building with correlation
- Internal files loading (listfile, attributes)
- Finalization with malformed archive detection
wow-mpq: Streamlined Process
- Basic parameter validation
- Find MPQ header at aligned offsets
- Parse header based on version
- Load hash/block tables or HET/BET tables
- Build file index
Missing features:
- Game-specific file type detection
- Automatic defragmentation
- Malformed archive recovery
- Protection scheme bypasses
3. HET/BET Table Implementation
StormLib Features
- Hash algorithms: Uses Jenkins hashlittle2 for HET, Jenkins one-at-a-time for BET
- Jenkins hash masking: Uses
and_mask_64andor_mask_64for optimization - Bit-packed storage: Custom
BitArrayimplementation for memory efficiency - Detailed bit tracking: Precise bit positions for all fields
- Name hash separation: Stores name hashes separately from file indices
#![allow(unused)]
fn main() {
// StormLib approach
struct HetTable {
bet_indexes: BitArray, // Bit-packed file indexes
name_hashes: Vec<u8>, // Separate name hash storage
and_mask_64: u64, // Hash optimization mask
or_mask_64: u64, // Hash optimization mask
// Detailed bit field tracking
name_hash_bit_size: u32,
index_size_total: u32,
index_size: u32,
}
}
wow-mpq Implementation
- Simpler structure without optimization masks
- Standard byte arrays instead of bit packing
- Less granular field tracking
4. Encryption and Key Detection
StormLib: Advanced Key Recovery
- Filename-based: Standard key calculation from filename
- Sector size detection: Analyze sector boundaries
- Content pattern matching:
- WAVE files:
0x46464952signature - EXE files:
0x00905A4Dsignature - XML files:
0x6D783F3Csignature
- WAVE files:
- Brute force recovery: Try all 256 possible keys using known patterns
#![allow(unused)]
fn main() {
// StormLib's key detection approach
fn detect_encryption_key(file: &MpqFile) -> Option<u32> {
// Try filename-based key
if let Some(key) = try_filename_key(file) {
return Some(key);
}
// Try sector size detection
if let Some(key) = detect_key_by_sector_size(file) {
return Some(key);
}
// Try known content patterns
if let Some(key) = detect_key_by_content(file) {
return Some(key);
}
None
}
}
wow-mpq: Basic Encryption
- Standard filename-based key calculation
- No automatic key detection
- No content-based recovery
5. Sector-Based File Reading
StormLib Optimizations
- Compression detection: Analyzes sector size to determine if compressed
- ADLER32 verification: Validates each sector’s checksum
- Dynamic loading: Loads sector offset tables on demand
- Caching: LRU cache for frequently accessed sectors
wow-mpq Implementation
- Basic sector reading and decompression
- No checksum verification
- Simple sequential processing
6. Special Behaviors and Edge Cases
StormLib: Game-Specific Handling
Map Type Detection
#![allow(unused)]
fn main() {
enum MapType {
NotRecognized,
AviFile, // Warcraft III cinematics
Starcraft, // .scm, .scx extensions
Warcraft3, // HM3W signature
Starcraft2, // .s2ma, .sc2map extensions
}
}
Protection Bypasses
- BOBA protector: Handles negative table offsets
- w3xMaster: Fixes invalid hi-word positions
- Integer overflow: Masks table sizes to prevent overflow
- Starcraft Beta: Special handling for specific archive sizes
wow-mpq: Standard Format Only
- No game-specific detection
- No protection bypass mechanisms
- Assumes well-formed archives
7. Memory Management and Performance
StormLib Optimizations
- Custom allocators: Specialized allocation for large tables
- Memory mapping:
MmapStreamfor large read-only archives - Sector caching: LRU cache with configurable size
- Thread safety: parking_lot mutexes
- Bit packing: Memory-efficient storage for table data
#![allow(unused)]
fn main() {
// StormLib's optimized approach
pub struct ThreadSafeMpqArchive {
archive: Arc<ParkingRwLock<MpqArchive>>,
sector_cache: Arc<Mutex<SectorCache>>,
mmap_stream: Option<MmapStream>,
}
}
wow-mpq: Standard Rust Patterns
- Standard
Arc<Mutex<>>for thread safety - No specialized memory mapping
- Basic caching strategies
- Standard allocators
8. Error Handling and Recovery
StormLib: Recovery
- Malformed archive detection: Marks archives as read-only
- Automatic recovery: Attempts to work with damaged archives
- Detailed error context: Extensive error information
- Graceful degradation: Continues operation with reduced functionality
wow-mpq: Fail-Fast Approach
- Returns errors on malformed data
- No automatic recovery mechanisms
- Clear error messages
- Predictable behavior
9. File Operations
StormLib: Full Read/Write Support
Advanced Write Features
- Atomic writes: File writer with Drop trait finalization
- Free space management: Finds optimal positions for new files
- Incremental updates: Modifies archives without full rebuild
- Automatic compression: Chooses compression method
Metadata Support
- MD5 calculation during write
- CRC32 for integrity
- Timestamp preservation
- Extended attributes
wow-mpq: Read-Focused Design
- Full read support
- Basic write functionality
- Archive creation support
- Limited in-place modification
10. Additional StormLib Features
Features Not in wow-mpq
- Patch metadata: Special support for WoW patch chains
- Compile-time tables: Storm buffer as const array
- Extended attributes: File timestamps and custom metadata
- Sparse compression: RLE algorithm for sparse data
- LZMA compression: LZMA compression support
- RSA signatures: Digital signature verification
- Listfile management: Automatic listfile updates
- Weak signature verification: Support for older signatures
Implementation Considerations
If you need these features, consider:
- Implementing them as separate crates
- Using FFI to call StormLib for specific operations
- Contributing implementations to wow-mpq
Path Separator Handling
Automatic Conversion
Both StormLib and wow-mpq automatically convert forward slashes to backslashes:
#![allow(unused)]
fn main() {
// All of these work identically:
archive.find_file("Units/Human/Footman.mdx")?; // Forward slashes
archive.find_file("Units\\Human\\Footman.mdx")?; // Backslashes
archive.find_file("Units/Human\\Footman.mdx")?; // Mixed
// The hash functions normalize all paths to use backslashes internally
}
CLI Path Conversion
The CLI tools properly convert MPQ paths to system paths:
# Extract with proper path conversion
warcraft-rs mpq extract archive.mpq --output ./extracted
# Files like "Units\Human\Footman.mdx" become:
# - Linux/macOS: ./extracted/Units/Human/Footman.mdx
# - Windows: .\extracted\Units\Human\Footman.mdx
Migration Guide
From StormLib to wow-mpq
- Check feature requirements: Ensure wow-mpq supports your needs
- Update error handling: Adapt to Rust’s Result type
- Adjust for missing features: Implement workarounds or alternatives
- Test thoroughly: Especially with protected or malformed archives
Common Migration Issues
#![allow(unused)]
fn main() {
// StormLib style
HANDLE hMpq;
if (!SFileOpenArchive("archive.mpq", 0, 0, &hMpq)) {
// Handle error
}
// wow-mpq style
let archive = match Archive::open("archive.mpq") {
Ok(a) => a,
Err(e) => {
// Handle error
return;
}
};
}
Performance Comparison
| Feature | StormLib | wow-mpq |
|---|---|---|
| Memory usage | Lower (bit packing) | Higher (standard types) |
| Thread safety | Manual with callbacks | Built-in with Rust |
| Large archives | Memory mapped | Standard I/O |
| Caching | Configurable LRU | Basic |
| Startup time | Slower (more checks) | Faster |
FFI Compatibility Layer
Storm-FFI: StormLib API in Rust
The storm-ffi crate provides a complete StormLib-compatible C API, allowing C/C++ applications to use wow-mpq as a drop-in replacement for StormLib:
// Existing StormLib code works unchanged
HANDLE hMpq;
if (SFileOpenArchive("archive.mpq", 0, MPQ_OPEN_READ_WRITE, &hMpq)) {
// Add, remove, rename files
SFileAddFile(hMpq, "new.txt", "data/new.txt", MPQ_FILE_COMPRESS);
SFileRemoveFile(hMpq, "old.txt", 0);
SFileRenameFile(hMpq, "file.txt", "renamed.txt");
// Compact archive
SFileCompactArchive(hMpq, NULL, false);
SFileCloseArchive(hMpq);
}
Supported StormLib Functions
Archive Operations:
- ✅
SFileOpenArchive/SFileCloseArchive - ✅
SFileCreateArchive/SFileCreateArchive2 - ✅
SFileFlushArchive/SFileCompactArchive - ✅
SFileGetArchiveName/SFileGetFileInfo
File Operations:
- ✅
SFileOpenFileEx/SFileCloseFile - ✅
SFileReadFile/SFileGetFileSize - ✅
SFileAddFile/SFileAddFileEx - ✅
SFileRemoveFile/SFileRenameFile - ✅
SFileCreateFile/SFileWriteFile/SFileFinishFile
Search Operations:
- ✅
SFileFindFirstFile/SFileFindNextFile/SFileFindClose - ✅
SFileEnumFiles(callback-based enumeration) - ✅
SFileHasFile(check file existence)
Verification:
- ✅
SFileVerifyFile/SFileVerifyArchive - ✅
SFileExtractFile(extract to disk)
Attributes:
- ✅
SFileGetFileAttributes/SFileSetFileAttributes - ✅
SFileUpdateFileAttributes/SFileFlushAttributes
Implementation Differences
While the API is identical, there are some implementation differences:
- Thread Safety: Built-in thread safety without manual synchronization
- Error Handling: Thread-local error storage instead of global state
- Memory Management: Automatic memory management internally
- Performance: Generally faster due to Rust optimizations
Recommendations
Use StormLib when
- Working with protected archives
- Need game-specific features
- Require key recovery
- Handle malformed archives
- Need all compression methods
Use wow-mpq when
- Want memory safety
- Prefer Rust ecosystem
- Work with standard MPQ files
- Value API design
- Need async support (future)
Use storm-ffi when
- Migrating existing C/C++ code from StormLib
- Need drop-in StormLib replacement
- Want Rust’s safety with C compatibility
- Require specific StormLib API functions
Blizzard Archive Compatibility
Attributes File Deviation
All official Blizzard MPQ archives have a consistent deviation from the specification:
- Expected: Attributes files sized exactly for the archive’s file count
- Actual: Blizzard adds exactly 28 extra zero bytes at the end
- Handling: Both StormLib and wow-mpq gracefully handle this deviation
#![allow(unused)]
fn main() {
// wow-mpq automatically detects and handles Blizzard's format:
let archive = Archive::open("Data/patch.mpq")?;
// Warning logged: "Attributes file size mismatch: ... difference=-28"
// But archive works perfectly!
}
Tested WoW Versions
Full compatibility confirmed with official archives from:
- WoW 1.12.1 (Vanilla)
- WoW 2.4.3 (The Burning Crusade)
- WoW 3.3.5a (Wrath of the Lich King)
- WoW 4.3.4 (Cataclysm)
- WoW 5.4.8 (Mists of Pandaria)
Contributing Missing Features
If you need StormLib features in wow-mpq:
- Open an issue: Discuss the feature need
- Reference StormLib: Link to relevant StormLib code
- Propose design: Suggest Rust-idiomatic implementation
- Submit PR: Include tests and documentation
See Also
WoW Patch Chain Summary
This document provides an overview of how patch chaining works in each World of Warcraft version from 1.12.1 through 5.4.8, based on analysis of actual game archives.
Overview
WoW’s MPQ patch chain system allows the game to override base content with patches, following a strict priority order. Higher priority archives override files from lower priority archives, enabling Blizzard to update game content without redistributing entire archives.
Version-by-Version Analysis
WoW 1.12.1 (Vanilla)
Archive Structure:
- 7 total archives (simple structure)
- Base:
dbc.MPQ,interface.MPQ,model.MPQ,sound.MPQ,texture.MPQ - Patches:
patch.MPQ,patch-2.MPQ
Loading Order:
Priority 0-4: Base archives
Priority 1000+: Patch archives (override everything)
Key Characteristics:
- Simple, straightforward patching
- No locale-specific archives
- Spell.dbc evolution: 14,502 → 22,357 records (+7,855 spells)
- File size growth: 9.1MB → 15.8MB
Example Override:
DBFilesClient\Spell.dbc:
dbc.MPQ (base) → patch.MPQ → patch-2.MPQ (final)
WoW 2.4.3 (The Burning Crusade)
Archive Structure:
- 8+ archives (introduces locale system)
- Base:
common.MPQ,expansion.MPQ - Locale:
locale-{LANG}.MPQ,expansion-locale-{LANG}.MPQ - Patches:
patch.MPQ,patch-2.MPQ,patch-{LANG}.MPQ,patch-{LANG}-2.MPQ
Loading Order:
Priority 0-1: Base archives
Priority 100-101: Locale base archives
Priority 1000-1001: General patches
Priority 2000-2001: Locale patches (highest priority)
Key Characteristics:
- Introduces locale-specific override system
- MPQ v2 format
- 185 unique DBCs with 134 having locale overrides
- Spell.dbc: 22.2MB → 25.7MB through patches
- 28,315 spells (864 bytes per record)
Locale Override Example:
DBFilesClient\Spell.dbc:
locale-enUS.MPQ → patch-enUS.MPQ → patch-enUS-2.MPQ (final)
WoW 3.3.5a (Wrath of the Lich King)
Archive Structure:
- 13 archives (most organized structure)
- Base:
common.MPQ,common-2.MPQ,expansion.MPQ,lichking.MPQ - Locale:
locale-{LANG}.MPQ,expansion-locale-{LANG}.MPQ,lichking-locale-{LANG}.MPQ - Patches:
patch.MPQ,patch-2.MPQ,patch-3.MPQ(+ locale versions)
Loading Order (TrinityCore Definitive):
Priority 0-3: Base archives (in order)
Priority 100-102: Locale base archives
Priority 1000-1002: General patches
Priority 2000-2002: Locale patches (highest)
Key Characteristics:
- Most structured patch hierarchy
- Clear content separation (base, TBC, WotLK)
- WotLK features: Achievements, Vehicles, Glyphs
- Largest archives: common.MPQ (2.7GB), lichking.MPQ (2.4GB)
New Systems:
- Achievement system (
Achievement.dbc) - Vehicle mechanics (
Vehicle.dbc,VehicleSeat.dbc) - Glyph system (
GlyphProperties.dbc) - Dungeon Finder (
BattlemasterList.dbc)
WoW 4.3.4 (Cataclysm)
Archive Structure:
- Variable (10-50+ archives depending on patch level)
- Content-based organization:
art.MPQ,sound.MPQ,world.MPQ,model.MPQ - Expansions:
expansion1.MPQthroughexpansion3.MPQ - Patches:
base-{1-20}.MPQ,wow-update-{13156-16000+}.MPQ
Loading Order:
Priority 0-99: Base archives (by content type)
Priority 100-199: Locale base archives
Priority 1000-1999: Base patches
Priority 2000-2999: Locale patches
Priority 3000+: wow-update archives
Priority 4000+: Locale wow-update archives
Key Characteristics:
- Switched to MPQ v4 format
- Introduced DB2 format alongside DBC
- Content reorganization (no more single dbc.MPQ)
- Complex patching with numbered base patches
- Introduced
wow-update-#####.MPQsystem
New Features:
- DB2 format (
Item.db2,Item-sparse.db2) - Guild system revamp
- Flying in old world
- Phasing technology
WoW 5.4.8 (Mists of Pandaria)
Archive Structure:
- Most complex (potentially 100+ archives)
- Base:
art.MPQ,expansion1-4.MPQ,misc.MPQ,model.MPQ,sound.MPQ,texture.MPQ,world.MPQ,world2.MPQ - Patches:
base-{1-50}.MPQ,wow-update-{13156-18500}.MPQ - Full locale structure for each component
Loading Order:
Priority 0-99: Base archives
Priority 100-199: Locale base archives
Priority 1000-2999: Base patches & locale patches
Priority 3000-3999: wow-update archives
Priority 4000+: Locale wow-update archives
Key Characteristics:
- Peak MPQ complexity before CASC
- Extensive use of wow-update system
- 11 character classes (added Monk)
- Preparing for CASC transition (WoW 6.0)
MoP Systems:
- Pet Battles (
BattlePetSpecies.db2,BattlePetAbility.db2) - Scenarios (
Scenario.dbc,ScenarioStep.dbc) - Item upgrades (
ItemUpgrade.dbc) - Account-wide collections
Priority System Rules
- Higher priority always wins: Files in higher priority archives override lower priority
- Locale overrides general: Locale-specific archives override their general counterparts
- Patches override base: All patches override all base content
- Loading order matters: Archives must be loaded in the correct sequence
Best Practices
- Always follow the official loading order (see TrinityCore for 3.3.5a reference)
- Test with real game data to verify patch chains work correctly
- Handle missing archives gracefully - not all installations have all patches
- Cache file lookups for performance in large patch chains
- Verify file resolution matches the game client behavior
Evolution Summary
- Vanilla: Simple base + patch structure
- TBC: Added locale system and override hierarchy
- WotLK: Perfected the structured approach with clear priorities
- Cataclysm: Reorganized by content type, added DB2 format
- MoP: Peak complexity, preparing for CASC storage system
- WoD (6.0)+: Switched to CASC, abandoning MPQ
The progression shows Blizzard’s evolution from simple file patching to increasingly sophisticated content delivery, ultimately leading to the complete replacement of MPQ with the CASC system in Warlords of Draenor.
ADT CLI Usage Guide 🏔️
This guide covers the ADT (terrain) commands available in the warcraft-rs CLI tool.
Overview
The ADT command provides tools for working with World of Warcraft terrain files, including parsing, validation, conversion, and visualization.
Installation
Ensure you have the ADT feature enabled when building warcraft-rs:
cargo install warcraft-rs --features adt
# Or for all features:
cargo install warcraft-rs --features full
Available Commands
Info Command
Display detailed information about an ADT file:
# Basic information
warcraft-rs adt info terrain.adt
# Detailed chunk information
warcraft-rs adt info terrain.adt --detailed
Output includes:
- File version and format
- Terrain chunk count and height range
- Texture references
- Model and WMO placements
- Water information
- Split file detection (Cataclysm+)
Validate Command
Check ADT files for errors and inconsistencies:
# Standard validation
warcraft-rs adt validate terrain.adt
# Strict validation with warnings
warcraft-rs adt validate terrain.adt --level strict --warnings
# Basic validation (faster)
warcraft-rs adt validate terrain.adt --level basic
Validation levels:
- basic: Essential structure checks
- standard: Full validation (default)
- strict: All checks including best practices
Convert Command
Convert ADT files between different WoW versions:
# Convert to Cataclysm format
warcraft-rs adt convert vanilla.adt cataclysm.adt --to cataclysm
# Convert to WotLK format
warcraft-rs adt convert cata.adt wotlk.adt --to wotlk
# Supported versions: classic, tbc, wotlk, cataclysm
Version conversion handles:
- Chunk format changes
- Water system updates (MCLQ → MH2O)
- Version-specific features
- Data preservation where possible
Tree Command
Visualize ADT structure hierarchically:
# Basic tree view
warcraft-rs adt tree terrain.adt
# Show external file references
warcraft-rs adt tree terrain.adt --show-refs
# Compact view
warcraft-rs adt tree terrain.adt --compact
# Limit depth
warcraft-rs adt tree terrain.adt --depth 2
# No colors (for piping)
warcraft-rs adt tree terrain.adt --no-color
Tree view shows:
- 🏔️ Root ADT file with version info
- 📋 Header chunks (MHDR, MCIN)
- 🌍 Terrain chunks (MCNK) with coordinates and properties
- 🎨 Texture references (MTEX) - lists actual texture filenames
- 🌲 Model data (MMDX/MMID) - shows M2 model filenames with indices
- 🏛️ WMO data (MWMO/MWID) - displays WMO filenames
- 📍 Placements (MDDF/MODF) - shows count of placed objects
- 💧 Water chunks (MH2O) with water chunk count
The enhanced tree view now displays the actual content of texture, model, and WMO chunks, showing filenames and counts rather than just chunk presence.
Extract Command (Optional Feature)
Extract data from ADT files (requires extract feature):
# Extract heightmap
warcraft-rs adt extract terrain.adt --heightmap --output ./extracted
# Extract with specific format
warcraft-rs adt extract terrain.adt --heightmap --heightmap-format png
# Extract texture information
warcraft-rs adt extract terrain.adt --textures
# Extract model placements
warcraft-rs adt extract terrain.adt --models
# Extract everything
warcraft-rs adt extract terrain.adt --all
Supported heightmap formats:
- pgm: Portable GrayMap (default)
- png: PNG image
- tiff: TIFF image
- raw: Raw float data
Batch Command (Optional Feature)
Process multiple ADT files (requires parallel feature):
# Validate all ADT files
warcraft-rs adt batch "World/Maps/Azeroth/*.adt" --output ./results --operation validate
# Convert multiple files
warcraft-rs adt batch "*.adt" --output ./converted --operation convert --to cataclysm
# Use specific thread count
warcraft-rs adt batch "**/*.adt" --output ./output --operation validate --threads 8
Working with Split Files
Cataclysm+ uses split ADT files:
# Main terrain file
warcraft-rs adt info Azeroth_32_48.adt
# Texture data
warcraft-rs adt info Azeroth_32_48_tex0.adt
# Object placement
warcraft-rs adt info Azeroth_32_48_obj0.adt
The tool automatically detects and reports related split files.
Examples
Analyzing a Zone
# Get overview of Elwynn Forest tile
warcraft-rs adt info World/Maps/Azeroth/Azeroth_32_48.adt
# Check for issues
warcraft-rs adt validate World/Maps/Azeroth/Azeroth_32_48.adt --warnings
# Visualize structure
warcraft-rs adt tree World/Maps/Azeroth/Azeroth_32_48.adt --show-refs
Version Migration
# Convert Classic ADT to Cataclysm format
warcraft-rs adt convert classic_terrain.adt cata_terrain.adt --to cataclysm
# Batch convert entire zone
warcraft-rs adt batch "Kalimdor/*.adt" --output ./cata_kalimdor --operation convert --to cataclysm
Data Extraction
# Extract heightmap for terrain editing
warcraft-rs adt extract terrain.adt --heightmap --heightmap-format tiff
# Extract all texture references
warcraft-rs adt extract terrain.adt --textures --output ./texture_data
# Get model placement data
warcraft-rs adt extract terrain.adt --models --output ./model_data
Tips and Best Practices
- Validation First: Always validate ADT files before conversion
- Backup Files: Keep original files when converting versions
- Check Split Files: For Cataclysm+, ensure all split files are present
- Use Appropriate Formats: Choose heightmap format based on your editing tool
- Batch Operations: Use glob patterns for processing multiple files
- Thread Count: For batch operations, use thread count = CPU cores - 1
Troubleshooting
Common Issues
“Failed to parse ADT file”
- Check file is not corrupted
- Ensure it’s a valid ADT file (not WDT/WDL)
- Try basic validation first
“Version conversion failed”
- Some features can’t be converted backwards
- Check source and target version compatibility
- Validate source file first
“Extract command not available”
- Install with
--features extractor--features full - Check feature is enabled in your build
Performance Tips
- Use
--compactfor tree view on large files - Limit
--depthfor quick structure overview - Use
parallelfeature for batch operations - Process files from local disk, not network drives
See Also
- ADT Format Documentation
- ADT Rendering Guide
- Coordinate System
- MPQ Archive Usage - Extract ADT files from game data
🌍 Rendering ADT Terrain
Overview
ADT (Azeroth Data Terrain) files contain the terrain data for World of Warcraft’s
seamless world. Each ADT file represents a 533.33x533.33 yard tile of the game
world, divided into 16x16 chunks. This guide covers how to load, process, and
render ADT terrain using warcraft-rs.
Prerequisites
Before rendering ADT terrain, ensure you have:
- Understanding of 3D graphics programming (OpenGL/Vulkan/DirectX)
- Basic knowledge of terrain rendering techniques
warcraft-rsinstalled with theadtandwdtfeatures enabled- A graphics rendering framework (wgpu, glow, etc.)
- Understanding of height maps and texture blending
- Knowledge of WDT files to determine which ADT tiles exist
Understanding ADT Files
ADT Structure
Each ADT file contains:
- Height map data: Vertex heights for terrain mesh
- Texture information: Up to 4 textures per chunk with alpha maps
- Water data: Lakes, rivers, and oceans
- Shadow maps: Pre-baked shadows
- Vertex colors: Lighting and shading information
- Normal maps: Surface normals for lighting
- Holes: Areas where terrain is not rendered
- Doodad placement: Positions for small objects
- WMO placement: Positions for buildings/large objects
Coordinate System
- ADT tiles are arranged in a grid system
- Each map has 64x64 ADT tiles
- Coordinate format:
World_Map_TileX_TileY.adt - Example:
Azeroth_32_48.adt
Step-by-Step Instructions
1. Discovering ADT Tiles with WDT
Before loading ADT files, use the WDT file to determine which tiles exist:
#![allow(unused)]
fn main() {
use wow_wdt::{Wdt, WdtVersion};
use std::fs::File;
use std::collections::HashSet;
fn discover_adt_tiles(map_name: &str) -> Result<HashSet<(u8, u8)>, Box<dyn std::error::Error>> {
let wdt_path = format!("World/Maps/{}/{}.wdt", map_name, map_name);
let file = File::open(wdt_path)?;
let wdt = Wdt::read(file)?;
let mut existing_tiles = HashSet::new();
// Skip WMO-only maps (dungeons, instances)
if wdt.is_wmo_only() {
println!("Map {} is WMO-only (no terrain tiles)", map_name);
return Ok(existing_tiles);
}
// Find all tiles that have ADT data
for y in 0..64u8 {
for x in 0..64u8 {
if wdt.has_adt(x, y) {
existing_tiles.insert((x, y));
if let Some(area_id) = wdt.get_area_id(x, y) {
println!("Found ADT tile at [{}, {}] - Area ID: {}", x, y, area_id);
}
}
}
}
println!("Map {} has {} ADT tiles", map_name, existing_tiles.len());
Ok(existing_tiles)
}
// Example usage
let existing_tiles = discover_adt_tiles("Azeroth")?;
}
2. Loading ADT Files
#![allow(unused)]
fn main() {
use wow_adt::Adt;
use std::path::Path;
fn load_adt_file(filename: &str) -> Result<Adt, Box<dyn std::error::Error>> {
// Load main terrain file
let adt = Adt::from_path(filename)?;
// For Cataclysm+ files, also load associated split files
let path = Path::new(filename);
let stem = path.file_stem().unwrap().to_str().unwrap();
let dir = path.parent().unwrap();
let tex0_file = dir.join(format!("{}_tex0.adt", stem));
let obj0_file = dir.join(format!("{}_obj0.adt", stem));
// Note: Split files are automatically handled by wow-adt
// These would contain texture and object data respectively
if tex0_file.exists() {
println!("Found texture file: {}", tex0_file.display());
}
if obj0_file.exists() {
println!("Found object file: {}", obj0_file.display());
}
Ok(adt)
}
}
2. Generating Terrain Mesh
#![allow(unused)]
fn main() {
use wow_adt::{Adt, McnkChunk};
#[derive(Debug, Clone)]
struct TerrainVertex {
position: [f32; 3],
normal: [f32; 3],
texcoord: [f32; 2],
vertex_color: [f32; 4],
}
fn generate_terrain_mesh(adt: &Adt) -> Vec<TerrainVertex> {
let mut vertices = Vec::new();
// ADT has up to 256 chunks (16x16)
for (idx, chunk) in adt.mcnk_chunks().iter().enumerate() {
let chunk_x = idx % 16;
let chunk_y = idx / 16;
vertices.extend(generate_chunk_vertices(chunk, chunk_x, chunk_y));
}
vertices
}
fn generate_chunk_vertices(chunk: &McnkChunk, chunk_x: usize, chunk_y: usize) -> Vec<TerrainVertex> {
let mut vertices = Vec::new();
// Each chunk has 9x9 vertices (including corners shared with neighbors)
// Height data is stored in the mcvt subchunk
const VERTS_PER_SIDE: usize = 9;
for y in 0..VERTS_PER_SIDE {
for x in 0..VERTS_PER_SIDE {
let idx = y * VERTS_PER_SIDE + x;
// Calculate world position
let world_x = (chunk_x as f32 * 33.333) + (x as f32 * 4.166);
let world_z = (chunk_y as f32 * 33.333) + (y as f32 * 4.166);
let world_y = chunk.height_map[idx];
// Get vertex normal
let normal = chunk.normals[idx];
// Calculate texture coordinates
let u = x as f32 / 8.0;
let v = y as f32 / 8.0;
// Get vertex color (pre-baked lighting)
let color = chunk.vertex_colors[idx];
vertices.push(TerrainVertex {
position: [world_x, world_y, world_z],
normal: [normal.x, normal.y, normal.z],
texcoord: [u, v],
vertex_color: [color.r, color.g, color.b, color.a],
});
}
}
vertices
}
}
3. Generating Index Buffer
#![allow(unused)]
fn main() {
fn generate_terrain_indices(adt: &Adt) -> Vec<u32> {
let mut indices = Vec::new();
for chunk_idx in 0..256 {
let chunk = &adt.chunks[chunk_idx];
let base_vertex = (chunk_idx * 81) as u32; // 9x9 vertices per chunk
// Check for holes in terrain
let holes = chunk.holes;
// Generate triangles for each quad
for y in 0..8 {
for x in 0..8 {
let quad_idx = y * 8 + x;
// Skip if this quad is a hole
if holes & (1 << quad_idx) != 0 {
continue;
}
// Calculate vertex indices
let tl = base_vertex + (y * 9 + x) as u32;
let tr = tl + 1;
let bl = tl + 9;
let br = bl + 1;
// Create two triangles per quad
// Triangle 1
indices.push(tl);
indices.push(bl);
indices.push(br);
// Triangle 2
indices.push(tl);
indices.push(br);
indices.push(tr);
}
}
}
indices
}
}
4. Loading and Applying Textures
#![allow(unused)]
fn main() {
use wow_adt::{TextureInfo, AlphaMap};
use wow_blp::Blp;
struct TerrainTextures {
diffuse_maps: Vec<TextureId>,
alpha_maps: Vec<TextureId>,
}
fn load_terrain_textures(adt: &Adt, adt_tex: &Adt) -> Result<TerrainTextures, Box<dyn std::error::Error>> {
let mut diffuse_maps = Vec::new();
let mut alpha_maps = Vec::new();
// Load texture filenames
let texture_files = &adt_tex.texture_filenames;
// Load each referenced texture
for filename in texture_files {
let blp = Blp::from_file(filename)?;
let texture_id = upload_texture_to_gpu(&blp);
diffuse_maps.push(texture_id);
}
// Generate alpha maps for texture blending
for chunk in &adt.chunks {
for layer in &chunk.texture_layers[1..] { // Skip first layer (base)
let alpha_texture = create_alpha_texture(&layer.alpha_map);
alpha_maps.push(alpha_texture);
}
}
Ok(TerrainTextures {
diffuse_maps,
alpha_maps,
})
}
fn create_alpha_texture(alpha_map: &AlphaMap) -> TextureId {
// Alpha maps can be compressed or uncompressed
let alpha_data = match alpha_map {
AlphaMap::Uncompressed(data) => data.clone(),
AlphaMap::Compressed(data) => decompress_alpha_map(data),
};
// Upload as single-channel texture
upload_alpha_texture(&alpha_data, 64, 64)
}
}
5. Implementing Texture Blending Shader
// Vertex Shader
#version 450
layout(location = 0) in vec3 position;
layout(location = 1) in vec3 normal;
layout(location = 2) in vec2 texcoord;
layout(location = 3) in vec4 vertex_color;
layout(set = 0, binding = 0) uniform Uniforms {
mat4 view_proj;
vec3 sun_direction;
float time;
};
layout(location = 0) out vec3 world_pos;
layout(location = 1) out vec3 out_normal;
layout(location = 2) out vec2 out_texcoord;
layout(location = 3) out vec4 out_vertex_color;
void main() {
world_pos = position;
out_normal = normal;
out_texcoord = texcoord;
out_vertex_color = vertex_color;
gl_Position = view_proj * vec4(position, 1.0);
}
// Fragment Shader
#version 450
layout(location = 0) in vec3 world_pos;
layout(location = 1) in vec3 normal;
layout(location = 2) in vec2 texcoord;
layout(location = 3) in vec4 vertex_color;
layout(set = 1, binding = 0) uniform sampler2D tex0;
layout(set = 1, binding = 1) uniform sampler2D tex1;
layout(set = 1, binding = 2) uniform sampler2D tex2;
layout(set = 1, binding = 3) uniform sampler2D tex3;
layout(set = 1, binding = 4) uniform sampler2D alpha1;
layout(set = 1, binding = 5) uniform sampler2D alpha2;
layout(set = 1, binding = 6) uniform sampler2D alpha3;
layout(location = 0) out vec4 out_color;
void main() {
// Sample base texture
vec4 color = texture(tex0, texcoord * 8.0);
// Blend additional layers
float a1 = texture(alpha1, texcoord).r;
color = mix(color, texture(tex1, texcoord * 8.0), a1);
float a2 = texture(alpha2, texcoord).r;
color = mix(color, texture(tex2, texcoord * 8.0), a2);
float a3 = texture(alpha3, texcoord).r;
color = mix(color, texture(tex3, texcoord * 8.0), a3);
// Apply vertex color (pre-baked lighting)
color.rgb *= vertex_color.rgb * 2.0;
// Simple diffuse lighting
float NdotL = max(dot(normalize(normal), normalize(vec3(0.5, 1.0, 0.3))), 0.0);
color.rgb *= 0.5 + 0.5 * NdotL;
out_color = color;
}
6. Handling Water
#![allow(unused)]
fn main() {
use wow_adt::{WaterChunk, LiquidType};
struct WaterMesh {
vertices: Vec<WaterVertex>,
indices: Vec<u32>,
liquid_type: LiquidType,
}
#[derive(Debug, Clone)]
struct WaterVertex {
position: [f32; 3],
texcoord: [f32; 2],
depth: f32,
}
fn generate_water_mesh(adt: &Adt) -> Vec<WaterMesh> {
let mut water_meshes = Vec::new();
for chunk in &adt.chunks {
if let Some(water) = &chunk.water {
let mesh = generate_chunk_water(water, chunk);
water_meshes.push(mesh);
}
}
water_meshes
}
fn generate_chunk_water(water: &WaterChunk, chunk: &TerrainChunk) -> WaterMesh {
let mut vertices = Vec::new();
let mut indices = Vec::new();
// Water uses 9x9 grid like terrain
for y in 0..9 {
for x in 0..9 {
let idx = y * 9 + x;
// Get water height and depth
let height = water.height_map[idx];
let depth = water.depth_map[idx];
// Calculate position
let pos_x = chunk.position.x + (x as f32 * 4.166);
let pos_z = chunk.position.z + (y as f32 * 4.166);
vertices.push(WaterVertex {
position: [pos_x, height, pos_z],
texcoord: [x as f32 / 8.0, y as f32 / 8.0],
depth,
});
}
}
// Generate indices (same pattern as terrain)
for y in 0..8 {
for x in 0..8 {
let tl = (y * 9 + x) as u32;
let tr = tl + 1;
let bl = tl + 9;
let br = bl + 1;
indices.extend_from_slice(&[tl, bl, br, tl, br, tr]);
}
}
WaterMesh {
vertices,
indices,
liquid_type: water.liquid_type,
}
}
}
Code Examples
Complete Terrain Renderer
#![allow(unused)]
fn main() {
use wow_adt::{Adt, Map};
use wgpu::*;
pub struct TerrainRenderer {
device: Device,
queue: Queue,
pipeline: RenderPipeline,
terrain_meshes: Vec<TerrainMesh>,
water_meshes: Vec<WaterMesh>,
textures: TerrainTextures,
}
impl TerrainRenderer {
pub fn new(device: Device, queue: Queue) -> Self {
let pipeline = create_terrain_pipeline(&device);
Self {
device,
queue,
pipeline,
terrain_meshes: Vec::new(),
water_meshes: Vec::new(),
textures: TerrainTextures::default(),
}
}
pub fn load_map_area(&mut self, map: &Map, center_x: i32, center_y: i32, radius: i32) -> Result<(), Box<dyn std::error::Error>> {
// Clear existing data
self.terrain_meshes.clear();
self.water_meshes.clear();
// First, discover which tiles exist using WDT
let existing_tiles = discover_adt_tiles(&map.internal_name, map.wow_version)?;
// Load ADTs in radius around center
for dy in -radius..=radius {
for dx in -radius..=radius {
let tile_x = center_x + dx;
let tile_y = center_y + dy;
// Check bounds
if tile_x < 0 || tile_x >= 64 || tile_y < 0 || tile_y >= 64 {
continue;
}
// Only try to load tiles that actually exist
if !existing_tiles.contains(&(tile_x as usize, tile_y as usize)) {
continue;
}
// Load ADT
let filename = format!("World/Maps/{}/{}_{:02}_{:02}.adt",
map.internal_name, map.internal_name, tile_x, tile_y);
if let Ok(adt) = Adt::from_file(&filename) {
let mesh = self.create_terrain_mesh(&adt)?;
self.terrain_meshes.push(mesh);
// Load water if present
if adt.has_water() {
let water_meshes = generate_water_mesh(&adt);
self.water_meshes.extend(water_meshes);
}
}
}
}
Ok(())
}
pub fn render(&self, encoder: &mut CommandEncoder, view: &TextureView, camera: &Camera) {
// Render terrain
{
let mut render_pass = encoder.begin_render_pass(&RenderPassDescriptor {
label: Some("Terrain Pass"),
color_attachments: &[Some(RenderPassColorAttachment {
view,
resolve_target: None,
ops: Operations {
load: LoadOp::Clear(Color::BLACK),
store: true,
},
})],
depth_stencil_attachment: Some(/* ... */),
});
render_pass.set_pipeline(&self.pipeline);
render_pass.set_bind_group(0, &camera.bind_group, &[]);
for mesh in &self.terrain_meshes {
render_pass.set_bind_group(1, &mesh.texture_bind_group, &[]);
render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
render_pass.set_index_buffer(mesh.index_buffer.slice(..), IndexFormat::Uint32);
render_pass.draw_indexed(0..mesh.index_count, 0, 0..1);
}
}
// Render water in separate pass
self.render_water(encoder, view, camera);
}
}
}
LOD System for Large Terrains
#![allow(unused)]
fn main() {
use wow_adt::{Adt, LodLevel};
pub struct TerrainLodSystem {
lod_levels: Vec<LodLevel>,
view_distance: f32,
}
impl TerrainLodSystem {
pub fn new(view_distance: f32) -> Self {
Self {
lod_levels: vec![
LodLevel { distance: 100.0, skip: 1 },
LodLevel { distance: 200.0, skip: 2 },
LodLevel { distance: 400.0, skip: 4 },
LodLevel { distance: 800.0, skip: 8 },
],
view_distance,
}
}
pub fn generate_lod_mesh(&self, adt: &Adt, camera_pos: Vec3) -> TerrainMesh {
let adt_center = adt.get_center();
let distance = (adt_center - camera_pos).length();
// Determine LOD level
let lod_skip = self.lod_levels
.iter()
.find(|lod| distance < lod.distance)
.map(|lod| lod.skip)
.unwrap_or(16);
// Generate mesh with reduced vertices
self.generate_mesh_with_skip(adt, lod_skip)
}
fn generate_mesh_with_skip(&self, adt: &Adt, skip: usize) -> TerrainMesh {
let mut vertices = Vec::new();
let mut indices = Vec::new();
// Sample vertices at reduced rate
for chunk in &adt.chunks {
for y in (0..9).step_by(skip) {
for x in (0..9).step_by(skip) {
// Add vertex
vertices.push(create_vertex(chunk, x, y));
}
}
}
// Generate indices for reduced mesh
// ... index generation with proper connectivity
TerrainMesh { vertices, indices }
}
}
}
Best Practices
1. Chunk-Based Culling
#![allow(unused)]
fn main() {
pub struct FrustumCuller {
view_frustum: Frustum,
}
impl FrustumCuller {
pub fn cull_chunks(&self, adt: &Adt) -> Vec<usize> {
let mut visible_chunks = Vec::new();
for (idx, chunk) in adt.chunks.iter().enumerate() {
let bounds = chunk.calculate_bounds();
if self.view_frustum.intersects_aabb(&bounds) {
visible_chunks.push(idx);
}
}
visible_chunks
}
}
}
2. Texture Streaming
#![allow(unused)]
fn main() {
pub struct TextureStreamer {
cache: HashMap<String, TextureId>,
max_cache_size: usize,
}
impl TextureStreamer {
pub fn get_texture(&mut self, filename: &str) -> Result<TextureId, Box<dyn std::error::Error>> {
// Check cache first
if let Some(&tex_id) = self.cache.get(filename) {
return Ok(tex_id);
}
// Load and cache
let blp = Blp::from_file(filename)?;
let tex_id = upload_texture(&blp);
self.cache.insert(filename.to_string(), tex_id);
self.enforce_cache_limit();
Ok(tex_id)
}
}
}
3. Height Map Queries
#![allow(unused)]
fn main() {
impl Adt {
pub fn get_height_at_position(&self, x: f32, z: f32) -> Option<f32> {
// Convert world coordinates to chunk coordinates
let chunk_x = (x / 33.333) as usize;
let chunk_z = (z / 33.333) as usize;
if chunk_x >= 16 || chunk_z >= 16 {
return None;
}
let chunk = &self.chunks[chunk_z * 16 + chunk_x];
// Get position within chunk
let local_x = x % 33.333;
let local_z = z % 33.333;
// Bilinear interpolation
let fx = local_x / 4.166;
let fz = local_z / 4.166;
let x0 = fx.floor() as usize;
let z0 = fz.floor() as usize;
let x1 = (x0 + 1).min(8);
let z1 = (z0 + 1).min(8);
let fx = fx.fract();
let fz = fz.fract();
// Get four corner heights
let h00 = chunk.height_map[z0 * 9 + x0];
let h10 = chunk.height_map[z0 * 9 + x1];
let h01 = chunk.height_map[z1 * 9 + x0];
let h11 = chunk.height_map[z1 * 9 + x1];
// Bilinear interpolation
let h0 = h00 * (1.0 - fx) + h10 * fx;
let h1 = h01 * (1.0 - fx) + h11 * fx;
Some(h0 * (1.0 - fz) + h1 * fz)
}
}
}
Common Issues and Solutions
Issue: Texture Seams
Problem: Visible seams between ADT tiles or chunks.
Solution:
#![allow(unused)]
fn main() {
// Ensure proper texture coordinate wrapping
fn fix_texture_seams(vertices: &mut [TerrainVertex]) {
// Add small offset to texture coordinates at edges
const SEAM_OFFSET: f32 = 0.5 / 512.0; // Half pixel for 512x512 texture
for vertex in vertices {
if vertex.texcoord[0] == 0.0 {
vertex.texcoord[0] += SEAM_OFFSET;
} else if vertex.texcoord[0] == 1.0 {
vertex.texcoord[0] -= SEAM_OFFSET;
}
if vertex.texcoord[1] == 0.0 {
vertex.texcoord[1] += SEAM_OFFSET;
} else if vertex.texcoord[1] == 1.0 {
vertex.texcoord[1] -= SEAM_OFFSET;
}
}
}
}
Issue: Z-Fighting with Water
Problem: Flickering where water meets terrain.
Solution:
#![allow(unused)]
fn main() {
// Render water with slight offset
fn render_water_with_offset(water_height: f32) -> f32 {
water_height + 0.01 // Small bias to prevent z-fighting
}
// Or use polygon offset in render state
let render_state = RenderState {
polygon_offset: Some(PolygonOffset {
factor: -1.0,
units: -1.0,
}),
..Default::default()
};
}
Issue: Performance with Many ADTs
Problem: Frame rate drops when rendering large areas.
Solution:
#![allow(unused)]
fn main() {
pub struct AdtBatcher {
batches: HashMap<TextureSetId, BatchedMesh>,
}
impl AdtBatcher {
pub fn batch_adts(&mut self, adts: &[Adt]) {
self.batches.clear();
for adt in adts {
for chunk in &adt.chunks {
let texture_set = chunk.get_texture_set_id();
let batch = self.batches.entry(texture_set).or_default();
batch.add_chunk(chunk);
}
}
}
}
}
Performance Tips
1. GPU Instancing for Repeated Elements
#![allow(unused)]
fn main() {
// Instance doodads and small objects
pub struct DoodadRenderer {
instance_buffer: Buffer,
instances: Vec<DoodadInstance>,
}
#[repr(C)]
struct DoodadInstance {
transform: [[f32; 4]; 4],
color_variation: [f32; 4],
}
}
2. Texture Atlas for Terrain
#![allow(unused)]
fn main() {
pub struct TerrainTextureAtlas {
atlas_texture: TextureId,
texture_coords: HashMap<String, AtlasRegion>,
}
impl TerrainTextureAtlas {
pub fn build_from_adts(adts: &[Adt]) -> Self {
// Collect all unique textures
let mut textures = HashSet::new();
for adt in adts {
textures.extend(adt.get_texture_filenames());
}
// Build atlas
// ... atlas generation code
}
}
}
3. Async ADT Loading
#![allow(unused)]
fn main() {
use tokio::task;
pub async fn load_adts_async(filenames: Vec<String>) -> Vec<Result<Adt, Box<dyn std::error::Error>>> {
let mut tasks = Vec::new();
for filename in filenames {
let task = task::spawn_blocking(move || {
Adt::from_file(&filename)
});
tasks.push(task);
}
let mut results = Vec::new();
for task in tasks {
results.push(task.await.unwrap());
}
results
}
}
Related Guides
- 📦 Working with MPQ Archives - Extract ADT files from game archives
- 🖼️ Texture Loading Guide - Load BLP textures for terrain
- 🏛️ WMO Rendering Guide - Render buildings placed on terrain
- 🎭 Loading M2 Models - Load doodads and objects
- 📊 LOD System Guide - Implement level-of-detail for terrain
References
- ADT Format Documentation - Complete ADT file format specification
- WoW Coordinate System - Understanding WoW’s coordinate system
- Terrain Rendering Techniques - GPU-based terrain rendering
- Texture Splatting - Multi-texture terrain blending
WDT CLI Usage Guide
The warcraft-rs command-line tool provides WDT (World Data Table) operations through the wdt subcommand, supporting all World of Warcraft versions from Classic through later expansions.
Key Features:
- ✅ Multi-Version Support - Works with WDT files from 1.12.1 through 5.4.8+
- ✅ Validation & Analysis - Detailed file structure validation and reporting
- ✅ Version Conversion - Convert WDT files between different WoW versions
- ✅ Tile Discovery - Find all existing ADT tiles efficiently
- ✅ Format Export - Output data in text, JSON, or CSV formats
Installation
# Build from source
cd warcraft-rs
cargo build --release
# Or install globally
cargo install --path .
# The binary will be available as 'warcraft-rs'
Basic Commands
Display WDT Information
# Basic information about a WDT file
warcraft-rs wdt info Azeroth.wdt
# Specify WoW version for accurate parsing
warcraft-rs wdt info Azeroth.wdt --version 3.3.5a
# Show detailed chunk information
warcraft-rs wdt info Azeroth.wdt --detailed
Example output:
WDT File Information
===================
File: Azeroth.wdt
Version: 18
Type: Terrain map
MPHD Flags:
✓ 0x0002 - ADTs have vertex colors
✓ 0x0004 - ADTs use big alpha
✓ 0x0040 - Universal flag (4.3.4+)
Raw value: 0x00000046
ADT Tiles: 1337 / 4096 tiles
Validate WDT Files
# Basic validation
warcraft-rs wdt validate Azeroth.wdt --version 3.3.5a
# Show all warnings (not just errors)
warcraft-rs wdt validate Azeroth.wdt --version 3.3.5a --warnings
Example output:
Validating WDT File
==================
✓ File is valid!
List ADT Tiles
# List all tiles in text format
warcraft-rs wdt tiles Azeroth.wdt
# Export as JSON
warcraft-rs wdt tiles Azeroth.wdt --format json
# Export as CSV for spreadsheet analysis
warcraft-rs wdt tiles Azeroth.wdt --format csv > azeroth_tiles.csv
Text output:
Existing ADT Tiles
==================
Total: 1337 tiles
[29,29] - Area ID: 12
[29,30] - Area ID: 12
[30,29] - Area ID: 12
[30,30] - Area ID: 12
...
JSON output:
[
{
"x": 29,
"y": 29,
"area_id": 12
},
{
"x": 29,
"y": 30,
"area_id": 12
}
]
Convert Between Versions
# Preview conversion changes
warcraft-rs wdt convert input.wdt output.wdt \
--from-version 1.12.1 \
--to-version 3.3.5a \
--preview
# Perform actual conversion
warcraft-rs wdt convert classic_map.wdt wotlk_map.wdt \
--from-version 1.12.1 \
--to-version 3.3.5a
Example conversion output:
WDT Version Conversion
=====================
Converting: Classic → WotLK
Input: classic_map.wdt
Output: wotlk_map.wdt
Changes to be made:
• MODF UniqueID: 0xFFFFFFFF → 0x00000000
• MODF Scale: 0 → 1024 (1.0x scale)
• Update version compatibility flags
✓ Conversion complete!
Tree Visualization
Visualize the internal structure of WDT files using the tree command:
# Basic tree view showing chunk structure
warcraft-rs wdt tree Azeroth.wdt
# Limit depth for focused view
warcraft-rs wdt tree Azeroth.wdt --depth 2
# Show external ADT file references
warcraft-rs wdt tree Azeroth.wdt --show-refs
# Compact mode for overview
warcraft-rs wdt tree Azeroth.wdt --compact
# No color output (for piping/redirecting)
warcraft-rs wdt tree Azeroth.wdt --no-color
The tree view shows:
- 📦 WDT file structure with all chunks
- 🗂️ Chunk hierarchy (MVER, MPHD, MAIN, etc.)
- 📊 Chunk sizes and metadata
- 🗺️ ADT tile references with coordinates
- 🏛️ WMO references for object-only maps
Advanced Usage
Batch Processing
Process multiple WDT files using shell scripting:
#!/bin/bash
# Validate all WDT files in a directory
for wdt_file in *.wdt; do
echo "Validating: $wdt_file"
warcraft-rs wdt validate "$wdt_file" --version 3.3.5a
done
#!/bin/bash
# Export tile lists for all maps
for wdt_file in *.wdt; do
map_name=$(basename "$wdt_file" .wdt)
warcraft-rs wdt tiles "$wdt_file" --format csv > "${map_name}_tiles.csv"
done
Integration with Other Tools
Use WDT CLI output in data pipelines:
# Find maps with many tiles (large outdoor zones)
warcraft-rs wdt tiles *.wdt --format csv | \
awk -F',' 'END {print "Tiles:", NR-1}' | \
sort -n
# Extract tile data for specific coordinate ranges
warcraft-rs wdt tiles Azeroth.wdt --format csv | \
awk -F',' '$1 >= 30 && $1 <= 35 && $2 >= 30 && $2 <= 35'
Version-Specific Examples
Classic (1.12.1)
# Classic WDT files often have empty MWMO chunks
warcraft-rs wdt info StormwindCity.wdt --version 1.12.1 --detailed
Classic maps characteristics:
- MODF UniqueID is 0xFFFFFFFF
- MODF Scale is 0 (not 1024)
- Terrain maps have empty MWMO chunks
Wrath of the Lich King (3.3.5a)
# WotLK introduces many new flags
warcraft-rs wdt info Icecrown.wdt --version 3.3.5a --detailed
WotLK improvements:
- Extensive use of vertex colors (0x0002 flag)
- Big alpha blending (0x0004 flag)
- Sorted doodad references (0x0008 flag)
Cataclysm (4.3.4)
# Cataclysm removes MWMO from terrain maps
warcraft-rs wdt info Deepholm.wdt --version 4.3.4 --detailed
Cataclysm breaking changes:
- Terrain maps have NO MWMO chunk
- Universal 0x0040 flag on all maps
- Improved terrain rendering capabilities
Mists of Pandaria (5.4.8)
# MoP introduces height texturing
warcraft-rs wdt info Pandaria.wdt --version 5.4.8 --detailed
MoP enhancements:
- Height texturing flag (0x0080) becomes active
- Scenario support (small instanced content)
- Pet battle arenas as dedicated maps
Common Use Cases
Map Development
# Check if your custom map has valid structure
warcraft-rs wdt validate MyCustomMap.wdt --version 3.3.5a --warnings
# List tiles to verify terrain coverage
warcraft-rs wdt tiles MyCustomMap.wdt --format text
Data Mining
# Export all tile data for analysis
for map in *.wdt; do
warcraft-rs wdt tiles "$map" --format json > "data/$(basename "$map" .wdt).json"
done
# Find WMO-only maps (dungeons, instances)
warcraft-rs wdt info *.wdt | grep -B2 "WMO-only"
Quality Assurance
# Validate entire map collection
find . -name "*.wdt" -exec warcraft-rs wdt validate {} --version 3.3.5a \;
# Check for version consistency
warcraft-rs wdt info *.wdt --version 3.3.5a | grep "✗"
Archive Analysis
# Combined with MPQ extraction
warcraft-rs mpq extract patch.mpq --filter "*.wdt" --output wdt_files/
cd wdt_files
warcraft-rs wdt tiles World/Maps/*/\*.wdt --format csv > all_tiles.csv
Output Formats
Text Format (Default)
Human-readable output suitable for terminal viewing and basic scripting.
JSON Format
Structured data for web applications and automated processing:
warcraft-rs wdt tiles Azeroth.wdt --format json | jq '.[] | select(.area_id == 12)'
CSV Format
Tabular data ideal for spreadsheet analysis and database import:
warcraft-rs wdt tiles *.wdt --format csv | sqlite3 :memory: \
"CREATE TABLE tiles(x,y,area_id); .import /dev/stdin tiles; SELECT area_id, COUNT(*) FROM tiles GROUP BY area_id;"
Error Handling
Common Issues
File Not Found:
warcraft-rs wdt info missing.wdt
# Error: Failed to open WDT file: No such file or directory
Invalid Version:
warcraft-rs wdt info map.wdt --version 99.99.99
# Error: Invalid version string
Corrupted File:
warcraft-rs wdt validate corrupted.wdt --version 3.3.5a
# ✗ 3 error(s) found:
# • Invalid WDT version: expected 18, found 0
# • Missing required chunk: MPHD
# • Missing required chunk: MAIN
Exit Codes
0: Success1: File not found or permission error2: Invalid command line arguments3: File parsing error4: Validation failed
Performance Tips
Large Archives
For processing many files:
# Use shell built-ins for better performance
shopt -s nullglob
files=(*.wdt)
printf '%s\n' "${files[@]}" | xargs -P4 -I{} warcraft-rs wdt info {}
Memory Usage
The WDT parser is memory-efficient and suitable for batch processing:
- Typical WDT file: < 1MB memory usage
- Large WDT with MAID: < 5MB memory usage
- No memory leaks in long-running scripts
Integration Examples
Python Integration
import subprocess
import json
def get_wdt_tiles(wdt_path):
result = subprocess.run([
'warcraft-rs', 'wdt', 'tiles', wdt_path, '--format', 'json'
], capture_output=True, text=True)
if result.returncode == 0:
return json.loads(result.stdout)
else:
raise Exception(f"WDT parsing failed: {result.stderr}")
tiles = get_wdt_tiles("Azeroth.wdt")
print(f"Found {len(tiles)} tiles")
Node.js Integration
const { spawn } = require('child_process');
function getWdtInfo(wdtPath) {
return new Promise((resolve, reject) => {
const proc = spawn('warcraft-rs', ['wdt', 'tiles', wdtPath, '--format', 'json']);
let output = '';
proc.stdout.on('data', (data) => output += data);
proc.on('close', (code) => {
if (code === 0) {
resolve(JSON.parse(output));
} else {
reject(new Error(`Process exited with code ${code}`));
}
});
});
}
Related Guides
- 📦 Working with MPQ Archives - Extract WDT files from game archives
- 🌍 ADT Rendering Guide - Use WDT data to load terrain tiles
- 🏛️ WMO Rendering Guide - Handle WMO-only maps from WDT data
- 📊 DBC Data Extraction - Cross-reference Area IDs from WDT tiles
References
- WDT Format Documentation - Complete file format specification
- WoW Version Support - Supported WoW versions and compatibility
- Map IDs Reference - Map naming conventions and IDs
WMO CLI Usage Guide 🏰
This guide covers the warcraft-rs wmo command and its subcommands for working with World Map Object (WMO) files.
Overview
The WMO command provides tools for parsing, validating, converting, and manipulating WMO files. WMO files represent buildings, dungeons, and other large structures in World of Warcraft.
Installation
# Install from source
cargo install --path warcraft-rs
# Or build and run directly
cargo run --bin warcraft-rs -- wmo <subcommand>
Subcommands
info - Display WMO Information
Get detailed information about a WMO file.
# Basic info
warcraft-rs wmo info building.wmo
# Detailed info with all chunks
warcraft-rs wmo info building.wmo --detailed
# Example output:
WMO Version: 17
Groups: 5
Materials: 12
Textures: 8
Doodads: 45
Portals: 3
Lights: 7
Flags: HAS_VERTEX_COLORS | INDOOR
Bounding Box: (-100.5, -50.2, 0.0) to (100.5, 50.2, 30.0)
validate - Check WMO File Integrity
Validate a WMO file for structural integrity and common issues.
# Basic validation
warcraft-rs wmo validate building.wmo
# Include warnings
warcraft-rs wmo validate building.wmo --warnings
# Detailed validation with all checks
warcraft-rs wmo validate building.wmo --warnings --detailed
# Example output:
✓ Header validation passed
✓ Material references valid
✓ Texture indices valid
✓ Group information consistent
⚠ Warning: Material 5 uses deprecated blend mode
✓ Overall: VALID (1 warning)
convert - Convert Between WMO Versions
Convert WMO files between different WoW expansion formats.
# Convert to specific version number
warcraft-rs wmo convert classic.wmo modern.wmo --to 21
# Convert from Classic to Cataclysm
warcraft-rs wmo convert classic.wmo cata.wmo --to 21
# Example output:
Converting WMO from version 17 to version 21...
✓ Header updated
✓ Materials converted (added extended format)
✓ Group data updated
✓ Conversion complete: modern.wmo
Supported Versions:
- 17: Classic through Wrath of the Lich King
- 18: Cataclysm
- 19: Mists of Pandaria
- 20: Warlords of Draenor
- 21: Legion and later
tree - Visualize WMO Structure
Display the hierarchical structure of a WMO file.
# Basic tree view
warcraft-rs wmo tree building.wmo
# Limit depth
warcraft-rs wmo tree building.wmo --depth 2
# Show references (textures, doodads)
warcraft-rs wmo tree building.wmo --show-refs
# Disable colors
warcraft-rs wmo tree building.wmo --no-color
# Hide metadata
warcraft-rs wmo tree building.wmo --no-metadata
# Compact view
warcraft-rs wmo tree building.wmo --compact
# Example output:
🏰 WMO Root: building.wmo (v17)
├── 📋 Header [MOHD] (64 bytes)
│ ├── Groups: 5
│ ├── Materials: 12
│ └── Flags: INDOOR | HAS_VERTEX_COLORS
├── 🎨 Textures [MOTX] (256 bytes)
│ ├── [0] "world/generic/stone_floor.blp"
│ └── [1] "world/generic/wood_wall.blp"
├── 🎭 Materials [MOMT] (480 bytes)
│ ├── [0] Shader: DIFFUSE | TWO_SIDED
│ └── [1] Shader: SPECULAR | ENV
├── 📁 Groups [MOGI] (160 bytes)
│ ├── [0] "MainHall" (flags: INDOOR | HAS_BSP)
│ └── [1] "Entrance" (flags: OUTDOOR)
└── 💡 Lights [MOLT] (336 bytes)
├── [0] Omni (intensity: 1.5)
└── [1] Spot (intensity: 2.0)
edit - Modify WMO Properties
Edit properties of a WMO file.
# Set flags
warcraft-rs wmo edit building.wmo --set-flag has-fog
warcraft-rs wmo edit building.wmo --unset-flag use-unified-render-path
# Change properties
warcraft-rs wmo edit building.wmo --ambient-color "0.5,0.5,0.7,1.0"
# Example output:
Editing WMO: building.wmo
✓ Set flag: HAS_FOG
✓ Ambient color changed to: RGBA(0.5, 0.5, 0.7, 1.0)
✓ Changes saved to: building.wmo
Available Flags:
do-not-attenuate-verticesuse-unified-render-pathuse-liquid-from-dbcdo-not-fix-vertex-color-alphalodhas-fog
build - Create WMO from Configuration
Build a new WMO file from a YAML configuration.
# Build from config
warcraft-rs wmo build output.wmo --from config.yaml
# Specify version
warcraft-rs wmo build output.wmo --from config.yaml --version 17
Example Configuration (config.yaml):
version: 17
header:
ambient_color: [0.5, 0.5, 0.5, 1.0]
flags:
- indoor
- has_vertex_colors
bounding_box:
min: [-50.0, -50.0, 0.0]
max: [50.0, 50.0, 30.0]
textures:
- "world/generic/stone_floor.blp"
- "world/generic/wood_wall.blp"
materials:
- texture: 0
flags: ["diffuse", "two_sided"]
blend_mode: 0
- texture: 1
flags: ["specular"]
blend_mode: 1
groups:
- name: "MainHall"
flags: ["indoor", "has_bsp"]
bounding_box:
min: [-50.0, -50.0, 0.0]
max: [50.0, 50.0, 30.0]
lights:
- type: "omni"
position: [0.0, 0.0, 15.0]
color: [1.0, 1.0, 0.8, 1.0]
intensity: 1.5
attenuation: [5.0, 25.0]
doodad_sets:
- name: "Furniture"
doodads:
- model: "world/generic/chair.m2"
position: [10.0, 10.0, 0.0]
rotation: [0.0, 0.0, 0.0, 1.0]
scale: 1.0
Working with Group Files
WMO files consist of a root file and multiple group files (_000.wmo to_999.wmo).
# Info for specific group
warcraft-rs wmo info building_000.wmo
# Validate all groups
for i in {000..004}; do
warcraft-rs wmo validate "building_${i}.wmo"
done
Common Use Cases
Extracting WMO Files from MPQ
# Extract a WMO and all its groups
warcraft-rs mpq extract patch.mpq "World/wmo/Azeroth/Buildings/Stormwind/*" --output ./
# List all WMO files in an archive
warcraft-rs mpq list patch.mpq --filter "*.wmo"
Batch Processing
# Validate all WMO files in a directory
find . -name "*.wmo" -not -name "*_[0-9][0-9][0-9].wmo" | while read wmo; do
echo "Validating: $wmo"
warcraft-rs wmo validate "$wmo"
done
# Convert all Classic WMOs to Cataclysm format
for wmo in *.wmo; do
if [[ ! "$wmo" =~ _[0-9]{3}\.wmo$ ]]; then
warcraft-rs wmo convert "$wmo" "converted/${wmo}" --to 18
fi
done
Analyzing WMO Structure
# Get a quick overview of all WMOs
for wmo in *.wmo; do
if [[ ! "$wmo" =~ _[0-9]{3}\.wmo$ ]]; then
echo "=== $wmo ==="
warcraft-rs wmo info "$wmo" | grep -E "Version:|Groups:|Materials:"
fi
done
# Find WMOs with specific features
warcraft-rs wmo info building.wmo --detailed | grep -i "skybox"
Debugging Rendering Issues
# Check for missing textures
warcraft-rs wmo validate building.wmo --warnings --detailed | grep -i "texture"
# Verify material setup
warcraft-rs wmo tree building.wmo --show-refs | grep -A5 "Materials"
# Check group flags
warcraft-rs wmo info building.wmo --detailed | grep -A10 "Group Information"
Tips and Tricks
Performance Optimization
- Use
--compactwith tree command for large WMOs to reduce output - Validate before converting to catch issues early
- Process root files separately from group files when batch processing
Common Issues
- Missing Group Files: Ensure all _XXX.wmo files are present
- Texture Path Issues: WoW uses backslashes; the tool handles conversion
- Version Compatibility: Not all features convert perfectly between versions
Integration with Other Tools
# Extract WMO, convert it, then re-import
warcraft-rs mpq extract archive.mpq "path/to/building.wmo" --output temp/
warcraft-rs wmo convert temp/building.wmo converted/building.wmo --to 21
warcraft-rs mpq create new_archive.mpq --add converted/building.wmo
# Validate WMOs after ADT modification
warcraft-rs adt info map.adt | grep -i "wmo" | while read line; do
wmo_file=$(echo $line | awk '{print $2}')
warcraft-rs wmo validate "$wmo_file"
done
Error Messages
Common Errors and Solutions
- “Failed to parse WMO header”: File is corrupted or not a valid WMO
- “Invalid texture index”: Material references non-existent texture
- “Missing group file”: Expected group file not found (e.g., _001.wmo)
- “Unsupported version”: WMO version not supported for this operation
- “Invalid chunk size”: File corruption or incorrect write operation
See Also
- WMO Format Documentation
- MPQ CLI Usage - For extracting WMO files
- ADT CLI Usage - For terrain that contains WMOs
- WMO Rendering Guide - For displaying WMOs
🏛️ WMO Rendering Guide
Overview
WMO (World Map Objects) are large static structures in World of Warcraft such as
buildings, dungeons, and cities. Unlike smaller decorative objects (M2 models),
WMOs have complex interior/exterior structures, multiple groups, portals, and
advanced lighting. This guide covers loading, processing, and rendering WMO files
using warcraft-rs.
Prerequisites
Before working with WMO files, ensure you have:
- Understanding of 3D graphics and scene graphs
- Knowledge of BSP trees and portal rendering
warcraft-rsinstalled with thewmofeature enabled- Graphics API experience (OpenGL/Vulkan/DirectX/WebGPU)
- Familiarity with occlusion culling techniques
Understanding WMO Structure
WMO Components
WMO files consist of:
- Root file (
.wmo): Contains general information, materials, portals - Group files (
_000.wmo,_001.wmo, etc.): Individual building sections - Portal system: Visibility determination between rooms
- Doodad sets: Furniture and decorative object placements
- Lighting: Pre-baked vertex lighting and light definitions
Key Concepts
- Groups: Self-contained mesh sections (rooms, floors)
- Portals: Openings between groups for visibility culling
- BSP Tree: Binary space partitioning for collision detection
- Batches: Render batches with material assignments
- MOCVs: Vertex colors for pre-baked lighting
- Liquid: Water planes within buildings
Step-by-Step Instructions
1. Loading WMO Files
#![allow(unused)]
fn main() {
use wow_wmo::{WmoRoot, WmoGroup, WmoParser, WmoGroupParser};
use std::fs::File;
use std::io::BufReader;
use std::path::Path;
struct LoadedWmo {
root: WmoRoot,
groups: Vec<WmoGroup>,
}
fn load_wmo(root_path: &str) -> Result<LoadedWmo, Box<dyn std::error::Error>> {
// Load root WMO file
let file = File::open(root_path)?;
let mut reader = BufReader::new(file);
let root = WmoParser::new().parse_root(&mut reader)?;
println!("Groups: {}", root.groups.len());
println!("Portals: {}", root.portals.len());
println!("Materials: {}", root.materials.len());
println!("Doodad Sets: {}", root.doodad_sets.len());
// Load group files
let mut groups = Vec::new();
let base_path = root_path.trim_end_matches(".wmo");
for i in 0..root.header.n_groups {
let group_path = format!("{}_{:03}.wmo", base_path, i);
if Path::new(&group_path).exists() {
let group_file = File::open(&group_path)?;
let mut group_reader = BufReader::new(group_file);
let group = WmoGroupParser::new().parse_group(&mut group_reader, i)?;
groups.push(group);
}
}
Ok(LoadedWmo { root, groups })
}
// Load with LOD support
fn load_wmo_with_lod(root_path: &str) -> Result<Vec<LoadedWmo>, Box<dyn std::error::Error>> {
let mut lods = Vec::new();
// Try to load LOD versions
for lod_level in 0..3 {
let lod_path = if lod_level == 0 {
root_path.to_string()
} else {
root_path.replace(".wmo", &format!("_lod{}.wmo", lod_level))
};
if Path::new(&lod_path).exists() {
let wmo = load_wmo(&lod_path)?;
lods.push(wmo);
}
}
Ok(lods)
}
}
2. Processing WMO Groups
#![allow(unused)]
fn main() {
use wow_wmo::{WmoGroup, WmoGroupFlags, BoundingBox, WmoBatch};
#[derive(Debug, Clone)]
struct ProcessedGroup {
vertices: Vec<GpuVertex>,
indices: Vec<u32>,
batches: Vec<WmoBatch>,
bounding_box: BoundingBox,
is_indoor: bool,
}
#[derive(Debug, Clone, Copy)]
#[repr(C)]
struct GpuVertex {
position: [f32; 3],
normal: [f32; 3],
texcoord: [f32; 2],
vertex_color: [f32; 4],
}
fn process_wmo_group(group: &WmoGroup) -> ProcessedGroup {
let mut vertices = Vec::with_capacity(group.vertices.len());
// Process vertices
for (i, vertex) in group.vertices.iter().enumerate() {
let vertex_color = if let Some(ref colors) = group.vertex_colors {
if i < colors.len() {
let color = &colors[i];
[
color.r as f32 / 255.0,
color.g as f32 / 255.0,
color.b as f32 / 255.0,
color.a as f32 / 255.0,
]
} else {
[1.0, 1.0, 1.0, 1.0]
}
} else {
[1.0, 1.0, 1.0, 1.0]
};
let normal = if i < group.normals.len() {
[group.normals[i].x, group.normals[i].y, group.normals[i].z]
} else {
[0.0, 0.0, 1.0]
};
let texcoord = if i < group.tex_coords.len() {
[group.tex_coords[i].u, group.tex_coords[i].v]
} else {
[0.0, 0.0]
};
vertices.push(GpuVertex {
position: [vertex.x, vertex.y, vertex.z],
normal,
texcoord,
vertex_color,
});
}
ProcessedGroup {
vertices,
indices: group.indices.iter().map(|&i| i as u32).collect(),
batches: group.batches.clone(),
bounding_box: group.header.bounding_box,
is_indoor: group.header.flags.contains(WmoGroupFlags::INDOOR),
}
}
}
3. Implementing Portal Culling
#![allow(unused)]
fn main() {
use wow_wmo::{WmoPortal, WmoPortalReference, Vec3};
use std::collections::HashSet;
struct PortalSystem {
portals: Vec<WmoPortal>,
relations: Vec<WmoPortalReference>,
group_visibility: Vec<bool>,
}
impl PortalSystem {
fn new(root: &WmoRoot) -> Self {
Self {
portals: root.portals.clone(),
relations: root.portal_references.clone(),
group_visibility: vec![false; root.header.n_groups as usize],
}
}
fn update_visibility(&mut self, camera_pos: Vec3, camera_group: usize) {
// Reset visibility
self.group_visibility.fill(false);
// Current group is always visible
self.group_visibility[camera_group] = true;
// Flood fill through portals
let mut to_check = vec![camera_group];
let mut checked = HashSet::new();
while let Some(current_group) = to_check.pop() {
if !checked.insert(current_group) {
continue;
}
// Find portals connected to this group
for relation in &self.relations {
if relation.group_index == current_group as u16 {
let portal = &self.portals[relation.portal_index as usize];
// Check if camera can see through portal
if self.is_portal_visible(portal, camera_pos) {
// Note: WmoPortalReference doesn't have target_group
// This would need to be determined from portal geometry
// For now, mark all connected groups as visible
// In a real implementation, you'd determine the other group
}
}
}
}
}
fn is_portal_visible(&self, portal: &WmoPortal, camera_pos: Vec3) -> bool {
// Simple visibility check - can be enhanced with frustum culling
let portal_center = Vec3 {
x: portal.vertices.iter().map(|v| v.x).sum::<f32>() / portal.vertices.len() as f32,
y: portal.vertices.iter().map(|v| v.y).sum::<f32>() / portal.vertices.len() as f32,
z: portal.vertices.iter().map(|v| v.z).sum::<f32>() / portal.vertices.len() as f32,
};
// Check if camera is on the positive side of portal plane
let to_portal = Vec3 {
x: portal_center.x - camera_pos.x,
y: portal_center.y - camera_pos.y,
z: portal_center.z - camera_pos.z,
};
let dot = to_portal.x * portal.normal.x +
to_portal.y * portal.normal.y +
to_portal.z * portal.normal.z;
dot > 0.0
}
}
}
4. Material and Texture Setup
#![allow(unused)]
fn main() {
use wow_wmo::{WmoMaterial, WmoMaterialFlags, WmoRoot};
// Mock types for rendering (would be defined by your graphics library)
type TextureId = u32;
type BlendMode = u32;
type CullMode = u32;
struct WmoMaterialSet {
materials: Vec<GpuMaterial>,
textures: Vec<TextureId>,
}
struct GpuMaterial {
diffuse_texture: TextureId,
blend_mode: BlendMode,
cull_mode: CullMode,
flags: WmoMaterialFlags,
shader_id: u32,
}
// Mock texture manager for example
struct TextureManager;
impl TextureManager {
fn load_texture(&mut self, path: &str) -> Result<TextureId, Box<dyn std::error::Error>> {
// Mock implementation
Ok(0)
}
}
fn load_wmo_materials(
root: &WmoRoot,
texture_manager: &mut TextureManager,
) -> Result<WmoMaterialSet, Box<dyn std::error::Error>> {
let mut materials = Vec::new();
let mut textures = Vec::new();
for (i, wmo_material) in root.materials.iter().enumerate() {
// Load diffuse texture using texture index
let texture_path = if wmo_material.texture1 as usize < root.textures.len() {
&root.textures[wmo_material.texture1 as usize]
} else {
"default.blp" // fallback
};
let texture_id = texture_manager.load_texture(texture_path)?;
textures.push(texture_id);
// Determine blend mode
let blend_mode = if wmo_material.flags.contains(WmoMaterialFlags::UNLIT) {
0 // Opaque
} else if wmo_material.blend_mode == 1 {
1 // AlphaBlend
} else {
0 // Opaque
};
// Determine cull mode
let cull_mode = if wmo_material.flags.contains(WmoMaterialFlags::TWO_SIDED) {
0 // None
} else {
1 // Back
};
materials.push(GpuMaterial {
diffuse_texture: texture_id,
blend_mode,
cull_mode,
flags: wmo_material.flags,
shader_id: wmo_material.shader,
});
}
Ok(WmoMaterialSet { materials, textures })
}
// Mock shader variant enum
enum ShaderVariant {
Unlit,
Window,
Diffuse,
Specular,
Standard,
}
// Shader selection based on material properties
fn select_shader_for_material(material: &GpuMaterial) -> ShaderVariant {
if material.flags.contains(WmoMaterialFlags::UNLIT) {
ShaderVariant::Unlit
} else if material.flags.contains(WmoMaterialFlags::WINDOW_LIGHT) {
ShaderVariant::Window
} else if material.shader_id == 1 {
ShaderVariant::Diffuse
} else if material.shader_id == 2 {
ShaderVariant::Specular
} else {
ShaderVariant::Standard
}
}
}
5. Implementing Doodad Placement
#![allow(unused)]
fn main() {
use wow_wmo::{WmoDoodadSet, WmoDoodadDef};
use std::collections::HashMap;
use std::sync::Arc;
// Mock M2 model type
struct M2Model;
struct WmoDoodadManager {
doodad_sets: Vec<WmoDoodadSet>,
instances: Vec<WmoDoodadDef>,
models: HashMap<String, Arc<M2Model>>,
}
// Mock types for example
type Matrix4<T> = [[T; 4]; 4];
type Vector3<T> = [T; 3];
type Vector4<T> = [T; 4];
type Quaternion<T> = [T; 4];
struct M2ModelManager;
impl M2ModelManager {
fn load_model(&mut self, _path: &str) -> Result<M2Model, Box<dyn std::error::Error>> {
Ok(M2Model)
}
}
impl WmoDoodadManager {
fn new(root: &WmoRoot) -> Self {
Self {
doodad_sets: root.doodad_sets.clone(),
instances: root.doodad_defs.clone(),
models: HashMap::new(),
}
}
fn load_doodad_set(
&mut self,
set_index: usize,
model_manager: &mut M2ModelManager,
doodad_names: &[String], // Would come from parsing MODN chunk
) -> Result<Vec<PlacedDoodad>, Box<dyn std::error::Error>> {
let set = &self.doodad_sets[set_index];
let mut placed_doodads = Vec::new();
for i in set.start_doodad..(set.start_doodad + set.n_doodads) {
let instance = &self.instances[i as usize];
// Get filename from name offset (simplified)
let filename = if instance.name_offset as usize < doodad_names.len() {
&doodad_names[instance.name_offset as usize]
} else {
"unknown.m2"
};
// Load model if not cached
let model = if let Some(cached) = self.models.get(filename) {
cached.clone()
} else {
let model = Arc::new(model_manager.load_model(filename)?);
self.models.insert(filename.to_string(), model.clone());
model
};
// Create transform matrix
let transform = create_doodad_transform(
[instance.position.x, instance.position.y, instance.position.z],
instance.orientation,
instance.scale,
);
placed_doodads.push(PlacedDoodad {
model,
transform,
color: [instance.color.r as f32 / 255.0, instance.color.g as f32 / 255.0,
instance.color.b as f32 / 255.0, instance.color.a as f32 / 255.0],
});
}
Ok(placed_doodads)
}
}
fn create_doodad_transform(
position: Vector3<f32>,
rotation: Quaternion<f32>,
scale: f32,
) -> Matrix4<f32> {
// Simplified transform creation - in a real implementation
// you'd use a proper math library like nalgebra or glam
let mut transform = [[0.0f32; 4]; 4];
// Identity matrix with scale
transform[0][0] = scale;
transform[1][1] = scale;
transform[2][2] = scale;
transform[3][3] = 1.0;
// Set translation
transform[3][0] = position[0];
transform[3][1] = position[1];
transform[3][2] = position[2];
// Note: rotation quaternion conversion omitted for brevity
transform
}
struct PlacedDoodad {
model: Arc<M2Model>,
transform: Matrix4<f32>,
color: Vector4<f32>,
}
}
6. Rendering Pipeline
#![allow(unused)]
fn main() {
// Mock GPU types for example (would be from wgpu/vulkan/etc)
struct Device;
struct Queue;
struct RenderPipeline;
struct Buffer;
pub struct WmoRenderer {
group_buffers: Vec<GroupGpuData>,
material_set: WmoMaterialSet,
portal_system: PortalSystem,
}
struct GroupGpuData {
vertex_buffer: Buffer,
index_buffer: Buffer,
batches: Vec<WmoBatch>,
}
impl WmoRenderer {
pub fn new(wmo: &LoadedWmo) -> Result<Self, Box<dyn std::error::Error>> {
// Process groups
let mut group_buffers = Vec::new();
for group in &wmo.groups {
let processed = process_wmo_group(group);
// In a real implementation, you'd create GPU buffers here
group_buffers.push(GroupGpuData {
vertex_buffer: Buffer, // Mock buffer
index_buffer: Buffer, // Mock buffer
batches: processed.batches,
});
}
// Load materials
let mut texture_manager = TextureManager;
let material_set = load_wmo_materials(&wmo.root, &mut texture_manager)?;
// Initialize portal system
let portal_system = PortalSystem::new(&wmo.root);
Ok(Self {
group_buffers,
material_set,
portal_system,
})
}
pub fn render(
&mut self,
wmo: &LoadedWmo,
camera_pos: Vec3,
) {
// Update portal visibility
let camera_group = self.find_camera_group(camera_pos, wmo);
self.portal_system.update_visibility(camera_pos, camera_group);
// Render visible groups
println!("Rendering WMO with {} groups", wmo.groups.len());
for (group_idx, group) in wmo.groups.iter().enumerate() {
if !self.portal_system.group_visibility[group_idx] {
continue;
}
println!("Rendering group {}", group_idx);
// In a real renderer, you'd submit draw calls here
}
}
fn render_group(
&self,
render_pass: &mut RenderPass,
group_idx: usize,
pipeline: &RenderPipeline,
camera: &Camera,
) {
let group_data = &self.group_buffers[group_idx];
render_pass.set_pipeline(pipeline);
render_pass.set_bind_group(0, &camera.bind_group, &[]);
render_pass.set_vertex_buffer(0, group_data.vertex_buffer.slice(..));
render_pass.set_index_buffer(group_data.index_buffer.slice(..), IndexFormat::Uint32);
// Render each batch with its material
for batch in &group_data.batches {
let material = &self.material_set.materials[batch.material_id as usize];
// Set material bind group
render_pass.set_bind_group(1, &material.bind_group, &[]);
// Apply render state based on material
self.apply_material_state(render_pass, material);
// Draw
render_pass.draw_indexed(
batch.start_index..(batch.start_index + batch.index_count),
0,
0..1,
);
}
}
fn find_camera_group(&self, camera_pos: Vec3, wmo: &LoadedWmo) -> usize {
// Find which group contains the camera
for (idx, group) in wmo.groups.iter().enumerate() {
let bbox = &group.header.bounding_box;
if camera_pos.x >= bbox.min.x && camera_pos.x <= bbox.max.x &&
camera_pos.y >= bbox.min.y && camera_pos.y <= bbox.max.y &&
camera_pos.z >= bbox.min.z && camera_pos.z <= bbox.max.z {
return idx;
}
}
// Default to first group
0
}
}
}
Code Examples
Complete WMO Scene Manager
#![allow(unused)]
fn main() {
use wow_wmo::*;
use std::sync::Arc;
pub struct WmoSceneManager {
loaded_wmos: HashMap<String, Arc<LoadedWmo>>,
instances: Vec<WmoInstance>,
renderer: WmoRenderer,
doodad_renderer: M2Renderer,
}
pub struct LoadedWmo {
wmo: Wmo,
gpu_data: WmoGpuData,
doodad_sets: Vec<Vec<PlacedDoodad>>,
collision_mesh: CollisionMesh,
}
pub struct WmoInstance {
wmo: Arc<LoadedWmo>,
transform: Matrix4<f32>,
doodad_set: usize,
tint_color: Vector4<f32>,
}
impl WmoSceneManager {
pub fn new(device: Device, queue: Queue) -> Self {
Self {
loaded_wmos: HashMap::new(),
instances: Vec::new(),
renderer: WmoRenderer::new(device.clone(), queue.clone()),
doodad_renderer: M2Renderer::new(device, queue),
}
}
pub async fn load_wmo(&mut self, path: &str) -> Result<Arc<LoadedWmo>, Box<dyn std::error::Error>> {
if let Some(cached) = self.loaded_wmos.get(path) {
return Ok(cached.clone());
}
// Load WMO files
let wmo = load_wmo(path)?;
// Create GPU resources
let gpu_data = self.renderer.create_gpu_data(&wmo)?;
// Load doodad sets
let mut doodad_sets = Vec::new();
for i in 0..wmo.root.doodad_sets.len() {
let doodads = load_doodad_set(&wmo, i)?;
doodad_sets.push(doodads);
}
// Generate collision mesh
let collision_mesh = generate_collision_mesh(&wmo)?;
let loaded = Arc::new(LoadedWmo {
wmo,
gpu_data,
doodad_sets,
collision_mesh,
});
self.loaded_wmos.insert(path.to_string(), loaded.clone());
Ok(loaded)
}
pub fn add_instance(&mut self, wmo: Arc<LoadedWmo>, transform: Matrix4<f32>, doodad_set: usize) {
self.instances.push(WmoInstance {
wmo,
transform,
doodad_set,
tint_color: Vector4::new(1.0, 1.0, 1.0, 1.0),
});
}
pub fn render_all(
&mut self,
encoder: &mut CommandEncoder,
view: &TextureView,
camera: &Camera,
) {
// Group instances by WMO for efficient rendering
let mut instance_groups: HashMap<Arc<LoadedWmo>, Vec<&WmoInstance>> = HashMap::new();
for instance in &self.instances {
instance_groups
.entry(instance.wmo.clone())
.or_insert_with(Vec::new)
.push(instance);
}
// Render each WMO type
for (wmo, instances) in instance_groups {
for instance in instances {
// Set instance transform
self.renderer.set_instance_transform(&instance.transform);
// Render WMO
self.renderer.render(encoder, view, camera, &wmo.wmo);
// Render doodads
if instance.doodad_set < wmo.doodad_sets.len() {
for doodad in &wmo.doodad_sets[instance.doodad_set] {
let world_transform = instance.transform * doodad.transform;
self.doodad_renderer.render_model(
encoder,
view,
&doodad.model,
&world_transform,
camera,
);
}
}
}
}
}
}
}
WMO Collision Detection
#![allow(unused)]
fn main() {
use ncollide3d::shape::TriMesh;
use ncollide3d::query::{Ray, RayCast};
pub struct WmoCollisionSystem {
meshes: HashMap<String, CollisionMesh>,
}
pub struct CollisionMesh {
trimesh: TriMesh<f32>,
groups: Vec<GroupCollisionData>,
}
struct GroupCollisionData {
trimesh: TriMesh<f32>,
is_indoor: bool,
material_flags: Vec<MaterialFlags>,
}
impl WmoCollisionSystem {
pub fn build_collision_mesh(wmo: &Wmo) -> CollisionMesh {
let mut all_vertices = Vec::new();
let mut all_indices = Vec::new();
let mut groups = Vec::new();
for (group_idx, group) in wmo.groups.iter().enumerate() {
let vertex_offset = all_vertices.len();
// Collect collision vertices
let mut group_vertices = Vec::new();
let mut group_indices = Vec::new();
for vertex in &group.vertices {
let point = Point3::new(vertex.position.x, vertex.position.y, vertex.position.z);
all_vertices.push(point);
group_vertices.push(point);
}
// Collect collision triangles
for batch in &group.batches {
let material = &wmo.root.materials[batch.material_id as usize];
// Skip non-collidable materials
if material.flags.contains(MaterialFlags::NO_COLLISION) {
continue;
}
for i in (batch.start_index..(batch.start_index + batch.index_count)).step_by(3) {
let i0 = group.indices[i as usize] as usize;
let i1 = group.indices[(i + 1) as usize] as usize;
let i2 = group.indices[(i + 2) as usize] as usize;
all_indices.push([
vertex_offset + i0,
vertex_offset + i1,
vertex_offset + i2,
]);
group_indices.push([i0, i1, i2]);
}
}
// Create group collision mesh
let group_mesh = TriMesh::new(
group_vertices,
group_indices,
None,
);
groups.push(GroupCollisionData {
trimesh: group_mesh,
is_indoor: group.flags.contains(GroupFlags::INDOOR),
material_flags: Vec::new(), // Populate with actual material flags
});
}
// Create overall collision mesh
let trimesh = TriMesh::new(all_vertices, all_indices, None);
CollisionMesh { trimesh, groups }
}
pub fn raycast(
&self,
wmo_instance: &WmoInstance,
ray: &Ray<f32>,
) -> Option<RaycastHit> {
let mesh = &wmo_instance.wmo.collision_mesh;
// Transform ray to WMO local space
let inv_transform = wmo_instance.transform.try_inverse()?;
let local_ray = Ray::new(
inv_transform.transform_point(&ray.origin),
inv_transform.transform_vector(&ray.dir),
);
// Perform raycast
if let Some(toi) = mesh.trimesh.toi_with_ray(&Isometry3::identity(), &local_ray, f32::MAX, true) {
// Transform hit back to world space
let local_point = local_ray.point_at(toi);
let world_point = wmo_instance.transform.transform_point(&local_point);
Some(RaycastHit {
point: world_point,
distance: toi,
normal: Vector3::y(), // Calculate actual normal
material_flags: MaterialFlags::empty(),
})
} else {
None
}
}
}
#[derive(Debug)]
pub struct RaycastHit {
pub point: Point3<f32>,
pub distance: f32,
pub normal: Vector3<f32>,
pub material_flags: MaterialFlags,
}
}
WMO Shader Implementation
// wmo_shader.wgsl
struct Camera {
view_proj: mat4x4<f32>,
view: mat4x4<f32>,
position: vec3<f32>,
time: f32,
}
struct Instance {
transform: mat4x4<f32>,
tint_color: vec4<f32>,
}
struct Material {
flags: u32,
blend_mode: u32,
shader_id: u32,
_padding: u32,
}
@group(0) @binding(0)
var<uniform> camera: Camera;
@group(1) @binding(0)
var<uniform> instance: Instance;
@group(2) @binding(0)
var<uniform> material: Material;
@group(2) @binding(1)
var diffuse_texture: texture_2d<f32>;
@group(2) @binding(2)
var texture_sampler: sampler;
struct VertexInput {
@location(0) position: vec3<f32>,
@location(1) normal: vec3<f32>,
@location(2) texcoord: vec2<f32>,
@location(3) vertex_color: vec4<f32>,
}
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) world_position: vec3<f32>,
@location(1) normal: vec3<f32>,
@location(2) texcoord: vec2<f32>,
@location(3) vertex_color: vec4<f32>,
}
@vertex
fn vs_main(input: VertexInput) -> VertexOutput {
var out: VertexOutput;
// Transform position
let world_pos = instance.transform * vec4<f32>(input.position, 1.0);
out.world_position = world_pos.xyz;
out.clip_position = camera.view_proj * world_pos;
// Transform normal
let normal_matrix = mat3x3<f32>(
instance.transform[0].xyz,
instance.transform[1].xyz,
instance.transform[2].xyz,
);
out.normal = normalize(normal_matrix * input.normal);
out.texcoord = input.texcoord;
out.vertex_color = input.vertex_color * instance.tint_color;
return out;
}
@fragment
fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
// Sample texture
var color = textureSample(diffuse_texture, texture_sampler, input.texcoord);
// Apply vertex color (pre-baked lighting)
color = color * input.vertex_color * 2.0;
// Check material flags
let is_unlit = (material.flags & 0x1u) != 0u;
let is_window = (material.flags & 0x8u) != 0u;
if (!is_unlit) {
// Simple ambient + directional light
let light_dir = normalize(vec3<f32>(0.5, 1.0, 0.3));
let n_dot_l = max(dot(input.normal, light_dir), 0.0);
let ambient = vec3<f32>(0.4, 0.4, 0.5);
color.xyz = color.xyz * (ambient + n_dot_l * 0.6);
}
// Window material effect
if (is_window) {
// Add some transparency and reflectivity
color.w = color.w * 0.8;
}
return color;
}
Best Practices
1. LOD Selection
#![allow(unused)]
fn main() {
pub struct WmoLodSelector {
distance_thresholds: Vec<f32>,
}
impl WmoLodSelector {
pub fn new() -> Self {
Self {
distance_thresholds: vec![100.0, 300.0, 600.0],
}
}
pub fn select_lod(
&self,
wmo_lods: &[Wmo],
camera_pos: Point3<f32>,
wmo_center: Point3<f32>,
) -> usize {
let distance = (camera_pos - wmo_center).norm();
for (i, threshold) in self.distance_thresholds.iter().enumerate() {
if distance < *threshold && i < wmo_lods.len() {
return i;
}
}
// Return lowest detail LOD
wmo_lods.len() - 1
}
pub fn select_group_detail(
&self,
group: &WmoGroup,
camera_pos: Point3<f32>,
) -> RenderDetail {
let distance = group.bounding_box.distance_to_point(&camera_pos);
if distance < 50.0 {
RenderDetail::Full
} else if distance < 200.0 {
RenderDetail::Simplified
} else {
RenderDetail::BoundingBox
}
}
}
enum RenderDetail {
Full,
Simplified,
BoundingBox,
}
}
2. Batching and Instancing
#![allow(unused)]
fn main() {
pub struct WmoBatcher {
instance_data: HashMap<String, Vec<InstanceData>>,
instance_buffers: HashMap<String, Buffer>,
}
impl WmoBatcher {
pub fn add_instance(&mut self, wmo_path: &str, transform: Matrix4<f32>, color: Vector4<f32>) {
self.instance_data
.entry(wmo_path.to_string())
.or_insert_with(Vec::new)
.push(InstanceData {
transform: transform.into(),
color: color.into(),
});
}
pub fn update_buffers(&mut self, device: &Device, queue: &Queue) {
for (path, instances) in &self.instance_data {
let buffer = device.create_buffer_init(&BufferInitDescriptor {
label: Some("WMO Instance Buffer"),
contents: bytemuck::cast_slice(instances),
usage: BufferUsages::VERTEX | BufferUsages::COPY_DST,
});
self.instance_buffers.insert(path.clone(), buffer);
}
}
pub fn render_batched(
&self,
render_pass: &mut RenderPass,
wmo_path: &str,
base_vertices: u32,
) {
if let Some(instance_buffer) = self.instance_buffers.get(wmo_path) {
let instance_count = self.instance_data[wmo_path].len() as u32;
render_pass.set_vertex_buffer(1, instance_buffer.slice(..));
render_pass.draw(0..base_vertices, 0..instance_count);
}
}
}
}
3. Occlusion Culling
#![allow(unused)]
fn main() {
pub struct OcclusionCuller {
query_pool: QuerySet,
visibility_buffer: Buffer,
}
impl OcclusionCuller {
pub fn test_group_visibility(
&mut self,
encoder: &mut CommandEncoder,
group_bounds: &[BoundingBox],
camera: &Camera,
) -> Vec<bool> {
// Render bounding boxes with occlusion queries
let mut render_pass = encoder.begin_render_pass(&RenderPassDescriptor {
label: Some("Occlusion Test Pass"),
color_attachments: &[],
depth_stencil_attachment: Some(/* depth only */),
});
for (i, bounds) in group_bounds.iter().enumerate() {
render_pass.begin_occlusion_query(i as u32);
self.render_bounding_box(&mut render_pass, bounds, camera);
render_pass.end_occlusion_query();
}
drop(render_pass);
// Read back results
self.read_visibility_results(encoder, group_bounds.len())
}
fn render_bounding_box(
&self,
render_pass: &mut RenderPass,
bounds: &BoundingBox,
camera: &Camera,
) {
// Render conservative bounding box
// Implementation details...
}
}
}
Common Issues and Solutions
Issue: Z-Fighting Between Groups
Problem: Flickering at group boundaries.
Solution:
#![allow(unused)]
fn main() {
// Add small offset between groups
fn adjust_group_boundaries(groups: &mut [WmoGroup]) {
const EPSILON: f32 = 0.001;
for (i, group) in groups.iter_mut().enumerate() {
// Slightly shrink each group's geometry
for vertex in &mut group.vertices {
let to_center = group.bounding_box.center() - vertex.position;
vertex.position += to_center.normalize() * EPSILON;
}
}
}
}
Issue: Portal Visibility Errors
Problem: Groups appearing/disappearing incorrectly.
Solution:
#![allow(unused)]
fn main() {
// Enhanced portal visibility with margin
fn is_portal_visible_conservative(
portal: &Portal,
camera_pos: Point3<f32>,
camera_frustum: &Frustum,
) -> bool {
// Add margin to portal bounds
let expanded_bounds = portal.bounding_box.expanded(1.0);
// Check frustum intersection
if !camera_frustum.intersects_aabb(&expanded_bounds) {
return false;
}
// Check if camera can see through portal
let portal_normal = calculate_portal_normal(&portal.vertices);
let to_portal = portal.center() - camera_pos;
// Add angle threshold for conservative culling
let dot = to_portal.normalize().dot(&portal_normal);
dot > -0.1 // Slightly visible from behind
}
}
Issue: Incorrect Indoor/Outdoor Lighting
Problem: Indoor areas too bright or outdoor areas too dark.
Solution:
#![allow(unused)]
fn main() {
// Separate lighting for indoor/outdoor
struct LightingSettings {
outdoor_ambient: Vector3<f32>,
outdoor_diffuse: Vector3<f32>,
indoor_ambient: Vector3<f32>,
indoor_diffuse: Vector3<f32>,
}
fn apply_group_lighting(
group: &WmoGroup,
settings: &LightingSettings,
) -> GroupLighting {
if group.flags.contains(GroupFlags::OUTDOOR) {
GroupLighting {
ambient: settings.outdoor_ambient,
diffuse: settings.outdoor_diffuse,
use_vertex_color: true,
}
} else {
GroupLighting {
ambient: settings.indoor_ambient,
diffuse: settings.indoor_diffuse,
use_vertex_color: true,
}
}
}
}
Performance Tips
1. Hierarchical Culling
#![allow(unused)]
fn main() {
pub struct HierarchicalCuller {
octree: Octree<usize>,
}
impl HierarchicalCuller {
pub fn build(wmo: &Wmo) -> Self {
let mut octree = Octree::new(wmo.root.bounding_box.clone());
for (i, group) in wmo.groups.iter().enumerate() {
octree.insert(i, &group.bounding_box);
}
Self { octree }
}
pub fn get_visible_groups(&self, frustum: &Frustum) -> Vec<usize> {
self.octree.query_frustum(frustum)
}
}
}
2. Texture Atlas for WMOs
#![allow(unused)]
fn main() {
pub struct WmoTextureAtlas {
atlas: TextureAtlas,
material_mappings: HashMap<u32, AtlasRegion>,
}
impl WmoTextureAtlas {
pub fn build_for_wmo(
wmo: &Wmo,
texture_manager: &TextureManager,
) -> Result<Self, Box<dyn std::error::Error>> {
let mut atlas = TextureAtlas::new(4096);
let mut mappings = HashMap::new();
// Collect unique textures
let mut textures = HashSet::new();
for material in &wmo.root.materials {
textures.insert(&material.texture);
}
// Pack into atlas
for (i, texture_path) in textures.iter().enumerate() {
let texture = texture_manager.load_texture(texture_path)?;
let region = atlas.add_texture(texture_path, &texture)?;
mappings.insert(i as u32, region);
}
Ok(Self {
atlas,
material_mappings: mappings,
})
}
}
}
3. Async WMO Loading
#![allow(unused)]
fn main() {
pub async fn load_wmo_async(
path: String,
) -> Result<Wmo, Box<dyn std::error::Error>> {
// Load root file
let root_data = tokio::fs::read(&path).await?;
let root = tokio::task::spawn_blocking(move || {
WmoRoot::from_bytes(&root_data)
}).await??;
// Load groups in parallel
let base_path = path.trim_end_matches(".wmo");
let mut group_futures = Vec::new();
for i in 0..root.group_count {
let group_path = format!("{}_{:03}.wmo", base_path, i);
group_futures.push(tokio::fs::read(group_path));
}
let group_data = futures::future::join_all(group_futures).await;
// Parse groups
let mut groups = Vec::new();
for data in group_data {
if let Ok(data) = data {
let group = WmoGroup::from_bytes(&data)?;
groups.push(group);
}
}
Ok(Wmo { root, groups })
}
}
Related Guides
- 📦 Working with MPQ Archives - Extract WMO files from archives
- 🖼️ Texture Loading Guide - Load BLP textures for WMOs
- 🎭 Loading M2 Models - Load doodads placed in WMOs
- 🌍 Rendering ADT Terrain - Integrate WMOs with terrain
- 📊 LOD System Guide - Implement LOD for large structures
References
- WMO Format Documentation - Complete WMO format specification
- Portal Rendering - Understanding portal-based visibility
- BSP Trees - Binary space partitioning for WMOs
- WoW Model Viewer - Reference WMO implementation
🎭 Loading M2 Models
Overview
M2 (Model 2) files are World of Warcraft’s primary 3D model format, used for
characters, creatures, items, and doodads. This guide covers loading, parsing, and
rendering M2 models using warcraft-rs, including handling associated files like
skins, animations, and physics data.
Prerequisites
Before working with M2 models, ensure you have:
- Understanding of 3D model rendering (vertices, bones, animations)
- Basic knowledge of skeletal animation systems
warcraft-rsinstalled with them2feature enabled- Graphics API knowledge (OpenGL/Vulkan/DirectX/WebGPU)
- Familiarity with texture mapping and shaders
Understanding M2 Models
M2 File Structure
M2 models consist of multiple files:
.m2: Main model file containing geometry, bones, animations.skin: Mesh data and render batches.anim: External animation sequences.bone: Bone data (newer versions).phys: Physics simulation data.skel: Shared skeleton data
Key Components
- Vertices: Position, normal, texture coords, bone weights
- Bones: Hierarchical skeleton for animation
- Animations: Keyframe sequences for movement
- Textures: Material and texture references
- Render Flags: Blending modes, culling, transparency
- Attachments: Points for weapons, effects, etc.
- Particles: Particle emitter definitions
- Ribbons: Trail effects (capes, weapon trails)
Step-by-Step Instructions
1. Loading M2 Model Files
#![allow(unused)]
fn main() {
use wow_m2::{M2Model, Skin};
use std::path::Path;
fn load_m2_model(model_path: &str) -> Result<(M2Model, Vec<Skin>), Box<dyn std::error::Error>> {
// Load main M2 file
let m2 = M2Model::load(model_path)?;
println!("Loaded M2 model: {:?}", m2.name);
println!("Version: {:?}", m2.header.version());
println!("Vertices: {}", m2.vertices.len());
println!("Bones: {}", m2.bones.len());
println!("Animations: {}", m2.animations.len());
// Load associated skin files
let mut skins = Vec::new();
// Note: The actual number of skin files varies by model
for i in 0..4 {
let skin_path = model_path.replace(".m2", &format!("{:02}.skin", i));
if Path::new(&skin_path).exists() {
let skin = Skin::load(&skin_path)?;
skins.push(skin);
}
}
Ok((m2, skins))
}
// For models with external animations
fn load_external_animations(m2: &M2Model, model_path: &str) -> Result<Vec<wow_m2::AnimFile>, Box<dyn std::error::Error>> {
let mut animations = Vec::new();
// Check for external animation files
for i in 0..m2.animation_lookup.len() {
let anim_path = model_path.replace(".m2", &format!("{:04}.anim", i));
if Path::new(&anim_path).exists() {
let anim = wow_m2::AnimFile::load(&anim_path)?;
animations.push(anim);
}
}
Ok(animations)
}
}
2. Processing Model Vertices
#![allow(unused)]
fn main() {
use wow_m2::M2Model;
#[derive(Debug, Clone)]
struct ProcessedVertex {
position: [f32; 3],
normal: [f32; 3],
tex_coords: [[f32; 2]; 2],
bone_indices: [u8; 4],
bone_weights: [u8; 4],
}
fn process_model_vertices(m2: &M2Model) -> Vec<ProcessedVertex> {
let mut processed = Vec::with_capacity(m2.vertices.len());
for vertex in &m2.vertices {
// Vertex positions are already in model space
let position = vertex.position;
// Normalize the normal vector
let normal = normalize_vec3(vertex.normal);
processed.push(ProcessedVertex {
position: [position.x, position.y, position.z],
normal: [normal.x, normal.y, normal.z],
tex_coords: [
vertex.texture_coords[0],
vertex.texture_coords[1],
],
bone_indices: vertex.bone_indices,
bone_weights: vertex.bone_weights,
});
}
processed
}
fn normalize_bone_weights(weights: [u8; 4]) -> [f32; 4] {
let sum: u32 = weights.iter().map(|&w| w as u32).sum();
if sum == 0 {
return [1.0, 0.0, 0.0, 0.0];
}
let factor = 1.0 / sum as f32;
[
weights[0] as f32 * factor,
weights[1] as f32 * factor,
weights[2] as f32 * factor,
weights[3] as f32 * factor,
]
}
}
3. Building Render Meshes from Skins
#![allow(unused)]
fn main() {
use wow_m2::{SkinFile, M2Model}; // RenderFlag is illustrative
struct ModelMesh {
vertex_buffer: Buffer,
index_buffer: Buffer,
submeshes: Vec<Submesh>,
}
struct Submesh {
index_start: u32,
index_count: u32,
material_id: u16,
render_flags: RenderFlag,
}
fn build_render_mesh(m2: &M2Model, skin: &M2Skin, device: &Device) -> ModelMesh {
// Reorder vertices according to skin
let mut skin_vertices = Vec::with_capacity(skin.vertices.len());
for &vertex_idx in &skin.vertices {
skin_vertices.push(m2.vertices[vertex_idx as usize].clone());
}
// Process vertices
let processed = process_model_vertices_from_slice(&skin_vertices);
// Create vertex buffer
let vertex_buffer = device.create_buffer_init(&BufferInitDescriptor {
label: Some("M2 Vertex Buffer"),
contents: bytemuck::cast_slice(&processed),
usage: BufferUsages::VERTEX,
});
// Build submeshes from skin
let mut submeshes = Vec::new();
let mut all_indices = Vec::new();
for submesh in &skin.submeshes {
let material = &m2.materials[submesh.material_id as usize];
submeshes.push(Submesh {
index_start: all_indices.len() as u32,
index_count: submesh.index_count as u32,
material_id: submesh.material_id,
render_flags: material.render_flags,
});
// Add indices for this submesh
for i in 0..submesh.index_count {
all_indices.push(skin.indices[(submesh.index_start + i) as usize]);
}
}
// Create index buffer
let index_buffer = device.create_buffer_init(&BufferInitDescriptor {
label: Some("M2 Index Buffer"),
contents: bytemuck::cast_slice(&all_indices),
usage: BufferUsages::INDEX,
});
ModelMesh {
vertex_buffer,
index_buffer,
submeshes,
}
}
}
4. Setting Up Skeletal Animation
#![allow(unused)]
fn main() {
// Bone and animation types from wow_m2
use nalgebra::{Matrix4, Vector3, Quaternion};
struct BoneTransform {
translation: Vector3<f32>,
rotation: Quaternion<f32>,
scale: Vector3<f32>,
}
struct AnimationState {
animation_id: u16,
current_time: u32,
looping: bool,
bone_matrices: Vec<Matrix4<f32>>,
}
impl AnimationState {
fn new(bone_count: usize) -> Self {
Self {
animation_id: 0,
current_time: 0,
looping: true,
bone_matrices: vec![Matrix4::identity(); bone_count],
}
}
fn update(&mut self, m2: &M2Model, animation: &M2Animation, delta_ms: u32) {
// Update animation time
self.current_time += delta_ms;
if self.current_time >= animation.duration {
if self.looping {
self.current_time %= animation.duration;
} else {
self.current_time = animation.duration - 1;
}
}
// Calculate bone transforms
self.calculate_bone_matrices(m2, animation);
}
fn calculate_bone_matrices(&mut self, m2: &M2Model, animation: &M2Animation) {
// First pass: calculate local transforms
let mut local_transforms = Vec::with_capacity(m2.bones.len());
for (bone_idx, bone) in m2.bones.iter().enumerate() {
let transform = self.interpolate_bone_transform(bone, animation, self.current_time);
local_transforms.push(transform);
}
// Second pass: calculate world transforms
for (bone_idx, bone) in m2.bones.iter().enumerate() {
let local_matrix = transform_to_matrix(&local_transforms[bone_idx]);
if bone.parent_bone == -1 {
// Root bone
self.bone_matrices[bone_idx] = local_matrix;
} else {
// Child bone - multiply by parent transform
let parent_matrix = self.bone_matrices[bone.parent_bone as usize];
self.bone_matrices[bone_idx] = parent_matrix * local_matrix;
}
}
}
fn interpolate_bone_transform(&self, bone: &M2Bone, animation: &M2Animation, time: u32) -> BoneTransform {
// Get animation tracks for this bone
let translation = interpolate_vec3_track(&bone.translation, animation.sequence_id, time);
let rotation = interpolate_quat_track(&bone.rotation, animation.sequence_id, time);
let scale = interpolate_vec3_track(&bone.scale, animation.sequence_id, time);
BoneTransform {
translation,
rotation,
scale,
}
}
}
fn transform_to_matrix(transform: &BoneTransform) -> Matrix4<f32> {
let translation = Matrix4::new_translation(&transform.translation);
let rotation = transform.rotation.to_homogeneous();
let scale = Matrix4::new_nonuniform_scaling(&transform.scale);
translation * rotation * scale
}
}
5. Loading and Applying Textures
#![allow(unused)]
fn main() {
// Texture types from wow_m2, BLP loading from wow_blp
struct ModelTextures {
textures: Vec<TextureHandle>,
texture_transforms: Vec<TextureTransform>,
}
#[derive(Clone)]
struct TextureTransform {
enabled: bool,
translation: AnimationBlock<Vector2<f32>>,
rotation: AnimationBlock<Quaternion<f32>>,
scale: AnimationBlock<Vector2<f32>>,
}
async fn load_model_textures(m2: &M2Model, mpq_archive: &Archive) -> Result<ModelTextures, Box<dyn std::error::Error>> {
let mut textures = Vec::new();
let mut texture_transforms = Vec::new();
for texture in &m2.textures {
// Load texture file
let texture_data = match texture.texture_type {
TextureType::Filename => {
// Extract from MPQ or load from file
mpq_archive.extract(&texture.filename)?
}
TextureType::Hardcoded => {
// Handle hardcoded textures (skin, hair, etc.)
load_hardcoded_texture(texture.hardcoded_id)?
}
};
// Parse BLP texture
let blp = Blp::from_bytes(&texture_data)?;
let texture_handle = upload_texture_to_gpu(&blp).await?;
textures.push(texture_handle);
// Store texture animation data
texture_transforms.push(TextureTransform {
enabled: texture.flags.contains(TextureFlags::ANIMATED),
translation: texture.translation.clone(),
rotation: texture.rotation.clone(),
scale: texture.scale.clone(),
});
}
Ok(ModelTextures {
textures,
texture_transforms,
})
}
}
6. Implementing Model Renderer
#![allow(unused)]
fn main() {
pub struct M2Renderer {
device: Device,
queue: Queue,
pipeline: RenderPipeline,
bone_buffer: Buffer,
texture_bind_groups: Vec<BindGroup>,
}
impl M2Renderer {
pub fn new(device: Device, queue: Queue) -> Self {
let shader = device.create_shader_module(ShaderModuleDescriptor {
label: Some("M2 Shader"),
source: ShaderSource::Wgsl(include_str!("m2_shader.wgsl")),
});
let pipeline = create_m2_pipeline(&device, &shader);
// Create bone matrix buffer (max 256 bones)
let bone_buffer = device.create_buffer(&BufferDescriptor {
label: Some("Bone Matrices"),
size: 256 * 64, // 256 4x4 matrices
usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST,
mapped_at_creation: false,
});
Self {
device,
queue,
pipeline,
bone_buffer,
texture_bind_groups: Vec::new(),
}
}
pub fn render_model(
&self,
encoder: &mut CommandEncoder,
view: &TextureView,
model: &LoadedM2Model,
animation_state: &AnimationState,
camera: &Camera,
) {
// Update bone matrices
self.queue.write_buffer(
&self.bone_buffer,
0,
bytemuck::cast_slice(&animation_state.bone_matrices),
);
let mut render_pass = encoder.begin_render_pass(&RenderPassDescriptor {
label: Some("M2 Render Pass"),
color_attachments: &[Some(RenderPassColorAttachment {
view,
resolve_target: None,
ops: Operations {
load: LoadOp::Load,
store: true,
},
})],
depth_stencil_attachment: Some(/* depth attachment */),
});
render_pass.set_pipeline(&self.pipeline);
render_pass.set_bind_group(0, &camera.bind_group, &[]);
render_pass.set_bind_group(1, &self.bone_bind_group, &[]);
// Render each submesh
for (mesh_idx, mesh) in model.meshes.iter().enumerate() {
render_pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
render_pass.set_index_buffer(mesh.index_buffer.slice(..), IndexFormat::Uint16);
for submesh in &mesh.submeshes {
// Set texture for this submesh
let texture_idx = model.texture_lookup[submesh.material_id as usize];
render_pass.set_bind_group(2, &self.texture_bind_groups[texture_idx], &[]);
// Apply render flags
self.apply_render_flags(&mut render_pass, submesh.render_flags);
// Draw
render_pass.draw_indexed(
submesh.index_start..(submesh.index_start + submesh.index_count),
0,
0..1,
);
}
}
}
fn apply_render_flags(&self, render_pass: &mut RenderPass, flags: RenderFlag) {
// Handle blending modes, culling, etc.
// This would typically be done through pipeline variants
}
}
}
Code Examples
Complete M2 Model Loader
#![allow(unused)]
fn main() {
use wow_m2::*;
use std::collections::HashMap;
pub struct M2ModelManager {
models: HashMap<String, LoadedM2Model>,
device: Device,
queue: Queue,
}
pub struct LoadedM2Model {
m2: M2Model,
skins: Vec<M2Skin>,
meshes: Vec<ModelMesh>,
textures: ModelTextures,
animations: HashMap<u16, M2Animation>,
current_animation: AnimationState,
}
impl M2ModelManager {
pub fn new(device: Device, queue: Queue) -> Self {
Self {
models: HashMap::new(),
device,
queue,
}
}
pub async fn load_model(&mut self, path: &str) -> Result<&LoadedM2Model, Box<dyn std::error::Error>> {
if self.models.contains_key(path) {
return Ok(&self.models[path]);
}
// Load M2 and skins
let (m2, skins) = load_m2_model(path)?;
// Build render meshes
let mut meshes = Vec::new();
for skin in &skins {
let mesh = build_render_mesh(&m2, skin, &self.device);
meshes.push(mesh);
}
// Load textures
let textures = load_model_textures(&m2, &self.archive).await?;
// Load animations
let mut animations = HashMap::new();
for (idx, anim_def) in m2.animations.iter().enumerate() {
animations.insert(idx as u16, anim_def.clone());
}
// Load external animations if any
let external_anims = load_external_animations(&m2, path)?;
for anim in external_anims {
animations.insert(anim.id, anim);
}
let loaded = LoadedM2Model {
m2,
skins,
meshes,
textures,
animations,
current_animation: AnimationState::new(m2.bones.len()),
};
self.models.insert(path.to_string(), loaded);
Ok(&self.models[path])
}
pub fn update_animation(&mut self, path: &str, animation_id: u16, delta_ms: u32) {
if let Some(model) = self.models.get_mut(path) {
if let Some(animation) = model.animations.get(&animation_id) {
model.current_animation.update(&model.m2, animation, delta_ms);
}
}
}
}
}
Shader for M2 Models
// m2_shader.wgsl
struct Camera {
view_proj: mat4x4<f32>,
view: mat4x4<f32>,
position: vec3<f32>,
_padding: f32,
}
struct BoneMatrices {
bones: array<mat4x4<f32>, 256>,
}
@group(0) @binding(0)
var<uniform> camera: Camera;
@group(1) @binding(0)
var<uniform> bones: BoneMatrices;
@group(2) @binding(0)
var diffuse_texture: texture_2d<f32>;
@group(2) @binding(1)
var diffuse_sampler: sampler;
struct VertexInput {
@location(0) position: vec3<f32>,
@location(1) normal: vec3<f32>,
@location(2) texcoord: vec2<f32>,
@location(3) texcoord2: vec2<f32>,
@location(4) bone_indices: vec4<u32>,
@location(5) bone_weights: vec4<f32>,
}
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) world_position: vec3<f32>,
@location(1) normal: vec3<f32>,
@location(2) texcoord: vec2<f32>,
}
@vertex
fn vs_main(input: VertexInput) -> VertexOutput {
var out: VertexOutput;
// Apply bone transformations
var skinned_position = vec4<f32>(0.0);
var skinned_normal = vec3<f32>(0.0);
for (var i = 0u; i < 4u; i++) {
let bone_idx = input.bone_indices[i];
let weight = input.bone_weights[i];
if (weight > 0.0) {
let bone_matrix = bones.bones[bone_idx];
skinned_position += bone_matrix * vec4<f32>(input.position, 1.0) * weight;
skinned_normal += (bone_matrix * vec4<f32>(input.normal, 0.0)).xyz * weight;
}
}
out.world_position = skinned_position.xyz;
out.normal = normalize(skinned_normal);
out.texcoord = input.texcoord;
out.clip_position = camera.view_proj * vec4<f32>(out.world_position, 1.0);
return out;
}
@fragment
fn fs_main(input: VertexOutput) -> @location(0) vec4<f32> {
// Sample diffuse texture
var color = textureSample(diffuse_texture, diffuse_sampler, input.texcoord);
// Basic lighting
let light_dir = normalize(vec3<f32>(0.5, 1.0, 0.3));
let n_dot_l = max(dot(input.normal, light_dir), 0.0);
let ambient = vec3<f32>(0.3, 0.3, 0.4);
color.xyz = color.xyz * (ambient + n_dot_l * 0.7);
return color;
}
Best Practices
1. Model Instancing
#![allow(unused)]
fn main() {
struct M2Instance {
transform: Matrix4<f32>,
animation_state: AnimationState,
tint_color: Vector4<f32>,
}
struct InstancedM2Renderer {
instance_buffer: Buffer,
max_instances: usize,
}
impl InstancedM2Renderer {
pub fn render_instances(
&self,
encoder: &mut CommandEncoder,
model: &LoadedM2Model,
instances: &[M2Instance],
) {
// Update instance buffer
let instance_data: Vec<InstanceData> = instances
.iter()
.map(|inst| InstanceData {
transform: inst.transform.into(),
color: inst.tint_color.into(),
})
.collect();
self.queue.write_buffer(
&self.instance_buffer,
0,
bytemuck::cast_slice(&instance_data),
);
// Render with instancing
render_pass.draw_indexed(
0..model.index_count,
0,
0..instances.len() as u32,
);
}
}
}
2. Animation Blending
#![allow(unused)]
fn main() {
pub struct AnimationBlender {
blend_time: f32,
source_animation: u16,
target_animation: u16,
blend_factor: f32,
}
impl AnimationBlender {
pub fn blend_animations(
&self,
m2: &M2Model,
source: &AnimationState,
target: &AnimationState,
) -> Vec<Matrix4<f32>> {
let mut blended_matrices = Vec::with_capacity(m2.bones.len());
for i in 0..m2.bones.len() {
let source_matrix = source.bone_matrices[i];
let target_matrix = target.bone_matrices[i];
// Decompose matrices
let (source_trans, source_rot, source_scale) = decompose_matrix(source_matrix);
let (target_trans, target_rot, target_scale) = decompose_matrix(target_matrix);
// Interpolate components
let trans = source_trans.lerp(&target_trans, self.blend_factor);
let rot = source_rot.slerp(&target_rot, self.blend_factor);
let scale = source_scale.lerp(&target_scale, self.blend_factor);
// Reconstruct matrix
let blended = Matrix4::from_translation(trans) *
Matrix4::from(rot) *
Matrix4::from_scale(scale);
blended_matrices.push(blended);
}
blended_matrices
}
}
}
3. LOD Support
#![allow(unused)]
fn main() {
pub struct M2LodSelector {
screen_space_threshold: f32,
}
impl M2LodSelector {
pub fn select_skin_lod(
&self,
model: &LoadedM2Model,
camera: &Camera,
model_position: Vector3<f32>,
) -> usize {
// Calculate screen space size
let distance = (camera.position - model_position).magnitude();
let screen_size = model.m2.bounding_radius / distance;
// Select appropriate skin LOD
if screen_size > self.screen_space_threshold {
0 // Highest detail
} else if screen_size > self.screen_space_threshold * 0.5 {
1.min(model.skins.len() - 1)
} else {
2.min(model.skins.len() - 1) // Lowest detail
}
}
}
}
Common Issues and Solutions
Issue: Incorrect Bone Weights
Problem: Model appears distorted during animation.
Solution:
#![allow(unused)]
fn main() {
fn validate_and_fix_bone_weights(vertex: &mut M2Vertex) {
// Ensure weights sum to 255 (1.0 when normalized)
let sum: u32 = vertex.bone_weights.iter().map(|&w| w as u32).sum();
if sum == 0 {
// No weights - bind to first bone
vertex.bone_weights[0] = 255;
vertex.bone_indices[0] = 0;
} else if sum != 255 {
// Normalize weights
let factor = 255.0 / sum as f32;
for weight in &mut vertex.bone_weights {
*weight = (*weight as f32 * factor) as u8;
}
}
}
}
Issue: Texture Coordinates Out of Range
Problem: Textures appear stretched or tiled incorrectly.
Solution:
#![allow(unused)]
fn main() {
fn fix_texture_coordinates(texcoord: Vector2<f32>) -> Vector2<f32> {
// M2 texture coordinates can exceed [0,1] range
// Use wrapping for tiled textures
Vector2::new(
texcoord.x.fract(),
texcoord.y.fract(),
)
}
}
Issue: Animation Playback Speed
Problem: Animations play too fast or too slow.
Solution:
#![allow(unused)]
fn main() {
impl AnimationState {
fn update_with_playback_speed(&mut self, m2: &M2Model, animation: &M2Animation, delta_ms: u32, speed: f32) {
// Apply playback speed modifier
let adjusted_delta = (delta_ms as f32 * speed) as u32;
self.current_time += adjusted_delta;
// Handle animation flags
if animation.flags.contains(AnimationFlags::LOOPED) {
self.current_time %= animation.duration;
} else if self.current_time >= animation.duration {
self.current_time = animation.duration - 1;
self.finished = true;
}
}
}
}
Performance Tips
1. Batch Similar Models
#![allow(unused)]
fn main() {
pub struct M2Batcher {
batches: HashMap<ModelId, ModelBatch>,
}
struct ModelBatch {
instances: Vec<M2Instance>,
instance_buffer: Buffer,
vertex_buffer: Buffer,
index_buffer: Buffer,
}
impl M2Batcher {
pub fn add_instance(&mut self, model_id: ModelId, instance: M2Instance) {
self.batches
.entry(model_id)
.or_insert_with(|| ModelBatch::new())
.instances
.push(instance);
}
pub fn render_all(&self, encoder: &mut CommandEncoder, view: &TextureView) {
for (model_id, batch) in &self.batches {
// Render entire batch with single draw call
self.render_batch(encoder, view, batch);
}
}
}
}
2. Async Model Loading
#![allow(unused)]
fn main() {
use tokio::task;
pub async fn load_models_async(paths: Vec<String>) -> Vec<Result<LoadedM2Model, Box<dyn std::error::Error>>> {
let mut tasks = Vec::new();
for path in paths {
let task = task::spawn(async move {
let (m2, skins) = load_m2_model(&path)?;
let textures = load_model_textures(&m2).await?;
Ok(LoadedM2Model {
m2,
skins,
textures,
// ... other fields
})
});
tasks.push(task);
}
// Wait for all models to load
let mut results = Vec::new();
for task in tasks {
results.push(task.await.unwrap());
}
results
}
}
3. Geometry Optimization
#![allow(unused)]
fn main() {
pub fn optimize_m2_geometry(skin: &M2Skin) -> OptimizedMesh {
use meshopt::*;
// Optimize vertex cache
let optimized_indices = optimize_vertex_cache(&skin.indices, skin.vertices.len());
// Remove duplicate vertices
let (unique_vertices, remap) = generate_vertex_remap(&skin.vertices);
let remapped_indices = remap_index_buffer(&optimized_indices, &remap);
// Optimize for GPU vertex fetch
let final_indices = optimize_vertex_fetch(
&remapped_indices,
&unique_vertices,
);
OptimizedMesh {
vertices: unique_vertices,
indices: final_indices,
}
}
}
Related Guides
- 📦 Working with MPQ Archives - Extract M2 files from archives
- 🖼️ Texture Loading Guide - Load BLP textures for models
- 🎬 Animation System Guide - Advanced animation techniques
- 🎨 Model Rendering Guide - Rendering optimization
References
- M2 Format Documentation - Complete M2 format specification
- WoW Model Viewer - Reference implementation
- Skeletal Animation - Understanding skeletal animation
- GPU Skinning - GPU-based skeletal animation
🎨 Model Rendering Guide
Overview
Rendering World of Warcraft models efficiently requires understanding of
GPU techniques, shader programming, and optimization strategies. This guide covers
advanced rendering techniques for M2 models and WMOs using warcraft-rs, including
materials, lighting, effects, and performance optimization.
Prerequisites
Before implementing model rendering, ensure you have:
- Strong understanding of graphics APIs (wgpu, Vulkan, OpenGL)
- Knowledge of shader programming (WGSL, GLSL, HLSL)
- Understanding of rendering pipelines and GPU architecture
- Familiarity with PBR (Physically Based Rendering) concepts
- Experience with graphics debugging tools
Understanding WoW Rendering
Rendering Features
- Multi-pass Rendering: Opaque, transparent, particle passes
- Material System: Diffuse, specular, emissive, environment maps
- Lighting: Vertex lighting, dynamic lights, ambient lighting
- Effects: Glow, transparency, billboarding, UV animation
- Shadows: Shadow mapping, cascaded shadows
- Post-processing: Bloom, fog, color grading
Rendering Challenges
- Draw Call Optimization: Thousands of objects per frame
- Transparency Sorting: Proper alpha blending order
- Texture Management: Efficient texture binding
- State Changes: Minimizing GPU state switches
- Memory Bandwidth: Vertex data optimization
Step-by-Step Instructions
1. Setting Up the Rendering Pipeline
#![allow(unused)]
fn main() {
use wgpu::*;
use bytemuck::{Pod, Zeroable};
pub struct ModelRenderer {
device: Device,
queue: Queue,
pipelines: RenderPipelineCache,
bind_group_layouts: BindGroupLayouts,
shader_cache: ShaderCache,
}
pub struct RenderPipelineCache {
opaque: HashMap<PipelineKey, RenderPipeline>,
transparent: HashMap<PipelineKey, RenderPipeline>,
particle: RenderPipeline,
}
#[derive(Debug, Clone, Hash, Eq, PartialEq)]
pub struct PipelineKey {
vertex_layout: VertexLayoutType,
material_flags: MaterialFlags,
blend_mode: BlendMode,
cull_mode: CullMode,
}
pub struct BindGroupLayouts {
camera: BindGroupLayout,
model: BindGroupLayout,
material: BindGroupLayout,
lighting: BindGroupLayout,
}
impl ModelRenderer {
pub fn new(device: Device, queue: Queue) -> Self {
let shader_cache = ShaderCache::new(&device);
let bind_group_layouts = Self::create_bind_group_layouts(&device);
let pipelines = Self::create_pipelines(&device, &shader_cache, &bind_group_layouts);
Self {
device,
queue,
pipelines,
bind_group_layouts,
shader_cache,
}
}
fn create_bind_group_layouts(device: &Device) -> BindGroupLayouts {
// Camera bind group layout
let camera = device.create_bind_group_layout(&BindGroupLayoutDescriptor {
label: Some("Camera Bind Group Layout"),
entries: &[
BindGroupLayoutEntry {
binding: 0,
visibility: ShaderStages::VERTEX | ShaderStages::FRAGMENT,
ty: BindingType::Buffer {
ty: BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: Some(NonZeroU64::new(256).unwrap()),
},
count: None,
},
],
});
// Model bind group layout (per-instance data)
let model = device.create_bind_group_layout(&BindGroupLayoutDescriptor {
label: Some("Model Bind Group Layout"),
entries: &[
// Model transform
BindGroupLayoutEntry {
binding: 0,
visibility: ShaderStages::VERTEX,
ty: BindingType::Buffer {
ty: BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: Some(NonZeroU64::new(64).unwrap()),
},
count: None,
},
// Bone matrices
BindGroupLayoutEntry {
binding: 1,
visibility: ShaderStages::VERTEX,
ty: BindingType::Buffer {
ty: BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: Some(NonZeroU64::new(64 * 256).unwrap()),
},
count: None,
},
],
});
// Material bind group layout
let material = device.create_bind_group_layout(&BindGroupLayoutDescriptor {
label: Some("Material Bind Group Layout"),
entries: &[
// Material properties
BindGroupLayoutEntry {
binding: 0,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Buffer {
ty: BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: Some(NonZeroU64::new(64).unwrap()),
},
count: None,
},
// Diffuse texture
BindGroupLayoutEntry {
binding: 1,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Texture {
multisampled: false,
view_dimension: TextureViewDimension::D2,
sample_type: TextureSampleType::Float { filterable: true },
},
count: None,
},
// Texture sampler
BindGroupLayoutEntry {
binding: 2,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Sampler(SamplerBindingType::Filtering),
count: None,
},
],
});
// Lighting bind group layout
let lighting = device.create_bind_group_layout(&BindGroupLayoutDescriptor {
label: Some("Lighting Bind Group Layout"),
entries: &[
BindGroupLayoutEntry {
binding: 0,
visibility: ShaderStages::FRAGMENT,
ty: BindingType::Buffer {
ty: BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: Some(NonZeroU64::new(32 * 128).unwrap()),
},
count: None,
},
],
});
BindGroupLayouts {
camera,
model,
material,
lighting,
}
}
}
}
2. Material System Implementation
#![allow(unused)]
fn main() {
use bitflags::bitflags;
#[repr(C)]
#[derive(Debug, Clone, Copy, Pod, Zeroable)]
pub struct GpuMaterial {
pub base_color: [f32; 4],
pub emissive: [f32; 3],
pub metallic: f32,
pub roughness: f32,
pub flags: u32,
pub blend_mode: u32,
pub _padding: u32,
}
bitflags! {
pub struct MaterialFlags: u32 {
const UNLIT = 0x01;
const UNFOGGED = 0x02;
const TWO_SIDED = 0x04;
const DEPTH_TEST = 0x08;
const DEPTH_WRITE = 0x10;
const ALPHA_TEST = 0x20;
const ADDITIVE = 0x40;
const ENVIRONMENT_MAP = 0x80;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BlendMode {
Opaque,
AlphaBlend,
Additive,
Multiply,
AlphaKey,
}
pub struct MaterialManager {
materials: HashMap<u32, Material>,
bind_groups: HashMap<u32, BindGroup>,
default_material: Material,
}
#[derive(Debug, Clone)]
pub struct Material {
pub gpu_data: GpuMaterial,
pub textures: MaterialTextures,
pub render_flags: RenderFlags,
}
#[derive(Debug, Clone)]
pub struct MaterialTextures {
pub diffuse: Option<TextureId>,
pub normal: Option<TextureId>,
pub specular: Option<TextureId>,
pub emissive: Option<TextureId>,
pub environment: Option<TextureId>,
}
impl MaterialManager {
pub fn create_material_from_m2(
&mut self,
m2_material: &M2Material,
texture_manager: &TextureManager,
) -> u32 {
let material = Material {
gpu_data: GpuMaterial {
base_color: [1.0, 1.0, 1.0, 1.0],
emissive: [0.0, 0.0, 0.0],
metallic: 0.0,
roughness: 0.8,
flags: m2_material.flags.bits(),
blend_mode: m2_material.blend_mode as u32,
_padding: 0,
},
textures: MaterialTextures {
diffuse: texture_manager.get_texture(m2_material.texture_id),
normal: None,
specular: None,
emissive: None,
environment: None,
},
render_flags: self.determine_render_flags(m2_material),
};
let material_id = self.materials.len() as u32;
self.materials.insert(material_id, material);
// Create bind group
self.create_material_bind_group(material_id);
material_id
}
fn determine_render_flags(&self, m2_material: &M2Material) -> RenderFlags {
let mut flags = RenderFlags::empty();
if m2_material.flags.contains(MaterialFlags::UNLIT) {
flags |= RenderFlags::DISABLE_LIGHTING;
}
if m2_material.flags.contains(MaterialFlags::TWO_SIDED) {
flags |= RenderFlags::DISABLE_CULLING;
}
match m2_material.blend_mode {
1 => flags |= RenderFlags::ALPHA_BLEND,
2 => flags |= RenderFlags::ADDITIVE_BLEND,
_ => {}
}
flags
}
}
}
3. Advanced Shader System
// model_shader.wgsl
// Vertex shader structures
struct CameraUniforms {
view: mat4x4<f32>,
proj: mat4x4<f32>,
view_proj: mat4x4<f32>,
eye_pos: vec3<f32>,
time: f32,
}
struct ModelUniforms {
model: mat4x4<f32>,
normal_matrix: mat3x3<f32>,
color: vec4<f32>,
}
struct MaterialUniforms {
base_color: vec4<f32>,
emissive: vec3<f32>,
metallic: f32,
roughness: f32,
flags: u32,
blend_mode: u32,
_padding: u32,
}
@group(0) @binding(0) var<uniform> camera: CameraUniforms;
@group(1) @binding(0) var<uniform> model: ModelUniforms;
@group(1) @binding(1) var<storage, read> bone_matrices: array<mat4x4<f32>>;
@group(2) @binding(0) var<uniform> material: MaterialUniforms;
@group(2) @binding(1) var diffuse_texture: texture_2d<f32>;
@group(2) @binding(2) var texture_sampler: sampler;
struct VertexInput {
@location(0) position: vec3<f32>,
@location(1) normal: vec3<f32>,
@location(2) tangent: vec4<f32>,
@location(3) texcoord0: vec2<f32>,
@location(4) texcoord1: vec2<f32>,
@location(5) color: vec4<f32>,
@location(6) bone_indices: vec4<u32>,
@location(7) bone_weights: vec4<f32>,
}
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) world_pos: vec3<f32>,
@location(1) normal: vec3<f32>,
@location(2) tangent: vec3<f32>,
@location(3) bitangent: vec3<f32>,
@location(4) texcoord0: vec2<f32>,
@location(5) texcoord1: vec2<f32>,
@location(6) vertex_color: vec4<f32>,
@location(7) view_dir: vec3<f32>,
}
@vertex
fn vs_main(in: VertexInput) -> VertexOutput {
var out: VertexOutput;
// Skeletal animation
var skinned_pos = vec4<f32>(0.0);
var skinned_normal = vec3<f32>(0.0);
var skinned_tangent = vec3<f32>(0.0);
for (var i = 0u; i < 4u; i++) {
let bone_idx = in.bone_indices[i];
let weight = in.bone_weights[i];
if (weight > 0.0) {
let bone_matrix = bone_matrices[bone_idx];
skinned_pos += bone_matrix * vec4<f32>(in.position, 1.0) * weight;
skinned_normal += (bone_matrix * vec4<f32>(in.normal, 0.0)).xyz * weight;
skinned_tangent += (bone_matrix * vec4<f32>(in.tangent.xyz, 0.0)).xyz * weight;
}
}
// Transform to world space
let world_pos = model.model * skinned_pos;
out.world_pos = world_pos.xyz;
out.clip_position = camera.view_proj * world_pos;
// Transform normals
out.normal = normalize(model.normal_matrix * skinned_normal);
out.tangent = normalize(model.normal_matrix * skinned_tangent);
out.bitangent = cross(out.normal, out.tangent) * in.tangent.w;
// Pass through vertex attributes
out.texcoord0 = in.texcoord0;
out.texcoord1 = in.texcoord1;
out.vertex_color = in.color * model.color;
// Calculate view direction
out.view_dir = normalize(camera.eye_pos - out.world_pos);
return out;
}
// Fragment shader with PBR lighting
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
// Sample textures
var base_color = textureSample(diffuse_texture, texture_sampler, in.texcoord0);
base_color *= material.base_color * in.vertex_color;
// Alpha test
if ((material.flags & 0x20u) != 0u && base_color.a < 0.5) {
discard;
}
// Check if unlit
if ((material.flags & 0x01u) != 0u) {
return vec4<f32>(base_color.rgb + material.emissive, base_color.a);
}
// Normal mapping (if available)
var N = normalize(in.normal);
// PBR calculations
let V = normalize(in.view_dir);
let NdotV = max(dot(N, V), 0.0);
// Lighting accumulation
var Lo = vec3<f32>(0.0);
// Directional light (sun)
let L = normalize(vec3<f32>(0.5, 1.0, 0.3));
let H = normalize(V + L);
let NdotL = max(dot(N, L), 0.0);
let NdotH = max(dot(N, H), 0.0);
let VdotH = max(dot(V, H), 0.0);
// BRDF
let D = distribution_ggx(NdotH, material.roughness);
let G = geometry_smith(NdotV, NdotL, material.roughness);
let F = fresnel_schlick(VdotH, vec3<f32>(0.04));
let numerator = D * G * F;
let denominator = 4.0 * NdotV * NdotL + 0.001;
let specular = numerator / denominator;
let kS = F;
let kD = (vec3<f32>(1.0) - kS) * (1.0 - material.metallic);
let radiance = vec3<f32>(2.0);
Lo += (kD * base_color.rgb / 3.14159265 + specular) * radiance * NdotL;
// Ambient
let ambient = vec3<f32>(0.3) * base_color.rgb;
// Final color
var color = ambient + Lo + material.emissive;
// Apply fog (if enabled)
if ((material.flags & 0x02u) == 0u) {
let fog_distance = length(in.world_pos - camera.eye_pos);
let fog_factor = exp(-fog_distance * 0.001);
color = mix(vec3<f32>(0.5, 0.6, 0.7), color, fog_factor);
}
return vec4<f32>(color, base_color.a);
}
// PBR helper functions
fn distribution_ggx(NdotH: f32, roughness: f32) -> f32 {
let a = roughness * roughness;
let a2 = a * a;
let NdotH2 = NdotH * NdotH;
let numerator = a2;
let denominator = NdotH2 * (a2 - 1.0) + 1.0;
let denominator2 = 3.14159265 * denominator * denominator;
return numerator / denominator2;
}
fn geometry_schlick_ggx(NdotV: f32, roughness: f32) -> f32 {
let r = roughness + 1.0;
let k = (r * r) / 8.0;
return NdotV / (NdotV * (1.0 - k) + k);
}
fn geometry_smith(NdotV: f32, NdotL: f32, roughness: f32) -> f32 {
let ggx1 = geometry_schlick_ggx(NdotV, roughness);
let ggx2 = geometry_schlick_ggx(NdotL, roughness);
return ggx1 * ggx2;
}
fn fresnel_schlick(cosTheta: f32, F0: vec3<f32>) -> vec3<f32> {
return F0 + (1.0 - F0) * pow(1.0 - cosTheta, 5.0);
}
4. Batch Rendering System
#![allow(unused)]
fn main() {
use std::sync::Arc;
pub struct BatchRenderer {
batches: HashMap<BatchKey, RenderBatch>,
instance_data: Vec<InstanceData>,
instance_buffer: Buffer,
draw_commands: Vec<DrawCommand>,
}
#[derive(Debug, Clone, Hash, Eq, PartialEq)]
pub struct BatchKey {
mesh_id: u64,
material_id: u32,
pipeline_key: PipelineKey,
}
pub struct RenderBatch {
instances: Vec<u32>, // indices into instance_data
vertex_buffer: Arc<Buffer>,
index_buffer: Arc<Buffer>,
index_count: u32,
}
#[repr(C)]
#[derive(Debug, Clone, Copy, Pod, Zeroable)]
pub struct InstanceData {
model_matrix: [[f32; 4]; 4],
normal_matrix: [[f32; 3]; 3],
color: [f32; 4],
texture_transform: [f32; 4], // scale_x, scale_y, offset_x, offset_y
}
pub struct DrawCommand {
batch_key: BatchKey,
instance_start: u32,
instance_count: u32,
}
impl BatchRenderer {
pub fn prepare_frame(&mut self, models: &[ModelInstance]) {
self.batches.clear();
self.instance_data.clear();
self.draw_commands.clear();
// Group models by batch key
for model in models {
let batch_key = BatchKey {
mesh_id: model.mesh.id(),
material_id: model.material_id,
pipeline_key: model.pipeline_key(),
};
let instance_idx = self.instance_data.len() as u32;
self.instance_data.push(model.instance_data());
self.batches
.entry(batch_key)
.or_insert_with(|| RenderBatch {
instances: Vec::new(),
vertex_buffer: model.mesh.vertex_buffer.clone(),
index_buffer: model.mesh.index_buffer.clone(),
index_count: model.mesh.index_count,
})
.instances
.push(instance_idx);
}
// Generate draw commands
for (batch_key, batch) in &self.batches {
if !batch.instances.is_empty() {
self.draw_commands.push(DrawCommand {
batch_key: batch_key.clone(),
instance_start: batch.instances[0],
instance_count: batch.instances.len() as u32,
});
}
}
// Sort draw commands for optimal rendering
self.sort_draw_commands();
// Update instance buffer
self.update_instance_buffer();
}
fn sort_draw_commands(&mut self) {
self.draw_commands.sort_by(|a, b| {
// Sort by pipeline first (minimize state changes)
match a.batch_key.pipeline_key.cmp(&b.batch_key.pipeline_key) {
std::cmp::Ordering::Equal => {
// Then by material (minimize texture bindings)
match a.batch_key.material_id.cmp(&b.batch_key.material_id) {
std::cmp::Ordering::Equal => {
// Finally by mesh
a.batch_key.mesh_id.cmp(&b.batch_key.mesh_id)
}
other => other,
}
}
other => other,
}
});
}
pub fn render(
&self,
render_pass: &mut RenderPass,
pipeline_cache: &RenderPipelineCache,
material_manager: &MaterialManager,
) {
let mut current_pipeline: Option<PipelineKey> = None;
let mut current_material: Option<u32> = None;
for command in &self.draw_commands {
let batch = &self.batches[&command.batch_key];
// Set pipeline if changed
if current_pipeline != Some(command.batch_key.pipeline_key.clone()) {
let pipeline = pipeline_cache.get(&command.batch_key.pipeline_key);
render_pass.set_pipeline(pipeline);
current_pipeline = Some(command.batch_key.pipeline_key.clone());
}
// Set material if changed
if current_material != Some(command.batch_key.material_id) {
let material_bind_group = material_manager.get_bind_group(command.batch_key.material_id);
render_pass.set_bind_group(2, material_bind_group, &[]);
current_material = Some(command.batch_key.material_id);
}
// Set mesh buffers
render_pass.set_vertex_buffer(0, batch.vertex_buffer.slice(..));
render_pass.set_index_buffer(batch.index_buffer.slice(..), IndexFormat::Uint32);
// Draw instanced
render_pass.draw_indexed(
0..batch.index_count,
0,
command.instance_start..(command.instance_start + command.instance_count),
);
}
}
}
}
5. Effect Rendering System
#![allow(unused)]
fn main() {
pub struct EffectRenderer {
glow_pipeline: RenderPipeline,
particle_pipeline: RenderPipeline,
ribbon_pipeline: RenderPipeline,
screen_effect_pipeline: RenderPipeline,
}
pub struct GlowEffect {
intensity: f32,
color: Vector3<f32>,
size: f32,
}
pub struct ParticleSystem {
emitters: Vec<ParticleEmitter>,
particles: Vec<Particle>,
vertex_buffer: Buffer,
instance_buffer: Buffer,
}
impl EffectRenderer {
pub fn render_glow_effects(
&self,
encoder: &mut CommandEncoder,
models: &[ModelWithGlow],
glow_texture: &TextureView,
depth_texture: &TextureView,
) {
// First pass: Render glowing parts to separate texture
{
let mut render_pass = encoder.begin_render_pass(&RenderPassDescriptor {
label: Some("Glow Pass"),
color_attachments: &[Some(RenderPassColorAttachment {
view: glow_texture,
resolve_target: None,
ops: Operations {
load: LoadOp::Clear(Color::BLACK),
store: true,
},
})],
depth_stencil_attachment: Some(RenderPassDepthStencilAttachment {
view: depth_texture,
depth_ops: Some(Operations {
load: LoadOp::Load,
store: false,
}),
stencil_ops: None,
}),
});
render_pass.set_pipeline(&self.glow_pipeline);
for model in models {
if model.has_glow() {
self.render_model_glow(&mut render_pass, model);
}
}
}
// Second pass: Blur glow texture
self.blur_texture(encoder, glow_texture);
// Third pass: Composite with main scene
self.composite_glow(encoder, glow_texture);
}
pub fn render_particles(
&self,
render_pass: &mut RenderPass,
particle_system: &ParticleSystem,
camera: &Camera,
) {
render_pass.set_pipeline(&self.particle_pipeline);
// Update particle vertices (billboarding)
let vertices = self.generate_particle_vertices(particle_system, camera);
particle_system.vertex_buffer.write(&vertices);
// Sort particles by distance for proper blending
let sorted_indices = self.sort_particles_by_distance(particle_system, camera);
render_pass.set_vertex_buffer(0, particle_system.vertex_buffer.slice(..));
render_pass.set_vertex_buffer(1, particle_system.instance_buffer.slice(..));
for emitter in &particle_system.emitters {
render_pass.set_bind_group(2, &emitter.material_bind_group, &[]);
let particle_range = emitter.particle_range();
render_pass.draw(
0..4, // Quad vertices
particle_range.start as u32..particle_range.end as u32,
);
}
}
fn generate_particle_vertices(
&self,
system: &ParticleSystem,
camera: &Camera,
) -> Vec<ParticleVertex> {
let mut vertices = Vec::new();
let right = camera.right();
let up = camera.up();
for particle in &system.particles {
let size = particle.size * 0.5;
// Billboard corners
let corners = [
particle.position - right * size - up * size,
particle.position + right * size - up * size,
particle.position + right * size + up * size,
particle.position - right * size + up * size,
];
for (i, corner) in corners.iter().enumerate() {
vertices.push(ParticleVertex {
position: corner.into(),
texcoord: match i {
0 => [0.0, 1.0],
1 => [1.0, 1.0],
2 => [1.0, 0.0],
3 => [0.0, 0.0],
_ => unreachable!(),
},
color: particle.color.into(),
});
}
}
vertices
}
}
}
6. Shadow Mapping
#![allow(unused)]
fn main() {
pub struct ShadowRenderer {
shadow_maps: Vec<ShadowMap>,
shadow_pipeline: RenderPipeline,
cascade_data: CascadeData,
}
pub struct ShadowMap {
texture: Texture,
view: TextureView,
size: u32,
view_proj: Matrix4<f32>,
}
pub struct CascadeData {
splits: Vec<f32>,
matrices: Vec<Matrix4<f32>>,
}
impl ShadowRenderer {
pub fn render_shadows(
&mut self,
encoder: &mut CommandEncoder,
models: &[ModelInstance],
light_direction: Vector3<f32>,
camera: &Camera,
) {
// Calculate cascade splits
self.update_cascades(camera, light_direction);
// Render each cascade
for (cascade_idx, shadow_map) in self.shadow_maps.iter_mut().enumerate() {
let mut render_pass = encoder.begin_render_pass(&RenderPassDescriptor {
label: Some(&format!("Shadow Pass Cascade {}", cascade_idx)),
color_attachments: &[],
depth_stencil_attachment: Some(RenderPassDepthStencilAttachment {
view: &shadow_map.view,
depth_ops: Some(Operations {
load: LoadOp::Clear(1.0),
store: true,
}),
stencil_ops: None,
}),
});
render_pass.set_pipeline(&self.shadow_pipeline);
// Set cascade view-projection matrix
let cascade_uniform = CascadeUniform {
view_proj: shadow_map.view_proj.into(),
};
render_pass.set_bind_group(0, &cascade_uniform.bind_group, &[]);
// Render models
for model in models {
if self.is_in_cascade_frustum(model, cascade_idx) {
self.render_model_depth_only(&mut render_pass, model);
}
}
}
}
fn update_cascades(&mut self, camera: &Camera, light_dir: Vector3<f32>) {
let view_matrix = camera.view_matrix();
let proj_matrix = camera.projection_matrix();
let inv_view_proj = (proj_matrix * view_matrix).try_inverse().unwrap();
for (i, split_distance) in self.cascade_data.splits.iter().enumerate() {
let near = if i == 0 {
camera.near()
} else {
self.cascade_data.splits[i - 1]
};
let far = *split_distance;
// Calculate cascade frustum corners
let frustum_corners = self.calculate_frustum_corners(near, far, &inv_view_proj);
// Calculate light view matrix
let center = frustum_corners.iter().sum::<Vector3<f32>>() / 8.0;
let light_view = Matrix4::look_at_rh(
&Point3::from(center + light_dir * 100.0),
&Point3::from(center),
&Vector3::y(),
);
// Calculate light projection matrix
let mut min = Vector3::new(f32::MAX, f32::MAX, f32::MAX);
let mut max = Vector3::new(f32::MIN, f32::MIN, f32::MIN);
for corner in &frustum_corners {
let view_space = light_view.transform_point(&Point3::from(*corner));
min = min.zip_map(&view_space.coords, f32::min);
max = max.zip_map(&view_space.coords, f32::max);
}
// Snap to texel grid to reduce shimmer
let texel_size = (max.x - min.x) / self.shadow_maps[i].size as f32;
min.x = (min.x / texel_size).floor() * texel_size;
max.x = (max.x / texel_size).ceil() * texel_size;
min.y = (min.y / texel_size).floor() * texel_size;
max.y = (max.y / texel_size).ceil() * texel_size;
let light_proj = Matrix4::new_orthographic(
min.x, max.x,
min.y, max.y,
min.z - 50.0, max.z + 50.0,
);
self.shadow_maps[i].view_proj = light_proj * light_view;
self.cascade_data.matrices[i] = self.shadow_maps[i].view_proj;
}
}
}
}
Code Examples
Complete Render Frame
#![allow(unused)]
fn main() {
pub struct RenderFrame {
models: Vec<ModelInstance>,
transparent_models: Vec<ModelInstance>,
particles: Vec<ParticleSystem>,
lights: Vec<Light>,
shadow_casters: Vec<ModelInstance>,
}
impl ModelRenderer {
pub fn render_frame(
&mut self,
encoder: &mut CommandEncoder,
frame: &RenderFrame,
camera: &Camera,
output_view: &TextureView,
) {
// Update per-frame uniforms
self.update_camera_uniforms(camera);
self.update_lighting_uniforms(&frame.lights);
// Shadow pass
if !frame.shadow_casters.is_empty() {
self.shadow_renderer.render_shadows(
encoder,
&frame.shadow_casters,
self.sun_direction,
camera,
);
}
// Depth pre-pass for opaque objects
self.render_depth_prepass(encoder, &frame.models);
// Main render pass
{
let mut render_pass = encoder.begin_render_pass(&RenderPassDescriptor {
label: Some("Main Render Pass"),
color_attachments: &[Some(RenderPassColorAttachment {
view: output_view,
resolve_target: None,
ops: Operations {
load: LoadOp::Clear(Color {
r: 0.1,
g: 0.2,
b: 0.3,
a: 1.0,
}),
store: true,
},
})],
depth_stencil_attachment: Some(self.depth_attachment()),
});
// Render opaque models
render_pass.push_debug_group("Opaque Models");
self.batch_renderer.prepare_frame(&frame.models);
self.batch_renderer.render(
&mut render_pass,
&self.pipelines,
&self.material_manager,
);
render_pass.pop_debug_group();
// Render transparent models (sorted back-to-front)
render_pass.push_debug_group("Transparent Models");
let sorted_transparent = self.sort_transparent_models(&frame.transparent_models, camera);
for model in sorted_transparent {
self.render_transparent_model(&mut render_pass, model);
}
render_pass.pop_debug_group();
// Render particles
render_pass.push_debug_group("Particles");
for particle_system in &frame.particles {
self.effect_renderer.render_particles(
&mut render_pass,
particle_system,
camera,
);
}
render_pass.pop_debug_group();
}
// Post-processing
self.render_post_processing(encoder, output_view);
}
fn sort_transparent_models<'a>(
&self,
models: &'a [ModelInstance],
camera: &Camera,
) -> Vec<&'a ModelInstance> {
let mut sorted: Vec<_> = models.iter().collect();
let camera_pos = camera.position();
sorted.sort_by(|a, b| {
let dist_a = (a.position() - camera_pos).magnitude_squared();
let dist_b = (b.position() - camera_pos).magnitude_squared();
dist_b.partial_cmp(&dist_a).unwrap()
});
sorted
}
}
}
GPU-Driven Rendering
#![allow(unused)]
fn main() {
pub struct GpuDrivenRenderer {
indirect_buffer: Buffer,
visibility_buffer: Buffer,
draw_commands_buffer: Buffer,
cull_shader: ComputePipeline,
}
#[repr(C)]
#[derive(Debug, Clone, Copy, Pod, Zeroable)]
struct DrawIndirectCommand {
vertex_count: u32,
instance_count: u32,
first_vertex: u32,
first_instance: u32,
}
impl GpuDrivenRenderer {
pub fn cull_and_render(
&mut self,
encoder: &mut CommandEncoder,
models: &[ModelInstance],
camera: &Camera,
) {
// Upload model data
let model_data: Vec<GpuModelData> = models
.iter()
.map(|m| GpuModelData {
bounding_sphere: m.bounding_sphere(),
lod_distances: m.lod_distances(),
instance_data: m.instance_data(),
})
.collect();
self.model_buffer.write(&model_data);
// Frustum culling compute pass
{
let mut compute_pass = encoder.begin_compute_pass(&ComputePassDescriptor {
label: Some("GPU Culling Pass"),
});
compute_pass.set_pipeline(&self.cull_shader);
compute_pass.set_bind_group(0, &self.cull_bind_group, &[]);
let dispatch_size = (models.len() as u32 + 63) / 64;
compute_pass.dispatch_workgroups(dispatch_size, 1, 1);
}
// Render using indirect draw
{
let mut render_pass = encoder.begin_render_pass(&RenderPassDescriptor {
label: Some("GPU-Driven Render Pass"),
// ... attachments
});
render_pass.set_pipeline(&self.render_pipeline);
// Multi-draw indirect
render_pass.multi_draw_indirect(
&self.indirect_buffer,
0,
models.len() as u32,
);
}
}
}
}
Best Practices
1. Render State Management
#![allow(unused)]
fn main() {
pub struct RenderStateManager {
current_state: RenderState,
state_cache: HashMap<RenderStateKey, RenderPipeline>,
}
#[derive(Debug, Clone, Hash, Eq, PartialEq)]
pub struct RenderState {
blend_mode: BlendMode,
cull_mode: CullMode,
depth_test: bool,
depth_write: bool,
stencil_test: bool,
}
impl RenderStateManager {
pub fn set_state(
&mut self,
render_pass: &mut RenderPass,
new_state: &RenderState,
) {
if self.current_state != *new_state {
// Get or create pipeline for this state
let pipeline = self.get_or_create_pipeline(new_state);
render_pass.set_pipeline(&pipeline);
self.current_state = new_state.clone();
}
}
fn get_or_create_pipeline(&mut self, state: &RenderState) -> &RenderPipeline {
let key = RenderStateKey::from(state);
self.state_cache.entry(key).or_insert_with(|| {
self.create_pipeline_for_state(state)
})
}
}
}
2. Texture Binding Optimization
#![allow(unused)]
fn main() {
pub struct TextureBindingOptimizer {
texture_arrays: HashMap<TextureFormat, TextureArray>,
bindless_heap: Option<BindlessTextureHeap>,
}
impl TextureBindingOptimizer {
pub fn optimize_texture_bindings(&mut self, materials: &[Material]) {
// Group textures by format and size
let mut texture_groups: HashMap<(TextureFormat, u32, u32), Vec<TextureId>> = HashMap::new();
for material in materials {
if let Some(texture_id) = material.textures.diffuse {
let info = self.get_texture_info(texture_id);
texture_groups
.entry((info.format, info.width, info.height))
.or_insert_with(Vec::new)
.push(texture_id);
}
}
// Create texture arrays for groups
for ((format, width, height), textures) in texture_groups {
if textures.len() > 4 {
self.create_texture_array(format, width, height, &textures);
}
}
}
}
}
3. Draw Call Merging
#![allow(unused)]
fn main() {
pub struct DrawCallMerger {
merge_distance: f32,
max_vertices_per_buffer: u32,
}
impl DrawCallMerger {
pub fn merge_static_geometry(
&self,
static_models: &[StaticModel],
) -> Vec<MergedMesh> {
let mut merged_meshes = Vec::new();
let mut spatial_hash = SpatialHash::new(self.merge_distance);
// Group models by material and spatial proximity
for model in static_models {
spatial_hash.insert(model.position, model);
}
// Merge nearby models with same material
for cell in spatial_hash.cells() {
let mut groups: HashMap<u32, Vec<&StaticModel>> = HashMap::new();
for model in cell {
groups
.entry(model.material_id)
.or_insert_with(Vec::new)
.push(model);
}
for (material_id, models) in groups {
if let Some(merged) = self.merge_models(&models) {
merged_meshes.push(merged);
}
}
}
merged_meshes
}
}
}
Common Issues and Solutions
Issue: Z-Fighting
Problem: Flickering between overlapping surfaces.
Solution:
#![allow(unused)]
fn main() {
// Use polygon offset for decals
let decal_pipeline = device.create_render_pipeline(&RenderPipelineDescriptor {
// ... other settings
depth_stencil: Some(DepthStencilState {
format: TextureFormat::Depth32Float,
depth_write_enabled: true,
depth_compare: CompareFunction::LessEqual,
stencil: StencilState::default(),
bias: DepthBiasState {
constant: -1,
slope_scale: -1.0,
clamp: 0.0,
},
}),
});
}
Issue: Transparency Sorting
Problem: Incorrect rendering order for transparent objects.
Solution:
#![allow(unused)]
fn main() {
pub struct TransparencyManager {
oit_buffers: OitBuffers, // Order-Independent Transparency
}
impl TransparencyManager {
pub fn render_transparent_oit(
&mut self,
render_pass: &mut RenderPass,
transparent_objects: &[TransparentObject],
) {
// Use per-pixel linked lists or weighted blended OIT
render_pass.set_pipeline(&self.oit_accumulation_pipeline);
for object in transparent_objects {
// Render to OIT buffers without sorting
self.render_to_oit_buffer(render_pass, object);
}
// Resolve OIT buffer to final image
self.resolve_oit(render_pass);
}
}
}
Issue: Shader Compilation Stutter
Problem: Frame drops when new shaders compile.
Solution:
#![allow(unused)]
fn main() {
pub struct ShaderPrecompiler {
compile_queue: Arc<Mutex<VecDeque<ShaderVariant>>>,
worker_thread: Option<JoinHandle<()>>,
}
impl ShaderPrecompiler {
pub fn precompile_variants(&mut self, materials: &[Material]) {
// Collect all possible shader variants
let mut variants = HashSet::new();
for material in materials {
variants.insert(ShaderVariant::from_material(material));
}
// Queue for background compilation
let mut queue = self.compile_queue.lock().unwrap();
for variant in variants {
queue.push_back(variant);
}
}
}
}
Performance Tips
1. GPU Instancing
#![allow(unused)]
fn main() {
pub fn optimize_instancing(models: &[ModelInstance]) -> Vec<InstancedDraw> {
let mut instanced_draws: HashMap<MeshId, Vec<InstanceData>> = HashMap::new();
for model in models {
instanced_draws
.entry(model.mesh_id)
.or_insert_with(Vec::new)
.push(model.instance_data());
}
instanced_draws
.into_iter()
.filter(|(_, instances)| instances.len() > 1)
.map(|(mesh_id, instances)| InstancedDraw {
mesh_id,
instances,
})
.collect()
}
}
2. Occlusion Culling
#![allow(unused)]
fn main() {
pub struct HiZOcclusionCuller {
hi_z_buffer: Texture,
hi_z_levels: Vec<TextureView>,
}
impl HiZOcclusionCuller {
pub fn test_visibility(
&self,
bounding_boxes: &[BoundingBox],
camera: &Camera,
) -> Vec<bool> {
// Use hierarchical Z-buffer for fast occlusion tests
let mut visibility = vec![true; bounding_boxes.len()];
for (i, bbox) in bounding_boxes.iter().enumerate() {
let screen_rect = self.project_to_screen(bbox, camera);
let mip_level = self.select_mip_level(screen_rect);
visibility[i] = self.test_rect_visibility(screen_rect, mip_level);
}
visibility
}
}
}
3. Mesh LOD Selection
#![allow(unused)]
fn main() {
pub struct LodSelector {
screen_space_error: f32,
}
impl LodSelector {
pub fn select_lod(
&self,
model: &Model,
camera: &Camera,
screen_height: f32,
) -> usize {
let distance = (model.center - camera.position()).magnitude();
let screen_size = (model.radius / distance) * screen_height;
// Select LOD based on screen coverage
for (i, lod) in model.lods.iter().enumerate() {
if screen_size * lod.error_metric < self.screen_space_error {
return i;
}
}
model.lods.len() - 1
}
}
}
Related Guides
- 🎭 Loading M2 Models - Load models for rendering
- 🏛️ WMO Rendering Guide - Render world objects
- 🖼️ Texture Loading Guide - Texture management
- 🎬 Animation System Guide - Animate rendered models
- 📊 LOD System Guide - Level-of-detail rendering
References
- Real-Time Rendering - Rendering techniques reference
- GPU Gems - Advanced GPU programming
- Learn OpenGL - OpenGL techniques
- GPU-Driven Rendering - GPU-driven techniques
🖼️ Texture Loading Guide
Overview
BLP (Blizzard Picture) is the proprietary texture format used throughout World
of Warcraft for all textures including UI elements, models, terrain, and
effects. This guide covers loading, decoding, and using BLP textures with
warcraft-rs, including mipmap handling, format conversion, and GPU upload.
Prerequisites
Before working with textures, ensure you have:
- Understanding of texture formats and graphics APIs
- Basic knowledge of image processing
warcraft-rsinstalled with theblpfeature enabled- A graphics framework (wgpu, OpenGL, Vulkan, DirectX)
- Familiarity with texture compression formats
Understanding BLP Format
BLP Versions
- BLP0: Legacy format (Warcraft III)
- BLP1: Classic WoW through WotLK
- BLP2: Cataclysm and later
Compression Types
- Uncompressed: Raw BGRA data
- Palettized: 256-color palette compression
- DXT: DirectX texture compression (DXT1, DXT3, DXT5)
- JPEG: JPEG compressed (BLP1 only)
Key Features
- Mipmaps: Pre-generated levels for efficient rendering
- Alpha Channel: Transparency support
- Power-of-two: Dimensions are always powers of 2
- Maximum Size: Typically 2048x2048 or 4096x4096
Step-by-Step Instructions
1. Loading BLP Files
#![allow(unused)]
fn main() {
use wow_blp::{BlpImage, parser::load_blp}; // BlpFormat is illustrative
use std::path::Path;
fn load_blp_texture(file_path: &str) -> Result<Blp, Box<dyn std::error::Error>> {
// Load from file
let blp = Blp::from_file(file_path)?;
println!("BLP Version: {}", blp.version());
println!("Size: {}x{}", blp.width(), blp.height());
println!("Format: {:?}", blp.format());
println!("Mipmap Levels: {}", blp.mipmap_count());
println!("Has Alpha: {}", blp.has_alpha());
Ok(blp)
}
// Load from MPQ archive
fn load_blp_from_mpq(archive: &mut Archive, texture_path: &str) -> Result<Blp, Box<dyn std::error::Error>> {
let data = archive.extract(texture_path)?;
let blp = Blp::from_bytes(&data)?;
Ok(blp)
}
// Batch loading textures
fn load_multiple_textures(paths: &[&str]) -> Vec<Result<Blp, Box<dyn std::error::Error>>> {
paths.iter()
.map(|path| load_blp_texture(path))
.collect()
}
}
2. Converting BLP to Raw RGBA
#![allow(unused)]
fn main() {
use wow_blp::BlpImage; // BlpFormat, MipmapLevel are illustrative
fn convert_blp_to_rgba(blp: &Blp, mipmap_level: usize) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
// Get specific mipmap level
let mipmap = blp.get_mipmap(mipmap_level)
.ok_or("Invalid mipmap level")?;
// Convert based on format
let rgba_data = match blp.format() {
BlpFormat::Jpeg => decode_jpeg_blp(blp, mipmap)?,
BlpFormat::Palettized => decode_palettized_blp(blp, mipmap)?,
BlpFormat::Dxt1 => decode_dxt1(mipmap.data(), mipmap.width(), mipmap.height())?,
BlpFormat::Dxt3 => decode_dxt3(mipmap.data(), mipmap.width(), mipmap.height())?,
BlpFormat::Dxt5 => decode_dxt5(mipmap.data(), mipmap.width(), mipmap.height())?,
BlpFormat::Uncompressed => convert_bgra_to_rgba(mipmap.data()),
};
Ok(rgba_data)
}
fn decode_palettized_blp(blp: &Blp, mipmap: &MipmapLevel) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let palette = blp.palette()
.ok_or("No palette found for palettized BLP")?;
let mut rgba = Vec::with_capacity(mipmap.width() * mipmap.height() * 4);
for &index in mipmap.data() {
let color = &palette[index as usize];
rgba.push(color.r);
rgba.push(color.g);
rgba.push(color.b);
rgba.push(color.a);
}
Ok(rgba)
}
fn convert_bgra_to_rgba(bgra_data: &[u8]) -> Vec<u8> {
let mut rgba = Vec::with_capacity(bgra_data.len());
for chunk in bgra_data.chunks_exact(4) {
rgba.push(chunk[2]); // R
rgba.push(chunk[1]); // G
rgba.push(chunk[0]); // B
rgba.push(chunk[3]); // A
}
rgba
}
}
3. DXT Decompression
#![allow(unused)]
fn main() {
use squish::{Format, decompress_image};
fn decode_dxt1(data: &[u8], width: u32, height: u32) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let mut rgba = vec![0u8; (width * height * 4) as usize];
decompress_image(
&mut rgba,
width as i32,
height as i32,
data,
Format::Bc1, // DXT1
);
Ok(rgba)
}
fn decode_dxt3(data: &[u8], width: u32, height: u32) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let mut rgba = vec![0u8; (width * height * 4) as usize];
decompress_image(
&mut rgba,
width as i32,
height as i32,
data,
Format::Bc2, // DXT3
);
Ok(rgba)
}
fn decode_dxt5(data: &[u8], width: u32, height: u32) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let mut rgba = vec![0u8; (width * height * 4) as usize];
decompress_image(
&mut rgba,
width as i32,
height as i32,
data,
Format::Bc3, // DXT5
);
Ok(rgba)
}
// Alternative: Manual DXT decompression for learning purposes
fn decode_dxt1_block(block: &[u8; 8], output: &mut [u8], pitch: usize) {
// Extract color endpoints
let c0 = u16::from_le_bytes([block[0], block[1]]);
let c1 = u16::from_le_bytes([block[2], block[3]]);
// Decode colors
let color0 = decode_565_color(c0);
let color1 = decode_565_color(c1);
// Generate color palette
let mut colors = [color0, color1, [0, 0, 0, 255], [0, 0, 0, 255]];
if c0 > c1 {
// 4-color block
colors[2] = interpolate_color(&color0, &color1, 1, 3);
colors[3] = interpolate_color(&color0, &color1, 2, 3);
} else {
// 3-color block with transparency
colors[2] = interpolate_color(&color0, &color1, 1, 2);
colors[3] = [0, 0, 0, 0]; // Transparent
}
// Decode pixel indices
let indices = u32::from_le_bytes([block[4], block[5], block[6], block[7]]);
for y in 0..4 {
for x in 0..4 {
let index = ((indices >> ((y * 4 + x) * 2)) & 0x3) as usize;
let offset = (y * pitch + x * 4);
output[offset..offset + 4].copy_from_slice(&colors[index]);
}
}
}
fn decode_565_color(color: u16) -> [u8; 4] {
let r = ((color >> 11) & 0x1F) as u8;
let g = ((color >> 5) & 0x3F) as u8;
let b = (color & 0x1F) as u8;
[
(r << 3) | (r >> 2), // Expand 5-bit to 8-bit
(g << 2) | (g >> 4), // Expand 6-bit to 8-bit
(b << 3) | (b >> 2), // Expand 5-bit to 8-bit
255,
]
}
}
4. GPU Texture Upload
#![allow(unused)]
fn main() {
use wgpu::*;
struct GpuTexture {
texture: Texture,
view: TextureView,
sampler: Sampler,
bind_group: BindGroup,
}
fn upload_blp_to_gpu(
device: &Device,
queue: &Queue,
blp: &Blp,
label: Option<&str>,
) -> Result<GpuTexture, Box<dyn std::error::Error>> {
// Determine GPU format based on BLP format
let format = match blp.format() {
BlpFormat::Dxt1 if !blp.has_alpha() => TextureFormat::Bc1RgbaUnorm,
BlpFormat::Dxt1 => TextureFormat::Bc1RgbaUnorm,
BlpFormat::Dxt3 => TextureFormat::Bc2RgbaUnorm,
BlpFormat::Dxt5 => TextureFormat::Bc3RgbaUnorm,
_ => TextureFormat::Rgba8Unorm, // Decompress on CPU
};
// Create texture
let texture = device.create_texture(&TextureDescriptor {
label,
size: Extent3d {
width: blp.width(),
height: blp.height(),
depth_or_array_layers: 1,
},
mip_level_count: blp.mipmap_count() as u32,
sample_count: 1,
dimension: TextureDimension::D2,
format,
usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST,
view_formats: &[],
});
// Upload mipmap levels
for level in 0..blp.mipmap_count() {
let mipmap = blp.get_mipmap(level).unwrap();
let data = if format.is_compressed() {
// Use compressed data directly
mipmap.data().to_vec()
} else {
// Convert to RGBA
convert_blp_to_rgba(blp, level)?
};
queue.write_texture(
ImageCopyTexture {
texture: &texture,
mip_level: level as u32,
origin: Origin3d::ZERO,
aspect: TextureAspect::All,
},
&data,
ImageDataLayout {
offset: 0,
bytes_per_row: Some(calculate_bytes_per_row(format, mipmap.width())),
rows_per_image: Some(mipmap.height()),
},
Extent3d {
width: mipmap.width(),
height: mipmap.height(),
depth_or_array_layers: 1,
},
);
}
// Create view and sampler
let view = texture.create_view(&TextureViewDescriptor::default());
let sampler = device.create_sampler(&SamplerDescriptor {
label: Some("BLP Sampler"),
address_mode_u: AddressMode::Repeat,
address_mode_v: AddressMode::Repeat,
address_mode_w: AddressMode::Repeat,
mag_filter: FilterMode::Linear,
min_filter: FilterMode::Linear,
mipmap_filter: FilterMode::Linear,
..Default::default()
});
// Create bind group
let bind_group_layout = create_texture_bind_group_layout(device);
let bind_group = device.create_bind_group(&BindGroupDescriptor {
label: Some("BLP Bind Group"),
layout: &bind_group_layout,
entries: &[
BindGroupEntry {
binding: 0,
resource: BindingResource::TextureView(&view),
},
BindGroupEntry {
binding: 1,
resource: BindingResource::Sampler(&sampler),
},
],
});
Ok(GpuTexture {
texture,
view,
sampler,
bind_group,
})
}
fn calculate_bytes_per_row(format: TextureFormat, width: u32) -> u32 {
match format {
TextureFormat::Bc1RgbaUnorm => ((width + 3) / 4) * 8,
TextureFormat::Bc2RgbaUnorm | TextureFormat::Bc3RgbaUnorm => ((width + 3) / 4) * 16,
TextureFormat::Rgba8Unorm => width * 4,
_ => panic!("Unsupported texture format"),
}
}
}
5. Texture Caching System
#![allow(unused)]
fn main() {
use std::collections::{HashMap, VecDeque};
use std::sync::{Arc, RwLock};
pub struct TextureCache {
cache: Arc<RwLock<HashMap<String, Arc<GpuTexture>>>>,
lru_queue: Arc<RwLock<VecDeque<String>>>,
max_size: usize,
current_size: Arc<RwLock<usize>>,
device: Arc<Device>,
queue: Arc<Queue>,
}
impl TextureCache {
pub fn new(device: Arc<Device>, queue: Arc<Queue>, max_size_mb: usize) -> Self {
Self {
cache: Arc::new(RwLock::new(HashMap::new())),
lru_queue: Arc::new(RwLock::new(VecDeque::new())),
max_size: max_size_mb * 1024 * 1024,
current_size: Arc::new(RwLock::new(0)),
device,
queue,
}
}
pub async fn get_texture(&self, path: &str) -> Result<Arc<GpuTexture>, Box<dyn std::error::Error>> {
// Check cache first
{
let cache = self.cache.read().unwrap();
if let Some(texture) = cache.get(path) {
self.update_lru(path);
return Ok(texture.clone());
}
}
// Load texture
let texture = self.load_texture(path).await?;
let texture_arc = Arc::new(texture);
// Add to cache
self.add_to_cache(path.to_string(), texture_arc.clone())?;
Ok(texture_arc)
}
async fn load_texture(&self, path: &str) -> Result<GpuTexture, Box<dyn std::error::Error>> {
// Load BLP file asynchronously
let blp_data = tokio::fs::read(path).await?;
let blp = Blp::from_bytes(&blp_data)?;
// Upload to GPU on main thread
let device = self.device.clone();
let queue = self.queue.clone();
tokio::task::spawn_blocking(move || {
upload_blp_to_gpu(&device, &queue, &blp, Some(path))
})
.await?
}
fn add_to_cache(&self, path: String, texture: Arc<GpuTexture>) -> Result<(), Box<dyn std::error::Error>> {
let size = self.estimate_texture_size(&texture);
// Evict old textures if needed
while *self.current_size.read().unwrap() + size > self.max_size {
self.evict_oldest()?;
}
// Add new texture
{
let mut cache = self.cache.write().unwrap();
cache.insert(path.clone(), texture);
}
{
let mut queue = self.lru_queue.write().unwrap();
queue.push_back(path);
}
{
let mut current = self.current_size.write().unwrap();
*current += size;
}
Ok(())
}
fn evict_oldest(&self) -> Result<(), Box<dyn std::error::Error>> {
let path = {
let mut queue = self.lru_queue.write().unwrap();
queue.pop_front()
};
if let Some(path) = path {
let size = {
let mut cache = self.cache.write().unwrap();
if let Some(texture) = cache.remove(&path) {
self.estimate_texture_size(&texture)
} else {
0
}
};
let mut current = self.current_size.write().unwrap();
*current = current.saturating_sub(size);
}
Ok(())
}
fn update_lru(&self, path: &str) {
let mut queue = self.lru_queue.write().unwrap();
queue.retain(|p| p != path);
queue.push_back(path.to_string());
}
fn estimate_texture_size(&self, texture: &GpuTexture) -> usize {
// Estimate based on texture dimensions and format
// This is a rough estimate
let desc = &texture.texture.size();
let bytes_per_pixel = 4; // Assume RGBA8
desc.width as usize * desc.height as usize * bytes_per_pixel
}
}
}
6. Texture Atlas Generation
#![allow(unused)]
fn main() {
use rectangle_pack::{RectanglePacker, PackedLocation};
pub struct TextureAtlas {
texture: Texture,
packer: RectanglePacker,
regions: HashMap<String, AtlasRegion>,
}
#[derive(Clone, Debug)]
pub struct AtlasRegion {
pub x: u32,
pub y: u32,
pub width: u32,
pub height: u32,
pub uv_min: [f32; 2],
pub uv_max: [f32; 2],
}
impl TextureAtlas {
pub fn new(device: &Device, size: u32) -> Self {
let texture = device.create_texture(&TextureDescriptor {
label: Some("Texture Atlas"),
size: Extent3d {
width: size,
height: size,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: TextureDimension::D2,
format: TextureFormat::Rgba8Unorm,
usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST,
view_formats: &[],
});
let packer = RectanglePacker::new(size, size);
Self {
texture,
packer,
regions: HashMap::new(),
}
}
pub fn add_texture(
&mut self,
queue: &Queue,
name: &str,
blp: &Blp,
) -> Result<AtlasRegion, Box<dyn std::error::Error>> {
// Convert BLP to RGBA
let rgba_data = convert_blp_to_rgba(blp, 0)?;
let width = blp.width();
let height = blp.height();
// Pack into atlas
let packed = self.packer
.pack(width as i32, height as i32, false)
.ok_or("Failed to pack texture into atlas")?;
// Upload to atlas
queue.write_texture(
ImageCopyTexture {
texture: &self.texture,
mip_level: 0,
origin: Origin3d {
x: packed.x as u32,
y: packed.y as u32,
z: 0,
},
aspect: TextureAspect::All,
},
&rgba_data,
ImageDataLayout {
offset: 0,
bytes_per_row: Some(width * 4),
rows_per_image: Some(height),
},
Extent3d {
width,
height,
depth_or_array_layers: 1,
},
);
// Calculate UV coordinates
let atlas_size = self.texture.size();
let region = AtlasRegion {
x: packed.x as u32,
y: packed.y as u32,
width,
height,
uv_min: [
packed.x as f32 / atlas_size.width as f32,
packed.y as f32 / atlas_size.height as f32,
],
uv_max: [
(packed.x + width as i32) as f32 / atlas_size.width as f32,
(packed.y + height as i32) as f32 / atlas_size.height as f32,
],
};
self.regions.insert(name.to_string(), region.clone());
Ok(region)
}
}
}
Code Examples
Complete Texture Manager
#![allow(unused)]
fn main() {
use wow_blp::*;
use std::sync::Arc;
use tokio::sync::RwLock;
pub struct TextureManager {
device: Arc<Device>,
queue: Arc<Queue>,
cache: Arc<TextureCache>,
atlases: Arc<RwLock<Vec<TextureAtlas>>>,
placeholder: Arc<GpuTexture>,
}
impl TextureManager {
pub fn new(device: Arc<Device>, queue: Arc<Queue>) -> Self {
let cache = Arc::new(TextureCache::new(
device.clone(),
queue.clone(),
512, // 512 MB cache
));
// Create placeholder texture
let placeholder = create_placeholder_texture(&device, &queue);
Self {
device,
queue,
cache,
atlases: Arc::new(RwLock::new(Vec::new())),
placeholder: Arc::new(placeholder),
}
}
pub async fn load_texture(&self, path: &str) -> Arc<GpuTexture> {
match self.cache.get_texture(path).await {
Ok(texture) => texture,
Err(e) => {
eprintln!("Failed to load texture {}: {}", path, e);
self.placeholder.clone()
}
}
}
pub async fn load_texture_array(
&self,
paths: &[&str],
) -> Result<Texture, Box<dyn std::error::Error>> {
let mut blps = Vec::new();
let mut max_width = 0;
let mut max_height = 0;
// Load all BLPs
for path in paths {
let data = tokio::fs::read(path).await?;
let blp = Blp::from_bytes(&data)?;
max_width = max_width.max(blp.width());
max_height = max_height.max(blp.height());
blps.push(blp);
}
// Create texture array
let texture = self.device.create_texture(&TextureDescriptor {
label: Some("Texture Array"),
size: Extent3d {
width: max_width,
height: max_height,
depth_or_array_layers: blps.len() as u32,
},
mip_level_count: 1,
sample_count: 1,
dimension: TextureDimension::D2,
format: TextureFormat::Rgba8Unorm,
usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST,
view_formats: &[],
});
// Upload each layer
for (i, blp) in blps.iter().enumerate() {
let rgba = convert_blp_to_rgba(blp, 0)?;
self.queue.write_texture(
ImageCopyTexture {
texture: &texture,
mip_level: 0,
origin: Origin3d {
x: 0,
y: 0,
z: i as u32,
},
aspect: TextureAspect::All,
},
&rgba,
ImageDataLayout {
offset: 0,
bytes_per_row: Some(blp.width() * 4),
rows_per_image: Some(blp.height()),
},
Extent3d {
width: blp.width(),
height: blp.height(),
depth_or_array_layers: 1,
},
);
}
Ok(texture)
}
pub async fn create_atlas_for_textures(
&self,
textures: &[(String, String)], // (name, path) pairs
atlas_size: u32,
) -> Result<Arc<TextureAtlas>, Box<dyn std::error::Error>> {
let mut atlas = TextureAtlas::new(&self.device, atlas_size);
for (name, path) in textures {
let data = tokio::fs::read(path).await?;
let blp = Blp::from_bytes(&data)?;
atlas.add_texture(&self.queue, name, &blp)?;
}
let atlas_arc = Arc::new(atlas);
self.atlases.write().await.push(atlas_arc.clone());
Ok(atlas_arc)
}
}
fn create_placeholder_texture(device: &Device, queue: &Queue) -> GpuTexture {
// Create a simple checkerboard pattern
let size = 64;
let mut data = vec![0u8; size * size * 4];
for y in 0..size {
for x in 0..size {
let idx = (y * size + x) * 4;
let color = if (x / 8 + y / 8) % 2 == 0 { 255 } else { 128 };
data[idx] = color; // R
data[idx + 1] = 0; // G
data[idx + 2] = color; // B
data[idx + 3] = 255; // A
}
}
let texture = device.create_texture(&TextureDescriptor {
label: Some("Placeholder Texture"),
size: Extent3d {
width: size as u32,
height: size as u32,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: TextureDimension::D2,
format: TextureFormat::Rgba8Unorm,
usage: TextureUsages::TEXTURE_BINDING | TextureUsages::COPY_DST,
view_formats: &[],
});
queue.write_texture(
ImageCopyTexture {
texture: &texture,
mip_level: 0,
origin: Origin3d::ZERO,
aspect: TextureAspect::All,
},
&data,
ImageDataLayout {
offset: 0,
bytes_per_row: Some(size as u32 * 4),
rows_per_image: Some(size as u32),
},
Extent3d {
width: size as u32,
height: size as u32,
depth_or_array_layers: 1,
},
);
// Create view and sampler
let view = texture.create_view(&TextureViewDescriptor::default());
let sampler = device.create_sampler(&SamplerDescriptor::default());
GpuTexture {
texture,
view,
sampler,
bind_group: todo!(), // Create appropriate bind group
}
}
}
BLP Conversion Utilities
#![allow(unused)]
fn main() {
use image::{DynamicImage, ImageBuffer, Rgba};
pub fn blp_to_image(blp: &Blp) -> Result<DynamicImage, Box<dyn std::error::Error>> {
let rgba_data = convert_blp_to_rgba(blp, 0)?;
let width = blp.width();
let height = blp.height();
let image_buffer = ImageBuffer::<Rgba<u8>, Vec<u8>>::from_raw(
width,
height,
rgba_data,
).ok_or("Failed to create image buffer")?;
Ok(DynamicImage::ImageRgba8(image_buffer))
}
pub fn image_to_blp(
image: &DynamicImage,
format: BlpFormat,
generate_mipmaps: bool,
) -> Result<Blp, Box<dyn std::error::Error>> {
let rgba = image.to_rgba8();
let (width, height) = (rgba.width(), rgba.height());
// Ensure power-of-two dimensions
if !width.is_power_of_two() || !height.is_power_of_two() {
return Err("BLP textures must have power-of-two dimensions".into());
}
let mut blp = Blp::new(width, height, format);
// Add base mipmap
match format {
BlpFormat::Dxt1 | BlpFormat::Dxt3 | BlpFormat::Dxt5 => {
let compressed = compress_to_dxt(&rgba, format)?;
blp.add_mipmap(0, compressed);
}
BlpFormat::Uncompressed => {
let bgra = convert_rgba_to_bgra(&rgba);
blp.add_mipmap(0, bgra);
}
_ => return Err("Unsupported BLP format for conversion".into()),
}
// Generate mipmaps if requested
if generate_mipmaps {
let mut current = rgba.clone();
let mut level = 1;
while current.width() > 1 && current.height() > 1 {
current = image::imageops::resize(
¤t,
current.width() / 2,
current.height() / 2,
image::imageops::FilterType::Lanczos3,
);
let mipmap_data = match format {
BlpFormat::Dxt1 | BlpFormat::Dxt3 | BlpFormat::Dxt5 => {
compress_to_dxt(¤t, format)?
}
BlpFormat::Uncompressed => {
convert_rgba_to_bgra(¤t)
}
_ => unreachable!(),
};
blp.add_mipmap(level, mipmap_data);
level += 1;
}
}
Ok(blp)
}
fn compress_to_dxt(
image: &ImageBuffer<Rgba<u8>, Vec<u8>>,
format: BlpFormat,
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
use squish::{Format, CompressImage};
let squish_format = match format {
BlpFormat::Dxt1 => Format::Bc1,
BlpFormat::Dxt3 => Format::Bc2,
BlpFormat::Dxt5 => Format::Bc3,
_ => return Err("Invalid DXT format".into()),
};
let compressed = compress_image(
image.as_raw(),
image.width() as i32,
image.height() as i32,
squish_format,
);
Ok(compressed)
}
}
Best Practices
1. Texture Streaming
#![allow(unused)]
fn main() {
pub struct TextureStreamer {
loader_thread: Option<std::thread::JoinHandle<()>>,
request_sender: mpsc::Sender<TextureRequest>,
result_receiver: mpsc::Receiver<TextureResult>,
}
struct TextureRequest {
path: String,
priority: u32,
}
struct TextureResult {
path: String,
texture: Result<Blp, Box<dyn std::error::Error>>,
}
impl TextureStreamer {
pub fn new() -> Self {
let (request_tx, request_rx) = mpsc::channel();
let (result_tx, result_rx) = mpsc::channel();
let loader_thread = std::thread::spawn(move || {
texture_loader_thread(request_rx, result_tx);
});
Self {
loader_thread: Some(loader_thread),
request_sender: request_tx,
result_receiver: result_rx,
}
}
pub fn request_texture(&self, path: String, priority: u32) {
let _ = self.request_sender.send(TextureRequest { path, priority });
}
pub fn poll_results(&self) -> Vec<TextureResult> {
let mut results = Vec::new();
while let Ok(result) = self.result_receiver.try_recv() {
results.push(result);
}
results
}
}
fn texture_loader_thread(
requests: mpsc::Receiver<TextureRequest>,
results: mpsc::Sender<TextureResult>,
) {
let mut queue = BinaryHeap::new();
loop {
// Collect requests
while let Ok(request) = requests.try_recv() {
queue.push(request);
}
// Process highest priority request
if let Some(request) = queue.pop() {
let texture = load_blp_texture(&request.path);
let _ = results.send(TextureResult {
path: request.path,
texture,
});
} else {
std::thread::sleep(std::time::Duration::from_millis(10));
}
}
}
}
2. Texture Quality Settings
#![allow(unused)]
fn main() {
pub struct TextureQualitySettings {
pub max_texture_size: u32,
pub force_dxt_compression: bool,
pub generate_mipmaps: bool,
pub anisotropic_filtering: u8,
}
impl TextureQualitySettings {
pub fn high() -> Self {
Self {
max_texture_size: 4096,
force_dxt_compression: false,
generate_mipmaps: true,
anisotropic_filtering: 16,
}
}
pub fn medium() -> Self {
Self {
max_texture_size: 2048,
force_dxt_compression: true,
generate_mipmaps: true,
anisotropic_filtering: 8,
}
}
pub fn low() -> Self {
Self {
max_texture_size: 1024,
force_dxt_compression: true,
generate_mipmaps: false,
anisotropic_filtering: 2,
}
}
pub fn apply_to_blp(&self, blp: &mut Blp) {
// Downscale if needed
if blp.width() > self.max_texture_size || blp.height() > self.max_texture_size {
let scale = (self.max_texture_size as f32 / blp.width().max(blp.height()) as f32).min(1.0);
let new_width = (blp.width() as f32 * scale) as u32;
let new_height = (blp.height() as f32 * scale) as u32;
blp.resize(new_width, new_height);
}
// Force compression if needed
if self.force_dxt_compression && !blp.is_compressed() {
blp.compress_to_dxt();
}
}
}
}
3. Texture Preloading
#![allow(unused)]
fn main() {
pub struct TexturePreloader {
preload_list: Vec<String>,
loaded: Arc<RwLock<HashMap<String, Arc<GpuTexture>>>>,
}
impl TexturePreloader {
pub fn new() -> Self {
Self {
preload_list: Vec::new(),
loaded: Arc::new(RwLock::new(HashMap::new())),
}
}
pub fn add_preload_list(&mut self, paths: Vec<String>) {
self.preload_list.extend(paths);
}
pub async fn preload_all(&self, texture_manager: &TextureManager) {
let futures: Vec<_> = self.preload_list
.iter()
.map(|path| {
let path = path.clone();
let manager = texture_manager.clone();
async move {
let texture = manager.load_texture(&path).await;
(path, texture)
}
})
.collect();
let results = futures::future::join_all(futures).await;
let mut loaded = self.loaded.write().await;
for (path, texture) in results {
loaded.insert(path, texture);
}
}
}
}
Common Issues and Solutions
Issue: Out of Memory
Problem: Loading too many large textures causes GPU memory exhaustion.
Solution:
#![allow(unused)]
fn main() {
pub struct MemoryBudget {
max_memory: usize,
current_usage: AtomicUsize,
}
impl MemoryBudget {
pub fn can_allocate(&self, size: usize) -> bool {
self.current_usage.load(Ordering::Relaxed) + size <= self.max_memory
}
pub fn allocate(&self, size: usize) -> bool {
let mut current = self.current_usage.load(Ordering::Relaxed);
loop {
if current + size > self.max_memory {
return false;
}
match self.current_usage.compare_exchange(
current,
current + size,
Ordering::SeqCst,
Ordering::Relaxed,
) {
Ok(_) => return true,
Err(actual) => current = actual,
}
}
}
}
}
Issue: Texture Corruption
Problem: Textures appear corrupted or have wrong colors.
Solution:
#![allow(unused)]
fn main() {
fn validate_blp(blp: &Blp) -> Result<(), String> {
// Check magic number
if !blp.is_valid_signature() {
return Err("Invalid BLP signature".to_string());
}
// Validate dimensions
if !blp.width().is_power_of_two() || !blp.height().is_power_of_two() {
return Err("BLP dimensions must be power of two".to_string());
}
// Validate mipmap chain
let expected_levels = (blp.width().max(blp.height()) as f32).log2() as usize + 1;
if blp.mipmap_count() > expected_levels {
return Err("Invalid mipmap count".to_string());
}
Ok(())
}
}
Issue: Performance with Many Small Textures
Problem: Rendering performance drops with many texture switches.
Solution:
#![allow(unused)]
fn main() {
// Use texture arrays for similar textures
pub fn batch_similar_textures(
textures: &[Blp],
device: &Device,
queue: &Queue,
) -> Result<Texture, Box<dyn std::error::Error>> {
// Group by size
let mut groups: HashMap<(u32, u32), Vec<&Blp>> = HashMap::new();
for texture in textures {
let key = (texture.width(), texture.height());
groups.entry(key).or_insert_with(Vec::new).push(texture);
}
// Create texture arrays for each size group
for ((width, height), group) in groups {
if group.len() > 1 {
create_texture_array(device, queue, &group, width, height)?;
}
}
Ok(())
}
}
Performance Tips
1. GPU Format Selection
#![allow(unused)]
fn main() {
fn select_optimal_gpu_format(blp: &Blp, device: &Device) -> TextureFormat {
let features = device.features();
match blp.format() {
BlpFormat::Dxt1 => {
if features.contains(Features::TEXTURE_COMPRESSION_BC) {
if blp.has_alpha() {
TextureFormat::Bc1RgbaUnorm
} else {
TextureFormat::Bc1RgbaUnorm // No separate RGB format in wgpu
}
} else {
TextureFormat::Rgba8Unorm
}
}
BlpFormat::Dxt3 => {
if features.contains(Features::TEXTURE_COMPRESSION_BC) {
TextureFormat::Bc2RgbaUnorm
} else {
TextureFormat::Rgba8Unorm
}
}
BlpFormat::Dxt5 => {
if features.contains(Features::TEXTURE_COMPRESSION_BC) {
TextureFormat::Bc3RgbaUnorm
} else {
TextureFormat::Rgba8Unorm
}
}
_ => TextureFormat::Rgba8Unorm,
}
}
}
2. Async Texture Loading
#![allow(unused)]
fn main() {
use futures::stream::{FuturesUnordered, StreamExt};
pub async fn load_textures_parallel(
paths: Vec<String>,
max_concurrent: usize,
) -> Vec<Result<Blp, Box<dyn std::error::Error>>> {
let mut futures = FuturesUnordered::new();
let mut results = Vec::with_capacity(paths.len());
for (i, path) in paths.into_iter().enumerate() {
if futures.len() >= max_concurrent {
if let Some(result) = futures.next().await {
results.push(result);
}
}
futures.push(async move {
let data = tokio::fs::read(&path).await?;
Blp::from_bytes(&data)
});
}
// Collect remaining futures
while let Some(result) = futures.next().await {
results.push(result);
}
results
}
}
3. Texture Compression Cache
#![allow(unused)]
fn main() {
use std::path::PathBuf;
pub struct CompressionCache {
cache_dir: PathBuf,
}
impl CompressionCache {
pub fn new(cache_dir: PathBuf) -> Self {
std::fs::create_dir_all(&cache_dir).unwrap();
Self { cache_dir }
}
pub fn get_compressed_path(&self, original_path: &str, format: BlpFormat) -> PathBuf {
let hash = calculate_file_hash(original_path);
let filename = format!("{}_{}_{:?}.cache",
Path::new(original_path).file_stem().unwrap().to_str().unwrap(),
hash,
format
);
self.cache_dir.join(filename)
}
pub fn load_or_compress(
&self,
blp: &Blp,
original_path: &str,
target_format: BlpFormat,
) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let cache_path = self.get_compressed_path(original_path, target_format);
// Check cache
if cache_path.exists() {
return Ok(std::fs::read(cache_path)?);
}
// Compress
let compressed = match target_format {
BlpFormat::Dxt1 => compress_to_dxt1(blp)?,
BlpFormat::Dxt3 => compress_to_dxt3(blp)?,
BlpFormat::Dxt5 => compress_to_dxt5(blp)?,
_ => return Err("Unsupported compression format".into()),
};
// Save to cache
std::fs::write(cache_path, &compressed)?;
Ok(compressed)
}
}
}
Related Guides
- 📦 Working with MPQ Archives - Extract BLP textures from archives
- 🎭 Loading M2 Models - Apply textures to models
- 🌍 Rendering ADT Terrain - Texture terrain with BLPs
- 🏛️ WMO Rendering Guide - Texture world objects
- 🎨 Model Rendering Guide - Advanced texture techniques
References
- BLP Format Documentation - Complete BLP format specification
- DXT Compression - Understanding DXT formats
- Texture Best Practices - GPU texture optimization
- WoW Model Viewer - Reference implementation
🎬 Animation System Guide
Overview
World of Warcraft’s animation system is sophisticated, supporting skeletal animations,
morph targets, texture animations, and complex blending. This guide covers
implementing a complete animation system using warcraft-rs, including bone
hierarchies, animation tracks, blending, and advanced features like animation
events and facial expressions.
Prerequisites
Before implementing the animation system, ensure you have:
- Strong understanding of skeletal animation concepts
- Knowledge of quaternion math and matrix transformations
warcraft-rsinstalled with animation support- Familiarity with interpolation techniques
- Understanding of animation state machines
Understanding WoW Animation System
Animation Components
- Bones: Hierarchical skeleton structure
- Animation Sequences: Named animations with timing data
- Keyframes: Transform data at specific times
- Tracks: Separate channels for translation, rotation, scale
- Interpolation: Linear, hermite, or bezier curves
- Global Sequences: Looping animations (texture scrolling, etc.)
- Animation Lookup: Mapping animation IDs to sequences
Animation Types
- Character Animations: Walk, run, attack, idle, etc.
- Facial Animations: Expressions and lip sync
- Texture Animations: UV scrolling and transformations
- Particle Animations: Emitter behavior over time
- Camera Animations: Cutscene camera movements
Step-by-Step Instructions
1. Building the Animation System Core
#![allow(unused)]
fn main() {
use nalgebra::{Vector3, Quaternion, Matrix4, Unit};
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct AnimationSystem {
skeletons: HashMap<String, Skeleton>,
animations: HashMap<String, AnimationClip>,
blend_trees: HashMap<String, BlendTree>,
global_time: f32,
}
#[derive(Debug, Clone)]
pub struct Skeleton {
bones: Vec<Bone>,
bone_names: HashMap<String, usize>,
rest_pose: Vec<Transform>,
}
#[derive(Debug, Clone)]
pub struct Bone {
name: String,
parent: Option<usize>,
flags: BoneFlags,
pivot: Vector3<f32>,
}
#[derive(Debug, Clone, Copy)]
pub struct Transform {
translation: Vector3<f32>,
rotation: Unit<Quaternion<f32>>,
scale: Vector3<f32>,
}
impl Transform {
pub fn identity() -> Self {
Self {
translation: Vector3::zeros(),
rotation: Unit::new_unchecked(Quaternion::identity()),
scale: Vector3::new(1.0, 1.0, 1.0),
}
}
pub fn to_matrix(&self) -> Matrix4<f32> {
let t = Matrix4::new_translation(&self.translation);
let r = self.rotation.to_homogeneous();
let s = Matrix4::new_nonuniform_scaling(&self.scale);
t * r * s
}
pub fn interpolate(&self, other: &Self, t: f32) -> Self {
Self {
translation: self.translation.lerp(&other.translation, t),
rotation: Unit::new_unchecked(self.rotation.slerp(&other.rotation, t)),
scale: self.scale.lerp(&other.scale, t),
}
}
}
}
2. Animation Tracks and Keyframes
#![allow(unused)]
fn main() {
use wow_m2::{AnimationBlock, InterpolationType};
#[derive(Debug, Clone)]
pub struct AnimationClip {
name: String,
duration: u32, // milliseconds
loop_mode: LoopMode,
bone_tracks: Vec<BoneTrack>,
events: Vec<AnimationEvent>,
}
#[derive(Debug, Clone)]
pub struct BoneTrack {
bone_index: usize,
translation: Track<Vector3<f32>>,
rotation: Track<Quaternion<f32>>,
scale: Track<Vector3<f32>>,
}
#[derive(Debug, Clone)]
pub struct Track<T> {
keyframes: Vec<Keyframe<T>>,
interpolation: InterpolationType,
}
#[derive(Debug, Clone)]
pub struct Keyframe<T> {
time: u32,
value: T,
in_tangent: Option<T>,
out_tangent: Option<T>,
}
#[derive(Debug, Clone, Copy)]
pub enum LoopMode {
Once,
Loop,
PingPong,
ClampForever,
}
impl<T: Interpolatable> Track<T> {
pub fn sample(&self, time: u32, loop_mode: LoopMode, duration: u32) -> T {
if self.keyframes.is_empty() {
return T::default();
}
// Handle looping
let time = match loop_mode {
LoopMode::Once => time.min(duration),
LoopMode::Loop => time % duration,
LoopMode::PingPong => {
let cycle = time / duration;
if cycle % 2 == 0 {
time % duration
} else {
duration - (time % duration)
}
}
LoopMode::ClampForever => time.min(duration),
};
// Find surrounding keyframes
let (prev, next) = self.find_keyframes(time);
if prev == next {
return self.keyframes[prev].value.clone();
}
// Calculate interpolation factor
let prev_key = &self.keyframes[prev];
let next_key = &self.keyframes[next];
let t = (time - prev_key.time) as f32 / (next_key.time - prev_key.time) as f32;
// Interpolate based on type
match self.interpolation {
InterpolationType::None => prev_key.value.clone(),
InterpolationType::Linear => {
prev_key.value.lerp(&next_key.value, t)
}
InterpolationType::Hermite => {
self.hermite_interpolate(prev, next, t)
}
InterpolationType::Bezier => {
self.bezier_interpolate(prev, next, t)
}
}
}
fn find_keyframes(&self, time: u32) -> (usize, usize) {
// Binary search for efficiency
let pos = self.keyframes.binary_search_by_key(&time, |k| k.time);
match pos {
Ok(idx) => (idx, idx),
Err(idx) => {
if idx == 0 {
(0, 0)
} else if idx >= self.keyframes.len() {
let last = self.keyframes.len() - 1;
(last, last)
} else {
(idx - 1, idx)
}
}
}
}
fn hermite_interpolate(&self, prev_idx: usize, next_idx: usize, t: f32) -> T {
let p0 = &self.keyframes[prev_idx];
let p1 = &self.keyframes[next_idx];
let m0 = p0.out_tangent.as_ref().unwrap_or(&p0.value);
let m1 = p1.in_tangent.as_ref().unwrap_or(&p1.value);
// Hermite interpolation formula
let t2 = t * t;
let t3 = t2 * t;
let h00 = 2.0 * t3 - 3.0 * t2 + 1.0;
let h10 = t3 - 2.0 * t2 + t;
let h01 = -2.0 * t3 + 3.0 * t2;
let h11 = t3 - t2;
p0.value.scale(h00)
.add(&m0.scale(h10))
.add(&p1.value.scale(h01))
.add(&m1.scale(h11))
}
}
trait Interpolatable: Clone + Default {
fn lerp(&self, other: &Self, t: f32) -> Self;
fn scale(&self, s: f32) -> Self;
fn add(&self, other: &Self) -> Self;
}
impl Interpolatable for Vector3<f32> {
fn lerp(&self, other: &Self, t: f32) -> Self {
self + (other - self) * t
}
fn scale(&self, s: f32) -> Self {
self * s
}
fn add(&self, other: &Self) -> Self {
self + other
}
}
impl Interpolatable for Quaternion<f32> {
fn lerp(&self, other: &Self, t: f32) -> Self {
self.slerp(other, t)
}
fn scale(&self, s: f32) -> Self {
self.powf(s)
}
fn add(&self, other: &Self) -> Self {
self * other
}
}
}
3. Animation State Machine
#![allow(unused)]
fn main() {
use std::collections::VecDeque;
#[derive(Debug, Clone)]
pub struct AnimationStateMachine {
states: HashMap<String, AnimationState>,
transitions: Vec<StateTransition>,
current_state: String,
parameters: HashMap<String, AnimationParameter>,
transition_queue: VecDeque<TransitionInfo>,
}
#[derive(Debug, Clone)]
pub struct AnimationState {
name: String,
animation_clip: String,
speed: f32,
motion: Option<RootMotion>,
}
#[derive(Debug, Clone)]
pub struct StateTransition {
from: String,
to: String,
duration: f32,
conditions: Vec<TransitionCondition>,
}
#[derive(Debug, Clone)]
pub enum TransitionCondition {
ParameterEquals(String, AnimationParameter),
ParameterGreaterThan(String, f32),
ParameterLessThan(String, f32),
OnAnimationEnd,
}
#[derive(Debug, Clone)]
pub enum AnimationParameter {
Float(f32),
Int(i32),
Bool(bool),
Trigger(bool),
}
impl AnimationStateMachine {
pub fn new(initial_state: String) -> Self {
Self {
states: HashMap::new(),
transitions: Vec::new(),
current_state: initial_state,
parameters: HashMap::new(),
transition_queue: VecDeque::new(),
}
}
pub fn update(&mut self, delta_time: f32) -> Option<AnimationTransition> {
// Check transition conditions
self.check_transitions();
// Process active transitions
if let Some(mut transition) = self.transition_queue.front_mut() {
transition.progress += delta_time / transition.duration;
if transition.progress >= 1.0 {
// Complete transition
let completed = self.transition_queue.pop_front().unwrap();
self.current_state = completed.to_state;
return Some(AnimationTransition {
from: completed.from_state,
to: completed.to_state,
blend_factor: 1.0,
});
}
return Some(AnimationTransition {
from: transition.from_state.clone(),
to: transition.to_state.clone(),
blend_factor: transition.progress,
});
}
None
}
fn check_transitions(&mut self) {
for transition in &self.transitions {
if transition.from != self.current_state {
continue;
}
let mut all_conditions_met = true;
for condition in &transition.conditions {
if !self.evaluate_condition(condition) {
all_conditions_met = false;
break;
}
}
if all_conditions_met {
self.transition_queue.push_back(TransitionInfo {
from_state: transition.from.clone(),
to_state: transition.to.clone(),
duration: transition.duration,
progress: 0.0,
});
break;
}
}
}
fn evaluate_condition(&self, condition: &TransitionCondition) -> bool {
match condition {
TransitionCondition::ParameterEquals(name, expected) => {
self.parameters.get(name) == Some(expected)
}
TransitionCondition::ParameterGreaterThan(name, threshold) => {
if let Some(AnimationParameter::Float(value)) = self.parameters.get(name) {
value > threshold
} else {
false
}
}
TransitionCondition::ParameterLessThan(name, threshold) => {
if let Some(AnimationParameter::Float(value)) = self.parameters.get(name) {
value < threshold
} else {
false
}
}
TransitionCondition::OnAnimationEnd => {
// Check if current animation has ended
false // Implement based on animation playback state
}
}
}
}
#[derive(Debug, Clone)]
struct TransitionInfo {
from_state: String,
to_state: String,
duration: f32,
progress: f32,
}
#[derive(Debug, Clone)]
pub struct AnimationTransition {
pub from: String,
pub to: String,
pub blend_factor: f32,
}
}
4. Animation Blending
#![allow(unused)]
fn main() {
#[derive(Debug, Clone)]
pub struct AnimationBlender {
blend_mode: BlendMode,
layers: Vec<AnimationLayer>,
}
#[derive(Debug, Clone)]
pub struct AnimationLayer {
animation: String,
weight: f32,
mask: Option<BoneMask>,
blend_mode: LayerBlendMode,
}
#[derive(Debug, Clone)]
pub struct BoneMask {
bones: HashSet<usize>,
include_descendants: bool,
}
#[derive(Debug, Clone, Copy)]
pub enum BlendMode {
Override,
Additive,
Blend,
}
#[derive(Debug, Clone, Copy)]
pub enum LayerBlendMode {
Override,
Additive,
Multiply,
}
impl AnimationBlender {
pub fn blend_animations(
&self,
animations: &HashMap<String, AnimationClip>,
skeleton: &Skeleton,
time: u32,
) -> Vec<Transform> {
let bone_count = skeleton.bones.len();
let mut final_transforms = vec![Transform::identity(); bone_count];
let mut bone_weights = vec![0.0; bone_count];
// Process each layer
for layer in &self.layers {
if layer.weight <= 0.0 {
continue;
}
let animation = match animations.get(&layer.animation) {
Some(anim) => anim,
None => continue,
};
// Sample animation
let layer_transforms = self.sample_animation(animation, skeleton, time);
// Apply layer blending
for bone_idx in 0..bone_count {
// Check bone mask
if let Some(mask) = &layer.mask {
if !self.is_bone_in_mask(bone_idx, mask, skeleton) {
continue;
}
}
let weight = layer.weight;
match layer.blend_mode {
LayerBlendMode::Override => {
if bone_weights[bone_idx] < 1.0 {
let remaining = 1.0 - bone_weights[bone_idx];
let actual_weight = weight.min(remaining);
final_transforms[bone_idx] = final_transforms[bone_idx]
.interpolate(&layer_transforms[bone_idx], actual_weight);
bone_weights[bone_idx] += actual_weight;
}
}
LayerBlendMode::Additive => {
// Add to existing transform
let additive = layer_transforms[bone_idx];
final_transforms[bone_idx].translation += additive.translation * weight;
// Blend rotation additively
let added_rot = Quaternion::identity().slerp(&additive.rotation, weight);
final_transforms[bone_idx].rotation =
Unit::new_normalize(final_transforms[bone_idx].rotation.as_ref() * added_rot);
}
LayerBlendMode::Multiply => {
// Multiply transforms
final_transforms[bone_idx].scale.component_mul_assign(
&layer_transforms[bone_idx].scale.lerp(&Vector3::new(1.0, 1.0, 1.0), 1.0 - weight)
);
}
}
}
}
final_transforms
}
fn sample_animation(
&self,
animation: &AnimationClip,
skeleton: &Skeleton,
time: u32,
) -> Vec<Transform> {
let mut transforms = skeleton.rest_pose.clone();
for track in &animation.bone_tracks {
let bone_idx = track.bone_index;
transforms[bone_idx] = Transform {
translation: track.translation.sample(time, animation.loop_mode, animation.duration),
rotation: Unit::new_normalize(
track.rotation.sample(time, animation.loop_mode, animation.duration)
),
scale: track.scale.sample(time, animation.loop_mode, animation.duration),
};
}
transforms
}
fn is_bone_in_mask(&self, bone_idx: usize, mask: &BoneMask, skeleton: &Skeleton) -> bool {
if mask.bones.contains(&bone_idx) {
return true;
}
if mask.include_descendants {
// Check if any ancestor is in the mask
let mut current = bone_idx;
while let Some(parent) = skeleton.bones[current].parent {
if mask.bones.contains(&parent) {
return true;
}
current = parent;
}
}
false
}
}
}
5. Procedural Animation System
#![allow(unused)]
fn main() {
#[derive(Debug, Clone)]
pub struct ProceduralAnimator {
ik_chains: Vec<IKChain>,
physics_bones: Vec<PhysicsBone>,
look_at_constraints: Vec<LookAtConstraint>,
}
#[derive(Debug, Clone)]
pub struct IKChain {
end_effector: usize,
chain_length: usize,
target: Vector3<f32>,
pole_target: Option<Vector3<f32>>,
iterations: usize,
tolerance: f32,
}
#[derive(Debug, Clone)]
pub struct PhysicsBone {
bone_index: usize,
mass: f32,
damping: f32,
stiffness: f32,
gravity_scale: f32,
velocity: Vector3<f32>,
constraints: Vec<PhysicsConstraint>,
}
#[derive(Debug, Clone)]
pub struct LookAtConstraint {
bone_index: usize,
target: Vector3<f32>,
up_vector: Vector3<f32>,
weight: f32,
limits: Option<RotationLimits>,
}
impl ProceduralAnimator {
pub fn apply_procedural_animation(
&mut self,
transforms: &mut [Transform],
skeleton: &Skeleton,
world_matrices: &[Matrix4<f32>],
delta_time: f32,
) {
// Apply IK chains
for chain in &self.ik_chains {
self.solve_ik_chain(chain, transforms, skeleton, world_matrices);
}
// Apply physics simulation
for physics_bone in &mut self.physics_bones {
self.simulate_physics_bone(physics_bone, transforms, world_matrices, delta_time);
}
// Apply look-at constraints
for constraint in &self.look_at_constraints {
self.apply_look_at(constraint, transforms, world_matrices);
}
}
fn solve_ik_chain(
&self,
chain: &IKChain,
transforms: &mut [Transform],
skeleton: &Skeleton,
world_matrices: &[Matrix4<f32>],
) {
// FABRIK (Forward And Backward Reaching Inverse Kinematics)
let mut bone_indices = Vec::new();
let mut current = chain.end_effector;
// Build chain
for _ in 0..chain.chain_length {
bone_indices.push(current);
if let Some(parent) = skeleton.bones[current].parent {
current = parent;
} else {
break;
}
}
bone_indices.reverse();
// Store original positions
let mut positions: Vec<Vector3<f32>> = bone_indices
.iter()
.map(|&idx| world_matrices[idx].transform_point(&Point3::origin()).coords)
.collect();
let base_pos = positions[0];
// FABRIK iterations
for _ in 0..chain.iterations {
// Forward reaching
positions[positions.len() - 1] = chain.target;
for i in (0..positions.len() - 1).rev() {
let direction = (positions[i] - positions[i + 1]).normalize();
let bone_length = self.calculate_bone_length(&bone_indices, i, skeleton);
positions[i] = positions[i + 1] + direction * bone_length;
}
// Backward reaching
positions[0] = base_pos;
for i in 0..positions.len() - 1 {
let direction = (positions[i + 1] - positions[i]).normalize();
let bone_length = self.calculate_bone_length(&bone_indices, i, skeleton);
positions[i + 1] = positions[i] + direction * bone_length;
}
// Check tolerance
let error = (positions[positions.len() - 1] - chain.target).magnitude();
if error < chain.tolerance {
break;
}
}
// Apply rotations to achieve positions
for i in 0..bone_indices.len() - 1 {
let bone_idx = bone_indices[i];
let child_idx = bone_indices[i + 1];
// Calculate required rotation
let current_dir = (world_matrices[child_idx].transform_point(&Point3::origin()) -
world_matrices[bone_idx].transform_point(&Point3::origin())).normalize();
let target_dir = (positions[i + 1] - positions[i]).normalize();
let rotation = Quaternion::rotation_between(¤t_dir, &target_dir)
.unwrap_or(Quaternion::identity());
// Apply rotation in local space
transforms[bone_idx].rotation = Unit::new_normalize(
transforms[bone_idx].rotation.as_ref() * rotation
);
}
}
fn calculate_bone_length(&self, chain: &[usize], index: usize, skeleton: &Skeleton) -> f32 {
if index >= chain.len() - 1 {
return 0.0;
}
let bone = &skeleton.bones[chain[index]];
let child = &skeleton.bones[chain[index + 1]];
(child.pivot - bone.pivot).magnitude()
}
}
}
6. Animation Events and Callbacks
#![allow(unused)]
fn main() {
#[derive(Debug, Clone)]
pub struct AnimationEvent {
time: u32,
event_type: AnimationEventType,
parameters: HashMap<String, String>,
}
#[derive(Debug, Clone)]
pub enum AnimationEventType {
Sound(String),
Particle(String),
Footstep(FootType),
WeaponSwing,
Custom(String),
}
#[derive(Debug, Clone, Copy)]
pub enum FootType {
Left,
Right,
}
pub struct AnimationEventHandler {
handlers: HashMap<String, Box<dyn Fn(&AnimationEvent) + Send + Sync>>,
queued_events: VecDeque<QueuedEvent>,
}
#[derive(Debug, Clone)]
struct QueuedEvent {
event: AnimationEvent,
fire_time: f32,
}
impl AnimationEventHandler {
pub fn new() -> Self {
Self {
handlers: HashMap::new(),
queued_events: VecDeque::new(),
}
}
pub fn register_handler<F>(&mut self, event_type: &str, handler: F)
where
F: Fn(&AnimationEvent) + Send + Sync + 'static,
{
self.handlers.insert(event_type.to_string(), Box::new(handler));
}
pub fn process_animation_events(
&mut self,
animation: &AnimationClip,
prev_time: u32,
current_time: u32,
global_time: f32,
) {
// Handle looping
let duration = animation.duration;
match animation.loop_mode {
LoopMode::Once => {
self.collect_events_in_range(animation, prev_time, current_time, global_time);
}
LoopMode::Loop => {
if current_time < prev_time {
// Wrapped around
self.collect_events_in_range(animation, prev_time, duration, global_time);
self.collect_events_in_range(animation, 0, current_time, global_time);
} else {
self.collect_events_in_range(animation, prev_time, current_time, global_time);
}
}
_ => {
// Handle other loop modes
}
}
// Fire queued events
self.fire_ready_events(global_time);
}
fn collect_events_in_range(
&mut self,
animation: &AnimationClip,
start_time: u32,
end_time: u32,
global_time: f32,
) {
for event in &animation.events {
if event.time > start_time && event.time <= end_time {
self.queued_events.push_back(QueuedEvent {
event: event.clone(),
fire_time: global_time,
});
}
}
}
fn fire_ready_events(&mut self, current_time: f32) {
while let Some(queued) = self.queued_events.front() {
if queued.fire_time <= current_time {
let event = self.queued_events.pop_front().unwrap();
self.fire_event(&event.event);
} else {
break;
}
}
}
fn fire_event(&self, event: &AnimationEvent) {
let type_name = match &event.event_type {
AnimationEventType::Sound(_) => "sound",
AnimationEventType::Particle(_) => "particle",
AnimationEventType::Footstep(_) => "footstep",
AnimationEventType::WeaponSwing => "weapon_swing",
AnimationEventType::Custom(name) => name,
};
if let Some(handler) = self.handlers.get(type_name) {
handler(event);
}
}
}
}
Code Examples
Complete Animation Player
#![allow(unused)]
fn main() {
use wow_m2::M2Model;
pub struct AnimationPlayer {
model: Arc<M2Model>,
skeleton: Skeleton,
animation_system: AnimationSystem,
state_machine: AnimationStateMachine,
blender: AnimationBlender,
event_handler: AnimationEventHandler,
current_pose: Vec<Transform>,
world_matrices: Vec<Matrix4<f32>>,
animation_time: HashMap<String, u32>,
}
impl AnimationPlayer {
pub fn new(model: Arc<M2Model>) -> Self {
let skeleton = build_skeleton_from_m2(&model);
let animations = load_animations_from_m2(&model);
let mut animation_system = AnimationSystem::new();
animation_system.skeletons.insert("main".to_string(), skeleton.clone());
for (name, clip) in animations {
animation_system.animations.insert(name, clip);
}
let bone_count = skeleton.bones.len();
Self {
model,
skeleton,
animation_system,
state_machine: AnimationStateMachine::new("idle".to_string()),
blender: AnimationBlender::default(),
event_handler: AnimationEventHandler::new(),
current_pose: vec![Transform::identity(); bone_count],
world_matrices: vec![Matrix4::identity(); bone_count],
animation_time: HashMap::new(),
}
}
pub fn update(&mut self, delta_time: f32) {
// Update state machine
if let Some(transition) = self.state_machine.update(delta_time) {
// Handle state transition
self.handle_transition(transition);
}
// Update animation times
for (anim_name, time) in &mut self.animation_time {
if let Some(animation) = self.animation_system.animations.get(anim_name) {
let prev_time = *time;
*time = (*time + (delta_time * 1000.0) as u32) % animation.duration;
// Process animation events
self.event_handler.process_animation_events(
animation,
prev_time,
*time,
self.animation_system.global_time,
);
}
}
// Blend animations
self.current_pose = self.blender.blend_animations(
&self.animation_system.animations,
&self.skeleton,
self.get_current_animation_time(),
);
// Calculate world matrices
self.calculate_world_matrices();
// Update global time
self.animation_system.global_time += delta_time;
}
fn calculate_world_matrices(&mut self) {
for (bone_idx, bone) in self.skeleton.bones.iter().enumerate() {
let local_matrix = self.current_pose[bone_idx].to_matrix();
self.world_matrices[bone_idx] = if let Some(parent_idx) = bone.parent {
self.world_matrices[parent_idx] * local_matrix
} else {
local_matrix
};
}
}
pub fn play_animation(&mut self, name: &str, fade_in: f32) {
self.blender.layers.clear();
self.blender.layers.push(AnimationLayer {
animation: name.to_string(),
weight: 1.0,
mask: None,
blend_mode: LayerBlendMode::Override,
});
self.animation_time.insert(name.to_string(), 0);
}
pub fn add_animation_layer(&mut self, name: &str, weight: f32, mask: Option<BoneMask>) {
self.blender.layers.push(AnimationLayer {
animation: name.to_string(),
weight,
mask,
blend_mode: LayerBlendMode::Override,
});
self.animation_time.entry(name.to_string()).or_insert(0);
}
pub fn get_bone_matrices(&self) -> &[Matrix4<f32>] {
&self.world_matrices
}
pub fn set_animation_speed(&mut self, animation: &str, speed: f32) {
if let Some(state) = self.state_machine.states.get_mut(animation) {
state.speed = speed;
}
}
}
fn build_skeleton_from_m2(model: &M2Model) -> Skeleton {
let mut bones = Vec::new();
let mut bone_names = HashMap::new();
for (idx, m2_bone) in model.bones.iter().enumerate() {
bones.push(Bone {
name: format!("bone_{}", idx),
parent: if m2_bone.parent_bone >= 0 {
Some(m2_bone.parent_bone as usize)
} else {
None
},
flags: BoneFlags::from_bits(m2_bone.flags).unwrap_or_default(),
pivot: m2_bone.pivot,
});
bone_names.insert(format!("bone_{}", idx), idx);
}
// Build rest pose
let rest_pose = bones.iter().map(|_| Transform::identity()).collect();
Skeleton {
bones,
bone_names,
rest_pose,
}
}
}
Facial Animation System
#![allow(unused)]
fn main() {
pub struct FacialAnimationSystem {
blend_shapes: Vec<BlendShape>,
emotion_presets: HashMap<String, EmotionPreset>,
lip_sync_data: Option<LipSyncData>,
current_emotion: String,
emotion_blend: f32,
}
#[derive(Debug, Clone)]
pub struct BlendShape {
name: String,
vertices: Vec<u32>,
deltas: Vec<Vector3<f32>>,
current_weight: f32,
}
#[derive(Debug, Clone)]
pub struct EmotionPreset {
name: String,
blend_shape_weights: HashMap<String, f32>,
duration: f32,
}
#[derive(Debug, Clone)]
pub struct LipSyncData {
phonemes: Vec<Phoneme>,
current_phoneme: usize,
}
#[derive(Debug, Clone)]
pub struct Phoneme {
time: f32,
duration: f32,
blend_shapes: HashMap<String, f32>,
}
impl FacialAnimationSystem {
pub fn apply_facial_animation(
&mut self,
vertices: &mut [Vertex],
audio_time: f32,
emotion: &str,
intensity: f32,
) {
// Apply emotion preset
if emotion != self.current_emotion {
self.transition_emotion(emotion);
}
// Update emotion blend
self.update_emotion_blend(intensity);
// Apply lip sync if available
if let Some(lip_sync) = &mut self.lip_sync_data {
self.apply_lip_sync(lip_sync, audio_time);
}
// Apply blend shapes to vertices
self.apply_blend_shapes(vertices);
}
fn apply_blend_shapes(&self, vertices: &mut [Vertex]) {
for shape in &self.blend_shapes {
if shape.current_weight > 0.0 {
for (vert_idx, delta) in shape.vertices.iter().zip(&shape.deltas) {
let vertex = &mut vertices[*vert_idx as usize];
vertex.position += delta * shape.current_weight;
}
}
}
}
fn transition_emotion(&mut self, new_emotion: &str) {
self.current_emotion = new_emotion.to_string();
self.emotion_blend = 0.0;
// Reset blend shape weights
for shape in &mut self.blend_shapes {
shape.current_weight = 0.0;
}
}
fn update_emotion_blend(&mut self, target_intensity: f32) {
self.emotion_blend = self.emotion_blend.lerp(&target_intensity, 0.1);
if let Some(preset) = self.emotion_presets.get(&self.current_emotion) {
for (shape_name, target_weight) in &preset.blend_shape_weights {
if let Some(shape) = self.blend_shapes.iter_mut()
.find(|s| s.name == *shape_name) {
shape.current_weight = shape.current_weight.lerp(
&(target_weight * self.emotion_blend),
0.2
);
}
}
}
}
}
}
Best Practices
1. Animation Compression
#![allow(unused)]
fn main() {
pub struct AnimationCompressor {
position_threshold: f32,
rotation_threshold: f32,
scale_threshold: f32,
}
impl AnimationCompressor {
pub fn compress_animation(&self, clip: &AnimationClip) -> CompressedAnimation {
let mut compressed = CompressedAnimation {
name: clip.name.clone(),
duration: clip.duration,
tracks: Vec::new(),
};
for track in &clip.bone_tracks {
let compressed_track = CompressedTrack {
bone_index: track.bone_index,
position_keys: self.compress_vector_track(&track.translation),
rotation_keys: self.compress_quaternion_track(&track.rotation),
scale_keys: self.compress_vector_track(&track.scale),
};
compressed.tracks.push(compressed_track);
}
compressed
}
fn compress_vector_track(&self, track: &Track<Vector3<f32>>) -> Vec<CompressedKey<[f16; 3]>> {
if track.keyframes.is_empty() {
return Vec::new();
}
let mut compressed = vec![self.compress_vector_key(&track.keyframes[0])];
let mut last_value = track.keyframes[0].value;
for key in track.keyframes.iter().skip(1) {
let delta = (key.value - last_value).magnitude();
if delta > self.position_threshold {
compressed.push(self.compress_vector_key(key));
last_value = key.value;
}
}
// Always include last key
let last = track.keyframes.last().unwrap();
compressed.push(self.compress_vector_key(last));
compressed
}
fn compress_vector_key(&self, key: &Keyframe<Vector3<f32>>) -> CompressedKey<[f16; 3]> {
CompressedKey {
time: key.time as u16,
value: [
half::f16::from_f32(key.value.x),
half::f16::from_f32(key.value.y),
half::f16::from_f32(key.value.z),
],
}
}
}
#[derive(Debug, Clone)]
pub struct CompressedAnimation {
name: String,
duration: u32,
tracks: Vec<CompressedTrack>,
}
#[derive(Debug, Clone)]
pub struct CompressedTrack {
bone_index: usize,
position_keys: Vec<CompressedKey<[f16; 3]>>,
rotation_keys: Vec<CompressedKey<[i16; 4]>>,
scale_keys: Vec<CompressedKey<[f16; 3]>>,
}
#[derive(Debug, Clone)]
pub struct CompressedKey<T> {
time: u16,
value: T,
}
}
2. Animation Caching
#![allow(unused)]
fn main() {
pub struct AnimationCache {
cache: LruCache<AnimationCacheKey, Arc<Vec<Transform>>>,
max_entries: usize,
}
#[derive(Debug, Clone, Hash, Eq, PartialEq)]
struct AnimationCacheKey {
animation_name: String,
time: u32,
blend_weights: Vec<OrderedFloat<f32>>,
}
impl AnimationCache {
pub fn new(max_entries: usize) -> Self {
Self {
cache: LruCache::new(max_entries),
max_entries,
}
}
pub fn get_or_compute<F>(
&mut self,
key: AnimationCacheKey,
compute: F,
) -> Arc<Vec<Transform>>
where
F: FnOnce() -> Vec<Transform>,
{
if let Some(cached) = self.cache.get(&key) {
return cached.clone();
}
let computed = Arc::new(compute());
self.cache.put(key, computed.clone());
computed
}
}
}
3. Multi-threaded Animation
#![allow(unused)]
fn main() {
use rayon::prelude::*;
pub struct ParallelAnimationProcessor {
thread_pool: ThreadPool,
}
impl ParallelAnimationProcessor {
pub fn process_animation_batch(
&self,
animations: &[AnimationInstance],
) -> Vec<AnimationResult> {
animations
.par_iter()
.map(|instance| self.process_single_animation(instance))
.collect()
}
fn process_single_animation(&self, instance: &AnimationInstance) -> AnimationResult {
// Process animation on thread pool
let pose = instance.player.calculate_pose(instance.time);
let matrices = self.calculate_matrices(&pose);
AnimationResult {
instance_id: instance.id,
bone_matrices: matrices,
}
}
}
}
Common Issues and Solutions
Issue: Animation Jitter
Problem: Animations appear jittery or stuttering.
Solution:
#![allow(unused)]
fn main() {
pub struct AnimationSmoother {
history: VecDeque<Vec<Transform>>,
max_history: usize,
}
impl AnimationSmoother {
pub fn smooth_animation(&mut self, current_pose: Vec<Transform>) -> Vec<Transform> {
self.history.push_back(current_pose.clone());
if self.history.len() > self.max_history {
self.history.pop_front();
}
// Average recent poses
let mut smoothed = current_pose;
let history_weight = 0.3;
for (i, pose) in self.history.iter().rev().enumerate().skip(1) {
let weight = history_weight * (1.0 / (i as f32 + 1.0));
for (bone_idx, transform) in pose.iter().enumerate() {
smoothed[bone_idx] = smoothed[bone_idx].interpolate(transform, weight);
}
}
smoothed
}
}
}
Issue: Bone Hierarchy Errors
Problem: Child bones not following parent transformations.
Solution:
#![allow(unused)]
fn main() {
fn validate_bone_hierarchy(skeleton: &Skeleton) -> Result<(), String> {
let mut visited = vec![false; skeleton.bones.len()];
for (idx, bone) in skeleton.bones.iter().enumerate() {
if let Some(parent) = bone.parent {
if parent >= skeleton.bones.len() {
return Err(format!("Bone {} has invalid parent {}", idx, parent));
}
if parent == idx {
return Err(format!("Bone {} is its own parent", idx));
}
// Check for cycles
let mut current = parent;
let mut chain = HashSet::new();
chain.insert(idx);
while let Some(next_parent) = skeleton.bones[current].parent {
if chain.contains(&next_parent) {
return Err(format!("Cycle detected in bone hierarchy at {}", idx));
}
chain.insert(current);
current = next_parent;
}
}
visited[idx] = true;
}
Ok(())
}
}
Issue: Animation Blending Artifacts
Problem: Unnatural poses when blending between animations.
Solution:
#![allow(unused)]
fn main() {
pub struct SmartBlender {
sync_markers: HashMap<String, Vec<SyncMarker>>,
}
#[derive(Debug, Clone)]
struct SyncMarker {
time: f32,
phase: f32,
marker_type: SyncMarkerType,
}
impl SmartBlender {
pub fn blend_with_sync(
&self,
from_anim: &str,
to_anim: &str,
blend_factor: f32,
) -> f32 {
// Find matching sync markers
let from_markers = self.sync_markers.get(from_anim);
let to_markers = self.sync_markers.get(to_anim);
if let (Some(from), Some(to)) = (from_markers, to_markers) {
// Align animations based on sync markers
let from_phase = self.calculate_phase(from);
let to_phase = self.calculate_phase(to);
// Adjust time to match phases
let phase_diff = to_phase - from_phase;
let time_adjustment = phase_diff * blend_factor;
return time_adjustment;
}
0.0
}
}
}
Performance Tips
1. GPU Skinning
// Vertex shader for GPU skinning
#version 450
layout(set = 0, binding = 0) uniform BoneMatrices {
mat4 bones[256];
};
layout(location = 0) in vec3 position;
layout(location = 1) in vec3 normal;
layout(location = 2) in vec2 texcoord;
layout(location = 3) in uvec4 bone_indices;
layout(location = 4) in vec4 bone_weights;
layout(location = 0) out vec3 world_normal;
layout(location = 1) out vec2 out_texcoord;
void main() {
mat4 skin_matrix =
bones[bone_indices.x] * bone_weights.x +
bones[bone_indices.y] * bone_weights.y +
bones[bone_indices.z] * bone_weights.z +
bones[bone_indices.w] * bone_weights.w;
vec4 world_pos = skin_matrix * vec4(position, 1.0);
gl_Position = view_proj * world_pos;
world_normal = normalize((skin_matrix * vec4(normal, 0.0)).xyz);
out_texcoord = texcoord;
}
2. Animation LOD
#![allow(unused)]
fn main() {
pub struct AnimationLod {
bone_importance: HashMap<usize, f32>,
distance_thresholds: Vec<f32>,
}
impl AnimationLod {
pub fn get_active_bones(&self, distance: f32) -> HashSet<usize> {
let importance_threshold = if distance < self.distance_thresholds[0] {
0.0 // All bones
} else if distance < self.distance_thresholds[1] {
0.3 // Important bones only
} else {
0.7 // Critical bones only
};
self.bone_importance
.iter()
.filter(|(_, importance)| **importance >= importance_threshold)
.map(|(idx, _)| *idx)
.collect()
}
}
}
3. Animation Streaming
#![allow(unused)]
fn main() {
pub struct AnimationStreamer {
loaded_clips: HashMap<String, Arc<AnimationClip>>,
loading_queue: Arc<Mutex<VecDeque<String>>>,
loader_thread: Option<JoinHandle<()>>,
}
impl AnimationStreamer {
pub async fn get_animation(&self, name: &str) -> Option<Arc<AnimationClip>> {
if let Some(clip) = self.loaded_clips.get(name) {
return Some(clip.clone());
}
// Queue for loading
self.loading_queue.lock().unwrap().push_back(name.to_string());
// Wait for load with timeout
let start = Instant::now();
while start.elapsed() < Duration::from_secs(5) {
if let Some(clip) = self.loaded_clips.get(name) {
return Some(clip.clone());
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
None
}
}
}
Related Guides
- 🎭 Loading M2 Models - Load models with animation data
- 🎨 Model Rendering Guide - Render animated models
- 📊 LOD System Guide - Animation LOD implementation
References
- Skeletal Animation - Understanding skeletal animation
- Animation Compression - GDC talk on animation compression
- FABRIK Algorithm - IK solving algorithm
- Animation State Machines - Unity’s approach to animation state machines
📊 Level of Detail (LoD) System Guide
Overview
The Level of Detail (LoD) system in World of Warcraft optimizes terrain rendering by using different levels of detail based on viewing distance. This guide explains how to implement terrain LoD using the warcraft-rs library, focusing on the WDL/ADT system.
Prerequisites
Before implementing LOD systems, ensure you have:
- Understanding of 3D graphics optimization techniques
- Knowledge of mesh simplification algorithms
- Familiarity with view-dependent rendering
- Experience with performance profiling
- Understanding of GPU bandwidth limitations
Understanding LOD in WoW
LOD Types
- Geometric LOD: Simplified mesh versions
- Texture LOD: Mipmapping and resolution reduction
- Shader LOD: Simplified shading models
- Animation LOD: Reduced bone counts
- Terrain LOD: Chunk simplification
- Object LOD: Billboard replacements
LOD Metrics
- Screen Space Error: Pixel deviation tolerance
- Distance-based: Simple distance thresholds
- View-dependent: Considers viewing angle
- Performance-based: Dynamic adjustment
- Memory-based: Texture/mesh budget
Step-by-Step Instructions
1. Core LOD System Architecture
#![allow(unused)]
fn main() {
use nalgebra::{Vector3, Point3};
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub struct LodSystem {
configs: HashMap<LodCategory, LodConfig>,
metrics: LodMetrics,
performance_monitor: PerformanceMonitor,
adaptive_settings: AdaptiveSettings,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum LodCategory {
Terrain,
Character,
Creature,
Vegetation,
Building,
Prop,
Effect,
}
#[derive(Debug, Clone)]
pub struct LodConfig {
distances: Vec<f32>,
screen_space_thresholds: Vec<f32>,
quality_levels: Vec<QualityLevel>,
transition_type: TransitionType,
}
#[derive(Debug, Clone)]
pub struct QualityLevel {
vertex_reduction: f32,
texture_scale: f32,
shader_complexity: ShaderComplexity,
animation_quality: AnimationQuality,
}
#[derive(Debug, Clone, Copy)]
pub enum TransitionType {
Instant,
Fade(f32), // fade duration
Dither,
Morph(f32), // morph duration
}
#[derive(Debug, Clone, Copy)]
pub enum ShaderComplexity {
Ultra, // Full PBR
High, // Simplified PBR
Medium, // Phong
Low, // Lambert
Minimal, // Flat shading
}
impl LodSystem {
pub fn new() -> Self {
let mut configs = HashMap::new();
// Configure LOD for different object types
configs.insert(LodCategory::Character, LodConfig {
distances: vec![0.0, 30.0, 60.0, 120.0, 200.0],
screen_space_thresholds: vec![1.0, 0.5, 0.25, 0.1, 0.05],
quality_levels: vec![
QualityLevel {
vertex_reduction: 1.0,
texture_scale: 1.0,
shader_complexity: ShaderComplexity::Ultra,
animation_quality: AnimationQuality::Full,
},
QualityLevel {
vertex_reduction: 0.7,
texture_scale: 1.0,
shader_complexity: ShaderComplexity::High,
animation_quality: AnimationQuality::Full,
},
QualityLevel {
vertex_reduction: 0.4,
texture_scale: 0.5,
shader_complexity: ShaderComplexity::Medium,
animation_quality: AnimationQuality::Reduced,
},
QualityLevel {
vertex_reduction: 0.2,
texture_scale: 0.25,
shader_complexity: ShaderComplexity::Low,
animation_quality: AnimationQuality::Minimal,
},
QualityLevel {
vertex_reduction: 0.1,
texture_scale: 0.125,
shader_complexity: ShaderComplexity::Minimal,
animation_quality: AnimationQuality::None,
},
],
transition_type: TransitionType::Fade(0.5),
});
configs.insert(LodCategory::Terrain, LodConfig {
distances: vec![0.0, 100.0, 300.0, 600.0, 1000.0],
screen_space_thresholds: vec![2.0, 1.0, 0.5, 0.25, 0.1],
quality_levels: Self::create_terrain_quality_levels(),
transition_type: TransitionType::Morph(1.0),
});
Self {
configs,
metrics: LodMetrics::new(),
performance_monitor: PerformanceMonitor::new(),
adaptive_settings: AdaptiveSettings::default(),
}
}
pub fn select_lod(
&self,
object: &LodObject,
camera: &Camera,
viewport: &Viewport,
) -> LodSelection {
let config = &self.configs[&object.category];
// Calculate multiple metrics
let distance = (object.center - camera.position()).magnitude();
let screen_size = self.calculate_screen_size(object, camera, viewport);
let importance = self.calculate_importance(object, camera);
// Performance-based adjustment
let performance_bias = self.performance_monitor.get_lod_bias();
// Select LOD level
let mut selected_level = 0;
// Distance-based selection
for (i, &threshold) in config.distances.iter().enumerate().skip(1) {
if distance > threshold * (1.0 + performance_bias) {
selected_level = i;
} else {
break;
}
}
// Screen space override
for (i, &threshold) in config.screen_space_thresholds.iter().enumerate() {
if screen_size < threshold {
selected_level = selected_level.max(i);
}
}
// Importance override
if importance > 0.8 {
selected_level = selected_level.saturating_sub(1);
}
LodSelection {
level: selected_level,
quality: config.quality_levels[selected_level].clone(),
transition: self.calculate_transition(object, selected_level),
}
}
fn calculate_screen_size(
&self,
object: &LodObject,
camera: &Camera,
viewport: &Viewport,
) -> f32 {
let distance = (object.center - camera.position()).magnitude();
let angular_size = 2.0 * (object.radius / distance).atan();
let screen_size = angular_size * viewport.height as f32 / camera.fov();
screen_size
}
fn calculate_importance(&self, object: &LodObject, camera: &Camera) -> f32 {
let mut importance = 0.5;
// View direction importance
let to_object = (object.center - camera.position()).normalize();
let view_dot = camera.forward().dot(&to_object);
importance += view_dot * 0.3;
// Object-specific importance
importance += object.base_importance * 0.2;
importance.clamp(0.0, 1.0)
}
}
}
2. Mesh LOD Generation
#![allow(unused)]
fn main() {
use std::sync::Arc;
pub struct MeshLodGenerator {
simplifier: MeshSimplifier,
quality_settings: QualitySettings,
}
pub struct MeshSimplifier {
error_threshold: f32,
preserve_boundaries: bool,
preserve_uv_seams: bool,
}
pub struct LodMesh {
vertices: Vec<Vertex>,
indices: Vec<u32>,
error_metric: f32,
bounding_sphere: BoundingSphere,
}
impl MeshLodGenerator {
pub fn generate_lods(
&self,
base_mesh: &Mesh,
lod_count: usize,
) -> Vec<LodMesh> {
let mut lods = Vec::with_capacity(lod_count);
// LOD 0 is the original mesh
lods.push(LodMesh {
vertices: base_mesh.vertices.clone(),
indices: base_mesh.indices.clone(),
error_metric: 0.0,
bounding_sphere: base_mesh.bounding_sphere.clone(),
});
// Generate progressively simplified meshes
let mut current_mesh = base_mesh.clone();
for i in 1..lod_count {
let target_ratio = self.calculate_reduction_ratio(i, lod_count);
let target_vertices = (base_mesh.vertices.len() as f32 * target_ratio) as usize;
let simplified = self.simplifier.simplify_mesh(
¤t_mesh,
target_vertices,
);
let error_metric = self.calculate_error_metric(&base_mesh, &simplified);
lods.push(LodMesh {
vertices: simplified.vertices.clone(),
indices: simplified.indices.clone(),
error_metric,
bounding_sphere: simplified.calculate_bounding_sphere(),
});
current_mesh = simplified;
}
lods
}
fn calculate_reduction_ratio(&self, lod_level: usize, total_levels: usize) -> f32 {
// Exponential reduction
let t = lod_level as f32 / (total_levels - 1) as f32;
0.1_f32.powf(t)
}
}
impl MeshSimplifier {
pub fn simplify_mesh(
&self,
mesh: &Mesh,
target_vertices: usize,
) -> Mesh {
// Quadric error metric simplification
let mut quadrics = self.compute_vertex_quadrics(mesh);
let mut edge_heap = self.build_edge_heap(mesh, &quadrics);
let mut vertex_map = (0..mesh.vertices.len()).collect::<Vec<_>>();
let mut active_vertices = mesh.vertices.len();
// Collapse edges until target reached
while active_vertices > target_vertices && !edge_heap.is_empty() {
let edge = edge_heap.pop().unwrap();
if self.can_collapse_edge(&edge, mesh) {
let new_vertex = self.calculate_optimal_position(&edge, &quadrics);
self.collapse_edge(
&edge,
new_vertex,
&mut quadrics,
&mut vertex_map,
&mut edge_heap,
);
active_vertices -= 1;
}
}
// Build simplified mesh
self.build_simplified_mesh(mesh, &vertex_map)
}
fn compute_vertex_quadrics(&self, mesh: &Mesh) -> Vec<Quadric> {
let mut quadrics = vec![Quadric::zero(); mesh.vertices.len()];
// Accumulate face quadrics
for face in mesh.indices.chunks(3) {
let v0 = &mesh.vertices[face[0] as usize];
let v1 = &mesh.vertices[face[1] as usize];
let v2 = &mesh.vertices[face[2] as usize];
let face_quadric = Quadric::from_triangle(
&v0.position,
&v1.position,
&v2.position,
);
for &idx in face {
quadrics[idx as usize] += face_quadric;
}
}
// Add boundary preservation constraints
if self.preserve_boundaries {
self.add_boundary_constraints(&mut quadrics, mesh);
}
quadrics
}
}
#[derive(Debug, Clone, Copy)]
struct Quadric {
matrix: [[f64; 4]; 4],
}
impl Quadric {
fn zero() -> Self {
Self {
matrix: [[0.0; 4]; 4],
}
}
fn from_triangle(v0: &Vector3<f32>, v1: &Vector3<f32>, v2: &Vector3<f32>) -> Self {
// Calculate plane equation
let edge1 = v1 - v0;
let edge2 = v2 - v0;
let normal = edge1.cross(&edge2).normalize();
let d = -normal.dot(v0);
// Build quadric matrix
let a = normal.x as f64;
let b = normal.y as f64;
let c = normal.z as f64;
let d = d as f64;
Self {
matrix: [
[a*a, a*b, a*c, a*d],
[a*b, b*b, b*c, b*d],
[a*c, b*c, c*c, c*d],
[a*d, b*d, c*d, d*d],
],
}
}
fn evaluate(&self, pos: &Vector3<f32>) -> f64 {
let v = [pos.x as f64, pos.y as f64, pos.z as f64, 1.0];
let mut result = 0.0;
for i in 0..4 {
for j in 0..4 {
result += v[i] * self.matrix[i][j] * v[j];
}
}
result
}
}
}
3. Terrain LOD System
#![allow(unused)]
fn main() {
pub struct TerrainLodSystem {
chunk_lods: HashMap<ChunkId, TerrainLod>,
height_map_cache: LruCache<ChunkId, HeightMap>,
normal_cache: LruCache<ChunkId, NormalMap>,
}
pub struct TerrainLod {
levels: Vec<TerrainLodLevel>,
current_level: usize,
blend_factor: f32,
}
pub struct TerrainLodLevel {
vertex_grid_size: usize,
height_data: Vec<f32>,
normal_data: Vec<Vector3<f32>>,
index_buffer: Arc<Buffer>,
skirt_indices: Option<Vec<u32>>,
}
impl TerrainLodSystem {
pub fn generate_terrain_lod(
&mut self,
chunk: &TerrainChunk,
camera: &Camera,
) -> TerrainRenderData {
let chunk_center = chunk.get_center();
let distance = (chunk_center - camera.position()).magnitude();
// Select LOD level based on distance
let lod_level = self.select_terrain_lod_level(distance);
// Get or generate LOD data
let lod_data = self.chunk_lods
.entry(chunk.id)
.or_insert_with(|| self.generate_chunk_lods(chunk));
// Handle LOD transition
if lod_data.current_level != lod_level {
lod_data.blend_factor = 0.0;
lod_data.current_level = lod_level;
} else {
lod_data.blend_factor = (lod_data.blend_factor + 0.02).min(1.0);
}
// Generate render data
self.create_terrain_render_data(chunk, lod_data, lod_level)
}
fn generate_chunk_lods(&self, chunk: &TerrainChunk) -> TerrainLod {
let mut levels = Vec::new();
// Generate different LOD levels
for i in 0..5 {
let grid_size = 33 >> i; // 33, 17, 9, 5, 3
let level = self.generate_lod_level(chunk, grid_size);
levels.push(level);
}
TerrainLod {
levels,
current_level: 0,
blend_factor: 1.0,
}
}
fn generate_lod_level(
&self,
chunk: &TerrainChunk,
grid_size: usize,
) -> TerrainLodLevel {
let step = 32 / (grid_size - 1);
let mut height_data = Vec::with_capacity(grid_size * grid_size);
let mut normal_data = Vec::with_capacity(grid_size * grid_size);
// Sample height map at reduced resolution
for y in 0..grid_size {
for x in 0..grid_size {
let src_x = x * step;
let src_y = y * step;
let height = chunk.sample_height(src_x, src_y);
let normal = chunk.calculate_normal(src_x, src_y);
height_data.push(height);
normal_data.push(normal);
}
}
// Generate index buffer with proper triangulation
let (indices, skirt_indices) = self.generate_terrain_indices(grid_size);
TerrainLodLevel {
vertex_grid_size: grid_size,
height_data,
normal_data,
index_buffer: Arc::new(create_index_buffer(&indices)),
skirt_indices: Some(skirt_indices),
}
}
fn generate_terrain_indices(
&self,
grid_size: usize,
) -> (Vec<u32>, Vec<u32>) {
let mut indices = Vec::new();
let mut skirt_indices = Vec::new();
// Main terrain triangles
for y in 0..grid_size - 1 {
for x in 0..grid_size - 1 {
let tl = (y * grid_size + x) as u32;
let tr = tl + 1;
let bl = tl + grid_size as u32;
let br = bl + 1;
// Two triangles per quad
indices.extend_from_slice(&[tl, bl, br, tl, br, tr]);
}
}
// Skirt triangles to hide gaps between LOD levels
let skirt_start = (grid_size * grid_size) as u32;
// Top edge
for x in 0..grid_size - 1 {
let edge = x as u32;
let skirt = skirt_start + x as u32;
skirt_indices.extend_from_slice(&[edge, edge + 1, skirt + 1, edge, skirt + 1, skirt]);
}
// Similar for other edges...
(indices, skirt_indices)
}
}
}
4. Shader LOD System
#![allow(unused)]
fn main() {
pub struct ShaderLodSystem {
shader_variants: HashMap<ShaderKey, ShaderProgram>,
active_shaders: HashMap<MaterialId, ShaderKey>,
}
#[derive(Debug, Clone, Hash, Eq, PartialEq)]
pub struct ShaderKey {
base_shader: ShaderType,
complexity: ShaderComplexity,
features: ShaderFeatures,
}
bitflags! {
pub struct ShaderFeatures: u32 {
const NORMAL_MAPPING = 0x01;
const SPECULAR = 0x02;
const SHADOWS = 0x04;
const FOG = 0x08;
const SKINNING = 0x10;
const VERTEX_COLOR = 0x20;
const TEXTURE_ANIMATION = 0x40;
const ENVIRONMENT_MAP = 0x80;
}
}
impl ShaderLodSystem {
pub fn get_shader_for_lod(
&self,
material: &Material,
lod_quality: &QualityLevel,
) -> &ShaderProgram {
let features = self.determine_features(material, lod_quality);
let key = ShaderKey {
base_shader: material.shader_type,
complexity: lod_quality.shader_complexity,
features,
};
&self.shader_variants[&key]
}
fn determine_features(
&self,
material: &Material,
quality: &QualityLevel,
) -> ShaderFeatures {
let mut features = ShaderFeatures::empty();
// Add features based on quality level
match quality.shader_complexity {
ShaderComplexity::Ultra => {
features |= ShaderFeatures::NORMAL_MAPPING;
features |= ShaderFeatures::SPECULAR;
features |= ShaderFeatures::SHADOWS;
features |= ShaderFeatures::FOG;
features |= ShaderFeatures::ENVIRONMENT_MAP;
}
ShaderComplexity::High => {
features |= ShaderFeatures::SPECULAR;
features |= ShaderFeatures::SHADOWS;
features |= ShaderFeatures::FOG;
}
ShaderComplexity::Medium => {
features |= ShaderFeatures::FOG;
}
_ => {}
}
// Always include certain features
if material.has_vertex_colors {
features |= ShaderFeatures::VERTEX_COLOR;
}
features
}
pub fn generate_shader_variant(
&mut self,
key: &ShaderKey,
) -> ShaderProgram {
let mut preprocessor = ShaderPreprocessor::new();
// Set defines based on features
if key.features.contains(ShaderFeatures::NORMAL_MAPPING) {
preprocessor.define("USE_NORMAL_MAPPING", "1");
}
if key.features.contains(ShaderFeatures::SPECULAR) {
preprocessor.define("USE_SPECULAR", "1");
}
if key.features.contains(ShaderFeatures::SHADOWS) {
preprocessor.define("USE_SHADOWS", "1");
preprocessor.define("SHADOW_CASCADE_COUNT", "4");
}
// Select shader template based on complexity
let template = match key.complexity {
ShaderComplexity::Ultra => include_str!("shaders/pbr.wgsl"),
ShaderComplexity::High => include_str!("shaders/phong.wgsl"),
ShaderComplexity::Medium => include_str!("shaders/lambert.wgsl"),
ShaderComplexity::Low => include_str!("shaders/simple.wgsl"),
ShaderComplexity::Minimal => include_str!("shaders/flat.wgsl"),
};
let processed = preprocessor.process(template);
compile_shader(&processed)
}
}
}
5. Animation LOD System
#![allow(unused)]
fn main() {
pub struct AnimationLodSystem {
bone_importance: HashMap<BoneId, f32>,
lod_configs: Vec<AnimationLodConfig>,
}
#[derive(Debug, Clone)]
pub struct AnimationLodConfig {
max_bones: usize,
update_rate: f32,
blend_quality: BlendQuality,
ik_enabled: bool,
procedural_enabled: bool,
}
#[derive(Debug, Clone, Copy)]
pub enum AnimationQuality {
Full,
Reduced,
Minimal,
None,
}
impl AnimationLodSystem {
pub fn optimize_animation(
&self,
skeleton: &Skeleton,
animation: &Animation,
quality: AnimationQuality,
) -> OptimizedAnimation {
match quality {
AnimationQuality::Full => {
OptimizedAnimation {
active_bones: (0..skeleton.bones.len()).collect(),
update_rate: 60.0,
interpolation: InterpolationQuality::High,
}
}
AnimationQuality::Reduced => {
let active_bones = self.select_important_bones(skeleton, 30);
OptimizedAnimation {
active_bones,
update_rate: 30.0,
interpolation: InterpolationQuality::Medium,
}
}
AnimationQuality::Minimal => {
let active_bones = self.select_important_bones(skeleton, 10);
OptimizedAnimation {
active_bones,
update_rate: 15.0,
interpolation: InterpolationQuality::Low,
}
}
AnimationQuality::None => {
OptimizedAnimation {
active_bones: vec![0], // Root only
update_rate: 0.0,
interpolation: InterpolationQuality::None,
}
}
}
}
fn select_important_bones(
&self,
skeleton: &Skeleton,
max_bones: usize,
) -> Vec<usize> {
// Sort bones by importance
let mut bone_scores: Vec<(usize, f32)> = skeleton.bones
.iter()
.enumerate()
.map(|(idx, bone)| {
let base_importance = self.bone_importance
.get(&bone.id)
.copied()
.unwrap_or(0.5);
// Factor in bone hierarchy depth
let depth_factor = 1.0 / (bone.depth as f32 + 1.0);
// Factor in number of vertices influenced
let influence_factor = (bone.vertex_count as f32 / 1000.0).min(1.0);
let score = base_importance * 0.5 +
depth_factor * 0.25 +
influence_factor * 0.25;
(idx, score)
})
.collect();
bone_scores.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
// Always include root and important bones
let mut selected = vec![0]; // Root
for (bone_idx, _) in bone_scores.iter().take(max_bones - 1) {
if *bone_idx != 0 {
selected.push(*bone_idx);
}
}
// Ensure parent bones are included
self.ensure_bone_hierarchy(&mut selected, skeleton);
selected
}
fn ensure_bone_hierarchy(
&self,
bones: &mut Vec<usize>,
skeleton: &Skeleton,
) {
let mut to_add = Vec::new();
for &bone_idx in bones.iter() {
let mut current = skeleton.bones[bone_idx].parent;
while let Some(parent_idx) = current {
if !bones.contains(&parent_idx) && !to_add.contains(&parent_idx) {
to_add.push(parent_idx);
}
current = skeleton.bones[parent_idx].parent;
}
}
bones.extend(to_add);
bones.sort();
bones.dedup();
}
}
}
6. Dynamic LOD Adjustment
#![allow(unused)]
fn main() {
pub struct DynamicLodController {
target_frametime: f32,
current_bias: f32,
adjustment_rate: f32,
history: VecDeque<FrameMetrics>,
}
#[derive(Debug, Clone)]
struct FrameMetrics {
frame_time: f32,
draw_calls: u32,
triangles: u32,
texture_memory: usize,
}
impl DynamicLodController {
pub fn new(target_fps: f32) -> Self {
Self {
target_frametime: 1.0 / target_fps,
current_bias: 0.0,
adjustment_rate: 0.1,
history: VecDeque::with_capacity(120),
}
}
pub fn update(&mut self, metrics: FrameMetrics) {
self.history.push_back(metrics);
if self.history.len() > 120 {
self.history.pop_front();
}
// Calculate average frame time
let avg_frametime = self.history
.iter()
.map(|m| m.frame_time)
.sum::<f32>() / self.history.len() as f32;
// Adjust LOD bias based on performance
if avg_frametime > self.target_frametime * 1.1 {
// Performance too low, increase LOD bias
self.current_bias = (self.current_bias + self.adjustment_rate).min(1.0);
} else if avg_frametime < self.target_frametime * 0.9 {
// Performance too high, decrease LOD bias
self.current_bias = (self.current_bias - self.adjustment_rate).max(-0.5);
}
// Gradual adjustment to avoid sudden changes
self.adjustment_rate = if (avg_frametime - self.target_frametime).abs() > 0.01 {
0.1
} else {
0.02
};
}
pub fn get_adjusted_distance(&self, base_distance: f32) -> f32 {
base_distance * (1.0 + self.current_bias)
}
pub fn get_quality_multiplier(&self) -> f32 {
1.0 - self.current_bias.max(0.0)
}
}
}
Code Examples
Complete LOD Manager
#![allow(unused)]
fn main() {
pub struct LodManager {
lod_system: LodSystem,
mesh_lods: HashMap<MeshId, Vec<LodMesh>>,
terrain_lod: TerrainLodSystem,
shader_lod: ShaderLodSystem,
animation_lod: AnimationLodSystem,
dynamic_controller: DynamicLodController,
transition_manager: TransitionManager,
}
impl LodManager {
pub fn new(config: LodConfig) -> Self {
Self {
lod_system: LodSystem::new(),
mesh_lods: HashMap::new(),
terrain_lod: TerrainLodSystem::new(),
shader_lod: ShaderLodSystem::new(),
animation_lod: AnimationLodSystem::new(),
dynamic_controller: DynamicLodController::new(config.target_fps),
transition_manager: TransitionManager::new(),
}
}
pub fn prepare_frame(
&mut self,
scene: &Scene,
camera: &Camera,
viewport: &Viewport,
frame_metrics: FrameMetrics,
) -> LodFrame {
// Update dynamic LOD adjustment
self.dynamic_controller.update(frame_metrics);
let mut lod_frame = LodFrame::new();
// Process each object in the scene
for object in &scene.objects {
let lod_object = LodObject {
id: object.id,
category: object.get_lod_category(),
center: object.get_center(),
radius: object.get_radius(),
base_importance: object.importance,
};
let selection = self.lod_system.select_lod(
&lod_object,
camera,
viewport,
);
// Get appropriate mesh LOD
if let Some(mesh_lods) = self.mesh_lods.get(&object.mesh_id) {
let mesh_lod = &mesh_lods[selection.level.min(mesh_lods.len() - 1)];
// Handle LOD transition
let transition_state = self.transition_manager.update_transition(
object.id,
selection.level,
selection.transition,
);
lod_frame.add_object(LodRenderObject {
object_id: object.id,
mesh: mesh_lod.clone(),
shader_key: self.shader_lod.get_shader_key(&object.material, &selection.quality),
animation_quality: selection.quality.animation_quality,
transition_state,
});
}
}
// Process terrain
for chunk in &scene.terrain_chunks {
let terrain_data = self.terrain_lod.generate_terrain_lod(
chunk,
camera,
);
lod_frame.add_terrain(terrain_data);
}
lod_frame
}
pub fn pregenerate_lods(&mut self, meshes: &[Mesh]) {
let generator = MeshLodGenerator::new();
for mesh in meshes {
let lods = generator.generate_lods(mesh, 5);
self.mesh_lods.insert(mesh.id, lods);
}
}
}
pub struct LodFrame {
render_objects: Vec<LodRenderObject>,
terrain_chunks: Vec<TerrainRenderData>,
statistics: LodStatistics,
}
pub struct LodRenderObject {
object_id: ObjectId,
mesh: LodMesh,
shader_key: ShaderKey,
animation_quality: AnimationQuality,
transition_state: TransitionState,
}
}
LOD Transition Effects
#![allow(unused)]
fn main() {
pub struct TransitionManager {
transitions: HashMap<ObjectId, TransitionState>,
fade_renderer: FadeTransitionRenderer,
morph_renderer: MorphTransitionRenderer,
}
#[derive(Debug, Clone)]
pub struct TransitionState {
from_level: usize,
to_level: usize,
progress: f32,
transition_type: TransitionType,
}
impl TransitionManager {
pub fn update_transition(
&mut self,
object_id: ObjectId,
new_level: usize,
transition_type: TransitionType,
) -> TransitionState {
let state = self.transitions.entry(object_id).or_insert_with(|| {
TransitionState {
from_level: new_level,
to_level: new_level,
progress: 1.0,
transition_type,
}
});
if state.to_level != new_level {
// Start new transition
state.from_level = state.to_level;
state.to_level = new_level;
state.progress = 0.0;
state.transition_type = transition_type;
} else if state.progress < 1.0 {
// Update ongoing transition
match state.transition_type {
TransitionType::Instant => state.progress = 1.0,
TransitionType::Fade(duration) => {
state.progress = (state.progress + 0.016 / duration).min(1.0);
}
TransitionType::Morph(duration) => {
state.progress = (state.progress + 0.016 / duration).min(1.0);
}
TransitionType::Dither => {
state.progress = (state.progress + 0.1).min(1.0);
}
}
}
state.clone()
}
}
// Shader for fade transition
fn fade_transition_shader() -> &'static str {
r#"
struct TransitionUniforms {
fade_factor: f32,
_padding: vec3<f32>,
}
@group(3) @binding(0)
var<uniform> transition: TransitionUniforms;
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
var color = standard_shading(in);
// Apply fade
color.a *= transition.fade_factor;
// Alpha test for dithering
if (color.a < 0.01) {
discard;
}
return color;
}
"#
}
// Shader for morph transition
fn morph_transition_shader() -> &'static str {
r#"
struct MorphUniforms {
morph_factor: f32,
_padding: vec3<f32>,
}
@group(3) @binding(0)
var<uniform> morph: MorphUniforms;
@group(3) @binding(1)
var<storage, read> target_positions: array<vec3<f32>>;
@vertex
fn vs_main(in: VertexInput) -> VertexOutput {
// Morph between LOD levels
let morphed_position = mix(
in.position,
target_positions[in.vertex_index],
morph.morph_factor
);
var out: VertexOutput;
out.position = transform_position(morphed_position);
// ... rest of vertex shader
return out;
}
"#
}
}
Best Practices
1. LOD Grouping
#![allow(unused)]
fn main() {
pub struct LodGroup {
objects: Vec<ObjectId>,
combined_bounds: BoundingBox,
representative: ObjectId,
}
impl LodGroup {
pub fn create_groups(objects: &[SceneObject], max_group_size: f32) -> Vec<LodGroup> {
let mut groups = Vec::new();
let mut processed = HashSet::new();
for object in objects {
if processed.contains(&object.id) {
continue;
}
let mut group = LodGroup {
objects: vec![object.id],
combined_bounds: object.bounds.clone(),
representative: object.id,
};
// Find nearby objects to group
for other in objects {
if processed.contains(&other.id) || other.id == object.id {
continue;
}
let distance = (object.position - other.position).magnitude();
if distance < max_group_size {
group.objects.push(other.id);
group.combined_bounds.expand(&other.bounds);
processed.insert(other.id);
}
}
processed.insert(object.id);
groups.push(group);
}
groups
}
}
}
2. LOD Prediction
#![allow(unused)]
fn main() {
pub struct LodPredictor {
movement_history: HashMap<ObjectId, VecDeque<Vector3<f32>>>,
prediction_frames: usize,
}
impl LodPredictor {
pub fn predict_lod(
&mut self,
object: &SceneObject,
camera: &Camera,
prediction_time: f32,
) -> usize {
// Track object movement
let history = self.movement_history
.entry(object.id)
.or_insert_with(|| VecDeque::with_capacity(10));
history.push_back(object.position);
if history.len() > 10 {
history.pop_front();
}
// Predict future position
let velocity = self.calculate_velocity(history);
let future_position = object.position + velocity * prediction_time;
// Calculate LOD for predicted position
let future_distance = (future_position - camera.position()).magnitude();
self.distance_to_lod(future_distance)
}
fn calculate_velocity(&self, history: &VecDeque<Vector3<f32>>) -> Vector3<f32> {
if history.len() < 2 {
return Vector3::zeros();
}
let recent = history.back().unwrap();
let previous = history.get(history.len() - 2).unwrap();
(recent - previous) * 60.0 // Assuming 60 FPS
}
}
}
3. Memory-Aware LOD
#![allow(unused)]
fn main() {
pub struct MemoryAwareLod {
memory_budget: usize,
current_usage: AtomicUsize,
lod_memory_costs: HashMap<(MeshId, usize), usize>,
}
impl MemoryAwareLod {
pub fn adjust_lod_for_memory(
&self,
base_lod: usize,
mesh_id: MeshId,
) -> usize {
let current = self.current_usage.load(Ordering::Relaxed);
if current > self.memory_budget {
// Force higher LOD (lower quality) to save memory
(base_lod + 1).min(4)
} else if current < self.memory_budget / 2 {
// Allow lower LOD (higher quality) if memory available
base_lod.saturating_sub(1)
} else {
base_lod
}
}
pub fn track_lod_memory(&self, mesh_id: MeshId, lod_level: usize, size: usize) {
self.lod_memory_costs.insert((mesh_id, lod_level), size);
}
}
}
Common Issues and Solutions
Issue: LOD Popping
Problem: Visible transitions between LOD levels.
Solution:
#![allow(unused)]
fn main() {
pub fn smooth_lod_transition(
current_lod: f32,
target_lod: f32,
delta_time: f32,
) -> f32 {
// Use smooth step function
let transition_speed = 2.0;
let diff = target_lod - current_lod;
if diff.abs() < 0.01 {
target_lod
} else {
current_lod + diff * (transition_speed * delta_time).min(1.0)
}
}
// In shader: blend between LOD levels
fn blend_lod_levels(
lod1_output: vec4<f32>,
lod2_output: vec4<f32>,
blend_factor: f32,
) -> vec4<f32> {
// Smooth interpolation
let t = smoothstep(0.0, 1.0, blend_factor);
return mix(lod1_output, lod2_output, t);
}
}
Issue: Terrain Cracks
Problem: Gaps between different terrain LOD levels.
Solution:
#![allow(unused)]
fn main() {
pub fn generate_terrain_skirts(
chunk: &TerrainChunk,
lod_level: usize,
) -> Vec<SkirtVertex> {
let mut skirt_vertices = Vec::new();
let skirt_depth = 10.0; // Units below terrain
// Generate skirt vertices for each edge
for edge in &[Edge::North, Edge::South, Edge::East, Edge::West] {
let edge_vertices = chunk.get_edge_vertices(*edge, lod_level);
for vertex in edge_vertices {
// Original vertex
skirt_vertices.push(vertex.clone());
// Skirt vertex (pushed down)
let mut skirt_vertex = vertex.clone();
skirt_vertex.position.y -= skirt_depth;
skirt_vertices.push(skirt_vertex);
}
}
skirt_vertices
}
}
Issue: Animation Jitter at Distance
Problem: Animations look jerky on distant objects.
Solution:
#![allow(unused)]
fn main() {
pub struct AnimationRateController {
base_rates: HashMap<AnimationQuality, f32>,
}
impl AnimationRateController {
pub fn get_update_rate(
&self,
quality: AnimationQuality,
distance: f32,
importance: f32,
) -> f32 {
let base_rate = self.base_rates[&quality];
// Interpolate update rate based on distance
let distance_factor = 1.0 - (distance / 200.0).min(1.0).powf(2.0);
// Boost rate for important objects
let importance_boost = 1.0 + importance * 0.5;
base_rate * distance_factor * importance_boost
}
pub fn should_update_frame(
&self,
last_update: f32,
current_time: f32,
update_rate: f32,
) -> bool {
if update_rate <= 0.0 {
return false;
}
let frame_interval = 1.0 / update_rate;
current_time - last_update >= frame_interval
}
}
}
Performance Tips
1. Hierarchical LOD
#![allow(unused)]
fn main() {
pub struct HierarchicalLod {
octree: Octree<LodNode>,
}
struct LodNode {
objects: Vec<ObjectId>,
combined_lod: usize,
bounds: BoundingBox,
}
impl HierarchicalLod {
pub fn update_hierarchical_lod(
&mut self,
camera: &Camera,
) {
self.octree.traverse_mut(|node, depth| {
let distance = node.bounds.distance_to_point(&camera.position());
// Higher levels in octree can use lower detail
let depth_bias = depth as f32 * 0.5;
let adjusted_distance = distance + depth_bias * 50.0;
node.combined_lod = self.distance_to_lod(adjusted_distance);
// Stop traversing if entire node is beyond max LOD
adjusted_distance < 1000.0
});
}
}
}
2. Predictive LOD Loading
#![allow(unused)]
fn main() {
pub struct PredictiveLodLoader {
loading_queue: Arc<Mutex<BinaryHeap<LoadRequest>>>,
loaded_lods: Arc<RwLock<HashMap<(MeshId, usize), Arc<LodMesh>>>>,
}
impl PredictiveLodLoader {
pub fn predict_and_load(
&self,
camera_path: &CameraPath,
scene_objects: &[SceneObject],
) {
let future_positions = camera_path.sample_future_positions(5.0);
for position in future_positions {
for object in scene_objects {
let distance = (object.position - position).magnitude();
let predicted_lod = self.distance_to_lod(distance);
// Queue LOD for loading if not already loaded
let key = (object.mesh_id, predicted_lod);
if !self.loaded_lods.read().unwrap().contains_key(&key) {
self.queue_lod_load(object.mesh_id, predicted_lod, distance);
}
}
}
}
}
}
3. GPU-Based LOD Selection
// Compute shader for LOD selection
const LOD_SELECTION_SHADER: &str = r#"
@group(0) @binding(0)
var<storage, read> objects: array<ObjectData>;
@group(0) @binding(1)
var<storage, write> lod_indices: array<u32>;
@group(0) @binding(2)
var<uniform> camera: CameraData;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
let index = id.x;
if (index >= arrayLength(&objects)) {
return;
}
let object = objects[index];
let distance = length(object.center - camera.position);
// Simple distance-based LOD selection
var lod = 0u;
if (distance > 200.0) { lod = 4u; }
else if (distance > 100.0) { lod = 3u; }
else if (distance > 50.0) { lod = 2u; }
else if (distance > 25.0) { lod = 1u; }
// Screen-space size override
let screen_size = object.radius / distance * camera.screen_height;
if (screen_size < 10.0) { lod = max(lod, 3u); }
lod_indices[index] = lod;
}
"#;
Related Guides
- 🎨 Model Rendering Guide - Render models with LOD
- 🌍 Rendering ADT Terrain - Terrain LOD implementation
- 🎬 Animation System Guide - Animation LOD techniques
- 🏛️ WMO Rendering Guide - Building LOD strategies
References
- Level of Detail for 3D Graphics - LOD reference book
- View-Dependent Rendering - Advanced LOD techniques
- Nanite Virtualized Geometry - LOD approaches
- Mesh Optimization - Mesh simplification algorithms
📝 DBC Data Extraction
Overview
DBC (DataBase Client) files contain game data in a structured table format, similar
to a database. These files store everything from spell information and item stats
to world map data and creature definitions. This guide covers extracting, parsing,
and working with DBC files using warcraft-rs.
Prerequisites
Before working with DBC files, ensure you have:
- Understanding of database concepts (tables, rows, columns)
- Basic knowledge of binary file formats
warcraft-rsinstalled with thecdbcfeature enabled- Access to WoW client files (MPQ archives)
- Familiarity with WoW data structures
Understanding DBC Files
DBC Structure
DBC files have a consistent structure:
- Header: File signature, record count, field count, record size
- Records: Fixed-size rows of data
- String Block: Variable-length strings referenced by offsets
Common DBC Files
Spell.dbc: Spell definitions and propertiesItem.dbc: Item templates and statsMap.dbc: World map informationAreaTable.dbc: Zone and area definitionsChrRaces.dbc: Playable race dataChrClasses.dbc: Class definitionsCreatureDisplayInfo.dbc: Creature model informationAchievement.dbc: Achievement data
Step-by-Step Instructions
1. Extracting DBC Files from MPQ
#![allow(unused)]
fn main() {
use wow_mpq::Archive;
use std::path::Path;
use std::fs;
use std::io::Write;
fn extract_dbc_files(mpq_path: &str, output_dir: &str) -> Result<Vec<String>, Box<dyn std::error::Error>> {
let mut archive = Archive::open(mpq_path)?;
let mut extracted_files = Vec::new();
// Create output directory
fs::create_dir_all(output_dir)?;
// List all files in the archive
let entries = archive.list_all()?;
for entry in entries {
if entry.name.ends_with(".dbc") && entry.name.contains("DBFilesClient") {
match archive.read_file(&entry.name) {
Ok(data) => {
let filename = Path::new(&entry.name).file_name().unwrap().to_str().unwrap();
let output_path = Path::new(output_dir).join(filename);
let mut file = fs::File::create(&output_path)?;
file.write_all(&data)?;
extracted_files.push(filename.to_string());
println!("Extracted: {}", filename);
}
Err(e) => eprintln!("Failed to extract {}: {}", entry.name, e),
}
}
}
Ok(extracted_files)
}
}
2. Parsing DBC Files
Note: The DBC parsing implementation is still under development. For now, you can extract the raw DBC files and use external tools or implement basic parsing:
#![allow(unused)]
fn main() {
// Basic DBC header structure (for reference)
#[repr(C, packed)]
struct DbcHeader {
signature: [u8; 4], // 'WDBC'
record_count: u32, // Number of records
field_count: u32, // Number of fields per record
record_size: u32, // Size of each record in bytes
string_block_size: u32, // Size of string block
}
fn parse_dbc_header(data: &[u8]) -> Option<DbcHeader> {
if data.len() < 20 || &data[0..4] != b"WDBC" {
return None;
}
// Parse header manually for now
let record_count = u32::from_le_bytes([data[4], data[5], data[6], data[7]]);
let field_count = u32::from_le_bytes([data[8], data[9], data[10], data[11]]);
let record_size = u32::from_le_bytes([data[12], data[13], data[14], data[15]]);
let string_block_size = u32::from_le_bytes([data[16], data[17], data[18], data[19]]);
Some(DbcHeader {
signature: [data[0], data[1], data[2], data[3]],
record_count,
field_count,
record_size,
string_block_size,
})
}
fn analyze_dbc_file(file_path: &str) -> Result<(), Box<dyn std::error::Error>> {
let data = std::fs::read(file_path)?;
if let Some(header) = parse_dbc_header(&data) {
println!("DBC File: {}", file_path);
println!("Records: {}", header.record_count);
println!("Fields: {}", header.field_count);
println!("Record Size: {} bytes", header.record_size);
println!("String Block Size: {} bytes", header.string_block_size);
let records_start = 20; // After header
let records_end = records_start + (header.record_count * header.record_size) as usize;
let strings_start = records_end;
println!("Total file size: {} bytes", data.len());
println!("Records section: {} - {} bytes", records_start, records_end);
println!("String block: {} - {} bytes", strings_start, data.len());
} else {
println!("Invalid DBC file: {}", file_path);
}
Ok(())
}
## 3. Working with Extracted DBC Files
Once you have extracted DBC files, you can work with them using external tools or implement custom parsing logic:
```rust
use std::fs;
// Example: Basic DBC analysis
fn analyze_extracted_dbc_files(dbc_dir: &str) -> Result<(), Box<dyn std::error::Error>> {
let entries = fs::read_dir(dbc_dir)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.extension().map_or(false, |ext| ext == "dbc") {
if let Some(filename) = path.file_name().and_then(|n| n.to_str()) {
println!("\n=== {} ===", filename);
analyze_dbc_file(path.to_str().unwrap())?;
}
}
}
Ok(())
}
// Example: Spell.dbc structure
#[derive(Debug, Clone)]
pub struct SpellRecord {
pub id: u32,
pub category: u32,
pub dispel_type: u32,
pub mechanic: u32,
pub attributes: [u32; 8],
pub stances: u32,
pub stances_not: u32,
pub targets: u32,
pub target_creature_type: u32,
pub requires_spell_focus: u32,
pub facing_caster_flags: u32,
pub caster_aura_state: u32,
pub target_aura_state: u32,
pub casting_time_index: u32,
pub recovery_time: u32,
pub category_recovery_time: u32,
pub interrupt_flags: u32,
pub aura_interrupt_flags: u32,
pub channel_interrupt_flags: u32,
pub proc_flags: u32,
pub proc_chance: u32,
pub proc_charges: u32,
pub max_level: u32,
pub base_level: u32,
pub spell_level: u32,
pub duration_index: u32,
pub power_type: i32,
pub mana_cost: u32,
pub mana_cost_per_level: u32,
pub mana_per_second: u32,
pub range_index: u32,
pub speed: f32,
pub modal_next_spell: u32,
pub stack_amount: u32,
pub totem: [u32; 2],
pub reagent: [i32; 8],
pub reagent_count: [u32; 8],
pub equipped_item_class: i32,
pub equipped_item_sub_class_mask: i32,
pub equipped_item_inventory_type_mask: i32,
pub effect: [SpellEffect; 3],
pub spell_visual: [u32; 2],
pub spell_icon_id: u32,
pub active_icon_id: u32,
pub spell_priority: u32,
pub spell_name: DbcString,
pub spell_name_subtext: DbcString,
pub description: DbcString,
pub tooltip: DbcString,
}
#[derive(Debug, Clone)]
pub struct SpellEffect {
pub effect: u32,
pub die_sides: u32,
pub real_points_per_level: f32,
pub base_points: i32,
pub mechanic: u32,
pub implicit_target_a: u32,
pub implicit_target_b: u32,
pub radius_index: u32,
pub aura: u32,
pub amplitude: u32,
pub multiple_value: f32,
pub chain_target: u32,
pub item_type: u32,
pub misc_value: i32,
pub misc_value_b: i32,
pub trigger_spell: u32,
pub points_per_combo_point: f32,
pub class_mask: [u32; 3],
pub spell_class_mask: [u32; 3],
}
impl DbcRecord for SpellRecord {
fn read(cursor: &mut Cursor<&[u8]>, strings: &[u8]) -> Result<Self> {
let id = cursor.read_u32::<LittleEndian>()?;
let category = cursor.read_u32::<LittleEndian>()?;
let dispel_type = cursor.read_u32::<LittleEndian>()?;
let mechanic = cursor.read_u32::<LittleEndian>()?;
let mut attributes = [0u32; 8];
for i in 0..8 {
attributes[i] = cursor.read_u32::<LittleEndian>()?;
}
// ... read remaining fields ...
let spell_name = DbcString::read(cursor, strings)?;
let spell_name_subtext = DbcString::read(cursor, strings)?;
let description = DbcString::read(cursor, strings)?;
let tooltip = DbcString::read(cursor, strings)?;
Ok(SpellRecord {
id,
category,
dispel_type,
mechanic,
attributes,
// ... all fields ...
spell_name,
spell_name_subtext,
description,
tooltip,
})
}
}
}
4. Creating a DBC Database
#![allow(unused)]
fn main() {
use std::collections::HashMap;
use wow_cdbc::*;
pub struct DbcDatabase {
spells: HashMap<u32, SpellRecord>,
items: HashMap<u32, ItemRecord>,
maps: HashMap<u32, MapRecord>,
areas: HashMap<u32, AreaTableRecord>,
creatures: HashMap<u32, CreatureDisplayInfoRecord>,
}
impl DbcDatabase {
pub fn new() -> Self {
Self {
spells: HashMap::new(),
items: HashMap::new(),
maps: HashMap::new(),
areas: HashMap::new(),
creatures: HashMap::new(),
}
}
pub fn load_from_directory(&mut self, dbc_dir: &str) -> Result<(), Box<dyn std::error::Error>> {
use std::path::Path;
// Load Spell.dbc
let spell_path = Path::new(dbc_dir).join("Spell.dbc");
if spell_path.exists() {
let spells = read_dbc_records::<SpellRecord>(&spell_path.to_string_lossy())?;
for spell in spells {
self.spells.insert(spell.id, spell);
}
println!("Loaded {} spells", self.spells.len());
}
// Load Item.dbc
let item_path = Path::new(dbc_dir).join("Item.dbc");
if item_path.exists() {
let items = read_dbc_records::<ItemRecord>(&item_path.to_string_lossy())?;
for item in items {
self.items.insert(item.id, item);
}
println!("Loaded {} items", self.items.len());
}
// Load other DBC files...
Ok(())
}
pub fn get_spell(&self, id: u32) -> Option<&SpellRecord> {
self.spells.get(&id)
}
pub fn find_spells_by_name(&self, name: &str) -> Vec<&SpellRecord> {
self.spells
.values()
.filter(|spell| spell.spell_name.to_string().contains(name))
.collect()
}
}
}
5. Exporting DBC Data
#![allow(unused)]
fn main() {
use serde::{Serialize, Deserialize};
use csv::Writer;
// Export to JSON
fn export_dbc_to_json<T: Serialize>(records: &[T], output_path: &str) -> Result<(), Box<dyn std::error::Error>> {
let json = serde_json::to_string_pretty(records)?;
std::fs::write(output_path, json)?;
Ok(())
}
// Export to CSV
fn export_spells_to_csv(spells: &[SpellRecord], output_path: &str) -> Result<(), Box<dyn std::error::Error>> {
let mut writer = Writer::from_path(output_path)?;
// Write header
writer.write_record(&[
"ID", "Name", "Description", "Category", "CastTime", "Range", "ManaCost", "Level"
])?;
// Write records
for spell in spells {
writer.write_record(&[
spell.id.to_string(),
spell.spell_name.to_string(),
spell.description.to_string(),
spell.category.to_string(),
spell.casting_time_index.to_string(),
spell.range_index.to_string(),
spell.mana_cost.to_string(),
spell.spell_level.to_string(),
])?;
}
writer.flush()?;
Ok(())
}
// Export to SQL
fn export_dbc_to_sql(table_name: &str, records: &[impl DbcRecord]) -> String {
let mut sql = String::new();
sql.push_str(&format!("CREATE TABLE {} (\n", table_name));
// Define schema based on record type
sql.push_str(");\n\n");
// Insert statements
for record in records {
sql.push_str(&format!("INSERT INTO {} VALUES (", table_name));
// Add values
sql.push_str(");\n");
}
sql
}
}
6. Building a DBC Query Tool
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "dbc-tool")]
#[command(about = "DBC file extraction and query tool")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Extract DBC files from MPQ
Extract {
#[arg(short, long)]
mpq: String,
#[arg(short, long)]
output: String,
},
/// Query spell information
Spell {
#[arg(short, long)]
id: Option<u32>,
#[arg(short, long)]
name: Option<String>,
},
/// Export DBC to various formats
Export {
#[arg(short, long)]
dbc: String,
#[arg(short, long)]
format: String,
#[arg(short, long)]
output: String,
},
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
let mut db = DbcDatabase::new();
match cli.command {
Commands::Extract { mpq, output } => {
extract_dbc_files(&mpq, &output)?;
}
Commands::Spell { id, name } => {
db.load_from_directory("./dbc")?;
if let Some(spell_id) = id {
if let Some(spell) = db.get_spell(spell_id) {
print_spell_info(spell);
}
} else if let Some(spell_name) = name {
let spells = db.find_spells_by_name(&spell_name);
for spell in spells {
print_spell_info(spell);
}
}
}
Commands::Export { dbc, format, output } => {
match format.as_str() {
"json" => {
let records = read_dbc_records::<SpellRecord>(&dbc)?;
export_dbc_to_json(&records, &output)?;
}
"csv" => {
let records = read_dbc_records::<SpellRecord>(&dbc)?;
export_spells_to_csv(&records, &output)?;
}
_ => eprintln!("Unsupported format: {}", format),
}
}
}
Ok(())
}
fn print_spell_info(spell: &SpellRecord) {
println!("Spell ID: {}", spell.id);
println!("Name: {}", spell.spell_name);
println!("Description: {}", spell.description);
println!("Level: {}", spell.spell_level);
println!("Mana Cost: {}", spell.mana_cost);
println!("Cast Time: {}", spell.casting_time_index);
println!("---");
}
Code Examples
Complete DBC Parser Library
#![allow(unused)]
fn main() {
use wow_cdbc::*;
use std::collections::HashMap;
use std::sync::Arc;
pub struct DbcParser {
cache: HashMap<String, Arc<DbcFile>>,
string_cache: HashMap<String, String>,
}
impl DbcParser {
pub fn new() -> Self {
Self {
cache: HashMap::new(),
string_cache: HashMap::new(),
}
}
pub fn parse_file(&mut self, path: &str) -> Result<Arc<DbcFile>, Box<dyn std::error::Error>> {
if let Some(cached) = self.cache.get(path) {
return Ok(cached.clone());
}
let data = std::fs::read(path)?;
let dbc = DbcFile::from_bytes(&data)?;
let arc_dbc = Arc::new(dbc);
self.cache.insert(path.to_string(), arc_dbc.clone());
Ok(arc_dbc)
}
pub fn parse_generic<T>(&mut self, path: &str) -> Result<Vec<T>, Box<dyn std::error::Error>>
where
T: DbcRecord + 'static,
{
let dbc = self.parse_file(path)?;
let mut records = Vec::with_capacity(dbc.header.record_count as usize);
let mut cursor = Cursor::new(&dbc.records);
for _ in 0..dbc.header.record_count {
let record = T::read(&mut cursor, &dbc.strings)?;
records.push(record);
}
Ok(records)
}
pub fn get_string(&mut self, offset: u32, strings: &[u8]) -> String {
if let Some(cached) = self.string_cache.get(&offset.to_string()) {
return cached.clone();
}
let string = read_cstring_at_offset(strings, offset as usize);
self.string_cache.insert(offset.to_string(), string.clone());
string
}
}
fn read_cstring_at_offset(data: &[u8], offset: usize) -> String {
let mut end = offset;
while end < data.len() && data[end] != 0 {
end += 1;
}
String::from_utf8_lossy(&data[offset..end]).to_string()
}
}
Localized DBC Support
#![allow(unused)]
fn main() {
use wow_cdbc::{DbcString, LocalizedString};
#[derive(Debug)]
pub struct LocalizedDbcString {
pub en_us: String,
pub ko_kr: String,
pub fr_fr: String,
pub de_de: String,
pub en_cn: String,
pub zh_cn: String,
pub en_tw: String,
pub zh_tw: String,
pub es_es: String,
pub es_mx: String,
pub ru_ru: String,
pub ja_jp: String,
pub pt_br: String,
pub it_it: String,
pub unknown: String,
pub flags: u32,
}
impl LocalizedDbcString {
pub fn read(cursor: &mut Cursor<&[u8]>, strings: &[u8]) -> Result<Self> {
let mut locales = Vec::with_capacity(16);
// Read 16 locale string offsets
for _ in 0..16 {
let offset = cursor.read_u32::<LittleEndian>()?;
let string = if offset > 0 {
read_cstring_at_offset(strings, offset as usize)
} else {
String::new()
};
locales.push(string);
}
let flags = cursor.read_u32::<LittleEndian>()?;
Ok(LocalizedDbcString {
en_us: locales[0].clone(),
ko_kr: locales[1].clone(),
fr_fr: locales[2].clone(),
de_de: locales[3].clone(),
en_cn: locales[4].clone(),
zh_cn: locales[5].clone(),
en_tw: locales[6].clone(),
zh_tw: locales[7].clone(),
es_es: locales[8].clone(),
es_mx: locales[9].clone(),
ru_ru: locales[10].clone(),
ja_jp: locales[11].clone(),
pt_br: locales[12].clone(),
it_it: locales[13].clone(),
unknown: locales[14].clone(),
flags,
})
}
pub fn get_locale(&self, locale: &str) -> &str {
match locale {
"enUS" => &self.en_us,
"koKR" => &self.ko_kr,
"frFR" => &self.fr_fr,
"deDE" => &self.de_de,
"enCN" => &self.en_cn,
"zhCN" => &self.zh_cn,
"enTW" => &self.en_tw,
"zhTW" => &self.zh_tw,
"esES" => &self.es_es,
"esMX" => &self.es_mx,
"ruRU" => &self.ru_ru,
"jaJP" => &self.ja_jp,
"ptBR" => &self.pt_br,
"itIT" => &self.it_it,
_ => &self.en_us, // Default to English
}
}
}
}
Best Practices
1. Lazy Loading
#![allow(unused)]
fn main() {
use std::cell::RefCell;
use std::rc::Rc;
pub struct LazyDbcLoader {
dbc_dir: String,
loaded: RefCell<HashMap<String, Rc<Box<dyn Any>>>>,
}
impl LazyDbcLoader {
pub fn new(dbc_dir: String) -> Self {
Self {
dbc_dir,
loaded: RefCell::new(HashMap::new()),
}
}
pub fn get<T: DbcRecord + 'static>(&self, filename: &str) -> Result<Rc<Vec<T>>, Box<dyn std::error::Error>> {
let mut loaded = self.loaded.borrow_mut();
if let Some(data) = loaded.get(filename) {
if let Ok(records) = data.clone().downcast::<Vec<T>>() {
return Ok(records);
}
}
// Load and parse
let path = Path::new(&self.dbc_dir).join(filename);
let records = read_dbc_records::<T>(&path.to_string_lossy())?;
let rc_records = Rc::new(records);
loaded.insert(filename.to_string(), Rc::new(Box::new(rc_records.clone()) as Box<dyn Any>));
Ok(rc_records)
}
}
}
2. Indexing for Fast Lookups
#![allow(unused)]
fn main() {
pub struct IndexedDbc<T> {
records: Vec<T>,
by_id: HashMap<u32, usize>,
by_name: HashMap<String, Vec<usize>>,
}
impl<T: DbcRecord + HasId + HasName> IndexedDbc<T> {
pub fn new(records: Vec<T>) -> Self {
let mut by_id = HashMap::new();
let mut by_name = HashMap::new();
for (idx, record) in records.iter().enumerate() {
by_id.insert(record.id(), idx);
by_name
.entry(record.name().to_lowercase())
.or_insert_with(Vec::new)
.push(idx);
}
Self {
records,
by_id,
by_name,
}
}
pub fn get_by_id(&self, id: u32) -> Option<&T> {
self.by_id.get(&id).map(|&idx| &self.records[idx])
}
pub fn search_by_name(&self, query: &str) -> Vec<&T> {
let query_lower = query.to_lowercase();
self.by_name
.iter()
.filter(|(name, _)| name.contains(&query_lower))
.flat_map(|(_, indices)| indices.iter().map(|&idx| &self.records[idx]))
.collect()
}
}
}
3. Version Compatibility
#![allow(unused)]
fn main() {
pub enum DbcVersion {
Classic,
TBC,
WotLK,
Cataclysm,
MoP,
}
pub trait VersionedDbcRecord: Sized {
fn read_classic(cursor: &mut Cursor<&[u8]>, strings: &[u8]) -> Result<Self>;
fn read_tbc(cursor: &mut Cursor<&[u8]>, strings: &[u8]) -> Result<Self>;
fn read_wotlk(cursor: &mut Cursor<&[u8]>, strings: &[u8]) -> Result<Self>;
fn read_cata(cursor: &mut Cursor<&[u8]>, strings: &[u8]) -> Result<Self>;
fn read_mop(cursor: &mut Cursor<&[u8]>, strings: &[u8]) -> Result<Self>;
fn read_for_version(
cursor: &mut Cursor<&[u8]>,
strings: &[u8],
version: DbcVersion
) -> Result<Self> {
match version {
DbcVersion::Classic => Self::read_classic(cursor, strings),
DbcVersion::TBC => Self::read_tbc(cursor, strings),
DbcVersion::WotLK => Self::read_wotlk(cursor, strings),
DbcVersion::Cataclysm => Self::read_cata(cursor, strings),
DbcVersion::MoP => Self::read_mop(cursor, strings),
}
}
}
}
Common Issues and Solutions
Issue: String Encoding
Problem: Non-ASCII characters appear corrupted.
Solution:
#![allow(unused)]
fn main() {
use encoding_rs::WINDOWS_1252;
fn decode_dbc_string(bytes: &[u8]) -> String {
// DBC files often use Windows-1252 encoding
let (decoded, _, had_errors) = WINDOWS_1252.decode(bytes);
if had_errors {
// Fall back to UTF-8 lossy
String::from_utf8_lossy(bytes).to_string()
} else {
decoded.to_string()
}
}
}
Issue: Missing DBC Files
Problem: Some DBC files are not in the MPQ archives.
Solution:
#![allow(unused)]
fn main() {
fn find_dbc_in_multiple_mpqs(filename: &str, mpq_paths: &[&str]) -> Option<Vec<u8>> {
for mpq_path in mpq_paths {
if let Ok(mut archive) = Archive::open(mpq_path) {
if let Ok(data) = archive.read_file(&format!("DBFilesClient/{}", filename)) {
return Some(data);
}
}
}
None
}
// Search in patch MPQs first (higher priority)
let mpq_search_order = [
"Data/patch-3.MPQ",
"Data/patch-2.MPQ",
"Data/patch.MPQ",
"Data/common.MPQ",
"Data/expansion.MPQ",
];
}
Issue: Record Size Mismatch
Problem: DBC parser fails due to unexpected record size.
Solution:
#![allow(unused)]
fn main() {
fn validate_dbc_header(header: &DbcHeader, expected_size: usize) -> Result<(), DbcError> {
if header.record_size != expected_size as u32 {
// Some DBCs have padding or version differences
eprintln!(
"Warning: Expected record size {}, got {}. Attempting to parse anyway.",
expected_size, header.record_size
);
// Check if it's a known variation
match header.record_size {
size if size > expected_size as u32 => {
// Newer version with additional fields
Ok(())
}
size if size < expected_size as u32 => {
// Older version, may need special handling
Err(DbcError::IncompatibleVersion)
}
_ => Ok(()),
}
} else {
Ok(())
}
}
}
Performance Tips
1. Parallel DBC Loading
#![allow(unused)]
fn main() {
use rayon::prelude::*;
use std::sync::Arc;
pub fn load_all_dbcs_parallel(dbc_dir: &str) -> HashMap<String, Arc<DbcFile>> {
let dbc_files: Vec<_> = std::fs::read_dir(dbc_dir)
.unwrap()
.filter_map(|entry| {
let entry = entry.ok()?;
let path = entry.path();
if path.extension()? == "dbc" {
Some(path)
} else {
None
}
})
.collect();
dbc_files
.par_iter()
.filter_map(|path| {
let filename = path.file_name()?.to_str()?.to_string();
let data = std::fs::read(path).ok()?;
let dbc = DbcFile::from_bytes(&data).ok()?;
Some((filename, Arc::new(dbc)))
})
.collect()
}
}
2. Memory-Mapped DBC Files
#![allow(unused)]
fn main() {
use memmap2::MmapOptions;
use std::fs::File;
pub struct MappedDbc {
_file: File,
mmap: memmap2::Mmap,
header: DbcHeader,
}
impl MappedDbc {
pub fn open(path: &str) -> Result<Self, Box<dyn std::error::Error>> {
let file = File::open(path)?;
let mmap = unsafe { MmapOptions::new().map(&file)? };
// Read header
let header = DbcHeader::read(&mmap[..20])?;
Ok(Self {
_file: file,
mmap,
header,
})
}
pub fn get_record(&self, index: u32) -> Option<&[u8]> {
if index >= self.header.record_count {
return None;
}
let offset = 20 + (index * self.header.record_size) as usize;
let end = offset + self.header.record_size as usize;
Some(&self.mmap[offset..end])
}
}
}
3. DBC Compression
#![allow(unused)]
fn main() {
use flate2::{Compression, write::GzEncoder, read::GzDecoder};
use std::io::prelude::*;
pub fn compress_dbc(input_path: &str, output_path: &str) -> Result<(), Box<dyn std::error::Error>> {
let input_data = std::fs::read(input_path)?;
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder.write_all(&input_data)?;
let compressed = encoder.finish()?;
std::fs::write(output_path, compressed)?;
println!("Compressed {} bytes to {} bytes", input_data.len(), compressed.len());
Ok(())
}
pub fn decompress_dbc(input_path: &str) -> Result<Vec<u8>, Box<dyn std::error::Error>> {
let compressed = std::fs::read(input_path)?;
let mut decoder = GzDecoder::new(&compressed[..]);
let mut decompressed = Vec::new();
decoder.read_to_end(&mut decompressed)?;
Ok(decompressed)
}
}
Related Guides
- 📦 Working with MPQ Archives - Extract DBC files from game archives
- 🎭 Loading M2 Models - Use DBC data to load models
- 🌍 Rendering ADT Terrain - Use map DBC data for terrain
References
- DBC Format Documentation - Complete DBC format specification
- DBC File List - List of all DBC files and their purposes
- Trinity Core DBC Structures - Reference DBC structures
- WoW Dev Tools - Tools for working with WoW files
Core Types
warcraft-rs does not have a shared core types library. Each crate defines its own types independently. This page documents common patterns across crates.
Math Types
The project uses glam for vector and matrix math.
Individual crates define their own geometry structs for binary-compatible
parsing (e.g., C3Vector, C4Quaternion in wow-m2).
Version Enums
Each crate that handles versioned formats defines its own version enum:
| Crate | Version Type | Variants |
|---|---|---|
| wow-mpq | FormatVersion | V1, V2, V3, V4 |
| wow-m2 | M2Version | Vanilla, TBC, WotLK, Cataclysm, MoP, WoD, Legion, BfA, Shadowlands, Dragonflight, TheWarWithin |
| wow-wmo | WmoVersion | Classic, Tbc, Wotlk, Cataclysm, Mop, Wod, Legion, Bfa, Shadowlands, Dragonflight, WarWithin |
| wow-wdt | WowVersion | Classic, TBC, WotLK, Cataclysm, MoP, WoD, Legion, BfA, Shadowlands, Dragonflight |
| wow-adt | AdtVersion | VanillaEarly, VanillaLate, TBC, WotLK, Cataclysm, MoP |
| wow-wdl | WdlVersion | Vanilla, Wotlk, Cataclysm, Mop, Wod, Legion, Bfa, Shadowlands, Dragonflight, Latest |
Chunk-based Parsing
Several WoW file formats use a chunk-based structure with four-character codes (FourCC). The project handles this differently per crate:
- wow-wdt: Defines a
Chunktrait that chunk types implement - wow-adt, wow-wmo: Uses binrw 0.14 with declarative chunk parsing
- wow-wdl: Custom parser that reads chunks sequentially
- wow-m2: Custom
ReadExttrait for reading binary data
Flag Types
The project uses the bitflags crate for type-safe flag handling in format-specific contexts.
Serialization
Crates that support serialization use serde behind feature flags:
- wow-m2:
serde-supportfeature - wow-wdt:
serdefeature - wow-cdbc:
serdefeature (alsocsv_export,yaml)
API Reference
For detailed type documentation, see the per-crate API docs on docs.rs:
See Also
Error Handling
Each crate in warcraft-rs defines its own error type using thiserror. There is
no shared error type across crates.
Error Types by Crate
| Crate | Error Type | Module |
|---|---|---|
| wow-mpq | Error | wow_mpq::error |
| wow-blp | LoadError, EncodeError, ConvertError | wow_blp::parser, wow_blp::encode, wow_blp::convert |
| wow-m2 | M2Error | wow_m2::error |
| wow-wmo | WmoError | wow_wmo::error |
| wow-adt | AdtError | wow_adt::error |
| wow-wdl | WdlError | wow_wdl::error |
| wow-wdt | Error | wow_wdt::error |
| wow-cdbc | Error | wow_cdbc (re-exported at crate root) |
Pattern
All error types follow the same pattern:
#![allow(unused)]
fn main() {
use thiserror::Error;
#[derive(Error, Debug)]
pub enum Error {
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("Invalid format: {0}")]
InvalidFormat(String),
// Format-specific variants...
}
pub type Result<T> = std::result::Result<T, Error>;
}
Each crate also provides a Result<T> type alias for convenience.
Usage
#![allow(unused)]
fn main() {
use wow_mpq::Archive;
fn read_file() -> Result<Vec<u8>, wow_mpq::error::Error> {
let mut archive = Archive::open("archive.mpq")?;
let data = archive.read_file("Interface/FrameXML/UIParent.lua")?;
Ok(data)
}
}
The CLI application (warcraft-rs) uses anyhow for error handling, converting
library errors via the ? operator.
See Also
Traits & Interfaces
warcraft-rs does not define shared traits across crates. Each crate has its own parsing and I/O approach. This page documents the common patterns.
Parsing Patterns
Three parsing approaches are used across the workspace:
binrw Declarative (wow-adt, wow-wmo, wow-cdbc)
Uses derive macros for binary parsing:
#![allow(unused)]
fn main() {
use binrw::BinRead;
#[derive(BinRead, Debug)]
#[br(little)]
pub struct ChunkHeader {
pub magic: [u8; 4],
pub size: u32,
}
}
wow-adt and wow-wmo use binrw 0.14. wow-cdbc uses binrw 0.15.
Chunk Trait (wow-wdt)
Defines a Chunk trait that format-specific chunk types implement:
#![allow(unused)]
fn main() {
pub trait Chunk: Sized {
fn magic() -> &'static [u8; 4];
fn expected_size() -> Option<usize> { None }
fn read(reader: &mut impl Read, size: usize) -> Result<Self>;
fn write(&self, writer: &mut impl Write) -> Result<()>;
fn size(&self) -> usize;
fn write_chunk(&self, writer: &mut impl Write) -> Result<()> { /* default impl */ }
}
}
Hand-Written Readers (wow-mpq, wow-blp, wow-wdl, wow-m2)
Custom byte-level parsing using Read + Seek traits:
#![allow(unused)]
fn main() {
// wow-m2 uses a ReadExt trait
pub trait ReadExt: Read {
fn read_u32_le(&mut self) -> io::Result<u32>;
fn read_f32_le(&mut self) -> io::Result<f32>;
// ...
}
}
Common API Patterns
Open/Parse Pattern
Most crates provide a way to parse from a reader or file:
#![allow(unused)]
fn main() {
// wow-mpq: static open method
let archive = Archive::open("archive.mpq")?;
// wow-wdt: reader struct
let reader = WdtReader::new(BufReader::new(file), WowVersion::WotLK);
let wdt = reader.read()?;
// wow-m2: parse_m2 returns M2Format (Legacy or Chunked variant)
let format = parse_m2(&mut reader)?;
let model = format.model();
// wow-adt: standalone function
let parsed = parse_adt(&mut reader)?;
}
Writer Pattern
Crates with write support provide builder or writer types:
#![allow(unused)]
fn main() {
// wow-mpq: OpenOptions with create method
let mut archive = OpenOptions::new().create("new.mpq")?;
// wow-wdt: WdtWriter wraps a writer
let writer = WdtWriter::new(&mut output);
writer.write(&wdt)?;
// wow-cdbc: DbcWriter wraps a writer
let writer = DbcWriter::new(&mut output);
}
See Also
Storm FFI (Foreign Function Interface)
Overview
The storm-ffi crate provides a C-compatible FFI layer that implements the StormLib API, allowing C/C++ applications to use the wow-mpq Rust implementation. This enables drop-in replacement of StormLib with a Rust-based implementation.
Key Features:
- ✅ StormLib API Compatibility - Implements StormLib’s C API
- ✅ Thread-Safe - Safe concurrent access from multiple threads
- ✅ Archive Modification - Full support for add/remove/rename operations
- ✅ Cross-Platform - Works on Windows, Linux, and macOS
- ✅ Memory Safe - Rust’s safety guarantees with C compatibility
API Reference
Archive Management
Opening Archives
// Open an archive for reading
HANDLE archive;
if (SFileOpenArchive("Data/patch.mpq", 0, MPQ_OPEN_READ_ONLY, &archive)) {
// Archive opened successfully
SFileCloseArchive(archive);
}
// Open an archive for modification
HANDLE mutable_archive;
if (SFileOpenArchive("my_archive.mpq", 0, MPQ_OPEN_READ_WRITE, &mutable_archive)) {
// Archive opened for modification
SFileCloseArchive(mutable_archive);
}
Creating Archives
// Create a new MPQ archive (simple)
HANDLE new_archive;
if (SFileCreateArchive("new.mpq", MPQ_CREATE_ARCHIVE_V2, 0x1000, &new_archive)) {
// Add files...
SFileCloseArchive(new_archive);
}
// Create with extended options
SFILE_CREATE_MPQ create_info = {0};
create_info.cbSize = sizeof(SFILE_CREATE_MPQ);
create_info.mpq_version = 2; // MPQ format v2
create_info.sector_size = 4; // 4096 bytes
create_info.attr_flags = MPQ_ATTRIBUTE_CRC32 | MPQ_ATTRIBUTE_MD5;
create_info.max_file_count = 1000;
HANDLE advanced_archive;
if (SFileCreateArchive2("advanced.mpq", &create_info, &advanced_archive)) {
// Archive created with custom settings
SFileCloseArchive(advanced_archive);
}
File Operations
Reading Files
// Open and read a file
HANDLE file;
if (SFileOpenFileEx(archive, "Interface\\Icons\\INV_Misc_QuestionMark.blp", 0, &file)) {
// Get file size
DWORD file_size = SFileGetFileSize(file, NULL);
// Read file data
char* buffer = malloc(file_size);
DWORD bytes_read;
if (SFileReadFile(file, buffer, file_size, &bytes_read, NULL)) {
// File read successfully
printf("Read %u bytes\n", bytes_read);
}
free(buffer);
SFileCloseFile(file);
}
Adding Files
// Add a file from disk
if (SFileAddFile(archive, "local_file.txt", "archived_name.txt", MPQ_FILE_COMPRESS)) {
printf("File added successfully\n");
}
// Add with extended options
DWORD flags = MPQ_FILE_ENCRYPTED | MPQ_FILE_REPLACEEXISTING;
DWORD compression = MPQ_COMPRESSION_ZLIB;
if (SFileAddFileEx(archive, "secret.dat", "Data\\Secret.dat", flags, compression, 0)) {
printf("Encrypted file added\n");
}
// Add from memory
const char* data = "Hello, MPQ!";
if (SFileCreateFile(archive, "hello.txt", 0, strlen(data) + 1, 0, MPQ_FILE_COMPRESS, &file)) {
DWORD written;
SFileWriteFile(file, data, strlen(data) + 1, &written, MPQ_COMPRESSION_ZLIB);
SFileFinishFile(file);
}
Modifying Archives
// Remove a file
if (SFileRemoveFile(archive, "old_file.txt", 0)) {
printf("File removed\n");
}
// Rename a file
if (SFileRenameFile(archive, "old_name.txt", "new_name.txt")) {
printf("File renamed\n");
}
// Flush changes to disk
SFileFlushArchive(archive);
// Compact archive to reclaim space
if (SFileCompactArchive(archive, NULL, false)) {
printf("Archive compacted\n");
}
File Finding
// Find files matching a pattern
SFILE_FIND_DATA find_data;
HANDLE find = SFileFindFirstFile(archive, "*.blp", &find_data);
if (find != INVALID_HANDLE_VALUE) {
do {
printf("Found: %s (%u bytes)\n",
find_data.c_file_name,
find_data.file_size);
} while (SFileFindNextFile(find, &find_data));
SFileFindClose(find);
}
Archive Information
// Get archive info
DWORD value;
DWORD size_needed;
// Get archive size
if (SFileGetFileInfo(archive, SFILE_INFO_ARCHIVE_SIZE, &value, sizeof(value), &size_needed)) {
printf("Archive size: %u bytes\n", value);
}
// Get format version
if (SFileGetFileInfo(archive, SFILE_INFO_FORMAT_VERSION, &value, sizeof(value), &size_needed)) {
printf("Format version: %u\n", value);
}
// Get archive name
char name_buffer[260];
if (SFileGetArchiveName(archive, name_buffer, sizeof(name_buffer))) {
printf("Archive path: %s\n", name_buffer);
}
Verification
// Verify a single file
DWORD verify_flags = SFILE_VERIFY_SECTOR_CRC | SFILE_VERIFY_FILE_CRC;
if (SFileVerifyFile(archive, "important.dat", verify_flags)) {
printf("File verification passed\n");
} else {
printf("File verification failed: %u\n", SFileGetLastError());
}
// Verify entire archive
DWORD archive_flags = SFILE_VERIFY_SIGNATURE | SFILE_VERIFY_ALL_FILES;
if (SFileVerifyArchive(archive, archive_flags)) {
printf("Archive verification passed\n");
}
Attributes
// Get file attributes
DWORD crc32, md5[4];
char file_time[8];
if (SFileGetFileAttributes(archive, 10, &crc32, file_time, md5)) {
printf("CRC32: 0x%08X\n", crc32);
}
// Update file attributes
DWORD new_crc = 0x12345678;
SFileUpdateFileAttributes(archive, "updated_file.txt");
// Work with archive attributes
if (SFileFlushAttributes(archive)) {
printf("Attributes flushed\n");
}
Error Handling
The FFI layer uses Windows-compatible error codes:
DWORD error = SFileGetLastError();
switch (error) {
case ERROR_SUCCESS:
printf("Operation successful\n");
break;
case ERROR_FILE_NOT_FOUND:
printf("File not found\n");
break;
case ERROR_ACCESS_DENIED:
printf("Access denied\n");
break;
case ERROR_NOT_SUPPORTED:
printf("Operation not supported\n");
break;
case ERROR_INVALID_PARAMETER:
printf("Invalid parameter\n");
break;
case ERROR_ALREADY_EXISTS:
printf("File already exists\n");
break;
case ERROR_DISK_FULL:
printf("Disk full\n");
break;
case ERROR_FILE_CORRUPT:
printf("File corrupt\n");
break;
default:
printf("Unknown error: %u\n", error);
}
Building and Linking
Rust Side
Add to your Cargo.toml:
[dependencies]
storm-ffi = { path = "../ffi/storm-ffi" }
C/C++ Side
Include the header and link the library:
#include "StormLib.h"
// Link with libstorm.so (Linux), storm.dll (Windows), or libstorm.dylib (macOS)
CMake example:
find_library(STORM_LIB storm PATHS ${CMAKE_SOURCE_DIR}/lib)
target_link_libraries(your_app ${STORM_LIB})
Thread Safety
All functions are thread-safe with the following guarantees:
- Multiple threads can read from the same archive concurrently
- Archive modification operations are serialized internally
- Each thread maintains its own error state
- File handles are not thread-safe (use one handle per thread)
Differences from StormLib
While the API is compatible, there are some implementation differences:
- Memory Management: The Rust implementation manages memory internally - no manual cleanup required except for handles
- Error Handling: Thread-local error storage instead of global error state
- Performance: Generally faster due to Rust optimizations
- Safety: Memory-safe implementation prevents buffer overflows and use-after-free
Example: Complete Program
#include <stdio.h>
#include <stdlib.h>
#include "StormLib.h"
int main() {
HANDLE archive;
// Open archive
if (!SFileOpenArchive("Data/patch.mpq", 0, MPQ_OPEN_READ_WRITE, &archive)) {
printf("Failed to open archive: %u\n", SFileGetLastError());
return 1;
}
// Add a new file
if (SFileAddFile(archive, "readme.txt", "README.txt", MPQ_FILE_COMPRESS)) {
printf("File added successfully\n");
}
// Find all DBC files
SFILE_FIND_DATA find_data;
HANDLE find = SFileFindFirstFile(archive, "*.dbc", &find_data);
if (find != INVALID_HANDLE_VALUE) {
do {
printf("DBC: %s\n", find_data.c_file_name);
} while (SFileFindNextFile(find, &find_data));
SFileFindClose(find);
}
// Extract a file
HANDLE file;
if (SFileOpenFileEx(archive, "Interface\\FrameXML\\Fonts.xml", 0, &file)) {
DWORD size = SFileGetFileSize(file, NULL);
char* buffer = malloc(size);
DWORD read;
if (SFileReadFile(file, buffer, size, &read, NULL)) {
FILE* out = fopen("Fonts.xml", "wb");
fwrite(buffer, 1, read, out);
fclose(out);
printf("Extracted Fonts.xml\n");
}
free(buffer);
SFileCloseFile(file);
}
// Close archive
SFileCloseArchive(archive);
return 0;
}
Testing
The storm-ffi crate includes the following tests:
# Run FFI tests
cargo test -p storm-ffi
# Run with StormLib comparison tests
cargo test -p storm-ffi --features stormlib-compare
See Also
- MPQ Archives Guide - Working with MPQ archives in Rust
- StormLib Differences - Detailed comparison with StormLib
- MPQ Format - MPQ format specification
CLI Architecture for warcraft-rs
This document outlines the CLI structure for the warcraft-rs project.
Overview
The warcraft-rs CLI provides a unified interface for working with World of
Warcraft file formats through subcommands for each format type.
Currently implemented:
- ✅ MPQ subcommands - Full-featured MPQ archive operations with 100% StormLib compatibility
list- List archive contentsextract- Extract filesinfo- Show archive informationvalidate- Validate archive integritycreate- Create new archivesrebuild- Rebuild archives with format upgradescompare- Compare two archives
- ✅ DBC subcommands - Database file operations
info- Display information about a DBC filevalidate- Validate a DBC file against a schemalist- List records in a DBC fileexport- Export DBC data to JSON/CSV formatsanalyze- Analyze DBC file performance and structurediscover- Discover the schema of a DBC file through analysis
- ✅ DBD subcommands - Database definition operations
convert- Convert a DBD file to YAML schemas
- ✅ BLP subcommands - Texture file operations
info- Display information about a BLP filevalidate- Validate BLP file integrityconvert- Convert BLP files to/from other image formats (PNG, JPEG, etc.)
- ✅ M2 subcommands - Model file operations (basic functionality)
info- Display information about an M2 model filevalidate- Validate an M2 model fileconvert- Convert an M2 model to a different versiontree- Display M2 file structure as a treeskin-info- Display information about a Skin fileskin-convert- Convert a Skin file to a different versionanim-info- Display information about an ANIM fileanim-convert- Convert an ANIM file to a different versionblp-info- Display information about a BLP texture file
- ✅ WMO subcommands - World object operations
info- Show information about a WMO filevalidate- Validate a WMO fileconvert- Convert WMO between different WoW versionslist- List WMO components (groups, materials, doodads, etc.)tree- Visualize WMO structure as a treeexport- Export WMO data (not yet implemented)extract-groups- Extract WMO groups (not yet implemented)
- ✅ ADT subcommands - Terrain operations
info- Show information about an ADT filevalidate- Validate an ADT fileconvert- Convert ADT between different WoW versionsextract- Extract data from ADT files (requires ‘extract’ feature)tree- Visualize ADT structure as a treebatch- Batch process multiple ADT files (requires ‘parallel’ feature)
- ✅ WDL subcommands - Low-res world operations
validate- Validate WDL file formatinfo- Show WDL file informationconvert- Convert between WDL versions
- ✅ WDT subcommands - Map definition operations
info- Display WDT file informationvalidate- Validate WDT file structureconvert- Convert between WDT versionstiles- List tiles with ADT data
Project Structure
The CLI is implemented in the warcraft-rs crate:
warcraft-rs/
├── Cargo.toml
├── src/
│ ├── main.rs # Entry point
│ ├── cli.rs # Root CLI structure
│ ├── commands/ # Format-specific commands
│ │ ├── mod.rs
│ │ ├── mpq.rs # MPQ subcommands (implemented)
│ │ ├── dbc.rs # DBC subcommands (implemented)
│ │ ├── dbd.rs # DBD subcommands (implemented)
│ │ ├── blp.rs # BLP subcommands (implemented)
│ │ ├── m2.rs # M2 subcommands (implemented)
│ │ ├── wmo.rs # WMO subcommands (implemented)
│ │ ├── adt.rs # ADT subcommands (implemented)
│ │ ├── wdl.rs # WDL subcommands (implemented)
│ │ └── wdt.rs # WDT subcommands (implemented)
│ └── utils/ # Shared utilities
│ ├── mod.rs
│ ├── progress.rs # Progress bars
│ ├── table.rs # Table formatting
│ ├── tree.rs # Tree visualization
│ ├── format.rs # Byte/time formatting
│ └── io.rs # File I/O helpers
Command Pattern
All format subcommands follow a consistent pattern:
#![allow(unused)]
fn main() {
warcraft-rs <format> <action> [options]
}
Examples:
warcraft-rs mpq list archive.mpq
warcraft-rs dbc info items.dbc
warcraft-rs dbc export items.dbc --format json
warcraft-rs dbd convert definitions.dbd --output schemas/
warcraft-rs blp convert texture.blp --to png
warcraft-rs m2 info model.m2
warcraft-rs wmo tree worldobject.wmo
warcraft-rs adt batch process --input maps/ --output processed/
warcraft-rs wdl convert old.wdl new.wdl --to wotlk
warcraft-rs wdt tiles map.wdt
Command Structure
MPQ Subcommands
The MPQ subcommands currently support:
# List files in archive
warcraft-rs mpq list archive.mpq [--long] [--filter pattern]
# Extract files
warcraft-rs mpq extract archive.mpq [files...] [--output dir] [--preserve-paths]
# Create new archive
warcraft-rs mpq create new.mpq --add files... [--version v2] [--compression zlib]
# Show archive information
warcraft-rs mpq info archive.mpq [--show-hash-table] [--show-block-table]
# Validate archive integrity
warcraft-rs mpq validate archive.mpq [--check-checksums]
Feature Flags
The CLI supports feature flags to include only the formats you need:
[features]
default = ["mpq", "dbc", "blp", "m2", "wmo", "adt", "wdt", "wdl"] # All formats included by default
full = ["mpq", "dbc", "blp", "m2", "wmo", "adt", "wdt", "wdl", "serde", "extract", "parallel", "yaml"]
mpq = [] # MPQ is always included (no optional dependency)
dbc = ["dep:wow-cdbc"]
blp = ["dep:wow-blp", "dep:image"]
m2 = ["dep:wow-m2"]
wmo = ["dep:wow-wmo"]
adt = ["dep:wow-adt"]
wdt = ["dep:wow-wdt", "serde"]
wdl = ["dep:wow-wdl"]
serde = ["dep:serde", "dep:serde_json"]
extract = ["wow-adt?/extract"]
parallel = ["wow-adt?/parallel", "dep:rayon"]
yaml = ["dbc", "serde", "dep:serde_yaml_ng"]
Implementation Guidelines
1. Dependencies
The CLI uses these dependencies:
# From warcraft-rs/Cargo.toml
[dependencies]
# CLI framework
clap = { version = "4.5", features = ["derive", "cargo", "env"] }
clap_complete = "4.5"
# File format crates
wow-mpq = { path = "../file-formats/archives/wow-mpq" }
wow-cdbc = { path = "../file-formats/database/wow-cdbc", optional = true }
wow-blp = { path = "../file-formats/graphics/wow-blp", optional = true }
wow-m2 = { path = "../file-formats/graphics/wow-m2", optional = true }
wow-wmo = { path = "../file-formats/graphics/wow-wmo", optional = true }
wow-adt = { path = "../file-formats/world-data/wow-adt", optional = true }
wow-wdt = { path = "../file-formats/world-data/wow-wdt", optional = true }
wow-wdl = { path = "../file-formats/world-data/wow-wdl", optional = true }
# Error handling and utilities
anyhow = "1.0"
log = "0.4"
env_logger = "0.11"
indicatif = "0.18"
prettytable-rs = "0.10"
2. Shared Utilities
The utils module provides common functionality:
#![allow(unused)]
fn main() {
// Available utilities in warcraft-rs/src/utils/:
- format_bytes() // Human-readable file sizes
- format_timestamp() // Time formatting
- format_percentage() // Percentage formatting
- format_compression_ratio() // Compression ratio display
- create_progress_bar() // Progress indicators
- create_spinner() // Indeterminate progress
- create_table() // Formatted table output
- add_table_row() // Add rows to tables
- truncate_path() // Path truncation for display
- matches_pattern() // Wildcard matching
}
3. CLI Structure Example
Example CLI structure (from MPQ implementation):
#![allow(unused)]
fn main() {
// Simplified version of the actual CLI structure
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(name = "mpq")]
#[command(about = "MPQ archive manipulation tool for World of Warcraft")]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
/// Verbosity level (can be repeated)
#[arg(short, long, action = clap::ArgAction::Count)]
pub verbose: u8,
/// Suppress all output except errors
#[arg(short, long)]
pub quiet: bool,
}
#[derive(Subcommand)]
pub enum Commands {
/// List files in an MPQ archive
List {
/// Path to the MPQ archive
archive: String,
/// Show detailed information (size, compression ratio)
#[arg(short, long)]
long: bool,
/// Filter files by pattern (supports wildcards)
#[arg(short, long)]
filter: Option<String>,
},
/// Extract files from an MPQ archive
Extract {
/// Path to the MPQ archive
archive: String,
/// Specific files to extract (extracts all if not specified)
files: Vec<String>,
/// Output directory
#[arg(short, long, default_value = ".")]
output: String,
/// Preserve directory structure
#[arg(short, long)]
preserve_paths: bool,
},
/// Show information about an MPQ archive
Info {
/// Path to the MPQ archive
archive: String,
/// Show hash table details
#[arg(long)]
show_hash_table: bool,
/// Show block table details
#[arg(long)]
show_block_table: bool,
},
/// Validate integrity of an MPQ archive
Validate {
/// Path to the MPQ archive
archive: String,
},
// Future commands (not yet implemented):
// Create, Add, Remove, Repair
}
}
Installation
The CLI can be built and used as follows:
# Build the CLI with default features (all formats)
cd warcraft-rs
cargo build --release
# Build with all features including extras (serialization, parallel processing, etc.)
cargo build --release --features full
# Build with only specific features
cargo build --release --no-default-features --features "mpq dbc blp"
# Install globally
cargo install --path . --features full
# Example usage
warcraft-rs mpq list archive.mpq
warcraft-rs mpq info archive.mpq
warcraft-rs mpq extract archive.mpq --output ./extracted
Testing
The CLI includes integration tests:
# Run CLI tests
cd warcraft-rs
cargo test
# Test with actual files
warcraft-rs mpq info /path/to/test.mpq
warcraft-rs mpq list /path/to/archive.mpq --filter "*.dbc"
Future Considerations
When implementing additional CLI tools or enhancing existing ones, consider:
- Consistency: Follow the same command patterns as existing CLIs
- Shared utilities: Use the
utilsmodule for common functionality - Error handling: Use
anyhowfor consistent error reporting - Testing: Include both unit and integration tests
- Documentation: Update this document and usage guides
- Feature flags: Consider adding feature flags for optional functionality
Current Limitations
- Some M2 subcommands have limited functionality due to API constraints
- WMO
exportandextract-groupssubcommands are not yet implemented - ADT
extractandbatchcommands require additional feature flags - No man pages (shell completions are generated but not yet exposed via CLI)
World of Warcraft Coordinate Systems
This document explains the coordinate system used by World of Warcraft and provides utilities for transforming coordinates to common target systems used in 3D applications and game engines.
WoW Coordinate System
World of Warcraft uses a right-handed coordinate system with the following axis orientations:
- X-axis: North (positive X points north)
- Y-axis: West (positive Y points west)
- Z-axis: Up (positive Z points upward, 0 = sea level)
Z (Up)
^
|
|
Y <----+
(West) \
\
v X (North)
This coordinate system is used consistently across all WoW file formats including:
- M2 models (vertices, bones, attachments)
- ADT terrain tiles
- WMO world objects
- Camera positions and orientations
Common Target Systems
Blender (Right-handed)
- Right = (1, 0, 0) → X-axis
- Forward = (0, 1, 0) → Y-axis
- Up = (0, 0, 1) → Z-axis
Z (Up)
^
|
|
+----> Y (Forward)
/
/
v X (Right)
Unity (Left-handed)
- Right = (1, 0, 0) → X-axis
- Up = (0, 1, 0) → Y-axis
- Forward = (0, 0, 1) → Z-axis
Y (Up)
^
|
|
+----> X (Right)
/
/
v Z (Forward)
Unreal Engine (Left-handed)
- Forward = (1, 0, 0) → X-axis
- Right = (0, 1, 0) → Y-axis
- Up = (0, 0, 1) → Z-axis
Transformation Formulas
WoW → Blender
Positions/Vertices:
blender_position = (wow_y, -wow_x, wow_z)
Rotations (Quaternions):
blender_quaternion = (wow_w, wow_y, -wow_x, wow_z)
Example with actual coordinates:
WoW position: (100.0, 200.0, 50.0) # 100 north, 200 west, 50 up
Blender position: (200.0, -100.0, 50.0) # 200 forward, -100 right, 50 up
WoW → Unity
Positions/Vertices:
unity_position = (-wow_y, wow_z, wow_x)
Rotations (Quaternions):
unity_quaternion = (wow_y, -wow_z, -wow_x, wow_w)
WoW → Unreal Engine
Positions/Vertices:
unreal_position = (wow_x, -wow_y, wow_z)
Rotations (Quaternions):
unreal_quaternion = (-wow_x, wow_y, -wow_z, wow_w)
Code Examples
Basic Usage
#![allow(unused)]
fn main() {
use wow_m2::coordinate::{CoordinateSystem, transform_position, transform_quaternion};
// Load a model
let model = wow_m2::M2Model::load("character.m2")?;
// Transform vertices for Blender
for vertex in &model.vertices {
let blender_pos = transform_position(vertex.position, CoordinateSystem::Blender);
println!("WoW: {:?} → Blender: {:?}", vertex.position, blender_pos);
}
// Transform bone rotations for Unity
for bone in &model.bones {
if let Some(rotation) = bone.rotation {
let unity_rot = transform_quaternion(rotation, CoordinateSystem::Unity);
println!("WoW: {:?} → Unity: {:?}", rotation, unity_rot);
}
}
}
Batch Transformation
#![allow(unused)]
fn main() {
use wow_m2::coordinate::{CoordinateTransformer, CoordinateSystem};
// Create a transformer for consistent conversions
let transformer = CoordinateTransformer::new(CoordinateSystem::Blender);
// Transform all model data at once
let transformed_model = transformer.transform_model(&model)?;
// Or transform specific data types
let blender_vertices = transformer.transform_positions(&model.vertices);
let blender_bones = transformer.transform_bones(&model.bones);
}
Working with Animations
#![allow(unused)]
fn main() {
use wow_m2::coordinate::transform_animation_data;
// Load animation file
let anim = wow_m2::AnimFile::load("character0-0.anim")?;
// Transform animation keyframes for target system
let blender_anim = transform_animation_data(&anim, CoordinateSystem::Blender)?;
}
Why Models Appear Wrong Without Transformation
When loading WoW models directly into 3D applications without coordinate transformation, you’ll typically see:
Blender Issues:
- Model rotated 90° clockwise (what should face north faces east)
- Model appears “sideways” relative to Blender’s default orientation
- Animations don’t align with Blender’s bone system expectations
Unity Issues:
- Model faces wrong direction (north becomes forward, confusing navigation)
- Physics and collision detection problems due to axis mismatch
- Camera controls feel inverted when adapted from WoW coordinate expectations
Root Cause:
These issues occur because each system interprets the same numeric coordinate values according to its own axis conventions. A point at (100, 0, 0) means “100 units north” in WoW but “100 units right” in Blender.
Implementation Details
Axis Mapping Table
| WoW Axis | Blender | Unity | Unreal |
|---|---|---|---|
| +X (North) | -Y | +Z | +X |
| +Y (West) | +X | -X | -Y |
| +Z (Up) | +Z | +Y | +Z |
Quaternion Component Mapping
Quaternion transformations require careful component remapping because rotations around different axes need to be preserved correctly:
| WoW Quat | Blender | Unity | Unreal |
|---|---|---|---|
| x | -y | -z | -x |
| y | x | x | y |
| z | z | -y | -z |
| w | w | w | w |
Matrix Transformation Approach
For more complex transformations or when working with transformation matrices directly:
WoW → Blender Transformation Matrix:
[ 0 1 0 0] [wow_x] [blender_x]
[-1 0 0 0] × [wow_y] = [blender_y]
[ 0 0 1 0] [wow_z] [blender_z]
[ 0 0 0 1] [ 1 ] [ 1 ]
Performance Considerations
Bulk Transformations
When transforming large numbers of coordinates (thousands of vertices), use SIMD operations:
#![allow(unused)]
fn main() {
// Efficient batch transformation using glam's SIMD support
let wow_positions: &[glam::Vec3] = &vertex_positions;
let blender_positions: Vec<glam::Vec3> = wow_positions
.iter()
.map(|pos| glam::Vec3::new(pos.y, -pos.x, pos.z))
.collect();
}
In-Place Transformations
For memory efficiency, prefer in-place transformations when possible:
#![allow(unused)]
fn main() {
// Transform coordinates in-place to avoid allocations
for position in vertex_positions.iter_mut() {
let temp_x = position.x;
position.x = position.y;
position.y = -temp_x;
// position.z unchanged for WoW → Blender
}
}
Common Pitfalls and Solutions
Pitfall 1: Forgetting Quaternion Sign Corrections
Problem: Rotations appear correct but are mirrored or inverted. Solution: Always apply the correct sign changes for quaternion components as shown in the transformation formulas.
Pitfall 2: Mixing Coordinate Systems
Problem: Some model parts use transformed coordinates while others don’t. Solution: Consistently transform ALL coordinate data - vertices, bones, attachments, cameras, etc.
Pitfall 3: Animation Timing Issues
Problem: Animations play correctly but bones rotate in wrong directions. Solution: Transform animation keyframe data using the same coordinate system as the rest of the model.
Pitfall 4: Texture Coordinate Confusion
Problem: Textures appear flipped or rotated incorrectly. Solution: Texture coordinates (UV mappings) typically don’t need coordinate system transformation - only 3D spatial coordinates do.
Validation and Testing
Visual Verification Checklist
When implementing coordinate transformations:
- Model faces the expected “forward” direction in target application
- Up/down orientation matches target system (Z+ or Y+ depending on system)
- Left/right handedness is correct (no mirroring)
- Animations rotate bones in expected directions
- Attachment points (weapons, accessories) align properly
- Camera positions and orientations work as expected
Test Cases
Use these known coordinate transformations to verify implementation:
#![allow(unused)]
fn main() {
// Test case 1: Cardinal directions
assert_eq!(
transform_position((1.0, 0.0, 0.0), CoordinateSystem::Blender),
(0.0, -1.0, 0.0) // North becomes -Y (backward) in Blender
);
// Test case 2: Identity quaternion
assert_eq!(
transform_quaternion((0.0, 0.0, 0.0, 1.0), CoordinateSystem::Blender),
(0.0, 0.0, 0.0, 1.0) // Identity should remain identity
);
}
See Also
- Map Grid and World Coordinates - ADT tile grid, world coordinates, and grid-to-world conversion
References
- wowdev.wiki ADT Format - Original coordinate system documentation
- wowdev.wiki M2 Format - Model coordinate specifications
- Blender Coordinate System
- Unity Coordinate System
- Unreal Engine Coordinate System
Coordinate Systems
A concise guide to understanding World of Warcraft’s coordinate systems.
Overview
WoW uses multiple coordinate systems that can be confusing. This guide clarifies how they work and how to convert between them.
Coordinate Systems
1. Map Grid Coordinates
The world is divided into a 64×64 grid of ADT tiles:
0 1 2 ... 63
┌───┬───┬───┬───┬───┐
0 │ │ │ │ │ │
├───┼───┼───┼───┼───┤
1 │ │ │ │ │ │
├───┼───┼───┼───┼───┤
2 │ │ADT│ │ │ │ Each cell = 1 ADT file
├───┼───┼───┼───┼───┤ Size: 533.33333 yards
│ │ │ │ │ │
└───┴───┴───┴───┴───┘
63
- Origin: Top-left corner at (0,0)
- Direction: X increases right, Y increases down
- Range: 0-63 for both axes
2. ADT Local Coordinates
Within each ADT, terrain is divided into 16×16 chunks:
0 1 2 ... 15
┌───┬───┬───┬───┬───┐
0 │ │ │ │ │ │
├───┼───┼───┼───┼───┤
1 │ │ │ │ │ │
├───┼───┼───┼───┼───┤ Each chunk = 33.3333 yards
2 │ │CHK│ │ │ │ Total ADT = 533.3333 yards
├───┼───┼───┼───┼───┤
│ │ │ │ │ │
└───┴───┴───┴───┴───┘
15
3. World Coordinates
The global coordinate system with origin at the center of the map:
+X (North)
↑
│
←──────────┼──────────→ -Y (East)
+Y (West) │ (0,0,0)
│
↓
-X (South)
- Origin: Center of the world (32,32 in grid coordinates)
- Unit: 1 unit = 1 yard (in-game)
- Range: ±17066.66656 yards from center
4. Client Coordinates
The coordinate system used in-game (displayed on maps):
North (+X)
↑
│
West ←───────┼───────→ East (-Y)
(+Y) │
│
↓
South (-X)
Note: This matches the world coordinate system!
Conversion Formulas
Grid to World Coordinates
#![allow(unused)]
fn main() {
fn grid_to_world(grid_x: u32, grid_y: u32) -> (f32, f32) {
const TILE_SIZE: f32 = 533.33333;
const GRID_CENTER: f32 = 32.0;
// Grid (0,0) is at the northwest corner
// X points North (grid_y increases southward)
// Y points West (grid_x increases eastward)
let world_x = (GRID_CENTER - grid_y as f32) * TILE_SIZE;
let world_y = (GRID_CENTER - grid_x as f32) * TILE_SIZE;
(world_x, world_y)
}
}
World to Grid Coordinates
#![allow(unused)]
fn main() {
fn world_to_grid(world_x: f32, world_y: f32) -> (u32, u32) {
const TILE_SIZE: f32 = 533.33333;
const GRID_CENTER: f32 = 32.0;
// Inverse of grid_to_world
let grid_y = (GRID_CENTER - world_x / TILE_SIZE) as u32;
let grid_x = (GRID_CENTER - world_y / TILE_SIZE) as u32;
(grid_x, grid_y)
}
}
ADT Local to World
#![allow(unused)]
fn main() {
fn adt_local_to_world(
grid_x: u32,
grid_y: u32,
chunk_x: u32,
chunk_y: u32,
local_x: f32,
local_y: f32
) -> (f32, f32, f32) {
const TILE_SIZE: f32 = 533.33333;
const CHUNK_SIZE: f32 = 33.33333;
const GRID_CENTER: f32 = 32.0;
// Convert to world coordinates
let world_x = (GRID_CENTER - grid_y as f32) * TILE_SIZE
- chunk_x as f32 * CHUNK_SIZE
- local_x;
let world_y = (GRID_CENTER - grid_x as f32) * TILE_SIZE
- chunk_y as f32 * CHUNK_SIZE
- local_y;
(world_x, world_y, 0.0) // Z handled separately
}
}
Quick Reference
| System | Origin | X Direction | Y Direction | Notes |
|---|---|---|---|---|
| Grid | Top-left (0,0) | Right → | Down ↓ | File naming |
| World | Center (0,0,0) | North → | West → | Internal coords |
| Client | Center (0,0) | North → | West → | UI display |
| ADT Local | Top-left (0,0) | Right → | Down ↓ | Per-tile |
Common Pitfalls
1. Coordinate System Consistency
World and client coordinates use the same system, so no conversion is needed:
#![allow(unused)]
fn main() {
// World and client coordinates are the same
fn world_to_client(world_x: f32, world_y: f32) -> (f32, f32) {
(world_x, world_y) // No conversion needed!
}
}
2. Grid Origin
Remember that grid (0,0) is NOT world (0,0):
Grid (0,0) = World (17066.66656, 17066.66656)
Grid (32,32) = World (0, 0)
Grid (63,63) = World (-17066.66656, -17066.66656)
3. Chunk Boundaries
Chunks within ADT also use top-left origin:
#![allow(unused)]
fn main() {
// Get world position of chunk corner
fn chunk_corner_world_pos(
grid_x: u32,
grid_y: u32,
chunk_x: u32,
chunk_y: u32
) -> (f32, f32) {
const TILE_SIZE: f32 = 533.33333;
const CHUNK_SIZE: f32 = 33.33333;
const HALF_GRID: f32 = 32.0;
let world_x = (HALF_GRID - grid_y as f32) * TILE_SIZE
- chunk_x as f32 * CHUNK_SIZE;
let world_y = (HALF_GRID - grid_x as f32) * TILE_SIZE
- chunk_y as f32 * CHUNK_SIZE;
(world_x, world_y)
}
}
Practical Examples
Finding Player’s ADT
#![allow(unused)]
fn main() {
fn get_player_adt(player_x: f32, player_y: f32) -> (u32, u32) {
// Player coordinates are client coordinates
let world_x = player_x;
let world_y = -player_y; // Flip Y!
world_to_grid(world_x, world_y)
}
}
Height Query
#![allow(unused)]
fn main() {
fn get_height_at_position(world: &World, x: f32, y: f32) -> Option<f32> {
// Convert world to grid
let (grid_x, grid_y) = world_to_grid(x, y);
// Load ADT
let adt = world.get_adt(grid_x, grid_y)?;
// Convert to ADT-local coordinates
let local_x = x - chunk_corner_world_pos(grid_x, grid_y, 0, 0).0;
let local_y = y - chunk_corner_world_pos(grid_x, grid_y, 0, 0).1;
// Query height
adt.get_height(local_x, local_y)
}
}
Visual Summary
┌─────────────────────────────────────┐
│ WORLD MAP VIEW │
│ │
│ Grid(0,0) ←───────→ Grid(63,0) │
│ ↑ ↑ │
│ │ North ↑ │ │
│ │ │ │ │
│ │ West ←───┼───→ East │
│ │ │ │ │
│ │ South ↓ │ │
│ ↓ ↓ │
│ Grid(0,63) ←───────→ Grid(63,63) │
│ │
│ World Origin = Grid(32,32) │
└─────────────────────────────────────┘
See Also
- Model Coordinate Transformations - WoW to Blender/Unity/Unreal coordinate transforms for M2 models
- ADT Format
- WDT Format
- Map IDs Reference
📖 Glossary
Common terms and abbreviations used in World of Warcraft file formats.
File Format Terms
ADT
Azeroth Data Terrain - Map tile files containing terrain mesh, textures, and object placement data.
BLP
Blizzard Picture - Proprietary texture format supporting DXT compression and mipmaps.
DBC
DataBase Client - Client-side database files storing game data in a tabular format.
M2
Model Version 2 - 3D model format for characters, creatures, and doodads. Includes animations, bones, and particle effects.
MPQ
Mo’PaQ (Mike O’Brien Pack) - Archive format for storing game assets with
compression and encryption. The wow-mpq implementation has 98.75% compatibility
with StormLib (the reference C++ implementation).
WDL
World Data Low-resolution - Low-detail terrain for distant rendering and minimaps.
WDT
World Data Table - Map definition files that reference ADT tiles and define map properties.
WMO
World Map Object - Large static models like buildings, dungeons, and cities.
Technical Terms
Chunk
A data block in a file, usually identified by a 4-character ID (e.g., MVER, MHDR).
Doodad
Small decorative objects like trees, rocks, and furniture (stored as M2 models).
FOURCC
Four-Character Code - A 32-bit identifier used for chunk types (e.g., ‘MCNK’).
Heightmap
2D array of elevation values defining terrain height.
Listfile
Text file mapping file hashes to filenames in MPQ archives.
Mipmap
Progressively smaller versions of a texture for efficient rendering at different distances.
Skinning
Process of binding mesh vertices to bones for animation.
UV Mapping
Texture coordinate system mapping 2D textures to 3D surfaces.
Vertex
A point in 3D space with position, normal, texture coordinates, and other attributes.
Compression Algorithms
ZLIB
Standard compression library used in MPQ archives.
PKWARE
PKZip compression algorithm, also used in MPQ.
BZip2
Block-sorting compression algorithm for better ratios.
LZMA
Lempel-Ziv-Markov chain algorithm for high compression.
Game-Specific Terms
Expansion IDs
- 0: Classic (Vanilla)
- 1: The Burning Crusade (TBC)
- 2: Wrath of the Lich King (WotLK)
- 3: Cataclysm
- 4: Mists of Pandaria (MoP)
Map IDs
- 0: Eastern Kingdoms
- 1: Kalimdor
- 530: Outland
- 571: Northrend
Coordinate System
- WoW uses a Y-up coordinate system
- Maps are divided into 64x64 ADT grid
- Each ADT is 533.33 yards square
Common Patterns
Magic Numbers
File signatures used to identify format:
- MPQ:
MPQ\x1A(0x1A51504D) - BLP:
BLP2(0x32504C42) - M2:
MD20(0x3032444D) - WMO:
MVERchunk at start
Byte Order
Most WoW files use little-endian byte order.
String Encoding
- Filenames: Usually ASCII or UTF-8
- Localized text: UTF-8 with locale-specific DBCs
Version Support
World of Warcraft client version compatibility and file format changes.
Supported Client Versions
| Expansion | Version | Patch | Build | Status |
|---|---|---|---|---|
| Classic (Vanilla) | 1.12.1 | 1.12.1.5875 | 5875 | ✅ Supported |
| The Burning Crusade | 2.4.3 | 2.4.3.8606 | 8606 | ✅ Supported |
| Wrath of the Lich King | 3.3.5 | 3.3.5a.12340 | 12340 | ✅ Supported |
| Cataclysm | 4.3.4 | 4.3.4.15595 | 15595 | ✅ Supported |
| Mists of Pandaria | 5.4.8 | 5.4.8.18414 | 18414 | ✅ Supported |
File Format Versions
MPQ Archives
| Version | Client | Changes | wow-mpq Support |
|---|---|---|---|
| v1 | 1.x - 3.x | Original format, hash table, block table | ✅ Supported |
| v2 | 3.x+ | Extended attributes, larger files | ✅ Supported |
| v3 | 4.x+ | HET/BET tables, increased hash table size | ✅ Supported |
| v4 | 5.x+ | 64-bit file support, MD5 checksums | ✅ Supported |
Note: wow-mpq has bidirectional compatibility with StormLib (the reference C++ implementation) and support for official Blizzard WoW archives.
M2 Models
| Version | Client | Major Changes |
|---|---|---|
| 256-257 | 1.x | Original format |
| 260-263 | 2.x | Particle emitters update |
| 264 | 3.0+ | .skin/.anim file separation |
| 272 | 3.3+ | Extended animations |
| 273 | 4.0+ | .phys physics data |
| 274 | 4.x+ | New texture types |
| 276 | 5.x+ | Improved bone structure |
ADT Terrain
| Version | Client | Changes |
|---|---|---|
| 18 | 1.x - 2.x | Original MCNK format |
| 20 | 3.x | Destructible doodads |
| 21 | 4.x | Terrain streaming, flight |
| 23 | 5.x | New texture blending |
BLP Textures
| Version | Client | Format Support |
|---|---|---|
| BLP1 | 1.x - 2.x | JPEG compression, palettized |
| BLP2 | 3.x+ | DXT compression, mipmaps |
DBC Database
| Client | Records | String Encoding | Features |
|---|---|---|---|
| 1.x | Fixed size | ASCII | Basic structure |
| 2.x | Fixed size | UTF-8 | Extended fields |
| 3.x | Fixed size | UTF-8 | Localization support |
| 4.x | Fixed size | UTF-8 | New index format |
| 5.x | Fixed size | UTF-8 | Compressed strings |
Version Detection
Each crate handles version detection differently:
- MPQ: Format version is read from the archive header (v1-v4)
- M2: Header version field distinguishes expansions; MD20 vs MD21 magic separates legacy from chunked format
- ADT: MVER chunk is always 18; actual version is detected from chunk presence (MFBO, MH2O, MAMP, MTXP)
- BLP: File magic is
BLP1orBLP2 - WMO: MVER chunk version number increases with expansions (17-27)
- WDT/WDL: Version detection via chunk analysis
File Magic Numbers
| Format | Magic | Notes |
|---|---|---|
| MPQ | MPQ\x1A | All versions |
| M2 (Legacy) | MD20 | Pre-Legion |
| M2 (Chunked) | MD21 | Legion+ |
| BLP1 | BLP1 | Classic, TBC |
| BLP2 | BLP2 | WotLK+ |
| WMO/ADT/WDT/WDL | RVER (MVER reversed) | Chunk-based |
Best Practices
- Use each crate’s version enum for version-aware code
- Let the parser detect versions automatically where possible
- Test with files from multiple WoW client versions
- Check optional chunk presence rather than assuming version
See Also
🗺️ Map IDs Reference
Quick reference for World of Warcraft map IDs and their properties.
Major Continents
| ID | Name | Introduced | Grid Size | Notes |
|---|---|---|---|---|
| 0 | Eastern Kingdoms | Classic | 64×64 | Azeroth |
| 1 | Kalimdor | Classic | 64×64 | Kalimdor |
| 530 | Outland | TBC | 64×64 | Expansion01 |
| 571 | Northrend | WotLK | 64×64 | Northrend |
| 646 | Deepholm | Cataclysm | 64×64 | Deephome |
| 860 | The Wandering Isle | MoP | 64×64 | NewRaceStartZone |
| 870 | Pandaria | MoP | 64×64 | HawaiiMainLand |
Classic Instances
| ID | Name | Type | Location |
|---|---|---|---|
| 30 | Alterac Valley | Battleground | Alterac Mountains |
| 33 | Shadowfang Keep | Dungeon | Silverpine Forest |
| 34 | The Stockade | Dungeon | Stormwind |
| 36 | Deadmines | Dungeon | Westfall |
| 43 | Wailing Caverns | Dungeon | The Barrens |
| 47 | Razorfen Kraul | Dungeon | The Barrens |
| 48 | Blackfathom Deeps | Dungeon | Ashenvale |
| 70 | Uldaman | Dungeon | Badlands |
| 90 | Gnomeregan | Dungeon | Dun Morogh |
| 109 | Sunken Temple | Dungeon | Swamp of Sorrows |
| 129 | Razorfen Downs | Dungeon | Thousand Needles |
| 189 | Scarlet Monastery | Dungeon | Tirisfal Glades |
| 209 | Zul’Farrak | Dungeon | Tanaris |
| 229 | Blackrock Spire | Dungeon | Blackrock Mountain |
| 230 | Blackrock Depths | Dungeon | Blackrock Mountain |
| 249 | Onyxia’s Lair | Raid | Dustwallow Marsh |
| 309 | Zul’Gurub | Raid | Stranglethorn Vale |
| 349 | Maraudon | Dungeon | Desolace |
| 369 | Deeprun Tram | Transport | Ironforge ↔ Stormwind |
| 389 | Ragefire Chasm | Dungeon | Orgrimmar |
| 409 | Molten Core | Raid | Blackrock Mountain |
| 429 | Dire Maul | Dungeon | Feralas |
| 469 | Blackwing Lair | Raid | Blackrock Mountain |
| 489 | Warsong Gulch | Battleground | Ashenvale/Barrens |
| 509 | Ruins of Ahn’Qiraj | Raid | Silithus |
| 529 | Arathi Basin | Battleground | Arathi Highlands |
| 531 | Temple of Ahn’Qiraj | Raid | Silithus |
| 533 | Naxxramas | Raid | Eastern Plaguelands |
The Burning Crusade
| ID | Name | Type | Location |
|---|---|---|---|
| 532 | Karazhan | Raid | Deadwind Pass |
| 534 | The Battle for Mount Hyjal | Raid | Caverns of Time |
| 540 | The Shattered Halls | Dungeon | Hellfire Citadel |
| 542 | The Blood Furnace | Dungeon | Hellfire Citadel |
| 543 | Hellfire Ramparts | Dungeon | Hellfire Citadel |
| 544 | Magtheridon’s Lair | Raid | Hellfire Citadel |
| 545 | The Steamvault | Dungeon | Coilfang Reservoir |
| 546 | The Underbog | Dungeon | Coilfang Reservoir |
| 547 | The Slave Pens | Dungeon | Coilfang Reservoir |
| 548 | Serpentshrine Cavern | Raid | Coilfang Reservoir |
| 550 | Tempest Keep | Raid | Netherstorm |
| 552 | The Arcatraz | Dungeon | Tempest Keep |
| 553 | The Botanica | Dungeon | Tempest Keep |
| 554 | The Mechanar | Dungeon | Tempest Keep |
| 555 | Shadow Labyrinth | Dungeon | Auchindoun |
| 556 | Sethekk Halls | Dungeon | Auchindoun |
| 557 | Mana-Tombs | Dungeon | Auchindoun |
| 558 | Auchenai Crypts | Dungeon | Auchindoun |
| 559 | Nagrand Arena | Arena | Nagrand |
| 560 | Old Hillsbrad Foothills | Dungeon | Caverns of Time |
| 562 | Blade’s Edge Arena | Arena | Blade’s Edge |
| 564 | Black Temple | Raid | Shadowmoon Valley |
| 565 | Gruul’s Lair | Raid | Blade’s Edge |
| 566 | Eye of the Storm | Battleground | Netherstorm |
| 568 | Zul’Aman | Raid | Ghostlands |
| 572 | Ruins of Lordaeron | Arena | Undercity |
| 580 | Sunwell Plateau | Raid | Isle of Quel’Danas |
| 585 | Magisters’ Terrace | Dungeon | Isle of Quel’Danas |
Wrath of the Lich King
| ID | Name | Type | Location |
|---|---|---|---|
| 574 | Utgarde Keep | Dungeon | Howling Fjord |
| 575 | Utgarde Pinnacle | Dungeon | Howling Fjord |
| 576 | The Nexus | Dungeon | Borean Tundra |
| 578 | The Oculus | Dungeon | Borean Tundra |
| 595 | The Culling of Stratholme | Dungeon | Caverns of Time |
| 599 | Halls of Stone | Dungeon | Storm Peaks |
| 600 | Drak’Tharon Keep | Dungeon | Grizzly Hills |
| 601 | Azjol-Nerub | Dungeon | Dragonblight |
| 602 | Halls of Lightning | Dungeon | Storm Peaks |
| 603 | Ulduar | Raid | Storm Peaks |
| 604 | Gundrak | Dungeon | Zul’Drak |
| 607 | Strand of the Ancients | Battleground | Dragonblight |
| 608 | Violet Hold | Dungeon | Dalaran |
| 615 | The Obsidian Sanctum | Raid | Dragonblight |
| 616 | The Eye of Eternity | Raid | Borean Tundra |
| 617 | Dalaran Sewers | Arena | Dalaran |
| 618 | The Ring of Valor | Arena | Orgrimmar |
| 619 | Ahn’kahet: The Old Kingdom | Dungeon | Dragonblight |
| 624 | Vault of Archavon | Raid | Wintergrasp |
| 628 | Isle of Conquest | Battleground | Icecrown |
| 631 | Icecrown Citadel | Raid | Icecrown |
| 632 | The Forge of Souls | Dungeon | Icecrown |
| 649 | Trial of the Crusader | Raid | Icecrown |
| 650 | Trial of the Champion | Dungeon | Icecrown |
| 658 | Pit of Saron | Dungeon | Icecrown |
| 668 | Halls of Reflection | Dungeon | Icecrown |
| 724 | The Ruby Sanctum | Raid | Dragonblight |
Map Properties
File Structure
World/
└── Maps/
└── [MapName]/
├── [MapName].wdt # World table
├── [MapName].wdl # Low-res data
├── [MapName]_[X]_[Y].adt # Terrain tiles
└── [MapName].tex # Texture list
Common Map Names
| ID | Internal Name | File Path |
|---|---|---|
| 0 | Azeroth | World/Maps/Azeroth/ |
| 1 | Kalimdor | World/Maps/Kalimdor/ |
| 530 | Expansion01 | World/Maps/Expansion01/ |
| 571 | Northrend | World/Maps/Northrend/ |
Map Flags (from Map.dbc)
#![allow(unused)]
fn main() {
bitflags! {
pub struct MapFlags: u32 {
const INSTANCE = 0x1; // Is instance
const RAID = 0x2; // Is raid
const PVP = 0x4; // Is PvP
const ARENA = 0x8; // Is arena
const TESTING = 0x10; // Testing map
const BATTLEGROUND = 0x20; // Is battleground
const DEVELOPMENT = 0x40; // Dev map
const DUNGEON = 0x100; // Is dungeon
}
}
}
Usage Example
#![allow(unused)]
fn main() {
use wow_cdbc::DbcParser;
use std::io::BufReader;
use std::fs::File;
// Load Map.dbc
let file = File::open("DBFilesClient/Map.dbc")?;
let parser = DbcParser::parse(&mut BufReader::new(file))?;
let header = parser.header();
println!("Map.dbc: {} records, {} fields each",
header.record_count, header.field_count);
}
Notes
- Map IDs are globally unique
- Instance maps use different coordinate systems
- Some maps have multiple versions (e.g., phased zones)
- Transport maps (ships, zeppelins) are separate maps
- Map IDs above 1000 are typically test/GM maps
See Also
🔗 External Links
Helpful resources for working with World of Warcraft file formats.
Community Documentation
wowdev.wiki
The primary community resource for WoW file format documentation:
Tools and Libraries
StormLib (MPQ)
- StormLib GitHub - C++ MPQ library (reference implementation)
- Note:
wow-mpqhas 98.75% compatibility with StormLib archives
BLP format
- BLPConverter - BLP texture converter
WoW Model Viewer
- WoW Model Viewer - 3D model viewer
- WMV GitHub - Source code
010 Editor Templates
- 010 Editor - Binary editor
Open Source Projects
Trinity Core
- TrinityCore - WoW server emulator
- GitHub Repository - Source code
- DBC Structures - DBC definitions
Mangos
- getMaNGOS - WoW server emulator
- GitHub Organization - Various repos
Research and Articles
Technical Deep Dives
- WoW Internals - Protocol documentation
- macOS Metal Renderer - WoW’s Metal implementation
- WoW 64-bit Client - Architecture changes
Related Projects
wow.export
- wow.export - WoW file explorer
File Format Specifications
Compression
Learning Resources
Videos
- WoW Modding YouTube - Video tutorials
- Machinima Techniques - Advanced techniques
Development Tools
Hex Editors
3D Tools
- Blender - 3D modeling (with WoW plugins)
Debugging
Historical Resources
Classic WoW
- Vanilla WoW Archive - 1.12 server
Legal Resources
Modding Policies
- Blizzard Legal - Terms of service
- WoW EULA - End user license
Contributing
Know of a helpful resource not listed here? Please submit a PR to add it!
Disclaimer
warcraft-rs is not affiliated with or endorsed by Blizzard Entertainment.
World of Warcraft® and Blizzard Entertainment® are trademarks or registered
trademarks of Blizzard Entertainment, Inc. in the U.S. and/or other countries.
Convert Command Testing Results
Date: 2026-01-11
Summary
Testing of all warcraft-rs convert subcommands using real game data from WoW clients
(1.12.1 Vanilla, 3.3.5a WotLK).
| Command | Status | Notes |
|---|---|---|
blp convert | Working | BLP <-> PNG/image formats functional |
m2 convert | Working | All versions parse and roundtrip correctly |
m2 skin-convert | Working | Old <-> New format conversion works |
m2 anim-convert | Working | Legacy format conversion works |
wmo convert | Partial | Root files work; group files pending |
adt convert | Working | Root files work with roundtrip; split files pending |
wdt convert | Working | Classic/TBC/WotLK/MoP conversion works |
wdl convert | Working | Version conversion works |
dbc export | Working | JSON/CSV export works (requires schema) |
dbc discover | Working | Schema discovery with locstring detection |
Detailed Results
BLP Convert
Status: Working
Tested conversions:
- BLP1 (1.12.1) -> PNG: Success
- BLP2 (3.3.5a) -> PNG: Success
- PNG -> BLP1 RAW1: Success
- PNG -> BLP1 JPEG: Success (alpha stripped with warning)
- PNG -> BLP2 DXT1: Success (auto-selects 1-bit alpha for RGBA input)
- PNG -> BLP2 DXT3: Success (auto-selects 8-bit alpha for RGBA input)
- PNG -> BLP2 DXT5: Success (auto-selects 8-bit alpha for RGBA input)
Features:
- Alpha bits are auto-detected from input image if not specified
- JPEG format now works with RGBA images (alpha is stripped with a warning)
- DXT1 auto-selects 1-bit alpha for images with transparency
- DXT3/DXT5/JPEG auto-select 8-bit alpha for images with transparency
- Raw1/Raw3 auto-select 8-bit alpha for full quality
Note: BLP JPEG format stores RGB in the JPEG stream. Alpha (if requested) would be stored separately, but this is a limitation of the current BLP format. For images requiring alpha, DXT5 or Raw1 formats are recommended.
M2 Convert
Status: Working - All versions parse and roundtrip correctly
The M2 converter preserves all animation data including bone animations, particle emitter animations, ribbon emitter animations, texture animations, color animations, transparency animations, event track data, attachment animations, camera animations, light animations, and embedded skin data through roundtrip and version conversion for all supported versions.
Working:
- Full model roundtrip (Vanilla, TBC, WotLK, Cataclysm, MoP)
- Version conversion between any supported versions
- Bone animation keyframes (timestamps, values, ranges) preserved
- Particle emitter animations (10 track types) preserved with offset relocation
- Ribbon emitter animations (4 track types) preserved with offset relocation
- Texture animations (5 track types) preserved with offset relocation
- Color animations (2 track types: RGB color, alpha) preserved with offset relocation
- Transparency animations (1 track type: alpha) preserved with offset relocation
- Event track data (simple M2Array
timestamps) preserved with offset relocation - Attachment animations (1 track type: scale) preserved with offset relocation
- Camera animations (3 track types: position, target position, roll) preserved with offset relocation
- Light animations (5 track types: ambient color, diffuse color, attenuation start/end, visibility) preserved with offset relocation
- Pre-WotLK embedded skin data preserved (ModelView, indices, triangles, submeshes, batches)
- Header fields correctly handle version-specific differences:
playable_animation_lookup(Vanilla/TBC only, versions 256-263)texture_flipbooks(pre-WotLK only, versions <= 263)viewsvsnum_skin_profiles(M2Array pre-WotLK, u32 for WotLK+)
- Vertex data preserved (48-byte format for all versions)
- Bone structure and animation tracks preserved with offset relocation
Test results with DwarfMale.m2 (vanilla 1.12.1):
Original: 2,124,656 bytes (version 256, embedded skins)
Vanilla roundtrip: 1,496,389 bytes (version 256, 4 skins, 138 bone anims, 34 attachment anims)
Converted to WotLK: 1,259,407 bytes (version 264, external skins)
Previous (before fix): 173,965 bytes (animation data was zeroed)
Note: Roundtrip file is smaller than original because we don’t preserve padding/alignment from the original file. The output contains all data needed to display and animate the model.
The converter preserves:
- Magic (MD20) and version
- Vertices and geometry (3246 vertices)
- Bones with animation tracks (110 bones, 138 animation data entries)
- Materials and textures (3 textures)
- Embedded skins for pre-WotLK (4 skin profiles)
- All animation sequences (135 animations)
- Particle emitter animations (emission speed, gravity, lifespan, etc.)
- Ribbon emitter animations (color, alpha, height)
- Texture animations (translation U/V, rotation, scale U/V)
- Color animations (RGB color, alpha)
- Transparency animations (alpha/texture weight)
- Event track timestamps (timeline triggers for sounds, effects)
- Attachment scale animations (weapon/effect attach points)
- Camera animations (position, target position, roll)
- Light animations (ambient/diffuse color, attenuation, visibility)
All M2 animation data types are now preserved through roundtrip conversion.
M2 Skin Convert
Status: Working
Tested conversions:
- WotLK old format -> MoP new format: Success
- MoP new format -> WotLK old format: Success
The skin file converter correctly maintains all data (indices, triangles, bone indices, submeshes, batches) across format changes.
M2 Anim Convert
Status: Working
Tested conversion:
- Legacy format -> MoP: Success (no actual conversion needed for legacy format)
WMO Convert
Status: Partial - Root files work; group files pending
Tested conversions:
- Classic root -> WotLK: Success
- Classic root -> Cataclysm: Success
The WMO root file converter works using expansion names (WotLK, Cataclysm, MoP) instead of raw version numbers. Uses WmoParser -> WmoConverter -> WmoWriter pipeline.
Working:
- Root file conversion between any supported versions
- Materials, groups, portals, lights, doodads preserved
- Header flags converted appropriately for target version
Not yet implemented:
- Group file conversion (requires bridging parser types to core types)
Group files (*_000.wmo, *_001.wmo, etc.) return an informative error explaining
that conversion is not yet supported due to internal type system differences.
ADT Convert
Status: Working - Root ADT files convert and roundtrip correctly
Tested conversions:
- Vanilla root -> WotLK: Success (roundtrip verified)
- Vanilla root -> MoP: Success (roundtrip verified)
- TBC root -> WotLK: Success (roundtrip verified)
- TBC root -> Classic: Success (roundtrip verified, strips MFBO)
- WotLK root -> MoP: Success (adds MFBO flight bounds for TBC+)
The ADT root file converter works using expansion names (classic, tbc, wotlk, cataclysm, mop). Uses ParsedAdt → BuiltAdt.from_root_adt() → write_to_file() pipeline.
Working:
- Parsing all ADT versions (Vanilla through MoP) - 256/256 MCNK chunks
- Conversion between any supported versions (up and down)
- Version-specific chunk handling (MFBO for TBC+, MH2O for WotLK+, MAMP for Cataclysm+, MTXP for MoP+)
- Terrain heights, normals, shadow maps, alpha maps, and vertex colors preserved
- Textures, models, WMOs, and placements preserved
- Fixed: MCAL/MCSH subchunk parsing now uses header size fields instead of corrupted subchunk sizes
- Fixed: has_shadow()/has_layer() now check offset/size instead of flags for Vanilla compatibility
- Fixed: MCNK serializer clears stale offsets to prevent invalid pointer references
Not yet supported:
- Split ADT files (_tex0, _obj0, _lod) from Cataclysm+
WDT Convert
Status: Working
Tested conversions:
- Classic -> WotLK: Success (no changes needed)
- WotLK -> MoP: Success (adds 0x0040 flag, updates MODF values)
The converter correctly:
- Adds the universal flag (0x0040) for Cataclysm+ versions
- Updates MODF scale values from 0 to 1024
- Updates MODF unique IDs from 0xFFFFFFFF to 0x00000000
Output verified by wdt info and the converted file is readable.
WDL Convert
Status: Working
Tested conversion:
- Classic -> MoP: Success
Output validated successfully with wdl validate.
DBC Export
Status: Working (with caveats)
JSON and CSV export work correctly when provided with a valid schema file.
Caveats:
- Requires a YAML schema file - no built-in schemas
- Schema must use
type_namefield (nottype) - Field order matters for matching record structure
DBC Discover
Status: Working
The schema discovery command analyzes DBC files and generates field type information.
Features:
- Correctly identifies String, Int32, UInt32, Float32, and Bool field types
- Validates string references point to actual string starts (not middle of strings)
- Detects localized string (locstring) patterns: 8 locale strings + 1 flags field
- Shows locale names (enUS, koKR, frFR, deDE, zhCN, zhTW, esES, esMX) for locstrings
- Identifies potential key fields
- Improved float detection: small integers (< 65536) no longer misdetected as floats
For AreaTable.dbc (21 fields, 84 bytes per record):
Field 0: Int32 (confidence: Low) ⚷ Key candidate
Field 1: Int32 (confidence: Low)
...
Field 11: String (confidence: High) 🌐 Locstring (enUS)
Field 12: String (confidence: Medium) 🌐 Locstring (koKR)
...
Field 19: Int32 (confidence: Low) 🌐 Locstring (flags)
Field 20: Int32 (confidence: Low)
For SpellRadius.dbc (actual float values correctly detected):
Field 0: Int32 (confidence: Low) ⚷ Key candidate
Field 1: Float32 (confidence: Medium) <- actual float (1.0, 2.0, 5.0, etc.)
Field 2: Bool (confidence: High)
Field 3: Float32 (confidence: Medium) <- actual float
Limitations:
- Field naming is generic (Float_1, Value_2, etc.) - use WoWDBDefs for accurate names
Test Data Locations
Test files extracted to /tmp/warcraft-rs-test/:
1.12.1 (Vanilla)
FacialLowerHair00_00.blp- BLP1 textureDwarfMale.m2- Character model (version 256)AltarOfStorms.wmo- WMO root (version 17)Azeroth_32_48.adt- Terrain tileAzeroth.wdt- Map definitionAzeroth.wdl- World lodAreaTable.dbc- Database table
3.3.5a (WotLK)
HAIR00_00.BLP- BLP2 textureIceTrollMale.m2- Character model (version 264)IceTrollMale00.skin- Skin file (old format)IceTrollMale0060-00.anim- Animation file (legacy)Duskwood_MageTowerPurple.wmo- WMO rootBlackTemple_28_28.adt- Terrain tileAuchindounDemon.wdt- WMO-only mapAuchindounDemon.wdl- World lod
Recommendations
-
Implement WMO group file conversion - Root files work but group files need type bridging between parser and converter/writer types.
-
Implement ADT split file conversion - Root ADT files work but Cataclysm+ split files (_tex0, _obj0, _lod) require additional support.
Test Commands Reference
# BLP conversion (alpha bits auto-detected from input)
cargo run -p warcraft-rs -- blp convert input.blp output.png
cargo run -p warcraft-rs -- blp convert input.png output.blp --blp-version blp2 --blp-format dxt5
cargo run -p warcraft-rs -- blp convert input.png output.blp --blp-version blp1 --blp-format jpeg
# Explicit alpha bits (optional)
cargo run -p warcraft-rs -- blp convert input.png output.blp --blp-version blp2 --blp-format dxt1 --alpha-bits 0
# M2 conversion (all animation types and embedded skins preserved)
cargo run -p warcraft-rs -- m2 convert input.m2 output.m2 --version WotLK
# Pre-WotLK roundtrip test
cargo run --example test_vanilla_roundtrip -p wow-m2 -- input.m2 [output.m2]
# Skin conversion
cargo run -p warcraft-rs -- m2 skin-convert input.skin output.skin --version MoP
# Anim conversion
cargo run -p warcraft-rs -- m2 anim-convert input.anim output.anim --version MoP
# WMO conversion (root files only)
cargo run -p warcraft-rs -- wmo convert input.wmo output.wmo --version WotLK
# ADT conversion (root files only)
cargo run -p warcraft-rs -- adt convert input.adt output.adt --to WotLK
# WDT conversion
cargo run -p warcraft-rs -- wdt convert input.wdt output.wdt --from-version Classic --to-version MoP
# WDL conversion
cargo run -p warcraft-rs -- wdl convert input.wdl output.wdl --to MoP
# DBC export
cargo run -p warcraft-rs -- dbc export input.dbc --schema schema.yaml -f json
# DBC schema discovery
cargo run -p warcraft-rs -- dbc discover input.dbc