Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

warcraft-rs Documentation

This documentation covers all supported World of Warcraft file formats and provides examples for parsing and handling them.

Getting Started

File Formats

Archives

  • MPQ Format - Blizzard’s archive format (100% StormLib compatible)

Terrain and World Data

Graphics & Models

Client Database

Guides

MPQ Archives

Terrain and World Tools

Graphics & Rendering

Client Database Tools

API Reference

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

Getting Help

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 support
  • extract - ADT data extraction features
  • parallel - Parallel processing support
  • yaml - YAML support for DBC schemas
  • full - 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

Next Steps

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

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

FormatTypeDescriptionTypical Size
MPQArchiveCompressed archive containing other files10MB - 4GB
ADTTerrainMap tile with terrain mesh and textures1-5MB
WDLTerrainLow-detail world map100-500KB
WDTMap InfoMap configuration and ADT references10-50KB
BLPTexture2D images with compression and mipmaps10KB - 2MB
M2Model3D models with animations50KB - 10MB
WMOModelLarge world objects100KB - 50MB
DBCDatabaseTabular data storage1KB - 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 files
  • patch-*.mpq - Incremental patches
  • locale-*.mpq - Localization files

Tools

  • warcraft-rs mpq - CLI commands for MPQ operations
  • Ladik’s MPQ Editor - Popular Windows MPQ editor

Performance Tips

  1. Caching: Cache frequently accessed files in memory
  2. Streaming: Read files on demand rather than extracting all at once
  3. Parallel Extraction: Use --threads flag in CLI for concurrent extraction
  4. 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 header
    • MPQ\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-ffi crate
  • 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:

  1. Pre-archive data (optional) - Allows MPQs to be appended to executables
  2. User data header (optional) - Custom data used in Starcraft II maps
  3. MPQ header (required) - Main archive header
  4. File data - Actual archived file contents
  5. Special files (optional) - (listfile), (attributes), (signature)
  6. HET table (optional, v3+) - Extended hash table
  7. BET table (optional, v3+) - Extended block table
  8. Hash table (optional in v3+) - File lookup table
  9. Block table (optional in v3+) - File information table
  10. High block table (optional, v2+) - Upper 16 bits of file offsets
  11. 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:

  1. MPQ Hash Algorithm - Used for hash and block tables (v1/v2)
  2. Jenkins one-at-a-time - Used for BET tables (v3+)
  3. 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-mpq implementation handles this discrepancy automatically
  • (signature): Digital signature for archive verification
  • (patch_metadata): Information for incremental patching

Implementation Notes

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.MPQpatch.MPQpatch-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-#####.MPQ system
  • Added DB2 format alongside DBC

WoW 5.4.8 (Mists of Pandaria)

  • Peak complexity with 100+ potential archives
  • Extensive wow-update system (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

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

  1. Level of Detail: Use WDL for distant terrain
  2. Frustum Culling: Cull ADT chunks outside view
  3. Texture Streaming: Load textures on demand
  4. 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 PatternDescriptionContentChunks Present
MapName_XX_YY.adtRoot fileTerrain structure and headerMVER, MHDR, MCNK, MFBO
MapName_XX_YY_tex0.adtTexture fileTexture data and amplitudeMVER, MTEX, MAMP, MCNK, MTXP (MoP+)
MapName_XX_YY_tex1.adtTexture fileAdditional texture layersMVER, MTEX, MAMP, MCNK, MTXP (MoP+)
MapName_XX_YY_obj0.adtObject fileM2 and WMO placement dataMVER, MMDX, MMID, MWMO, MWID, MDDF, MODF, MCNK
MapName_XX_YY_obj1.adtObject fileAdditional object dataMVER, MMDX, MMID, MWMO, MWID, MDDF, MODF, MCNK
MapName_XX_YY_lod.adtLOD fileLevel 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:

  1. Layer Weight Calculation - Based on alpha map values
  2. Height-based Scaling - Terrain height influences blending
  3. 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

  1. Memory mapping: Use memory-mapped files for large ADT files
  2. Lazy loading: Only parse chunks when needed
  3. Caching: Cache frequently accessed data like height maps
  4. LOD: Use WDL files for distant terrain
  5. Frustum culling: Only render visible chunks
  6. Texture atlasing: Combine texture layers to reduce draw calls

Common Pitfalls

  1. Byte order: All values are little-endian
  2. Chunk alignment: Chunks are not always aligned to 4-byte boundaries
  3. String parsing: Strings in MTEX/MMDX/MWMO are null-terminated
  4. Coordinate systems: Y-axis is north/south (not up/down)
  5. Height interpolation: Must use the center vertices for proper interpolation
  6. Alpha map compression: Check MCLY flags to determine format
  7. 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

ChunkConfidenceServer ValidationUsage
MFBOVery HighTrinityCore 3.3.5a + Cataclysm 4.3.4Flight bounds for gameplay
MH2OVery HighTrinityCore 3.3.5a + SkyFire 5.4.8Water collision/rendering
Split FilesHighCataclysm 4.3.4 + SkyFire 5.4.8Data organization
MAMPMediumNone (empirical only)Client rendering optimization
MTXPMediumNone (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 VersionGame VersionsMajor Changes
18All analyzed versions (1.12.1 - 5.4.8)Consistent version across all expansions

Format Evolution by Game Version

Game VersionMVERChanges
1.12.1 (Vanilla)18Core ADT format established
2.4.3 (The Burning Crusade)18Added MFBO chunk
3.3.5a (Wrath of the Lich King)18Added MH2O water, replaced MCLQ
4.3.4 (Cataclysm)18Split files (_tex0,_obj0, _obj1), added MAMP
5.4.8 (Mists of Pandaria)18Added MTXP chunk

References

See Also

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:

VersionWoW VersionsNotes
18All 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

ChunkSizeDescriptionFirst SeenRequired
MVER4Version number (always 18)1.12.1
MAOF16384Map area offset table (64×64×4)1.12.1
MARE1090Map area terrain heights1.12.1
MAHO32-2176Map area height holes2.4.3
MWIDVariableWMO instance IDs4.3.4
MWMOVariableWMO filenames4.3.4
MODFVariableWMO placement data4.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

  1. Distance-Based Switching: Engine switches between ADT and WDL based on camera distance
  2. World Map Generation: WDL data generates world map imagery
  3. Minimap Support: Low-resolution data for minimap rendering
  4. 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

  1. MARE Chunk Structure: Internal format needs reverse engineering
  2. Height Data Format: Exact encoding of height information unknown
  3. Texture Mapping: How low-resolution textures are stored/referenced
  4. Version Differences: Changes between game versions not fully documented
  5. Coordinate Precision: Exact parameters need verification

Implementation Challenges

  1. Reverse Engineering Required: Most technical details need verification
  2. Version Compatibility: Supporting multiple game versions
  3. Performance Requirements: Real-time terrain rendering demands
  4. Memory Constraints: Efficient loading and caching strategies

References

See Also

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

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:

  1. Map Tile Presence: Define which of the potential 64×64 ADT tiles actually exist for a given map - ✅ Implemented
  2. Global WMO Reference: For indoor/instance maps, reference a single global WMO - ✅ Implemented
  3. Map Properties: Store various flags and properties that affect how the entire map is rendered - ✅ Implemented
  4. Lighting Information: Define global lighting properties and light sources - ⚠️ Format Specification Only
  5. Fog Effects: Control volumetric fog and atmospheric effects - ⚠️ Format Specification Only
  6. 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):

  1. X rotation: Around West/East axis
  2. Y rotation: Around Up axis
  3. 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

  1. Handedness: All WoW coordinate systems are right-handed
  2. Units: 1 unit = 1 yard in game world
  3. Tile Size: Each ADT tile is 533.33333 units
  4. Map Size: 64×64 tiles = 34133.333 units total
  5. Array Ordering: Tile arrays use [Y][X] ordering (row-major)

Common Pitfalls

  1. Array vs World: Tile arrays are indexed [Y][X] but world coordinates are (X, Y, Z)
  2. Rotation Units: Always radians in files (beware of 2.4.3 DireMaul bug using degrees)
  3. Model Placement: MODF coordinates need transformation to world space
  4. Left-handed Renderers: Negate all rotations when converting to left-handed systems

Typical Chunk Order

For main WDT files:

  1. MVER - Version information (always first, version 18 across all tested versions)
  2. MPHD - Map header with flags and file references
  3. MAIN - Map tile presence information
  4. MAID - FileDataIDs for ADT files (post-8.1)
  5. MWMO - Global WMO filename (WMO-only maps have data; pre-4.x terrain maps have empty chunk; 4.x+ terrain maps have NO chunk)
  6. 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
}
}

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:

  1. 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' }
  2. Chunk Offset Calculation: Chunks are located at:

    • MVER: Start of file
    • MPHD: version_offset + version->size + 8
    • MAIN: mphd_offset + mphd->size + 8
  3. ADT File Existence: Check adt_list[y][x].exist & 0x1

  4. Scale Field: The MODF structure includes a 16-bit scale field (1024 = 1.0)

  5. 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

  1. wowdev.wiki - Primary source for WoW file format documentation

    • Contains chunk definitions and flag values
  2. libwarcraft - C# implementation by WowDevTools

    • Fully compliant WDT read/write support
  3. StormLib - C++ MPQ library by Ladislav Zezula

    • Reference implementation for reading WoW data files
  4. AzerothCore - Open-source WoW server

    • Map extractor implementation and MWMO handling
  5. Noggit - Open-source WoW map editor

    • Practical implementation of WDT generation
  6. 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

  • .anim - External animation sequences
  • .skin - Mesh and LOD data
  • .phys - Physics simulation data

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

  1. Texture Atlasing: Combine small textures
  2. LOD System: Use lower detail models at distance
  3. Instancing: Batch render identical models
  4. Culling: Skip hidden WMO groups
  5. 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

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.

  • 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.

  • 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

See Also

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 .mdx in 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):

VersionExpansionFiles AnalyzedNotable Changes
256Vanilla WoW (1.12.1)50 filesOriginal format, inline data structure
260The Burning Crusade (2.4.3)48 filesStructure updates, maintains inline format
264Wrath of the Lich King (3.3.5a)20 filesChunked format capability introduced
272Cataclysm (4.3.4)27 filesChunked format established
272Mists of Pandaria (5.4.8)10 filesSame 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:

  1. No External Chunks Found: Despite chunked format support from v264+, zero external chunks detected in 105 analyzed files
  2. Version Consistency: 100% version consistency within each expansion - no mixed versions found
  3. Inline Data Persistence: All M2 files through MoP 5.4.8 maintain traditional inline data structure
  4. Forward Compatibility: Chunked format appears to be infrastructure for post-MoP expansions
  5. 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 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 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_max field

References

See Also

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

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_id field 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_id field 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_id field 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

  1. Texture Resolution:

    • MCNK chunk contains texture layers with texture_id
    • texture_id is used as an index into MTEX chunk
    • MTEX contains the actual BLP filename
  2. M2 Model Resolution:

    • MDDF chunk contains doodad placements with name_id
    • name_id is used as an index into MMID chunk
    • MMID contains an offset into MMDX chunk
    • MMDX contains the actual M2 filename at that offset
  3. WMO Resolution:

    • MODF chunk contains WMO placements with name_id
    • name_id is used as an index into MWID chunk
    • MWID contains an offset into MWMO chunk
    • MWMO contains the actual WMO filename at that offset

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.wmo to _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:

VersionExpansionCore ChunksNotable Changes
17Vanilla WoW (1.12.1)MVER, MOHD, MOTX, MOMT, MOGN, MOGI, MOSB, MOPV, MOPT, MOPR, MOVV, MOVB, MOLT, MODS, MODN, MODD, MFOGOriginal format with 17 core chunks
17The Burning Crusade (2.4.3)Same as 1.12.1No new chunks detected in samples
17Wrath of the Lich King (3.3.5a)Same as 1.12.1No new chunks detected in samples
17Cataclysm (4.3.4)Core + MCVPAdded MCVP (Convex Volume Planes, 496 bytes in transport WMOs)
17Mists of Pandaria (5.4.8)Core + MCVPNo additional chunks detected
17Warlords of Draenor (6.x)Core + GFIDAdded GFID chunk for file IDs
17Legion (7.x)Core + MOP2, MPVDAdded MOP2 (Portal Info 2), MPVD (particle volumes)
17Battle for Azeroth (8.x)Core + shadow chunksEnhanced shadow mapping (MLSP, MLSS, MLSK)
17Shadowlands (9.x)Core + volume chunksAdditional 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:

  1. MVER (4 bytes) - Version, always value 17
  2. MOHD (64 bytes) - Header with counts and flags
  3. MOTX (variable) - Texture filenames, null-terminated strings
  4. MOMT (variable) - Materials, 64 bytes per material
  5. MOGN (variable) - Group names, null-terminated strings
  6. MOGI (variable) - Group information, 32 bytes per group
  7. MOSB (4 bytes) - Skybox filename offset or empty
  8. MOPV (variable) - Portal vertices, 12 bytes per vertex
  9. MOPT (variable) - Portal information, 20 bytes per portal
  10. MOPR (variable) - Portal references, 8 bytes per reference
  11. MOVV (0 bytes typically) - Visible block vertices (often empty)
  12. MOVB (0 bytes typically) - Visible block list (often empty)
  13. MOLT (variable) - Lighting, 48 bytes per light
  14. MODS (32 bytes typically) - Doodad sets, single default set common
  15. MODN (variable) - Doodad names, null-terminated M2 filenames
  16. MODD (variable) - Doodad definitions, 40 bytes per doodad
  17. 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:

  1. Vertex Colors: Baked lighting stored per-vertex
  2. Dynamic Lights: Point and spot lights that affect nearby geometry
  3. 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

  1. WoWDev Wiki - WMO Format
  2. WoWDev Wiki - WMO/v17
  3. Ladislav Zezula’s WMO Documentation
  4. libwarcraft WMO Implementation
  5. Neo (WoW Model Viewer) Source
  6. WoWMapViewer Source Code
  7. 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:

  1. Essential: MVER, MOHD, MOTX, MOMT, MOGI, MOGN (basic structure)
  2. Important: MODD, MODN, MODS (doodad placement)
  3. Lighting: MOLT, MFOG (visual quality)
  4. Advanced: MOPV, MOPT, MOPR (portal culling)
  5. 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

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, pub render_batches: Vec, pub bsp_tree: Option, pub liquid: Option, }

impl WMOGroup { pub fn read<R: Read + Seek>(reader: &mut R) -> io::Result { let chunk = Chunk::read(reader)?; if chunk.header.id_string() != “MOGP” { return Err(io::Error::new( io::ErrorKind::InvalidData, “Expected MOGP chunk”, )); }

    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(data: &[u8]) -> io::Result { if data.len() < std::mem::size_of::() { return Err(io::Error::new( io::ErrorKind::UnexpectedEof, “Not enough data for struct”, )); }

unsafe {
    Ok(std::ptr::read_unaligned(data.as_ptr() as *const T))
}

}

/// Helper function to read an array of structs fn read_array(data: &[u8]) -> io::Result<Vec> { let item_size = std::mem::size_of::(); let count = data.len() / item_size;

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

  1. Byte Order: All multi-byte values are little-endian
  2. Chunk Alignment: Some chunks may have padding to align to 4-byte boundaries
  3. String Parsing: Strings in MOTX, MOGN, MODN are null-terminated and can be empty
  4. Group Numbering: Group files are numbered from 000, not 001
  5. Coordinate System: Remember WoW uses a right-handed system with Y pointing north
  6. Material IDs: Material IDs in groups index into the root file’s MOMT chunk
  7. BSP Face Indices: BSP face indices refer to triangles, not vertices
  8. Portal Normals: Portal plane normals point toward the positive side
  9. Vertex Colors: MOCV can have 1 or 2 sets of colors (check MOGI flags)
  10. Texture Coordinates: Groups can have up to 3 sets of texture coordinates

References

  1. WoWDev Wiki - WMO Format
  2. WoWDev Wiki - WMO/v17
  3. Ladislav Zezula’s WMO Documentation
  4. libwarcraft WMO Implementation
  5. Neo (WoW Model Viewer) Source
  6. WoWMapViewer Source Code
  7. PyWoW WMO Module

Implementation References

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:

  1. Essential: MVER, MOHD, MOTX, MOMT, MOGI, MOGN (basic structure)
  2. Important: MODD, MODN, MODS (doodad placement)
  3. Lighting: MOLT, MFOG (visual quality)
  4. Advanced: MOPV, MOPT, MOPR (portal culling)
  5. 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

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 definitions
  • Spell.dbc - Spell data
  • CreatureDisplayInfo.dbc - Creature models
  • Map.dbc - Map definitions

Display Data

  • ItemDisplayInfo.dbc - Item visuals
  • CharSections.dbc - Character customization
  • CreatureModelData.dbc - Model parameters

Game Mechanics

  • Talent.dbc - Talent trees
  • SkillLine.dbc - Skills and professions
  • Achievement.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:

IDLocaleDescription
0enUSEnglish (US)
1koKRKorean
2frFRFrench
3deDEGerman
4zhCNChinese (Simplified)
5zhTWChinese (Traditional)
6esESSpanish (Spain)
7esMXSpanish (Mexico)
8ruRURussian

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 VersionDBC FormatNotable Changes
Classic (1.12.x)WDBC v1Original format
TBC (2.4.3)WDBC v1No format changes, new files
WotLK (3.3.5)WDBC v1No format changes, new fields
Cataclysm (4.x)DB2New format with variable record sizes
MoP+ (5.x+)DB2/DB5/DB6Progressive 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_count fields
  • 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:

TypeSizeDescription
u324 bytesUnsigned integer
i324 bytesSigned integer
f324 bytesIEEE 754 single-precision float
StringRef4 bytesOffset 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

  1. Read and validate header
  2. Allocate memory for records
  3. Read all records sequentially
  4. Read string block
  5. 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

  1. Build string block with deduplication
  2. Calculate header values
  3. Write header
  4. Write records with updated string references
  5. 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:

  1. Empty string at offset 0 (for null references)
  2. Strings in order of first reference
  3. 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-rs installed with the mpq feature 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:

  1. Check the exact filename (case-sensitive)
  2. Check if archive has a listfile (use archive.list())
  3. Check if using correct patch archive
  4. 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:

  1. Add a listfile to the archive manually
  2. Extract files by their exact known names
  3. 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:

  1. Check archive information
  2. 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:

  1. Base Archives First: Common game data (common.MPQ, common-2.MPQ)
  2. Expansion Archives: Each expansion adds its archives (expansion.MPQ, lichking.MPQ)
  3. Locale Archives: Language-specific content that overrides base content
  4. General Patches: Numbered patches (patch.MPQ, patch-2.MPQ, patch-3.MPQ)
  5. 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)
        }
    }
}
}

References

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

  1. Use filters to reduce processing time when working with large archives
  2. Enable quiet mode (-q) for scripting to reduce output overhead
  3. Extract to SSD for better performance with many small files
  4. Use specific file names when possible instead of extracting everything
  5. 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:

  1. Weak Signatures: Use MD5 hash over 64KB chunks
  2. Strong Signatures: Use SHA-1 hash over 64KB chunks
  3. 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

  1. Always Verify Signatures: Check signatures on downloaded or received archives
  2. Sign Distribution Archives: Add signatures to archives you distribute
  3. Use Appropriate Signature Type:
    • Weak signatures for compatibility with older tools
    • Strong signatures for maximum security (when possible)
  4. Handle Missing Signatures Gracefully: Not all archives have signatures
  5. Log Verification Results: Keep audit trails of signature checks

Limitations

  1. Strong Signature Generation: Requires Blizzard’s private key (not publicly available)
  2. Signature Modification: Cannot modify signatures on existing archives without rebuilding
  3. 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_mask for version-specific offset handling
    • raw_chunk_size for 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

  1. Parameter validation with checks
  2. File size validation with platform-specific handling
  3. 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.)
  4. Header processing with compatibility quirks
  5. Cryptography initialization including storm buffer
  6. Table loading with defragmentation
  7. File table building with correlation
  8. Internal files loading (listfile, attributes)
  9. Finalization with malformed archive detection

wow-mpq: Streamlined Process

  1. Basic parameter validation
  2. Find MPQ header at aligned offsets
  3. Parse header based on version
  4. Load hash/block tables or HET/BET tables
  5. 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_64 and or_mask_64 for optimization
  • Bit-packed storage: Custom BitArray implementation 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

  1. Filename-based: Standard key calculation from filename
  2. Sector size detection: Analyze sector boundaries
  3. Content pattern matching:
    • WAVE files: 0x46464952 signature
    • EXE files: 0x00905A4D signature
    • XML files: 0x6D783F3C signature
  4. 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

  1. Custom allocators: Specialized allocation for large tables
  2. Memory mapping: MmapStream for large read-only archives
  3. Sector caching: LRU cache with configurable size
  4. Thread safety: parking_lot mutexes
  5. 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

  1. Patch metadata: Special support for WoW patch chains
  2. Compile-time tables: Storm buffer as const array
  3. Extended attributes: File timestamps and custom metadata
  4. Sparse compression: RLE algorithm for sparse data
  5. LZMA compression: LZMA compression support
  6. RSA signatures: Digital signature verification
  7. Listfile management: Automatic listfile updates
  8. 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

  1. Check feature requirements: Ensure wow-mpq supports your needs
  2. Update error handling: Adapt to Rust’s Result type
  3. Adjust for missing features: Implement workarounds or alternatives
  4. 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

FeatureStormLibwow-mpq
Memory usageLower (bit packing)Higher (standard types)
Thread safetyManual with callbacksBuilt-in with Rust
Large archivesMemory mappedStandard I/O
CachingConfigurable LRUBasic
Startup timeSlower (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:

  1. Thread Safety: Built-in thread safety without manual synchronization
  2. Error Handling: Thread-local error storage instead of global state
  3. Memory Management: Automatic memory management internally
  4. 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:

  1. Open an issue: Discuss the feature need
  2. Reference StormLib: Link to relevant StormLib code
  3. Propose design: Suggest Rust-idiomatic implementation
  4. 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.MPQ through expansion3.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-#####.MPQ system

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

  1. Higher priority always wins: Files in higher priority archives override lower priority
  2. Locale overrides general: Locale-specific archives override their general counterparts
  3. Patches override base: All patches override all base content
  4. Loading order matters: Archives must be loaded in the correct sequence

Best Practices

  1. Always follow the official loading order (see TrinityCore for 3.3.5a reference)
  2. Test with real game data to verify patch chains work correctly
  3. Handle missing archives gracefully - not all installations have all patches
  4. Cache file lookups for performance in large patch chains
  5. 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

  1. Validation First: Always validate ADT files before conversion
  2. Backup Files: Keep original files when converting versions
  3. Check Split Files: For Cataclysm+, ensure all split files are present
  4. Use Appropriate Formats: Choose heightmap format based on your editing tool
  5. Batch Operations: Use glob patterns for processing multiple files
  6. 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 extract or --features full
  • Check feature is enabled in your build

Performance Tips

  • Use --compact for tree view on large files
  • Limit --depth for quick structure overview
  • Use parallel feature for batch operations
  • Process files from local disk, not network drives

See Also

🌍 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-rs installed with the adt and wdt features 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
}
}

References

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: Success
  • 1: File not found or permission error
  • 2: Invalid command line arguments
  • 3: File parsing error
  • 4: 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}`));
            }
        });
    });
}

References

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-vertices
  • use-unified-render-path
  • use-liquid-from-dbc
  • do-not-fix-vertex-color-alpha
  • lod
  • has-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

  1. Use --compact with tree command for large WMOs to reduce output
  2. Validate before converting to catch issues early
  3. Process root files separately from group files when batch processing

Common Issues

  1. Missing Group Files: Ensure all _XXX.wmo files are present
  2. Texture Path Issues: WoW uses backslashes; the tool handles conversion
  3. 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 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-rs installed with the wmo feature 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 })
}
}

References

🎭 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-rs installed with the m2 feature 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,
    }
}
}

References

🎨 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
    }
}
}

References

🖼️ 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-rs installed with the blp feature 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(
                &current,
                current.width() / 2,
                current.height() / 2,
                image::imageops::FilterType::Lanczos3,
            );

            let mipmap_data = match format {
                BlpFormat::Dxt1 | BlpFormat::Dxt3 | BlpFormat::Dxt5 => {
                    compress_to_dxt(&current, format)?
                }
                BlpFormat::Uncompressed => {
                    convert_rgba_to_bgra(&current)
                }
                _ => 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)
    }
}
}

References

🎬 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-rs installed 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(&current_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
    }
}
}

References

📊 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(
                &current_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;
}
"#;

References

📝 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-rs installed with the cdbc feature 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 properties
  • Item.dbc: Item templates and stats
  • Map.dbc: World map information
  • AreaTable.dbc: Zone and area definitions
  • ChrRaces.dbc: Playable race data
  • ChrClasses.dbc: Class definitions
  • CreatureDisplayInfo.dbc: Creature model information
  • Achievement.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)
}
}

References

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:

CrateVersion TypeVariants
wow-mpqFormatVersionV1, V2, V3, V4
wow-m2M2VersionVanilla, TBC, WotLK, Cataclysm, MoP, WoD, Legion, BfA, Shadowlands, Dragonflight, TheWarWithin
wow-wmoWmoVersionClassic, Tbc, Wotlk, Cataclysm, Mop, Wod, Legion, Bfa, Shadowlands, Dragonflight, WarWithin
wow-wdtWowVersionClassic, TBC, WotLK, Cataclysm, MoP, WoD, Legion, BfA, Shadowlands, Dragonflight
wow-adtAdtVersionVanillaEarly, VanillaLate, TBC, WotLK, Cataclysm, MoP
wow-wdlWdlVersionVanilla, 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 Chunk trait 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 ReadExt trait 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-support feature
  • wow-wdt: serde feature
  • wow-cdbc: serde feature (also csv_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

CrateError TypeModule
wow-mpqErrorwow_mpq::error
wow-blpLoadError, EncodeError, ConvertErrorwow_blp::parser, wow_blp::encode, wow_blp::convert
wow-m2M2Errorwow_m2::error
wow-wmoWmoErrorwow_wmo::error
wow-adtAdtErrorwow_adt::error
wow-wdlWdlErrorwow_wdl::error
wow-wdtErrorwow_wdt::error
wow-cdbcErrorwow_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:

  1. Memory Management: The Rust implementation manages memory internally - no manual cleanup required except for handles
  2. Error Handling: Thread-local error storage instead of global error state
  3. Performance: Generally faster due to Rust optimizations
  4. 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

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 contents
    • extract - Extract files
    • info - Show archive information
    • validate - Validate archive integrity
    • create - Create new archives
    • rebuild - Rebuild archives with format upgrades
    • compare - Compare two archives
  • DBC subcommands - Database file operations
    • info - Display information about a DBC file
    • validate - Validate a DBC file against a schema
    • list - List records in a DBC file
    • export - Export DBC data to JSON/CSV formats
    • analyze - Analyze DBC file performance and structure
    • discover - 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 file
    • validate - Validate BLP file integrity
    • convert - 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 file
    • validate - Validate an M2 model file
    • convert - Convert an M2 model to a different version
    • tree - Display M2 file structure as a tree
    • skin-info - Display information about a Skin file
    • skin-convert - Convert a Skin file to a different version
    • anim-info - Display information about an ANIM file
    • anim-convert - Convert an ANIM file to a different version
    • blp-info - Display information about a BLP texture file
  • WMO subcommands - World object operations
    • info - Show information about a WMO file
    • validate - Validate a WMO file
    • convert - Convert WMO between different WoW versions
    • list - List WMO components (groups, materials, doodads, etc.)
    • tree - Visualize WMO structure as a tree
    • export - Export WMO data (not yet implemented)
    • extract-groups - Extract WMO groups (not yet implemented)
  • ADT subcommands - Terrain operations
    • info - Show information about an ADT file
    • validate - Validate an ADT file
    • convert - Convert ADT between different WoW versions
    • extract - Extract data from ADT files (requires ‘extract’ feature)
    • tree - Visualize ADT structure as a tree
    • batch - Batch process multiple ADT files (requires ‘parallel’ feature)
  • WDL subcommands - Low-res world operations
    • validate - Validate WDL file format
    • info - Show WDL file information
    • convert - Convert between WDL versions
  • WDT subcommands - Map definition operations
    • info - Display WDT file information
    • validate - Validate WDT file structure
    • convert - Convert between WDT versions
    • tiles - 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:

  1. Consistency: Follow the same command patterns as existing CLIs
  2. Shared utilities: Use the utils module for common functionality
  3. Error handling: Use anyhow for consistent error reporting
  4. Testing: Include both unit and integration tests
  5. Documentation: Update this document and usage guides
  6. Feature flags: Consider adding feature flags for optional functionality

Current Limitations

  • Some M2 subcommands have limited functionality due to API constraints
  • WMO export and extract-groups subcommands are not yet implemented
  • ADT extract and batch commands 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 AxisBlenderUnityUnreal
+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 QuatBlenderUnityUnreal
x-y-z-x
yxxy
zz-y-z
wwww

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

References

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

SystemOriginX DirectionY DirectionNotes
GridTop-left (0,0)Right →Down ↓File naming
WorldCenter (0,0,0)North →West →Internal coords
ClientCenter (0,0)North →West →UI display
ADT LocalTop-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

📖 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: MVER chunk 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

ExpansionVersionPatchBuildStatus
Classic (Vanilla)1.12.11.12.1.58755875✅ Supported
The Burning Crusade2.4.32.4.3.86068606✅ Supported
Wrath of the Lich King3.3.53.3.5a.1234012340✅ Supported
Cataclysm4.3.44.3.4.1559515595✅ Supported
Mists of Pandaria5.4.85.4.8.1841418414✅ Supported

File Format Versions

MPQ Archives

VersionClientChangeswow-mpq Support
v11.x - 3.xOriginal format, hash table, block table✅ Supported
v23.x+Extended attributes, larger files✅ Supported
v34.x+HET/BET tables, increased hash table size✅ Supported
v45.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

VersionClientMajor Changes
256-2571.xOriginal format
260-2632.xParticle emitters update
2643.0+.skin/.anim file separation
2723.3+Extended animations
2734.0+.phys physics data
2744.x+New texture types
2765.x+Improved bone structure

ADT Terrain

VersionClientChanges
181.x - 2.xOriginal MCNK format
203.xDestructible doodads
214.xTerrain streaming, flight
235.xNew texture blending

BLP Textures

VersionClientFormat Support
BLP11.x - 2.xJPEG compression, palettized
BLP23.x+DXT compression, mipmaps

DBC Database

ClientRecordsString EncodingFeatures
1.xFixed sizeASCIIBasic structure
2.xFixed sizeUTF-8Extended fields
3.xFixed sizeUTF-8Localization support
4.xFixed sizeUTF-8New index format
5.xFixed sizeUTF-8Compressed 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 BLP1 or BLP2
  • WMO: MVER chunk version number increases with expansions (17-27)
  • WDT/WDL: Version detection via chunk analysis

File Magic Numbers

FormatMagicNotes
MPQMPQ\x1AAll versions
M2 (Legacy)MD20Pre-Legion
M2 (Chunked)MD21Legion+
BLP1BLP1Classic, TBC
BLP2BLP2WotLK+
WMO/ADT/WDT/WDLRVER (MVER reversed)Chunk-based

Best Practices

  1. Use each crate’s version enum for version-aware code
  2. Let the parser detect versions automatically where possible
  3. Test with files from multiple WoW client versions
  4. 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

IDNameIntroducedGrid SizeNotes
0Eastern KingdomsClassic64×64Azeroth
1KalimdorClassic64×64Kalimdor
530OutlandTBC64×64Expansion01
571NorthrendWotLK64×64Northrend
646DeepholmCataclysm64×64Deephome
860The Wandering IsleMoP64×64NewRaceStartZone
870PandariaMoP64×64HawaiiMainLand

Classic Instances

IDNameTypeLocation
30Alterac ValleyBattlegroundAlterac Mountains
33Shadowfang KeepDungeonSilverpine Forest
34The StockadeDungeonStormwind
36DeadminesDungeonWestfall
43Wailing CavernsDungeonThe Barrens
47Razorfen KraulDungeonThe Barrens
48Blackfathom DeepsDungeonAshenvale
70UldamanDungeonBadlands
90GnomereganDungeonDun Morogh
109Sunken TempleDungeonSwamp of Sorrows
129Razorfen DownsDungeonThousand Needles
189Scarlet MonasteryDungeonTirisfal Glades
209Zul’FarrakDungeonTanaris
229Blackrock SpireDungeonBlackrock Mountain
230Blackrock DepthsDungeonBlackrock Mountain
249Onyxia’s LairRaidDustwallow Marsh
309Zul’GurubRaidStranglethorn Vale
349MaraudonDungeonDesolace
369Deeprun TramTransportIronforge ↔ Stormwind
389Ragefire ChasmDungeonOrgrimmar
409Molten CoreRaidBlackrock Mountain
429Dire MaulDungeonFeralas
469Blackwing LairRaidBlackrock Mountain
489Warsong GulchBattlegroundAshenvale/Barrens
509Ruins of Ahn’QirajRaidSilithus
529Arathi BasinBattlegroundArathi Highlands
531Temple of Ahn’QirajRaidSilithus
533NaxxramasRaidEastern Plaguelands

The Burning Crusade

IDNameTypeLocation
532KarazhanRaidDeadwind Pass
534The Battle for Mount HyjalRaidCaverns of Time
540The Shattered HallsDungeonHellfire Citadel
542The Blood FurnaceDungeonHellfire Citadel
543Hellfire RampartsDungeonHellfire Citadel
544Magtheridon’s LairRaidHellfire Citadel
545The SteamvaultDungeonCoilfang Reservoir
546The UnderbogDungeonCoilfang Reservoir
547The Slave PensDungeonCoilfang Reservoir
548Serpentshrine CavernRaidCoilfang Reservoir
550Tempest KeepRaidNetherstorm
552The ArcatrazDungeonTempest Keep
553The BotanicaDungeonTempest Keep
554The MechanarDungeonTempest Keep
555Shadow LabyrinthDungeonAuchindoun
556Sethekk HallsDungeonAuchindoun
557Mana-TombsDungeonAuchindoun
558Auchenai CryptsDungeonAuchindoun
559Nagrand ArenaArenaNagrand
560Old Hillsbrad FoothillsDungeonCaverns of Time
562Blade’s Edge ArenaArenaBlade’s Edge
564Black TempleRaidShadowmoon Valley
565Gruul’s LairRaidBlade’s Edge
566Eye of the StormBattlegroundNetherstorm
568Zul’AmanRaidGhostlands
572Ruins of LordaeronArenaUndercity
580Sunwell PlateauRaidIsle of Quel’Danas
585Magisters’ TerraceDungeonIsle of Quel’Danas

Wrath of the Lich King

IDNameTypeLocation
574Utgarde KeepDungeonHowling Fjord
575Utgarde PinnacleDungeonHowling Fjord
576The NexusDungeonBorean Tundra
578The OculusDungeonBorean Tundra
595The Culling of StratholmeDungeonCaverns of Time
599Halls of StoneDungeonStorm Peaks
600Drak’Tharon KeepDungeonGrizzly Hills
601Azjol-NerubDungeonDragonblight
602Halls of LightningDungeonStorm Peaks
603UlduarRaidStorm Peaks
604GundrakDungeonZul’Drak
607Strand of the AncientsBattlegroundDragonblight
608Violet HoldDungeonDalaran
615The Obsidian SanctumRaidDragonblight
616The Eye of EternityRaidBorean Tundra
617Dalaran SewersArenaDalaran
618The Ring of ValorArenaOrgrimmar
619Ahn’kahet: The Old KingdomDungeonDragonblight
624Vault of ArchavonRaidWintergrasp
628Isle of ConquestBattlegroundIcecrown
631Icecrown CitadelRaidIcecrown
632The Forge of SoulsDungeonIcecrown
649Trial of the CrusaderRaidIcecrown
650Trial of the ChampionDungeonIcecrown
658Pit of SaronDungeonIcecrown
668Halls of ReflectionDungeonIcecrown
724The Ruby SanctumRaidDragonblight

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

IDInternal NameFile Path
0AzerothWorld/Maps/Azeroth/
1KalimdorWorld/Maps/Kalimdor/
530Expansion01World/Maps/Expansion01/
571NorthrendWorld/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-mpq has 98.75% compatibility with StormLib archives

BLP format

WoW Model Viewer

010 Editor Templates

Open Source Projects

Trinity Core

Mangos

Research and Articles

Technical Deep Dives

wow.export

File Format Specifications

Compression

  • zlib - Compression library
  • bzip2 - Alternative compression
  • LZMA SDK - LZMA compression

Learning Resources

Videos

Development Tools

Hex Editors

  • HxD - Free hex editor (Windows)
  • Hex Fiend - macOS hex editor
  • Bless - Linux hex editor

3D Tools

  • Blender - 3D modeling (with WoW plugins)

Debugging

Historical Resources

Classic WoW

Modding Policies

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).

CommandStatusNotes
blp convertWorkingBLP <-> PNG/image formats functional
m2 convertWorkingAll versions parse and roundtrip correctly
m2 skin-convertWorkingOld <-> New format conversion works
m2 anim-convertWorkingLegacy format conversion works
wmo convertPartialRoot files work; group files pending
adt convertWorkingRoot files work with roundtrip; split files pending
wdt convertWorkingClassic/TBC/WotLK/MoP conversion works
wdl convertWorkingVersion conversion works
dbc exportWorkingJSON/CSV export works (requires schema)
dbc discoverWorkingSchema 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)
    • views vs num_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_name field (not type)
  • 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 texture
  • DwarfMale.m2 - Character model (version 256)
  • AltarOfStorms.wmo - WMO root (version 17)
  • Azeroth_32_48.adt - Terrain tile
  • Azeroth.wdt - Map definition
  • Azeroth.wdl - World lod
  • AreaTable.dbc - Database table

3.3.5a (WotLK)

  • HAIR00_00.BLP - BLP2 texture
  • IceTrollMale.m2 - Character model (version 264)
  • IceTrollMale00.skin - Skin file (old format)
  • IceTrollMale0060-00.anim - Animation file (legacy)
  • Duskwood_MageTowerPurple.wmo - WMO root
  • BlackTemple_28_28.adt - Terrain tile
  • AuchindounDemon.wdt - WMO-only map
  • AuchindounDemon.wdl - World lod

Recommendations

  1. Implement WMO group file conversion - Root files work but group files need type bridging between parser and converter/writer types.

  2. 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