diff --git a/.gitignore b/.gitignore index f95c18ed..101f6f98 100644 --- a/.gitignore +++ b/.gitignore @@ -89,9 +89,6 @@ Data/expansions/*/overlay/ Data/hd/ ingest/ -# Asset pipeline state and texture packs -asset_pipeline/ - # Local texture dumps / extracted art should never be committed assets/textures/ node_modules/ diff --git a/README.md b/README.md index 6a91237c..8a21add7 100644 --- a/README.md +++ b/README.md @@ -106,9 +106,6 @@ This project requires WoW client data that you extract from your own legally obt Wowee loads assets via an extracted loose-file tree indexed by `manifest.json` (it does not read MPQs at runtime). -For a cross-platform GUI workflow (extraction + texture pack management + active override state), see: -- [Asset Pipeline GUI](docs/asset-pipeline-gui.md) - #### 1) Extract MPQs into `./Data/` ```bash @@ -199,7 +196,6 @@ make -j$(nproc) - [Project Status](docs/status.md) -- Current code state, limitations, and near-term direction - [Quick Start](docs/quickstart.md) -- Installation and first steps - [Build Instructions](BUILD_INSTRUCTIONS.md) -- Detailed dependency, build, and run guide -- [Asset Pipeline GUI](docs/asset-pipeline-gui.md) -- Python GUI for extraction, pack installs, ordering, and override rebuilds ### Technical Documentation - [Architecture](docs/architecture.md) -- System design and module overview diff --git a/docs/asset-pipeline-gui.md b/docs/asset-pipeline-gui.md deleted file mode 100644 index 08c66711..00000000 --- a/docs/asset-pipeline-gui.md +++ /dev/null @@ -1,194 +0,0 @@ -# Asset Pipeline GUI - -WoWee includes a Python GUI for extraction and texture-pack management: - -```bash -python3 tools/asset_pipeline_gui.py -``` - -The script is also executable directly: `./tools/asset_pipeline_gui.py` - -## Supported Platforms - -- Linux -- macOS -- Windows - -The app uses Python's built-in `tkinter` module. If `tkinter` is missing, install the platform package: - -- Linux (Debian/Ubuntu): `sudo apt install python3-tk` -- Fedora: `sudo dnf install python3-tkinter` -- Arch: `sudo pacman -S tk` -- macOS: use the official Python.org installer (includes Tk) -- Windows: use the official Python installer and enable Tcl/Tk support - -## What It Does - -- Runs `asset_extract` (or shell/PowerShell script fallback) to extract MPQ data -- Saves extraction config in `asset_pipeline/state.json` -- Installs texture packs from ZIP or folders (with zip-slip protection) -- Lets users activate/deactivate packs and reorder active pack priority -- Rebuilds `Data/override` from active pack order (runs in background thread) -- Shows current data state (`manifest.json`, entry count, override file count, last runs) -- Browses extracted assets with inline previews (images, 3D wireframes, data tables, text, hex dumps) - -## Configuration Tab - -### Path Settings - -| Field | Description | -|-------|-------------| -| **WoW Data (MPQ source)** | Path to your WoW client's `Data/` folder containing `.MPQ` files | -| **Output Data directory** | Where extracted assets land. Defaults to `/Data` | -| **Extractor binary/script** | Optional. Leave blank for auto-detection (see below) | - -### Extractor Auto-Detection - -When no extractor path is configured, the GUI searches in order: - -1. `build/bin/asset_extract` — CMake build with bin subdirectory -2. `build/asset_extract` — CMake build without bin subdirectory -3. `bin/asset_extract` — standalone binary -4. **Windows only**: `extract_assets.ps1` — invoked via `powershell -ExecutionPolicy Bypass -File` -5. **Linux/macOS only**: `extract_assets.sh` — invoked via `bash` - -On Windows, `.exe` is appended to binary candidates automatically. - -### Extraction Options - -| Option | Description | -|--------|-------------| -| **Expansion** | `auto`, `classic`, `turtle`, `tbc`, or `wotlk`. Read-only dropdown. | -| **Locale** | `auto`, `enUS`, `enGB`, `deDE`, `frFR`, etc. Read-only dropdown. | -| **Threads** | Worker thread count. 0 = auto (uses all cores). | -| **Skip DBC extraction** | Skip database client files (faster if you only want textures). | -| **Generate DBC CSV** | Output human-readable CSV alongside binary DBC files. | -| **Verify CRC** | Check file integrity during extraction (slower but safer). | -| **Verbose output** | More detail in the Logs tab. | - -### Buttons - -| Button | Action | -|--------|--------| -| **Save Configuration** | Writes all settings to `asset_pipeline/state.json`. | -| **Run Extraction** | Starts the extractor in a background thread. Output streams to the Logs tab. | -| **Cancel Extraction** | Terminates a running extraction. Grayed out when idle, active during extraction. | -| **Refresh State** | Reloads the Current State tab. | - -## Texture Packs Tab - -### Installing Packs - -- **Install ZIP**: Opens a file picker for `.zip` archives. Each member path is validated against zip-slip attacks before extraction. -- **Install Folder**: Opens a folder picker and copies the entire folder into the pipeline's internal pack storage. - -### Managing Packs - -| Button | Action | -|--------|--------| -| **Activate** | Adds the selected pack to the active override list. | -| **Deactivate** | Removes the selected pack from the active list (stays installed). | -| **Move Up / Move Down** | Changes priority order. Pack #1 is the base layer; higher numbers override lower. | -| **Rebuild Override** | Merges all active packs into `Data/override/` in a background thread. UI stays responsive. | -| **Uninstall** | Removes the pack from disk after confirmation. | - -Pack list selection is preserved across refreshes — you can activate a pack and immediately reorder it without re-selecting. - -## Pack Format - -Supported pack layouts: - -1. `PackName/Data/...` -2. `PackName/data/...` -3. `PackName/...` where top folders include game folders (`Interface`, `World`, `Character`, `Textures`, `Sound`) -4. Single wrapper directory containing any of the above - -When multiple active packs contain the same file path, **later packs in active order win**. - -## Asset Browser Tab - -Browse and preview every extracted asset visually. Requires a completed extraction with a `manifest.json` in the output directory. - -### Layout - -- **Top bar**: Search entry, file type filter dropdown, Search/Reset buttons, result count -- **Left panel** (~30%): Directory tree built lazily from `manifest.json` -- **Right panel** (~70%): Preview area that adapts to the selected file type -- **Bottom bar**: File path, size, and CRC from manifest - -### Search and Filtering - -Type a substring into the search box and/or pick a file type from the dropdown, then click **Search**. The tree repopulates with matching results (capped at 5000 entries). Click **Reset** to restore the full tree. - -File type filters: All, BLP, M2, WMO, DBC, ADT, Audio, Text. - -### Preview Types - -| Type | What You See | -|------|--------------| -| **BLP** | Converted to PNG via `blp_convert --to-png`, displayed as an image. Cached in `asset_pipeline/preview_cache/`. | -| **M2** | Wireframe rendering of model vertices and triangles on a Canvas. Drag to rotate, scroll to zoom. | -| **WMO** | Wireframe of group geometry (MOVT/MOVI chunks). Root WMOs auto-load the `_000` group file. | -| **CSV** (DBC exports) | Scrollable table with column names from `dbc_layouts.json`. First 500 rows loaded, click "Load more" for the rest. | -| **ADT** | Colored heightmap grid parsed from MCNK chunks. | -| **Text** (XML, LUA, JSON, HTML, TOC) | Syntax-highlighted scrollable text view. | -| **Audio** (WAV, MP3, OGG) | Metadata display — format, channels, sample rate, duration (WAV). | -| **Other** | Hex dump of the first 512 bytes. | - -### Wireframe Controls - -- **Left-click drag**: Rotate the model (azimuth + elevation) -- **Scroll wheel**: Zoom in/out -- Depth coloring: closer geometry renders brighter - -### Optional Dependencies - -| Dependency | Required For | Fallback | -|------------|-------------|----------| -| [Pillow](https://pypi.org/project/Pillow/) (`pip install Pillow`) | BLP image preview | Shows install instructions | -| `blp_convert` (built with project) | BLP → PNG conversion | Shows "not found" message | - -All other previews (wireframe, table, text, hex) work without any extra dependencies. - -### Cache - -BLP previews are cached as PNG files in `asset_pipeline/preview_cache/` keyed by path and file size. Delete this directory to clear the cache. - -## Current State Tab - -Shows a summary of pipeline state: - -- Output directory existence and `manifest.json` entry count -- Override folder file count and last build timestamp -- Installed and active pack counts with priority order -- Last extraction time, success/failure, and the exact command used -- Paths to the state file and packs directory - -Click **Refresh** to reload, or it auto-refreshes after operations. - -## Logs Tab - -All extraction output, override rebuild messages, cancellations, and errors stream here in real time via a log queue polled every 120ms. Click **Clear Logs** to reset. - -## State Files and Folders - -| Path | Description | -|------|-------------| -| `asset_pipeline/state.json` | All configuration, pack metadata, and extraction history | -| `asset_pipeline/packs//` | Installed pack contents (one directory per pack) | -| `asset_pipeline/preview_cache/` | Cached BLP → PNG conversions for the Asset Browser | -| `/override/` | Merged output from active packs | - -The `asset_pipeline/` directory is gitignored. - -## Typical Workflow - -1. Launch: `python3 tools/asset_pipeline_gui.py` -2. **Configuration tab**: Browse to your WoW `Data/` folder, pick expansion, click **Save Configuration**. -3. Click **Run Extraction** — watch progress in the **Logs** tab. Cancel with **Cancel Extraction** if needed. -4. Switch to **Texture Packs** tab. Click **Install ZIP** and pick a texture pack. -5. Select the pack and click **Activate**. -6. (Optional) Install more packs, activate them, and use **Move Up/Down** to set priority. -7. Click **Rebuild Override** — the status bar shows progress, and the result appears in Logs. -8. (Optional) Switch to **Asset Browser** to explore extracted files — preview textures, inspect models, browse DBC tables. -9. Run wowee — it loads override textures on top of the extracted base assets. diff --git a/include/platform/process.hpp b/include/platform/process.hpp index 5356b04b..0fa9e981 100644 --- a/include/platform/process.hpp +++ b/include/platform/process.hpp @@ -92,8 +92,8 @@ inline ProcessHandle spawnProcess(const std::vector& args) { if (pid == 0) { // Child process setpgid(0, 0); - if (!freopen("/dev/null", "w", stdout)) { _exit(1); } - if (!freopen("/dev/null", "w", stderr)) { _exit(1); } + freopen("/dev/null", "w", stdout); + freopen("/dev/null", "w", stderr); // Build argv for exec std::vector argv; diff --git a/src/audio/activity_sound_manager.cpp b/src/audio/activity_sound_manager.cpp index 5b28d5c8..9ab37a2d 100644 --- a/src/audio/activity_sound_manager.cpp +++ b/src/audio/activity_sound_manager.cpp @@ -91,7 +91,7 @@ void ActivitySoundManager::shutdown() { assetManager = nullptr; } -void ActivitySoundManager::update([[maybe_unused]] float deltaTime) { +void ActivitySoundManager::update(float deltaTime) { reapProcesses(); // Play swimming stroke sounds periodically when swimming and moving @@ -168,7 +168,7 @@ void ActivitySoundManager::rebuildJumpClipsForProfile(const std::string& raceFol } } -void ActivitySoundManager::rebuildSwimLoopClipsForProfile([[maybe_unused]] const std::string& raceFolder, [[maybe_unused]] const std::string& raceBase, [[maybe_unused]] bool male) { +void ActivitySoundManager::rebuildSwimLoopClipsForProfile(const std::string& raceFolder, const std::string& raceBase, bool male) { swimLoopClips.clear(); // WoW 3.3.5a doesn't have dedicated swim loop sounds diff --git a/src/audio/ambient_sound_manager.cpp b/src/audio/ambient_sound_manager.cpp index 7ab88689..f976cbdf 100644 --- a/src/audio/ambient_sound_manager.cpp +++ b/src/audio/ambient_sound_manager.cpp @@ -117,10 +117,10 @@ bool AmbientSoundManager::initialize(pipeline::AssetManager* assets) { bool forestNightLoaded = loadSound("Sound\\Ambience\\ZoneAmbience\\ForestNormalNight.wav", forestNormalNightSounds_[0], assets); forestSnowDaySounds_.resize(1); - loadSound("Sound\\Ambience\\ZoneAmbience\\ForestSnowDay.wav", forestSnowDaySounds_[0], assets); + bool forestSnowDayLoaded = loadSound("Sound\\Ambience\\ZoneAmbience\\ForestSnowDay.wav", forestSnowDaySounds_[0], assets); forestSnowNightSounds_.resize(1); - loadSound("Sound\\Ambience\\ZoneAmbience\\ForestSnowNight.wav", forestSnowNightSounds_[0], assets); + bool forestSnowNightLoaded = loadSound("Sound\\Ambience\\ZoneAmbience\\ForestSnowNight.wav", forestSnowNightSounds_[0], assets); beachDaySounds_.resize(1); bool beachDayLoaded = loadSound("Sound\\Ambience\\ZoneAmbience\\BeachDay.wav", beachDaySounds_[0], assets); @@ -129,34 +129,34 @@ bool AmbientSoundManager::initialize(pipeline::AssetManager* assets) { bool beachNightLoaded = loadSound("Sound\\Ambience\\ZoneAmbience\\BeachNight.wav", beachNightSounds_[0], assets); grasslandsDaySounds_.resize(1); - loadSound("Sound\\Ambience\\ZoneAmbience\\GrasslandsDay.wav", grasslandsDaySounds_[0], assets); + bool grasslandsDayLoaded = loadSound("Sound\\Ambience\\ZoneAmbience\\GrasslandsDay.wav", grasslandsDaySounds_[0], assets); grasslandsNightSounds_.resize(1); - loadSound("Sound\\Ambience\\ZoneAmbience\\GrassLandsNight.wav", grasslandsNightSounds_[0], assets); + bool grasslandsNightLoaded = loadSound("Sound\\Ambience\\ZoneAmbience\\GrassLandsNight.wav", grasslandsNightSounds_[0], assets); jungleDaySounds_.resize(1); - loadSound("Sound\\Ambience\\ZoneAmbience\\JungleDay.wav", jungleDaySounds_[0], assets); + bool jungleDayLoaded = loadSound("Sound\\Ambience\\ZoneAmbience\\JungleDay.wav", jungleDaySounds_[0], assets); jungleNightSounds_.resize(1); - loadSound("Sound\\Ambience\\ZoneAmbience\\JungleNight.wav", jungleNightSounds_[0], assets); + bool jungleNightLoaded = loadSound("Sound\\Ambience\\ZoneAmbience\\JungleNight.wav", jungleNightSounds_[0], assets); marshDaySounds_.resize(1); - loadSound("Sound\\Ambience\\ZoneAmbience\\MarshDay.wav", marshDaySounds_[0], assets); + bool marshDayLoaded = loadSound("Sound\\Ambience\\ZoneAmbience\\MarshDay.wav", marshDaySounds_[0], assets); marshNightSounds_.resize(1); - loadSound("Sound\\Ambience\\ZoneAmbience\\MarshNight.wav", marshNightSounds_[0], assets); + bool marshNightLoaded = loadSound("Sound\\Ambience\\ZoneAmbience\\MarshNight.wav", marshNightSounds_[0], assets); desertCanyonDaySounds_.resize(1); bool desertCanyonDayLoaded = loadSound("Sound\\Ambience\\ZoneAmbience\\CanyonDesertDay.wav", desertCanyonDaySounds_[0], assets); desertCanyonNightSounds_.resize(1); - loadSound("Sound\\Ambience\\ZoneAmbience\\CanyonDesertNight.wav", desertCanyonNightSounds_[0], assets); + bool desertCanyonNightLoaded = loadSound("Sound\\Ambience\\ZoneAmbience\\CanyonDesertNight.wav", desertCanyonNightSounds_[0], assets); desertPlainsDaySounds_.resize(1); bool desertPlainsDayLoaded = loadSound("Sound\\Ambience\\ZoneAmbience\\PlainsDesertDay.wav", desertPlainsDaySounds_[0], assets); desertPlainsNightSounds_.resize(1); - loadSound("Sound\\Ambience\\ZoneAmbience\\PlainsDesertNight.wav", desertPlainsNightSounds_[0], assets); + bool desertPlainsNightLoaded = loadSound("Sound\\Ambience\\ZoneAmbience\\PlainsDesertNight.wav", desertPlainsNightSounds_[0], assets); // Load city ambience sounds (day and night where available) stormwindDaySounds_.resize(1); @@ -169,10 +169,10 @@ bool AmbientSoundManager::initialize(pipeline::AssetManager* assets) { bool ironforgeLoaded = loadSound("Sound\\Ambience\\WMOAmbience\\Ironforge.wav", ironforgeSounds_[0], assets); darnassusDaySounds_.resize(1); - loadSound("Sound\\Ambience\\WMOAmbience\\DarnassusDay.wav", darnassusDaySounds_[0], assets); + bool darnassusDayLoaded = loadSound("Sound\\Ambience\\WMOAmbience\\DarnassusDay.wav", darnassusDaySounds_[0], assets); darnassusNightSounds_.resize(1); - loadSound("Sound\\Ambience\\WMOAmbience\\DarnassusNight.wav", darnassusNightSounds_[0], assets); + bool darnassusNightLoaded = loadSound("Sound\\Ambience\\WMOAmbience\\DarnassusNight.wav", darnassusNightSounds_[0], assets); orgrimmarDaySounds_.resize(1); bool orgrimmarDayLoaded = loadSound("Sound\\Ambience\\WMOAmbience\\OrgrimmarDay.wav", orgrimmarDaySounds_[0], assets); @@ -181,13 +181,13 @@ bool AmbientSoundManager::initialize(pipeline::AssetManager* assets) { bool orgrimmarNightLoaded = loadSound("Sound\\Ambience\\WMOAmbience\\OrgrimmarNight.wav", orgrimmarNightSounds_[0], assets); undercitySounds_.resize(1); - loadSound("Sound\\Ambience\\WMOAmbience\\Undercity.wav", undercitySounds_[0], assets); + bool undercityLoaded = loadSound("Sound\\Ambience\\WMOAmbience\\Undercity.wav", undercitySounds_[0], assets); thunderbluffDaySounds_.resize(1); - loadSound("Sound\\Ambience\\WMOAmbience\\ThunderBluffDay.wav", thunderbluffDaySounds_[0], assets); + bool thunderbluffDayLoaded = loadSound("Sound\\Ambience\\WMOAmbience\\ThunderBluffDay.wav", thunderbluffDaySounds_[0], assets); thunderbluffNightSounds_.resize(1); - loadSound("Sound\\Ambience\\WMOAmbience\\ThunderBluffNight.wav", thunderbluffNightSounds_[0], assets); + bool thunderbluffNightLoaded = loadSound("Sound\\Ambience\\WMOAmbience\\ThunderBluffNight.wav", thunderbluffNightSounds_[0], assets); // Load bell toll sounds bellAllianceSounds_.resize(1); diff --git a/src/audio/combat_sound_manager.cpp b/src/audio/combat_sound_manager.cpp index 55352459..d433a40c 100644 --- a/src/audio/combat_sound_manager.cpp +++ b/src/audio/combat_sound_manager.cpp @@ -295,7 +295,7 @@ void CombatSoundManager::playWeaponMiss(bool twoHanded) { } } -void CombatSoundManager::playImpact([[maybe_unused]] WeaponSize weaponSize, ImpactType impactType, bool isCrit) { +void CombatSoundManager::playImpact(WeaponSize weaponSize, ImpactType impactType, bool isCrit) { // Select appropriate impact sound library const std::vector* normalLibrary = nullptr; const std::vector* critLibrary = nullptr; diff --git a/src/audio/ui_sound_manager.cpp b/src/audio/ui_sound_manager.cpp index f50f1d6f..626263d3 100644 --- a/src/audio/ui_sound_manager.cpp +++ b/src/audio/ui_sound_manager.cpp @@ -34,16 +34,16 @@ bool UiSoundManager::initialize(pipeline::AssetManager* assets) { bool charSheetCloseLoaded = loadSound("Sound\\Interface\\iAbilitiesCloseA.wav", characterSheetCloseSounds_[0], assets); auctionOpenSounds_.resize(1); - loadSound("Sound\\Interface\\AuctionWindowOpen.wav", auctionOpenSounds_[0], assets); + bool auctionOpenLoaded = loadSound("Sound\\Interface\\AuctionWindowOpen.wav", auctionOpenSounds_[0], assets); auctionCloseSounds_.resize(1); - loadSound("Sound\\Interface\\AuctionWindowClose.wav", auctionCloseSounds_[0], assets); + bool auctionCloseLoaded = loadSound("Sound\\Interface\\AuctionWindowClose.wav", auctionCloseSounds_[0], assets); guildBankOpenSounds_.resize(1); - loadSound("Sound\\Interface\\GuildVaultOpen.wav", guildBankOpenSounds_[0], assets); + bool guildBankOpenLoaded = loadSound("Sound\\Interface\\GuildVaultOpen.wav", guildBankOpenSounds_[0], assets); guildBankCloseSounds_.resize(1); - loadSound("Sound\\Interface\\GuildVaultClose.wav", guildBankCloseSounds_[0], assets); + bool guildBankCloseLoaded = loadSound("Sound\\Interface\\GuildVaultClose.wav", guildBankCloseSounds_[0], assets); // Load button sounds buttonClickSounds_.resize(1); @@ -63,7 +63,7 @@ bool UiSoundManager::initialize(pipeline::AssetManager* assets) { bool questFailedLoaded = loadSound("Sound\\Interface\\igQuestFailed.wav", questFailedSounds_[0], assets); questUpdateSounds_.resize(1); - loadSound("Sound\\Interface\\iQuestUpdate.wav", questUpdateSounds_[0], assets); + bool questUpdateLoaded = loadSound("Sound\\Interface\\iQuestUpdate.wav", questUpdateSounds_[0], assets); // Load loot sounds lootCoinSmallSounds_.resize(1); @@ -86,13 +86,13 @@ bool UiSoundManager::initialize(pipeline::AssetManager* assets) { bool pickupBookLoaded = loadSound("Sound\\Interface\\PickUp\\PickUpBook.wav", pickupBookSounds_[0], assets); pickupClothSounds_.resize(1); - loadSound("Sound\\Interface\\PickUp\\PickUpCloth_Leather01.wav", pickupClothSounds_[0], assets); + bool pickupClothLoaded = loadSound("Sound\\Interface\\PickUp\\PickUpCloth_Leather01.wav", pickupClothSounds_[0], assets); pickupFoodSounds_.resize(1); - loadSound("Sound\\Interface\\PickUp\\PickUpFoodGeneric.wav", pickupFoodSounds_[0], assets); + bool pickupFoodLoaded = loadSound("Sound\\Interface\\PickUp\\PickUpFoodGeneric.wav", pickupFoodSounds_[0], assets); pickupGemSounds_.resize(1); - loadSound("Sound\\Interface\\PickUp\\PickUpGems.wav", pickupGemSounds_[0], assets); + bool pickupGemLoaded = loadSound("Sound\\Interface\\PickUp\\PickUpGems.wav", pickupGemSounds_[0], assets); // Load eating/drinking sounds eatingSounds_.resize(1); @@ -107,13 +107,13 @@ bool UiSoundManager::initialize(pipeline::AssetManager* assets) { // Load error/feedback sounds errorSounds_.resize(1); - loadSound("Sound\\Interface\\Error.wav", errorSounds_[0], assets); + bool errorLoaded = loadSound("Sound\\Interface\\Error.wav", errorSounds_[0], assets); selectTargetSounds_.resize(1); - loadSound("Sound\\Interface\\iSelectTarget.wav", selectTargetSounds_[0], assets); + bool selectTargetLoaded = loadSound("Sound\\Interface\\iSelectTarget.wav", selectTargetSounds_[0], assets); deselectTargetSounds_.resize(1); - loadSound("Sound\\Interface\\iDeselectTarget.wav", deselectTargetSounds_[0], assets); + bool deselectTargetLoaded = loadSound("Sound\\Interface\\iDeselectTarget.wav", deselectTargetSounds_[0], assets); LOG_INFO("UISoundManager: Window sounds - Bag: ", (bagOpenLoaded && bagCloseLoaded) ? "YES" : "NO", ", QuestLog: ", (questLogOpenLoaded && questLogCloseLoaded) ? "YES" : "NO", diff --git a/src/game/warden_emulator.cpp b/src/game/warden_emulator.cpp index 1d43768b..11ff861f 100644 --- a/src/game/warden_emulator.cpp +++ b/src/game/warden_emulator.cpp @@ -122,7 +122,7 @@ bool WardenEmulator::initialize(const void* moduleCode, size_t moduleSize, uint3 uint32_t WardenEmulator::hookAPI(const std::string& dllName, const std::string& functionName, - [[maybe_unused]] std::function&)> handler) { + std::function&)> handler) { // Allocate address for this API stub static uint32_t nextStubAddr = API_STUB_BASE; uint32_t stubAddr = nextStubAddr; @@ -239,7 +239,7 @@ std::string WardenEmulator::readString(uint32_t address, size_t maxLen) { return std::string(buffer.data()); } -uint32_t WardenEmulator::allocateMemory(size_t size, [[maybe_unused]] uint32_t protection) { +uint32_t WardenEmulator::allocateMemory(size_t size, uint32_t protection) { // Align to 4KB size = (size + 0xFFF) & ~0xFFF; @@ -315,7 +315,7 @@ uint32_t WardenEmulator::apiVirtualFree(WardenEmulator& emu, const std::vector& args) { +uint32_t WardenEmulator::apiGetTickCount(WardenEmulator& emu, const std::vector& args) { auto now = std::chrono::steady_clock::now(); auto ms = std::chrono::duration_cast(now.time_since_epoch()).count(); uint32_t ticks = static_cast(ms & 0xFFFFFFFF); @@ -324,7 +324,7 @@ uint32_t WardenEmulator::apiGetTickCount([[maybe_unused]] WardenEmulator& emu, [ return ticks; } -uint32_t WardenEmulator::apiSleep([[maybe_unused]] WardenEmulator& emu, const std::vector& args) { +uint32_t WardenEmulator::apiSleep(WardenEmulator& emu, const std::vector& args) { if (args.size() < 1) return 0; uint32_t dwMilliseconds = args[0]; @@ -333,12 +333,12 @@ uint32_t WardenEmulator::apiSleep([[maybe_unused]] WardenEmulator& emu, const st return 0; } -uint32_t WardenEmulator::apiGetCurrentThreadId([[maybe_unused]] WardenEmulator& emu, [[maybe_unused]] const std::vector& args) { +uint32_t WardenEmulator::apiGetCurrentThreadId(WardenEmulator& emu, const std::vector& args) { std::cout << "[WinAPI] GetCurrentThreadId() = 1234" << '\n'; return 1234; // Fake thread ID } -uint32_t WardenEmulator::apiGetCurrentProcessId([[maybe_unused]] WardenEmulator& emu, [[maybe_unused]] const std::vector& args) { +uint32_t WardenEmulator::apiGetCurrentProcessId(WardenEmulator& emu, const std::vector& args) { std::cout << "[WinAPI] GetCurrentProcessId() = 5678" << '\n'; return 5678; // Fake process ID } @@ -347,7 +347,7 @@ uint32_t WardenEmulator::apiReadProcessMemory(WardenEmulator& emu, const std::ve // ReadProcessMemory(hProcess, lpBaseAddress, lpBuffer, nSize, lpNumberOfBytesRead) if (args.size() < 5) return 0; - [[maybe_unused]] uint32_t hProcess = args[0]; + uint32_t hProcess = args[0]; uint32_t lpBaseAddress = args[1]; uint32_t lpBuffer = args[2]; uint32_t nSize = args[3]; @@ -377,11 +377,13 @@ uint32_t WardenEmulator::apiReadProcessMemory(WardenEmulator& emu, const std::ve // Unicorn Callbacks // ============================================================================ -void WardenEmulator::hookCode([[maybe_unused]] uc_engine* uc, uint64_t address, [[maybe_unused]] uint32_t size, [[maybe_unused]] void* userData) { +void WardenEmulator::hookCode(uc_engine* uc, uint64_t address, uint32_t size, void* userData) { + WardenEmulator* emu = static_cast(userData); std::cout << "[Trace] 0x" << std::hex << address << std::dec << '\n'; } -void WardenEmulator::hookMemInvalid([[maybe_unused]] uc_engine* uc, int type, uint64_t address, int size, [[maybe_unused]] int64_t value, [[maybe_unused]] void* userData) { +void WardenEmulator::hookMemInvalid(uc_engine* uc, int type, uint64_t address, int size, int64_t value, void* userData) { + WardenEmulator* emu = static_cast(userData); const char* typeStr = "UNKNOWN"; switch (type) { diff --git a/src/game/warden_module.cpp b/src/game/warden_module.cpp index bad36430..fba39960 100644 --- a/src/game/warden_module.cpp +++ b/src/game/warden_module.cpp @@ -129,7 +129,7 @@ bool WardenModule::load(const std::vector& moduleData, } bool WardenModule::processCheckRequest(const std::vector& checkData, - [[maybe_unused]] std::vector& responseOut) { + std::vector& responseOut) { if (!loaded_) { std::cerr << "[WardenModule] Module not loaded, cannot process checks" << '\n'; return false; @@ -198,7 +198,7 @@ bool WardenModule::processCheckRequest(const std::vector& checkData, return false; } -uint32_t WardenModule::tick([[maybe_unused]] uint32_t deltaMs) { +uint32_t WardenModule::tick(uint32_t deltaMs) { if (!loaded_ || !funcList_.tick) { return 0; // No tick needed } @@ -209,7 +209,7 @@ uint32_t WardenModule::tick([[maybe_unused]] uint32_t deltaMs) { return 0; } -void WardenModule::generateRC4Keys([[maybe_unused]] uint8_t* packet) { +void WardenModule::generateRC4Keys(uint8_t* packet) { if (!loaded_ || !funcList_.generateRC4Keys) { return; } @@ -633,11 +633,9 @@ bool WardenModule::applyRelocations() { currentOffset += delta; if (currentOffset + 4 <= moduleSize_) { - uint8_t* addr = static_cast(moduleMemory_) + currentOffset; - uint32_t val; - std::memcpy(&val, addr, sizeof(uint32_t)); - val += moduleBase_; - std::memcpy(addr, &val, sizeof(uint32_t)); + uint32_t* ptr = reinterpret_cast( + static_cast(moduleMemory_) + currentOffset); + *ptr += moduleBase_; relocCount++; } else { std::cerr << "[WardenModule] Relocation offset " << currentOffset @@ -757,16 +755,16 @@ bool WardenModule::initializeModule() { void (*logMessage)(const char* msg); }; - // Setup client callbacks (used when calling module entry point below) - [[maybe_unused]] ClientCallbacks callbacks = {}; + // Setup client callbacks + ClientCallbacks callbacks = {}; // Stub callbacks (would need real implementations) - callbacks.sendPacket = []([[maybe_unused]] uint8_t* data, size_t len) { + callbacks.sendPacket = [](uint8_t* data, size_t len) { std::cout << "[WardenModule Callback] sendPacket(" << len << " bytes)" << '\n'; // TODO: Send CMSG_WARDEN_DATA packet }; - callbacks.validateModule = []([[maybe_unused]] uint8_t* hash) { + callbacks.validateModule = [](uint8_t* hash) { std::cout << "[WardenModule Callback] validateModule()" << '\n'; // TODO: Validate module hash }; @@ -781,7 +779,7 @@ bool WardenModule::initializeModule() { free(ptr); }; - callbacks.generateRC4 = []([[maybe_unused]] uint8_t* seed) { + callbacks.generateRC4 = [](uint8_t* seed) { std::cout << "[WardenModule Callback] generateRC4()" << '\n'; // TODO: Re-key RC4 cipher }; diff --git a/src/pipeline/blp_loader.cpp b/src/pipeline/blp_loader.cpp index 8c817890..e0df4639 100644 --- a/src/pipeline/blp_loader.cpp +++ b/src/pipeline/blp_loader.cpp @@ -30,39 +30,34 @@ BLPImage BLPLoader::load(const std::vector& blpData) { } BLPImage BLPLoader::loadBLP1(const uint8_t* data, size_t size) { - // Copy header to stack to avoid unaligned reinterpret_cast (UB on strict platforms) - if (size < sizeof(BLP1Header)) { - LOG_ERROR("BLP1 data too small for header"); - return BLPImage(); - } - BLP1Header header; - std::memcpy(&header, data, sizeof(BLP1Header)); + // BLP1 header has all uint32 fields (different layout from BLP2) + const BLP1Header* header = reinterpret_cast(data); BLPImage image; image.format = BLPFormat::BLP1; - image.width = header.width; - image.height = header.height; + image.width = header->width; + image.height = header->height; image.channels = 4; - image.mipLevels = header.hasMips ? 16 : 1; + image.mipLevels = header->hasMips ? 16 : 1; // BLP1 compression: 0=JPEG (not used in WoW), 1=palette/indexed // BLP1 does NOT support DXT — only palette with optional alpha - if (header.compression == 1) { + if (header->compression == 1) { image.compression = BLPCompression::PALETTE; - } else if (header.compression == 0) { + } else if (header->compression == 0) { LOG_WARNING("BLP1 JPEG compression not supported"); return BLPImage(); } else { - LOG_WARNING("BLP1 unknown compression: ", header.compression); + LOG_WARNING("BLP1 unknown compression: ", header->compression); return BLPImage(); } LOG_DEBUG("Loading BLP1: ", image.width, "x", image.height, " ", - getCompressionName(image.compression), " alpha=", header.alphaBits); + getCompressionName(image.compression), " alpha=", header->alphaBits); // Get first mipmap (full resolution) - uint32_t offset = header.mipOffsets[0]; - uint32_t mipSize = header.mipSizes[0]; + uint32_t offset = header->mipOffsets[0]; + uint32_t mipSize = header->mipSizes[0]; if (offset + mipSize > size) { LOG_ERROR("BLP1 mipmap data out of bounds (offset=", offset, " size=", mipSize, " fileSize=", size, ")"); @@ -75,50 +70,45 @@ BLPImage BLPLoader::loadBLP1(const uint8_t* data, size_t size) { int pixelCount = image.width * image.height; image.data.resize(pixelCount * 4); // RGBA8 - decompressPalette(mipData, image.data.data(), header.palette, - image.width, image.height, static_cast(header.alphaBits)); + decompressPalette(mipData, image.data.data(), header->palette, + image.width, image.height, static_cast(header->alphaBits)); return image; } BLPImage BLPLoader::loadBLP2(const uint8_t* data, size_t size) { - // Copy header to stack to avoid unaligned reinterpret_cast (UB on strict platforms) - if (size < sizeof(BLP2Header)) { - LOG_ERROR("BLP2 data too small for header"); - return BLPImage(); - } - BLP2Header header; - std::memcpy(&header, data, sizeof(BLP2Header)); + // BLP2 header has uint8 fields for compression/alpha/encoding + const BLP2Header* header = reinterpret_cast(data); BLPImage image; image.format = BLPFormat::BLP2; - image.width = header.width; - image.height = header.height; + image.width = header->width; + image.height = header->height; image.channels = 4; - image.mipLevels = header.hasMips ? 16 : 1; + image.mipLevels = header->hasMips ? 16 : 1; // BLP2 compression types: // 1 = palette/uncompressed // 2 = DXTC (DXT1/DXT3/DXT5 based on alphaDepth + alphaEncoding) // 3 = plain A8R8G8B8 - if (header.compression == 1) { + if (header->compression == 1) { image.compression = BLPCompression::PALETTE; - } else if (header.compression == 2) { + } else if (header->compression == 2) { // BLP2 DXTC format selection based on alphaDepth + alphaEncoding: // alphaDepth=0 → DXT1 (no alpha) // alphaDepth>0, alphaEncoding=0 → DXT1 (1-bit alpha) // alphaDepth>0, alphaEncoding=1 → DXT3 (explicit 4-bit alpha) // alphaDepth>0, alphaEncoding=7 → DXT5 (interpolated alpha) - if (header.alphaDepth == 0 || header.alphaEncoding == 0) { + if (header->alphaDepth == 0 || header->alphaEncoding == 0) { image.compression = BLPCompression::DXT1; - } else if (header.alphaEncoding == 1) { + } else if (header->alphaEncoding == 1) { image.compression = BLPCompression::DXT3; - } else if (header.alphaEncoding == 7) { + } else if (header->alphaEncoding == 7) { image.compression = BLPCompression::DXT5; } else { image.compression = BLPCompression::DXT1; } - } else if (header.compression == 3) { + } else if (header->compression == 3) { image.compression = BLPCompression::ARGB8888; } else { image.compression = BLPCompression::ARGB8888; @@ -126,13 +116,13 @@ BLPImage BLPLoader::loadBLP2(const uint8_t* data, size_t size) { LOG_DEBUG("Loading BLP2: ", image.width, "x", image.height, " ", getCompressionName(image.compression), - " (comp=", (int)header.compression, " alphaDepth=", (int)header.alphaDepth, - " alphaEnc=", (int)header.alphaEncoding, " mipOfs=", header.mipOffsets[0], - " mipSize=", header.mipSizes[0], ")"); + " (comp=", (int)header->compression, " alphaDepth=", (int)header->alphaDepth, + " alphaEnc=", (int)header->alphaEncoding, " mipOfs=", header->mipOffsets[0], + " mipSize=", header->mipSizes[0], ")"); // Get first mipmap (full resolution) - uint32_t offset = header.mipOffsets[0]; - uint32_t mipSize = header.mipSizes[0]; + uint32_t offset = header->mipOffsets[0]; + uint32_t mipSize = header->mipSizes[0]; if (offset + mipSize > size) { LOG_ERROR("BLP2 mipmap data out of bounds"); @@ -159,8 +149,8 @@ BLPImage BLPLoader::loadBLP2(const uint8_t* data, size_t size) { break; case BLPCompression::PALETTE: - decompressPalette(mipData, image.data.data(), header.palette, - image.width, image.height, header.alphaDepth); + decompressPalette(mipData, image.data.data(), header->palette, + image.width, image.height, header->alphaDepth); break; case BLPCompression::ARGB8888: diff --git a/src/pipeline/dbc_loader.cpp b/src/pipeline/dbc_loader.cpp index f7e040da..dd1d6f52 100644 --- a/src/pipeline/dbc_loader.cpp +++ b/src/pipeline/dbc_loader.cpp @@ -42,20 +42,19 @@ bool DBCFile::load(const std::vector& dbcData) { return false; } - // Read header safely (avoid unaligned reinterpret_cast — UB on strict platforms) - DBCHeader header; - std::memcpy(&header, dbcData.data(), sizeof(DBCHeader)); + // Read header + const DBCHeader* header = reinterpret_cast(dbcData.data()); // Verify magic - if (std::memcmp(header.magic, "WDBC", 4) != 0) { - LOG_ERROR("Invalid DBC magic: ", std::string(header.magic, 4)); + if (std::memcmp(header->magic, "WDBC", 4) != 0) { + LOG_ERROR("Invalid DBC magic: ", std::string(header->magic, 4)); return false; } - recordCount = header.recordCount; - fieldCount = header.fieldCount; - recordSize = header.recordSize; - stringBlockSize = header.stringBlockSize; + recordCount = header->recordCount; + fieldCount = header->fieldCount; + recordSize = header->recordSize; + stringBlockSize = header->stringBlockSize; // Validate sizes uint32_t expectedSize = sizeof(DBCHeader) + (recordCount * recordSize) + stringBlockSize; @@ -112,9 +111,8 @@ uint32_t DBCFile::getUInt32(uint32_t recordIndex, uint32_t fieldIndex) const { return 0; } - uint32_t value; - std::memcpy(&value, record + (fieldIndex * 4), sizeof(uint32_t)); - return value; + const uint32_t* field = reinterpret_cast(record + (fieldIndex * 4)); + return *field; } int32_t DBCFile::getInt32(uint32_t recordIndex, uint32_t fieldIndex) const { @@ -131,9 +129,8 @@ float DBCFile::getFloat(uint32_t recordIndex, uint32_t fieldIndex) const { return 0.0f; } - float value; - std::memcpy(&value, record + (fieldIndex * 4), sizeof(float)); - return value; + const float* field = reinterpret_cast(record + (fieldIndex * 4)); + return *field; } std::string DBCFile::getString(uint32_t recordIndex, uint32_t fieldIndex) const { diff --git a/src/pipeline/m2_loader.cpp b/src/pipeline/m2_loader.cpp index b3d057d6..fc4cf6ae 100644 --- a/src/pipeline/m2_loader.cpp +++ b/src/pipeline/m2_loader.cpp @@ -1456,7 +1456,9 @@ bool M2Loader::loadSkin(const std::vector& skinData, M2Model& model) { if (header.nSubmeshes > 0 && header.ofsSubmeshes > 0) { submeshes = readArray(skinData, header.ofsSubmeshes, header.nSubmeshes); core::Logger::getInstance().debug(" Submeshes: ", submeshes.size()); - (void)submeshes; + for (size_t i = 0; i < submeshes.size(); i++) { + const auto& sm = submeshes[i]; + } } // Read batches with proper submesh references diff --git a/src/rendering/character_renderer.cpp b/src/rendering/character_renderer.cpp index 33ff425a..522f6a48 100644 --- a/src/rendering/character_renderer.cpp +++ b/src/rendering/character_renderer.cpp @@ -1610,7 +1610,7 @@ glm::mat4 CharacterRenderer::getBoneTransform(const pipeline::M2Bone& bone, floa // --- Rendering --- -void CharacterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, [[maybe_unused]] const Camera& camera) { +void CharacterRenderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const Camera& camera) { if (instances.empty() || !opaquePipeline_) { return; } diff --git a/src/rendering/m2_renderer.cpp b/src/rendering/m2_renderer.cpp index 378a7b41..2cbc1188 100644 --- a/src/rendering/m2_renderer.cpp +++ b/src/rendering/m2_renderer.cpp @@ -2381,11 +2381,11 @@ void M2Renderer::render(VkCommandBuffer cmd, VkDescriptorSet perFrameSet, const } } - // Bind material descriptor set (set 1) — skip batch if missing - // to avoid inheriting a stale descriptor set from a prior renderer - if (!batch.materialSet) continue; - vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, - pipelineLayout_, 1, 1, &batch.materialSet, 0, nullptr); + // Bind material descriptor set (set 1) + if (batch.materialSet) { + vkCmdBindDescriptorSets(cmd, VK_PIPELINE_BIND_POINT_GRAPHICS, + pipelineLayout_, 1, 1, &batch.materialSet, 0, nullptr); + } // Push constants M2PushConstants pc; diff --git a/src/rendering/vk_render_target.cpp b/src/rendering/vk_render_target.cpp index f2099bbf..48e3a50e 100644 --- a/src/rendering/vk_render_target.cpp +++ b/src/rendering/vk_render_target.cpp @@ -19,7 +19,7 @@ bool VkRenderTarget::create(VkContext& ctx, uint32_t width, uint32_t height, // Create color image (multisampled if MSAA) colorImage_ = createImage(device, allocator, width, height, format, - VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | (useMSAA ? static_cast(0) : static_cast(VK_IMAGE_USAGE_SAMPLED_BIT)), + VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | (useMSAA ? VkImageUsageFlags(0) : VK_IMAGE_USAGE_SAMPLED_BIT), msaaSamples); if (!colorImage_.image) { diff --git a/tools/asset_pipeline_gui.py b/tools/asset_pipeline_gui.py deleted file mode 100755 index 965b36e0..00000000 --- a/tools/asset_pipeline_gui.py +++ /dev/null @@ -1,2198 +0,0 @@ -#!/usr/bin/env python3 -"""WoWee Asset Pipeline GUI. - -Cross-platform Tkinter app for running asset extraction and managing texture packs -that are merged into Data/override in deterministic order. -""" - -from __future__ import annotations - -import hashlib -import json -import math -import os -import platform -import queue -import shutil -import struct -import subprocess -import tempfile -import threading -import time -import zipfile -from dataclasses import asdict, dataclass, field -from datetime import datetime -from pathlib import Path -from typing import Any - -import tkinter as tk -from tkinter import filedialog, messagebox, ttk -from tkinter.scrolledtext import ScrolledText - -try: - from PIL import Image, ImageTk - HAS_PILLOW = True -except ImportError: - HAS_PILLOW = False - - -ROOT_DIR = Path(__file__).resolve().parents[1] -PIPELINE_DIR = ROOT_DIR / "asset_pipeline" -STATE_FILE = PIPELINE_DIR / "state.json" - - -def _audio_subprocess(file_path: str) -> None: - """Play an audio file using pygame.mixer in a subprocess.""" - try: - import pygame - pygame.mixer.init(frequency=44100, size=-16, channels=2, buffer=2048) - pygame.mixer.music.load(file_path) - pygame.mixer.music.play() - while pygame.mixer.music.get_busy(): - pygame.time.wait(100) - pygame.mixer.quit() - except Exception: - pass - - -@dataclass -class PackInfo: - pack_id: str - name: str - source: str - installed_dir: str - installed_at: str - file_count: int = 0 - - -@dataclass -class AppState: - wow_data_dir: str = "" - output_data_dir: str = str(ROOT_DIR / "Data") - extractor_path: str = "" - expansion: str = "auto" - locale: str = "auto" - skip_dbc: bool = False - dbc_csv: bool = False - verify: bool = False - verbose: bool = False - threads: int = 0 - packs: list[PackInfo] = field(default_factory=list) - active_pack_ids: list[str] = field(default_factory=list) - last_extract_at: str = "" - last_extract_ok: bool = False - last_extract_command: str = "" - last_override_build_at: str = "" - - -class PipelineManager: - def __init__(self) -> None: - PIPELINE_DIR.mkdir(parents=True, exist_ok=True) - (PIPELINE_DIR / "packs").mkdir(parents=True, exist_ok=True) - self.state = self._load_state() - - def _default_state(self) -> AppState: - return AppState() - - def _load_state(self) -> AppState: - if not STATE_FILE.exists(): - return self._default_state() - try: - doc = json.loads(STATE_FILE.read_text(encoding="utf-8")) - packs = [PackInfo(**item) for item in doc.get("packs", [])] - doc["packs"] = packs - state = AppState(**doc) - return state - except (OSError, ValueError, TypeError): - return self._default_state() - - def save_state(self) -> None: - serializable = asdict(self.state) - STATE_FILE.write_text(json.dumps(serializable, indent=2), encoding="utf-8") - - def now_str(self) -> str: - return datetime.now().strftime("%Y-%m-%d %H:%M:%S") - - def _normalize_id(self, name: str) -> str: - raw = "".join(ch.lower() if ch.isalnum() else "-" for ch in name).strip("-") - base = raw or "pack" - return f"{base}-{int(time.time())}" - - def _pack_dir(self, pack_id: str) -> Path: - return PIPELINE_DIR / "packs" / pack_id - - def _looks_like_data_root(self, path: Path) -> bool: - markers = {"interface", "world", "character", "textures", "sound"} - names = {p.name.lower() for p in path.iterdir() if p.is_dir()} if path.is_dir() else set() - return bool(markers.intersection(names)) - - def find_data_root(self, pack_path: Path) -> Path: - direct_data = pack_path / "Data" - if direct_data.is_dir(): - return direct_data - - lower_data = pack_path / "data" - if lower_data.is_dir(): - return lower_data - - if self._looks_like_data_root(pack_path): - return pack_path - - # Common zip layout: one wrapper directory. - children = [p for p in pack_path.iterdir() if p.is_dir()] if pack_path.is_dir() else [] - if len(children) == 1: - child = children[0] - child_data = child / "Data" - if child_data.is_dir(): - return child_data - if self._looks_like_data_root(child): - return child - - return pack_path - - def _count_files(self, root: Path) -> int: - if not root.exists(): - return 0 - return sum(1 for p in root.rglob("*") if p.is_file()) - - def install_pack_from_zip(self, zip_path: Path) -> PackInfo: - pack_name = zip_path.stem - pack_id = self._normalize_id(pack_name) - target = self._pack_dir(pack_id) - target.mkdir(parents=True, exist_ok=False) - - with zipfile.ZipFile(zip_path, "r") as zf: - for member in zf.infolist(): - member_path = (target / member.filename).resolve() - if not str(member_path).startswith(str(target.resolve()) + "/") and member_path != target.resolve(): - raise ValueError(f"Zip slip detected: {member.filename!r} escapes target directory") - zf.extract(member, target) - - data_root = self.find_data_root(target) - info = PackInfo( - pack_id=pack_id, - name=pack_name, - source=str(zip_path), - installed_dir=str(target), - installed_at=self.now_str(), - file_count=self._count_files(data_root), - ) - self.state.packs.append(info) - self.save_state() - return info - - def install_pack_from_folder(self, folder_path: Path) -> PackInfo: - pack_name = folder_path.name - pack_id = self._normalize_id(pack_name) - target = self._pack_dir(pack_id) - shutil.copytree(folder_path, target) - - data_root = self.find_data_root(target) - info = PackInfo( - pack_id=pack_id, - name=pack_name, - source=str(folder_path), - installed_dir=str(target), - installed_at=self.now_str(), - file_count=self._count_files(data_root), - ) - self.state.packs.append(info) - self.save_state() - return info - - def uninstall_pack(self, pack_id: str) -> None: - self.state.packs = [p for p in self.state.packs if p.pack_id != pack_id] - self.state.active_pack_ids = [pid for pid in self.state.active_pack_ids if pid != pack_id] - target = self._pack_dir(pack_id) - if target.exists(): - shutil.rmtree(target) - self.save_state() - - def set_pack_active(self, pack_id: str, active: bool) -> None: - if active: - if pack_id not in self.state.active_pack_ids: - self.state.active_pack_ids.append(pack_id) - else: - self.state.active_pack_ids = [pid for pid in self.state.active_pack_ids if pid != pack_id] - self.save_state() - - def move_active_pack(self, pack_id: str, delta: int) -> None: - ids = self.state.active_pack_ids - if pack_id not in ids: - return - idx = ids.index(pack_id) - nidx = idx + delta - if nidx < 0 or nidx >= len(ids): - return - ids[idx], ids[nidx] = ids[nidx], ids[idx] - self.state.active_pack_ids = ids - self.save_state() - - def rebuild_override(self) -> dict[str, int]: - out_dir = Path(self.state.output_data_dir) - override_dir = out_dir / "override" - if override_dir.exists(): - shutil.rmtree(override_dir) - override_dir.mkdir(parents=True, exist_ok=True) - - copied = 0 - replaced = 0 - - active_map = {p.pack_id: p for p in self.state.packs} - for pack_id in self.state.active_pack_ids: - info = active_map.get(pack_id) - if info is None: - continue - pack_dir = Path(info.installed_dir) - if not pack_dir.exists(): - continue - - data_root = self.find_data_root(pack_dir) - for source in data_root.rglob("*"): - if not source.is_file(): - continue - rel = source.relative_to(data_root) - target = override_dir / rel - target.parent.mkdir(parents=True, exist_ok=True) - if target.exists(): - replaced += 1 - shutil.copy2(source, target) - copied += 1 - - self.state.last_override_build_at = self.now_str() - self.save_state() - return {"copied": copied, "replaced": replaced} - - def _resolve_extractor(self) -> list[str] | None: - configured = self.state.extractor_path.strip() - if configured: - path = Path(configured) - if path.exists() and path.is_file(): - return [str(path)] - - is_win = platform.system().lower().startswith("win") - ext = ".exe" if is_win else "" - for candidate in [ - ROOT_DIR / "build" / "bin" / f"asset_extract{ext}", - ROOT_DIR / "build" / f"asset_extract{ext}", - ROOT_DIR / "bin" / f"asset_extract{ext}", - ]: - if candidate.exists(): - return [str(candidate)] - - if is_win: - ps_script = ROOT_DIR / "extract_assets.ps1" - if ps_script.exists(): - return ["powershell", "-ExecutionPolicy", "Bypass", "-File", str(ps_script)] - return None - - shell_script = ROOT_DIR / "extract_assets.sh" - if shell_script.exists(): - return ["bash", str(shell_script)] - - return None - - def build_extract_command(self) -> list[str]: - mpq_dir = self.state.wow_data_dir.strip() - output_dir = self.state.output_data_dir.strip() - if not mpq_dir or not output_dir: - raise ValueError("Both WoW Data directory and output directory are required.") - - extractor = self._resolve_extractor() - if extractor is None: - raise ValueError( - "No extractor found. Build asset_extract first or set the extractor path in Configuration." - ) - - if extractor[0].endswith("extract_assets.sh") or extractor[-1].endswith("extract_assets.sh"): - cmd = [*extractor, mpq_dir] - if self.state.expansion and self.state.expansion != "auto": - cmd.append(self.state.expansion) - return cmd - - cmd = [*extractor, "--mpq-dir", mpq_dir, "--output", output_dir] - if self.state.expansion and self.state.expansion != "auto": - cmd.extend(["--expansion", self.state.expansion]) - if self.state.locale and self.state.locale != "auto": - cmd.extend(["--locale", self.state.locale]) - if self.state.skip_dbc: - cmd.append("--skip-dbc") - if self.state.dbc_csv: - cmd.append("--dbc-csv") - if self.state.verify: - cmd.append("--verify") - if self.state.verbose: - cmd.append("--verbose") - if self.state.threads > 0: - cmd.extend(["--threads", str(self.state.threads)]) - return cmd - - def summarize_state(self) -> dict[str, Any]: - output_dir = Path(self.state.output_data_dir) - manifest_path = output_dir / "manifest.json" - override_dir = output_dir / "override" - - summary: dict[str, Any] = { - "output_dir": str(output_dir), - "output_exists": output_dir.exists(), - "manifest_exists": manifest_path.exists(), - "manifest_entries": 0, - "override_exists": override_dir.exists(), - "override_files": self._count_files(override_dir), - "packs_installed": len(self.state.packs), - "packs_active": len(self.state.active_pack_ids), - "last_extract_at": self.state.last_extract_at or "never", - "last_extract_ok": self.state.last_extract_ok, - "last_override_build_at": self.state.last_override_build_at or "never", - } - - if manifest_path.exists(): - try: - doc = json.loads(manifest_path.read_text(encoding="utf-8")) - entries = doc.get("entries", {}) - if isinstance(entries, dict): - summary["manifest_entries"] = len(entries) - except (OSError, ValueError, TypeError): - summary["manifest_entries"] = -1 - - return summary - - -class AssetPipelineGUI: - def __init__(self, root: tk.Tk) -> None: - self.root = root - self.manager = PipelineManager() - - self.log_queue: queue.Queue[str] = queue.Queue() - self.proc_thread: threading.Thread | None = None - self.proc_process: subprocess.Popen | None = None - self.proc_running = False - - self.root.title("WoWee Asset Pipeline") - self.root.geometry("1120x760") - - self.status_var = tk.StringVar(value="Ready") - self._build_ui() - self._load_vars_from_state() - self.refresh_pack_list() - self.refresh_state_view() - self.root.after(120, self._poll_logs) - - def _build_ui(self) -> None: - top = ttk.Frame(self.root, padding=10) - top.pack(fill="both", expand=True) - - status = ttk.Label(top, textvariable=self.status_var, anchor="w") - status.pack(fill="x", pady=(0, 8)) - - self.notebook = ttk.Notebook(top) - self.notebook.pack(fill="both", expand=True) - - self.cfg_tab = ttk.Frame(self.notebook, padding=10) - self.packs_tab = ttk.Frame(self.notebook, padding=10) - self.browser_tab = ttk.Frame(self.notebook, padding=4) - self.state_tab = ttk.Frame(self.notebook, padding=10) - self.logs_tab = ttk.Frame(self.notebook, padding=10) - - self.notebook.add(self.cfg_tab, text="Configuration") - self.notebook.add(self.packs_tab, text="Texture Packs") - self.notebook.add(self.browser_tab, text="Asset Browser") - self.notebook.add(self.state_tab, text="Current State") - self.notebook.add(self.logs_tab, text="Logs") - - self._build_config_tab() - self._build_packs_tab() - self._build_browser_tab() - self._build_state_tab() - self._build_logs_tab() - - def _build_config_tab(self) -> None: - self.var_wow_data = tk.StringVar() - self.var_output_data = tk.StringVar() - self.var_extractor = tk.StringVar() - self.var_expansion = tk.StringVar(value="auto") - self.var_locale = tk.StringVar(value="auto") - self.var_skip_dbc = tk.BooleanVar(value=False) - self.var_dbc_csv = tk.BooleanVar(value=False) - self.var_verify = tk.BooleanVar(value=False) - self.var_verbose = tk.BooleanVar(value=False) - self.var_threads = tk.IntVar(value=0) - - frame = self.cfg_tab - - self._path_row(frame, 0, "WoW Data (MPQ source)", self.var_wow_data, self._pick_wow_data_dir) - self._path_row(frame, 1, "Output Data directory", self.var_output_data, self._pick_output_dir) - self._path_row(frame, 2, "Extractor binary/script (optional)", self.var_extractor, self._pick_extractor) - - ttk.Label(frame, text="Expansion").grid(row=3, column=0, sticky="w", pady=6) - exp_combo = ttk.Combobox( - frame, - textvariable=self.var_expansion, - values=["auto", "classic", "turtle", "tbc", "wotlk"], - state="readonly", - width=18, - ) - exp_combo.grid(row=3, column=1, sticky="w", pady=6) - - ttk.Label(frame, text="Locale").grid(row=3, column=2, sticky="w", pady=6) - loc_combo = ttk.Combobox( - frame, - textvariable=self.var_locale, - values=["auto", "enUS", "enGB", "deDE", "frFR", "esES", "esMX", "ruRU", "koKR", "zhCN", "zhTW"], - state="readonly", - width=12, - ) - loc_combo.grid(row=3, column=3, sticky="w", pady=6) - - ttk.Label(frame, text="Threads (0 = auto)").grid(row=4, column=0, sticky="w", pady=6) - ttk.Spinbox(frame, from_=0, to=256, textvariable=self.var_threads, width=8).grid( - row=4, column=1, sticky="w", pady=6 - ) - - opts = ttk.Frame(frame) - opts.grid(row=5, column=0, columnspan=4, sticky="w", pady=6) - ttk.Checkbutton(opts, text="Skip DBC extraction", variable=self.var_skip_dbc).pack(side="left", padx=(0, 12)) - ttk.Checkbutton(opts, text="Generate DBC CSV", variable=self.var_dbc_csv).pack(side="left", padx=(0, 12)) - ttk.Checkbutton(opts, text="Verify CRC", variable=self.var_verify).pack(side="left", padx=(0, 12)) - ttk.Checkbutton(opts, text="Verbose output", variable=self.var_verbose).pack(side="left", padx=(0, 12)) - - buttons = ttk.Frame(frame) - buttons.grid(row=6, column=0, columnspan=4, sticky="w", pady=12) - ttk.Button(buttons, text="Save Configuration", command=self.save_config).pack(side="left", padx=(0, 8)) - ttk.Button(buttons, text="Run Extraction", command=self.run_extraction).pack(side="left", padx=(0, 8)) - self.cancel_btn = ttk.Button(buttons, text="Cancel Extraction", command=self.cancel_extraction, state="disabled") - self.cancel_btn.pack(side="left", padx=(0, 8)) - ttk.Button(buttons, text="Refresh State", command=self.refresh_state_view).pack(side="left") - - tip = ( - "Texture packs are merged into /override in active order. " - "Later packs override earlier packs file-by-file." - ) - ttk.Label(frame, text=tip, foreground="#444").grid(row=7, column=0, columnspan=4, sticky="w", pady=(8, 0)) - - frame.columnconfigure(1, weight=1) - - def _build_packs_tab(self) -> None: - left = ttk.Frame(self.packs_tab) - left.pack(side="left", fill="both", expand=True) - - right = ttk.Frame(self.packs_tab) - right.pack(side="right", fill="y", padx=(12, 0)) - - self.pack_list = tk.Listbox(left, height=22) - self.pack_list.pack(fill="both", expand=True) - self.pack_list.bind("<>", lambda _evt: self._refresh_pack_detail()) - - self.pack_detail = ScrolledText(left, height=10, wrap="word", state="disabled") - self.pack_detail.pack(fill="both", expand=False, pady=(10, 0)) - - ttk.Button(right, text="Install ZIP", width=22, command=self.install_zip).pack(pady=4) - ttk.Button(right, text="Install Folder", width=22, command=self.install_folder).pack(pady=4) - ttk.Separator(right, orient="horizontal").pack(fill="x", pady=8) - ttk.Button(right, text="Activate", width=22, command=self.activate_selected_pack).pack(pady=4) - ttk.Button(right, text="Deactivate", width=22, command=self.deactivate_selected_pack).pack(pady=4) - ttk.Button(right, text="Move Up", width=22, command=lambda: self.move_selected_pack(-1)).pack(pady=4) - ttk.Button(right, text="Move Down", width=22, command=lambda: self.move_selected_pack(1)).pack(pady=4) - ttk.Separator(right, orient="horizontal").pack(fill="x", pady=8) - ttk.Button(right, text="Rebuild Override", width=22, command=self.rebuild_override).pack(pady=4) - ttk.Button(right, text="Uninstall", width=22, command=self.uninstall_selected_pack).pack(pady=4) - - # ── Asset Browser Tab ────────────────────────────────────────────── - - def _build_browser_tab(self) -> None: - self._browser_manifest: dict[str, dict] = {} - self._browser_manifest_lc: dict[str, str] = {} - self._browser_manifest_list: list[str] = [] - self._browser_tree_populated: set[str] = set() - self._browser_photo: Any = None # prevent GC of PhotoImage - self._browser_wireframe_verts: list[tuple[float, float, float]] = [] - self._browser_wireframe_tris: list[tuple[int, int, int]] = [] - self._browser_az = 0.0 - self._browser_el = 0.3 - self._browser_zoom = 1.0 - self._browser_drag_start: tuple[int, int] | None = None - self._browser_dbc_rows: list[list[str]] = [] - self._browser_dbc_shown = 0 - - # Top bar: search + filter - top_bar = ttk.Frame(self.browser_tab) - top_bar.pack(fill="x", pady=(0, 4)) - - ttk.Label(top_bar, text="Search:").pack(side="left") - self._browser_search_var = tk.StringVar() - search_entry = ttk.Entry(top_bar, textvariable=self._browser_search_var, width=40) - search_entry.pack(side="left", padx=(4, 8)) - search_entry.bind("", lambda _: self._browser_do_search()) - - ttk.Label(top_bar, text="Type:").pack(side="left") - self._browser_type_var = tk.StringVar(value="All") - type_combo = ttk.Combobox( - top_bar, - textvariable=self._browser_type_var, - values=["All", "BLP", "M2", "WMO", "DBC", "ADT", "Audio", "Text"], - state="readonly", - width=8, - ) - type_combo.pack(side="left", padx=(4, 8)) - - ttk.Button(top_bar, text="Search", command=self._browser_do_search).pack(side="left", padx=(0, 4)) - ttk.Button(top_bar, text="Reset", command=self._browser_reset_search).pack(side="left", padx=(0, 8)) - - self._browser_hide_anim_var = tk.BooleanVar(value=True) - ttk.Checkbutton(top_bar, text="Hide .anim/.skin", variable=self._browser_hide_anim_var, - command=self._browser_reset_search).pack(side="left") - - self._browser_count_var = tk.StringVar(value="") - ttk.Label(top_bar, textvariable=self._browser_count_var).pack(side="right") - - # Main paned: left tree + right preview - paned = ttk.PanedWindow(self.browser_tab, orient="horizontal") - paned.pack(fill="both", expand=True) - - # Left: directory tree - left_frame = ttk.Frame(paned) - paned.add(left_frame, weight=1) - - tree_scroll = ttk.Scrollbar(left_frame, orient="vertical") - self._browser_tree = ttk.Treeview(left_frame, show="tree", yscrollcommand=tree_scroll.set) - tree_scroll.config(command=self._browser_tree.yview) - self._browser_tree.pack(side="left", fill="both", expand=True) - tree_scroll.pack(side="right", fill="y") - - self._browser_tree.bind("<>", self._browser_on_expand) - self._browser_tree.bind("<>", self._browser_on_select) - - # Right: preview area - right_frame = ttk.Frame(paned) - paned.add(right_frame, weight=3) - - self._browser_preview_frame = ttk.Frame(right_frame) - self._browser_preview_frame.pack(fill="both", expand=True) - - # Bottom bar: file info - self._browser_info_var = tk.StringVar(value="Select a file to preview") - info_bar = ttk.Label(self.browser_tab, textvariable=self._browser_info_var, anchor="w", relief="sunken") - info_bar.pack(fill="x", pady=(4, 0)) - - # Load manifest - self._browser_load_manifest() - - def _browser_load_manifest(self) -> None: - output_dir = Path(self.manager.state.output_data_dir) - manifest_path = output_dir / "manifest.json" - if not manifest_path.exists(): - self._browser_count_var.set("No manifest.json found") - return - - try: - doc = json.loads(manifest_path.read_text(encoding="utf-8")) - entries = doc.get("entries", {}) - if not isinstance(entries, dict): - self._browser_count_var.set("Invalid manifest format") - return - except (OSError, ValueError, TypeError) as exc: - self._browser_count_var.set(f"Manifest error: {exc}") - return - - # Re-key manifest by the 'p' field (forward-slash paths) for tree display - self._browser_manifest = {} - for _key, val in entries.items(): - display_path = val.get("p", _key).replace("\\", "/") - self._browser_manifest[display_path] = val - self._browser_manifest_list = sorted(self._browser_manifest.keys(), key=str.lower) - self._browser_count_var.set(f"{len(self._browser_manifest)} entries") - - # Build case-insensitive lookup: lowercase forward-slash path -> actual manifest path - self._browser_manifest_lc: dict[str, str] = {} - for p in self._browser_manifest: - self._browser_manifest_lc[p.lower()] = p - - # Build directory tree indices: one full, one filtered - # Single O(N) pass so tree operations are O(1) lookups - _hidden_exts = {".anim", ".skin"} - self._browser_dir_index_full = self._build_dir_index(self._browser_manifest_list) - filtered = [p for p in self._browser_manifest_list - if os.path.splitext(p)[1].lower() not in _hidden_exts] - self._browser_dir_index_filtered = self._build_dir_index(filtered) - - self._browser_populate_tree_root() - - @staticmethod - def _build_dir_index(paths: list[str]) -> dict[str, tuple[set[str], list[str]]]: - index: dict[str, tuple[set[str], list[str]]] = {} - for path in paths: - parts = path.split("/") - for depth in range(len(parts)): - dir_key = "/".join(parts[:depth]) if depth > 0 else "" - if dir_key not in index: - index[dir_key] = (set(), []) - entry = index[dir_key] - if depth < len(parts) - 1: - entry[0].add(parts[depth]) - else: - entry[1].append(parts[depth]) - return index - - def _browser_active_index(self) -> dict[str, tuple[set[str], list[str]]]: - if self._browser_hide_anim_var.get(): - return self._browser_dir_index_filtered - return self._browser_dir_index_full - - def _browser_populate_tree_root(self) -> None: - self._browser_tree.delete(*self._browser_tree.get_children()) - self._browser_tree_populated.clear() - - root_entry = self._browser_active_index().get("", (set(), [])) - subdirs, files = root_entry - - for name in sorted(subdirs, key=str.lower): - node = self._browser_tree.insert("", "end", iid=name, text=name, open=False) - self._browser_tree.insert(node, "end", iid=name + "/__dummy__", text="") - - for name in sorted(files, key=str.lower): - self._browser_tree.insert("", "end", iid=name, text=name) - - def _browser_on_expand(self, event: Any) -> None: - node = self._browser_tree.focus() - if not node or node in self._browser_tree_populated: - return - self._browser_tree_populated.add(node) - - # Remove dummy child - dummy = node + "/__dummy__" - if self._browser_tree.exists(dummy): - self._browser_tree.delete(dummy) - - dir_entry = self._browser_active_index().get(node, (set(), [])) - child_dirs, child_files = dir_entry - - for d in sorted(child_dirs, key=str.lower): - child_id = node + "/" + d - if not self._browser_tree.exists(child_id): - n = self._browser_tree.insert(node, "end", iid=child_id, text=d, open=False) - self._browser_tree.insert(n, "end", iid=child_id + "/__dummy__", text="") - - for f in sorted(child_files, key=str.lower): - child_id = node + "/" + f - if not self._browser_tree.exists(child_id): - self._browser_tree.insert(node, "end", iid=child_id, text=f) - - def _browser_on_select(self, event: Any) -> None: - sel = self._browser_tree.selection() - if not sel: - return - path = sel[0] - entry = self._browser_manifest.get(path) - if entry is None: - # It's a directory node - self._browser_info_var.set(f"Directory: {path}") - return - self._browser_preview_file(path, entry) - - def _browser_do_search(self) -> None: - query = self._browser_search_var.get().strip().lower() - type_filter = self._browser_type_var.get() - - type_exts: dict[str, set[str]] = { - "BLP": {".blp"}, - "M2": {".m2"}, - "WMO": {".wmo"}, - "DBC": {".dbc", ".csv"}, - "ADT": {".adt"}, - "Audio": {".wav", ".mp3", ".ogg"}, - "Text": {".xml", ".lua", ".json", ".html", ".toc", ".txt", ".wtf"}, - } - - hidden_exts = {".anim", ".skin"} if self._browser_hide_anim_var.get() else set() - results: list[str] = [] - exts = type_exts.get(type_filter) - for path in self._browser_manifest_list: - ext = os.path.splitext(path)[1].lower() - if ext in hidden_exts: - continue - if exts and ext not in exts: - continue - if query and query not in path.lower(): - continue - results.append(path) - - # Repopulate tree with filtered results - self._browser_tree.delete(*self._browser_tree.get_children()) - self._browser_tree_populated.clear() - - if len(results) > 5000: - # Too many results — show directory structure - self._browser_count_var.set(f"{len(results)} results (showing first 5000)") - results = results[:5000] - else: - self._browser_count_var.set(f"{len(results)} results") - - # Build tree from filtered results - dirs_added: set[str] = set() - for path in results: - parts = path.split("/") - # Ensure parent directories exist - for i in range(1, len(parts)): - dir_id = "/".join(parts[:i]) - if dir_id not in dirs_added: - dirs_added.add(dir_id) - parent_id = "/".join(parts[:i - 1]) if i > 1 else "" - if not self._browser_tree.exists(dir_id): - self._browser_tree.insert(parent_id, "end", iid=dir_id, text=parts[i - 1], open=True) - # Insert file - parent_id = "/".join(parts[:-1]) if len(parts) > 1 else "" - if not self._browser_tree.exists(path): - self._browser_tree.insert(parent_id, "end", iid=path, text=parts[-1]) - self._browser_tree_populated.add(parent_id) - - def _browser_reset_search(self) -> None: - self._browser_search_var.set("") - self._browser_type_var.set("All") - self._browser_populate_tree_root() - self._browser_count_var.set(f"{len(self._browser_manifest)} entries") - - def _browser_clear_preview(self) -> None: - for widget in self._browser_preview_frame.winfo_children(): - widget.destroy() - self._browser_photo = None - - def _browser_file_ext(self, path: str) -> str: - return os.path.splitext(path)[1].lower() - - def _browser_resolve_path(self, manifest_path: str) -> Path | None: - entry = self._browser_manifest.get(manifest_path) - if entry is None: - return None - rel = entry.get("p", manifest_path) - output_dir = Path(self.manager.state.output_data_dir) - full = output_dir / rel - if full.exists(): - return full - return None - - def _browser_preview_file(self, path: str, entry: dict) -> None: - self._browser_clear_preview() - - size = entry.get("s", 0) - crc = entry.get("h", "") - ext = self._browser_file_ext(path) - - self._browser_info_var.set(f"{path} | Size: {self._format_size(size)} | CRC: {crc}") - - if ext == ".blp": - self._browser_preview_blp(path, entry) - elif ext == ".m2": - self._browser_preview_m2(path, entry) - elif ext == ".wmo": - self._browser_preview_wmo(path, entry) - elif ext in (".csv",): - self._browser_preview_dbc(path, entry) - elif ext == ".adt": - self._browser_preview_adt(path, entry) - elif ext in (".xml", ".lua", ".json", ".html", ".toc", ".txt", ".wtf", ".ini"): - self._browser_preview_text(path, entry) - elif ext in (".wav", ".mp3", ".ogg"): - self._browser_preview_audio(path, entry) - else: - self._browser_preview_hex(path, entry) - - def _format_size(self, size: int) -> str: - if size < 1024: - return f"{size} B" - elif size < 1024 * 1024: - return f"{size / 1024:.1f} KB" - else: - return f"{size / (1024 * 1024):.1f} MB" - - # ── BLP Preview ── - - def _browser_preview_blp(self, path: str, entry: dict) -> None: - if not HAS_PILLOW: - lbl = ttk.Label(self._browser_preview_frame, text="Install Pillow for image preview:\n pip install Pillow", anchor="center") - lbl.pack(expand=True) - return - - file_path = self._browser_resolve_path(path) - if file_path is None: - ttk.Label(self._browser_preview_frame, text="File not found on disk").pack(expand=True) - return - - # Check for blp_convert - blp_convert = ROOT_DIR / "build" / "bin" / "blp_convert" - if not blp_convert.exists(): - ttk.Label(self._browser_preview_frame, text="blp_convert not found in build/bin/\nBuild the project first.").pack(expand=True) - return - - # Cache directory - cache_dir = PIPELINE_DIR / "preview_cache" - cache_dir.mkdir(parents=True, exist_ok=True) - - cache_key = hashlib.md5(f"{path}:{entry.get('s', 0)}".encode()).hexdigest() - cached_png = cache_dir / f"{cache_key}.png" - - if not cached_png.exists(): - # blp_convert outputs PNG alongside source: foo.blp -> foo.png - try: - result = subprocess.run( - [str(blp_convert), "--to-png", str(file_path)], - capture_output=True, text=True, timeout=10 - ) - output_png = file_path.with_suffix(".png") - if result.returncode != 0 or not output_png.exists(): - ttk.Label(self._browser_preview_frame, text=f"blp_convert failed:\n{result.stderr[:500]}").pack(expand=True) - return - shutil.move(str(output_png), cached_png) - except Exception as exc: - ttk.Label(self._browser_preview_frame, text=f"Conversion error: {exc}").pack(expand=True) - return - - # Load and display - try: - img = Image.open(cached_png) - orig_w, orig_h = img.size - - # Fit to preview area - max_w = self._browser_preview_frame.winfo_width() or 600 - max_h = self._browser_preview_frame.winfo_height() or 500 - max_w = max(max_w - 20, 200) - max_h = max(max_h - 40, 200) - - scale = min(max_w / orig_w, max_h / orig_h, 1.0) - if scale < 1.0: - new_w = int(orig_w * scale) - new_h = int(orig_h * scale) - img = img.resize((new_w, new_h), Image.LANCZOS) - - self._browser_photo = ImageTk.PhotoImage(img) - info_text = f"{orig_w} x {orig_h}" - ttk.Label(self._browser_preview_frame, text=info_text).pack(pady=(4, 2)) - lbl = ttk.Label(self._browser_preview_frame, image=self._browser_photo) - lbl.pack(expand=True) - except Exception as exc: - ttk.Label(self._browser_preview_frame, text=f"Image load error: {exc}").pack(expand=True) - - # ── M2 Preview (wireframe + textures + animations) ── - - # Common animation ID names - _ANIM_NAMES: dict[int, str] = { - 0: "Stand", 1: "Death", 2: "Spell", 3: "Stop", 4: "Walk", 5: "Run", - 6: "Dead", 7: "Rise", 8: "StandWound", 9: "CombatWound", 10: "CombatCritical", - 11: "ShuffleLeft", 12: "ShuffleRight", 13: "Walkbackwards", 14: "Stun", - 15: "HandsClosed", 16: "AttackUnarmed", 17: "Attack1H", 18: "Attack2H", - 19: "Attack2HL", 20: "ParryUnarmed", 21: "Parry1H", 22: "Parry2H", - 23: "Parry2HL", 24: "ShieldBlock", 25: "ReadyUnarmed", 26: "Ready1H", - 27: "Ready2H", 28: "Ready2HL", 29: "ReadyBow", 30: "Dodge", - 31: "SpellPrecast", 32: "SpellCast", 33: "SpellCastArea", - 34: "NPCWelcome", 35: "NPCGoodbye", 36: "Block", 37: "JumpStart", - 38: "Jump", 39: "JumpEnd", 40: "Fall", 41: "SwimIdle", 42: "Swim", - 43: "SwimLeft", 44: "SwimRight", 45: "SwimBackwards", - 60: "SpellChannelDirected", 61: "SpellChannelOmni", - 69: "CombatAbility", 70: "CombatAbility2H", - 94: "Kneel", 113: "Loot", - 135: "ReadyRifle", 138: "Fly", 143: "CustomSpell01", - 157: "EmoteTalk", 185: "FlyIdle", - } - - # Texture type names for non-filename textures - _TEX_TYPE_NAMES: dict[int, str] = { - 0: "Filename", 1: "Body/Skin", 2: "Object Skin", 3: "Weapon Blade", - 4: "Weapon Handle", 5: "Environment", 6: "Hair", 7: "Facial Hair", - 8: "Skin Extra", 9: "UI Skin", 10: "Tauren Mane", 11: "Monster Skin 1", - 12: "Monster Skin 2", 13: "Monster Skin 3", 14: "Item Icon", - } - - def _browser_parse_m2_textures(self, data: bytes, version: int) -> list[dict]: - """Parse M2 texture definitions. Returns list of {type, flags, filename}.""" - if version <= 256: - ofs = 92 - else: - ofs = 80 - - if len(data) < ofs + 8: - return [] - - n_tex, ofs_tex = struct.unpack_from(" 1000 or ofs_tex + n_tex * 16 > len(data): - return [] - - textures = [] - for i in range(n_tex): - base = ofs_tex + i * 16 - tex_type, tex_flags = struct.unpack_from(" 1 and name_ofs + name_len <= len(data): - raw = data[name_ofs:name_ofs + name_len] - filename = raw.split(b"\x00", 1)[0].decode("ascii", errors="replace") - textures.append({"type": tex_type, "flags": tex_flags, "filename": filename}) - return textures - - def _browser_parse_m2_animations(self, data: bytes, version: int) -> list[dict]: - """Parse M2 animation sequences. Returns list of {id, variation, duration, speed, flags}.""" - if len(data) < 36: - return [] - - n_anim, ofs_anim = struct.unpack_from(" 5000: - return [] - - seq_size = 68 if version <= 256 else 64 - if ofs_anim + n_anim * seq_size > len(data): - return [] - - anims = [] - for i in range(n_anim): - base = ofs_anim + i * seq_size - anim_id, variation = struct.unpack_from(" Path | None: - """Resolve a BLP filename from M2 texture to a filesystem path, case-insensitively.""" - # Normalize: backslash -> forward slash - normalized = blp_name.replace("\\", "/") - lc = normalized.lower() - - # Try direct manifest lookup - actual = self._browser_manifest_lc.get(lc) - if actual: - return self._browser_resolve_path(actual) - - # Try without leading slash - if lc.startswith("/"): - actual = self._browser_manifest_lc.get(lc[1:]) - if actual: - return self._browser_resolve_path(actual) - - return None - - def _browser_load_blp_thumbnail(self, blp_path: Path, size: int = 64) -> Any: - """Convert BLP to PNG and return a PhotoImage thumbnail, or None.""" - if not HAS_PILLOW: - return None - - blp_convert = ROOT_DIR / "build" / "bin" / "blp_convert" - if not blp_convert.exists(): - return None - - cache_dir = PIPELINE_DIR / "preview_cache" - cache_dir.mkdir(parents=True, exist_ok=True) - cache_key = hashlib.md5(str(blp_path).encode()).hexdigest() - cached_png = cache_dir / f"{cache_key}.png" - - if not cached_png.exists(): - try: - result = subprocess.run( - [str(blp_convert), "--to-png", str(blp_path)], - capture_output=True, text=True, timeout=10, - ) - output_png = blp_path.with_suffix(".png") - if result.returncode != 0 or not output_png.exists(): - return None - shutil.move(str(output_png), cached_png) - except Exception: - return None - - try: - img = Image.open(cached_png) - img.thumbnail((size, size), Image.LANCZOS) - return ImageTk.PhotoImage(img) - except Exception: - return None - - def _browser_preview_m2(self, path: str, entry: dict) -> None: - file_path = self._browser_resolve_path(path) - if file_path is None: - ttk.Label(self._browser_preview_frame, text="File not found on disk").pack(expand=True) - return - - try: - data = file_path.read_bytes() - if len(data) < 108: - ttk.Label(self._browser_preview_frame, text="M2 file too small").pack(expand=True) - return - - magic = data[:4] - if magic != b"MD20": - ttk.Label(self._browser_preview_frame, text=f"Not an M2 file (magic: {magic!r})").pack(expand=True) - return - - version = struct.unpack_from(" 500000 or ofs_verts + n_verts * 48 > len(data): - ttk.Label(self._browser_preview_frame, text=f"M2: {n_verts} vertices (no preview)").pack(expand=True) - return - - verts: list[tuple[float, float, float]] = [] - for i in range(n_verts): - off = ofs_verts + i * 48 - x, y, z = struct.unpack_from("", self._browser_wf_mouse_down) - canvas.bind("", self._browser_wf_mouse_drag) - canvas.bind("", self._browser_wf_scroll) - canvas.bind("", lambda e: self._browser_wf_scroll_linux(e, 1)) - canvas.bind("", lambda e: self._browser_wf_scroll_linux(e, -1)) - canvas.bind("", lambda e: self._browser_wf_render()) - self.root.after(50, self._browser_wf_render) - - # Right: textures + animations sidebar - right_frame = ttk.Frame(main_pane) - main_pane.add(right_frame, weight=1) - - # --- Textures section --- - ttk.Label(right_frame, text="Textures", font=("", 10, "bold")).pack(anchor="w", pady=(4, 2)) - - # Keep references to thumbnail PhotoImages to prevent GC - self._browser_m2_thumbs: list[Any] = [] - - if textures: - tex_frame = ttk.Frame(right_frame) - tex_frame.pack(fill="x", padx=2) - - for i, tex in enumerate(textures): - row_frame = ttk.Frame(tex_frame) - row_frame.pack(fill="x", pady=1) - - tex_type = tex["type"] - filename = tex["filename"] - - if tex_type == 0 and filename: - # Try to load BLP thumbnail - display_name = filename.replace("\\", "/").split("/")[-1] - blp_fs_path = self._browser_resolve_blp_path(filename) - thumb = None - if blp_fs_path: - thumb = self._browser_load_blp_thumbnail(blp_fs_path) - - if thumb: - self._browser_m2_thumbs.append(thumb) - lbl_img = ttk.Label(row_frame, image=thumb) - lbl_img.pack(side="left", padx=(0, 4)) - - lbl_text = ttk.Label(row_frame, text=display_name, wraplength=180) - lbl_text.pack(side="left", fill="x") - else: - type_name = self._TEX_TYPE_NAMES.get(tex_type, f"Type {tex_type}") - lbl = ttk.Label(row_frame, text=f"[{type_name}]", foreground="#888") - lbl.pack(side="left") - else: - ttk.Label(right_frame, text="(none)", foreground="#888").pack(anchor="w") - - # --- Separator --- - ttk.Separator(right_frame, orient="horizontal").pack(fill="x", pady=6) - - # --- Animations section --- - ttk.Label(right_frame, text="Animations", font=("", 10, "bold")).pack(anchor="w", pady=(0, 2)) - - if animations: - anim_frame = ttk.Frame(right_frame) - anim_frame.pack(fill="both", expand=True) - - anim_scroll = ttk.Scrollbar(anim_frame, orient="vertical") - anim_tree = ttk.Treeview( - anim_frame, columns=("id", "name", "var", "dur", "spd"), - show="headings", height=8, - yscrollcommand=anim_scroll.set, - ) - anim_scroll.config(command=anim_tree.yview) - - anim_tree.heading("id", text="ID") - anim_tree.heading("name", text="Name") - anim_tree.heading("var", text="Var") - anim_tree.heading("dur", text="Dur(ms)") - anim_tree.heading("spd", text="Speed") - - anim_tree.column("id", width=35, minwidth=30) - anim_tree.column("name", width=90, minwidth=60) - anim_tree.column("var", width=30, minwidth=25) - anim_tree.column("dur", width=55, minwidth=40) - anim_tree.column("spd", width=45, minwidth=35) - - for anim in animations: - aid = anim["id"] - name = self._ANIM_NAMES.get(aid, "") - anim_tree.insert("", "end", values=( - aid, name, anim["variation"], - anim["duration"], f"{anim['speed']:.1f}", - )) - - anim_tree.pack(side="left", fill="both", expand=True) - anim_scroll.pack(side="right", fill="y") - else: - ttk.Label(right_frame, text="(none)", foreground="#888").pack(anchor="w") - - except Exception as exc: - ttk.Label(self._browser_preview_frame, text=f"M2 parse error: {exc}").pack(expand=True) - - def _parse_skin_triangles(self, data: bytes) -> list[tuple[int, int, int]]: - if len(data) < 48: - return [] - - # Check for SKIN magic - off = 0 - if data[:4] == b"SKIN": - off = 4 - - n_indices, ofs_indices = struct.unpack_from(" 500000: - return [] - if n_tris == 0 or n_tris > 500000: - return [] - - # Indices are uint16 vertex lookup - if ofs_indices + n_indices * 2 > len(data): - return [] - indices = list(struct.unpack_from(f"<{n_indices}H", data, ofs_indices)) - - # Triangles are uint16 index-into-indices - if ofs_tris + n_tris * 2 > len(data): - return [] - tri_idx = list(struct.unpack_from(f"<{n_tris}H", data, ofs_tris)) - - tris: list[tuple[int, int, int]] = [] - for i in range(0, len(tri_idx) - 2, 3): - a, b, c = tri_idx[i], tri_idx[i + 1], tri_idx[i + 2] - if a < n_indices and b < n_indices and c < n_indices: - tris.append((indices[a], indices[b], indices[c])) - - return tris - - # ── WMO Preview ── - - def _browser_preview_wmo(self, path: str, entry: dict) -> None: - file_path = self._browser_resolve_path(path) - if file_path is None: - ttk.Label(self._browser_preview_frame, text="File not found on disk").pack(expand=True) - return - - # Check if this is a root WMO or group WMO - name = file_path.name.lower() - # Group WMOs typically end with _NNN.wmo - is_group = len(name) > 8 and name[-8:-4].isdigit() and name[-9] == "_" - - try: - if is_group: - verts, tris = self._parse_wmo_group(file_path) - else: - # Root WMO — try to load first group - verts, tris = self._parse_wmo_root_first_group(file_path) - - if not verts: - data = file_path.read_bytes() - if len(data) >= 24 and data[:4] in (b"MVER", b"REVM"): - n_groups = 0 - # Try to find nGroups in MOHD chunk - pos = 0 - while pos < len(data) - 8: - chunk_id = data[pos:pos + 4] - chunk_size = struct.unpack_from("= 16: - n_groups = struct.unpack_from(" len(rdata): - break - tag = cid if cid[:1].isupper() else cid[::-1] - if tag == b"MOTX": - off = 0 - while off < csz: - end = rdata.find(b"\x00", cs + off, ce) - if end < 0: - break - s = rdata[cs + off:end].decode("ascii", errors="replace") - if s: - resolved = self._browser_resolve_blp_path(s) - if resolved: - norm = s.replace("\\", "/") - blp_map[norm] = str(resolved) - blp_map[norm.lower()] = str(resolved) - off = end - cs + 1 - else: - off += 1 - break - pos = ce - try: - from tools.m2_viewer import launch_wmo_viewer - launch_wmo_viewer( - str(root_path) if root_path.exists() else "", - [str(g) for g in groups], - blp_map, str(blp_convert)) - except ImportError: - try: - from m2_viewer import launch_wmo_viewer as lwv - lwv(str(root_path) if root_path.exists() else "", - [str(g) for g in groups], blp_map, str(blp_convert)) - except ImportError: - messagebox.showerror("Error", "m2_viewer.py not found. Requires pygame, PyOpenGL, numpy, Pillow.") - - ttk.Button(top_bar, text="Open 3D Viewer", command=_open_wmo_viewer).pack(side="right", padx=4) - - self._browser_create_wireframe_canvas() - - except Exception as exc: - ttk.Label(self._browser_preview_frame, text=f"WMO parse error: {exc}").pack(expand=True) - - def _parse_wmo_group(self, file_path: Path) -> tuple[list[tuple[float, float, float]], list[tuple[int, int, int]]]: - data = file_path.read_bytes() - verts: list[tuple[float, float, float]] = [] - tris: list[tuple[int, int, int]] = [] - - pos = 0 - while pos < len(data) - 8: - chunk_id = data[pos:pos + 4] - chunk_size = struct.unpack_from(" tuple[list[tuple[float, float, float]], list[tuple[int, int, int]]]: - # Try _000.wmo - stem = file_path.stem - group_path = file_path.parent / f"{stem}_000.wmo" - if group_path.exists(): - return self._parse_wmo_group(group_path) - return [], [] - - # ── Wireframe Canvas (shared M2/WMO) ── - - def _browser_create_wireframe_canvas(self) -> None: - canvas = tk.Canvas(self._browser_preview_frame, bg="#1a1a2e", highlightthickness=0) - canvas.pack(fill="both", expand=True) - self._browser_canvas = canvas - - canvas.bind("", self._browser_wf_mouse_down) - canvas.bind("", self._browser_wf_mouse_drag) - canvas.bind("", self._browser_wf_scroll) - canvas.bind("", lambda e: self._browser_wf_scroll_linux(e, 1)) - canvas.bind("", lambda e: self._browser_wf_scroll_linux(e, -1)) - canvas.bind("", lambda e: self._browser_wf_render()) - - self.root.after(50, self._browser_wf_render) - - def _browser_wf_mouse_down(self, event: Any) -> None: - self._browser_drag_start = (event.x, event.y) - - def _browser_wf_mouse_drag(self, event: Any) -> None: - if self._browser_drag_start is None: - return - dx = event.x - self._browser_drag_start[0] - dy = event.y - self._browser_drag_start[1] - self._browser_az += dx * 0.01 - self._browser_el += dy * 0.01 - self._browser_el = max(-math.pi / 2, min(math.pi / 2, self._browser_el)) - self._browser_drag_start = (event.x, event.y) - self._browser_wf_render() - - def _browser_wf_scroll(self, event: Any) -> None: - if event.delta > 0: - self._browser_zoom *= 1.1 - else: - self._browser_zoom /= 1.1 - self._browser_wf_render() - - def _browser_wf_scroll_linux(self, event: Any, direction: int) -> None: - if direction > 0: - self._browser_zoom *= 1.1 - else: - self._browser_zoom /= 1.1 - self._browser_wf_render() - - def _browser_wf_render(self) -> None: - canvas = self._browser_canvas - canvas.delete("all") - w = canvas.winfo_width() - h = canvas.winfo_height() - if w < 10 or h < 10: - return - - verts = self._browser_wireframe_verts - tris = self._browser_wireframe_tris - if not verts: - return - - # Compute bounding box for auto-scale - xs = [v[0] for v in verts] - ys = [v[1] for v in verts] - zs = [v[2] for v in verts] - cx = (min(xs) + max(xs)) / 2 - cy = (min(ys) + max(ys)) / 2 - cz = (min(zs) + max(zs)) / 2 - extent = max(max(xs) - min(xs), max(ys) - min(ys), max(zs) - min(zs), 0.001) - scale = min(w, h) * 0.4 / extent * self._browser_zoom - - # Rotation matrix (azimuth around Z, elevation around X) - cos_a, sin_a = math.cos(self._browser_az), math.sin(self._browser_az) - cos_e, sin_e = math.cos(self._browser_el), math.sin(self._browser_el) - - def project(v: tuple[float, float, float]) -> tuple[float, float, float]: - x, y, z = v[0] - cx, v[1] - cy, v[2] - cz - # Rotate around Z (azimuth) - rx = x * cos_a - y * sin_a - ry = x * sin_a + y * cos_a - rz = z - # Rotate around X (elevation) - ry2 = ry * cos_e - rz * sin_e - rz2 = ry * sin_e + rz * cos_e - return (w / 2 + rx * scale, h / 2 - rz2 * scale, ry2) - - projected = [project(v) for v in verts] - - # Depth-sort triangles - if tris: - tri_depths: list[tuple[float, int]] = [] - for i, (a, b, c) in enumerate(tris): - if a < len(projected) and b < len(projected) and c < len(projected): - avg_depth = (projected[a][2] + projected[b][2] + projected[c][2]) / 3 - tri_depths.append((avg_depth, i)) - tri_depths.sort() - - # Draw max 20000 triangles for performance - max_draw = min(len(tri_depths), 20000) - min_d = tri_depths[0][0] if tri_depths else 0 - max_d = tri_depths[-1][0] if tri_depths else 1 - d_range = max_d - min_d if max_d != min_d else 1 - - for j in range(max_draw): - depth, idx = tri_depths[j] - a, b, c = tris[idx] - if a >= len(projected) or b >= len(projected) or c >= len(projected): - continue - - # Depth coloring: closer = brighter - t = 1.0 - (depth - min_d) / d_range - intensity = int(60 + t * 160) - color = f"#{intensity:02x}{intensity:02x}{int(intensity * 1.2) & 0xff:02x}" - - p1, p2, p3 = projected[a], projected[b], projected[c] - canvas.create_line(p1[0], p1[1], p2[0], p2[1], fill=color, width=1) - canvas.create_line(p2[0], p2[1], p3[0], p3[1], fill=color, width=1) - canvas.create_line(p3[0], p3[1], p1[0], p1[1], fill=color, width=1) - - # ── DBC/CSV Preview ── - - def _browser_preview_dbc(self, path: str, entry: dict) -> None: - file_path = self._browser_resolve_path(path) - if file_path is None: - ttk.Label(self._browser_preview_frame, text="File not found on disk").pack(expand=True) - return - - try: - text = file_path.read_text(encoding="utf-8", errors="replace") - except Exception as exc: - ttk.Label(self._browser_preview_frame, text=f"Read error: {exc}").pack(expand=True) - return - - lines = text.splitlines() - if not lines: - ttk.Label(self._browser_preview_frame, text="Empty file").pack(expand=True) - return - - # Parse header comment if present - header_line = "" - data_start = 0 - if lines[0].startswith("#"): - header_line = lines[0] - data_start = 1 - - # Split CSV - rows: list[list[str]] = [] - for line in lines[data_start:]: - if line.strip(): - rows.append(line.split(",")) - self._browser_dbc_rows = rows - self._browser_dbc_shown = 0 - - if not rows: - ttk.Label(self._browser_preview_frame, text="No data rows").pack(expand=True) - return - - n_cols = len(rows[0]) - - # Try to find column names from dbc_layouts.json - col_names: list[str] = [] - dbc_name = file_path.stem # e.g. "Spell" - for exp in ("wotlk", "tbc", "classic", "turtle"): - layout_path = ROOT_DIR / "Data" / "expansions" / exp / "dbc_layouts.json" - if layout_path.exists(): - try: - layouts = json.loads(layout_path.read_text(encoding="utf-8")) - if dbc_name in layouts: - mapping = layouts[dbc_name] - names = [""] * n_cols - for name, idx in mapping.items(): - if isinstance(idx, int) and 0 <= idx < n_cols: - names[idx] = name - col_names = [n if n else f"col_{i}" for i, n in enumerate(names)] - break - except (OSError, ValueError): - pass - - if not col_names: - col_names = [f"col_{i}" for i in range(n_cols)] - - # Info - info = f"{len(rows)} rows, {n_cols} columns" - if header_line: - info += f" ({header_line[:80]})" - ttk.Label(self._browser_preview_frame, text=info).pack(pady=(4, 2)) - - # Table frame with scrollbars - table_frame = ttk.Frame(self._browser_preview_frame) - table_frame.pack(fill="both", expand=True) - - xscroll = ttk.Scrollbar(table_frame, orient="horizontal") - yscroll = ttk.Scrollbar(table_frame, orient="vertical") - - col_ids = [f"c{i}" for i in range(n_cols)] - tree = ttk.Treeview( - table_frame, columns=col_ids, show="headings", - xscrollcommand=xscroll.set, yscrollcommand=yscroll.set - ) - xscroll.config(command=tree.xview) - yscroll.config(command=tree.yview) - - for i, cid in enumerate(col_ids): - name = col_names[i] if i < len(col_names) else f"col_{i}" - tree.heading(cid, text=name) - tree.column(cid, width=80, minwidth=40) - - tree.pack(side="left", fill="both", expand=True) - yscroll.pack(side="right", fill="y") - xscroll.pack(side="bottom", fill="x") - - self._browser_dbc_tree = tree - self._browser_dbc_col_ids = col_ids - self._browser_load_more_dbc(500) - - if len(rows) > 500: - btn = ttk.Button(self._browser_preview_frame, text="Load more rows...", command=lambda: self._browser_load_more_dbc(500)) - btn.pack(pady=4) - self._browser_dbc_more_btn = btn - - def _browser_load_more_dbc(self, count: int) -> None: - rows = self._browser_dbc_rows - start = self._browser_dbc_shown - end = min(start + count, len(rows)) - - tree = self._browser_dbc_tree - col_ids = self._browser_dbc_col_ids - n_cols = len(col_ids) - - for i in range(start, end): - row = rows[i] - values = row[:n_cols] - while len(values) < n_cols: - values.append("") - tree.insert("", "end", values=values) - - self._browser_dbc_shown = end - if end >= len(rows) and hasattr(self, "_browser_dbc_more_btn"): - self._browser_dbc_more_btn.configure(state="disabled", text="All rows loaded") - - # ── ADT Preview ── - - def _browser_preview_adt(self, path: str, entry: dict) -> None: - file_path = self._browser_resolve_path(path) - if file_path is None: - ttk.Label(self._browser_preview_frame, text="File not found on disk").pack(expand=True) - return - - try: - data = file_path.read_bytes() - except Exception as exc: - ttk.Label(self._browser_preview_frame, text=f"Read error: {exc}").pack(expand=True) - return - - # Parse MCNK chunks for height data - heights: list[list[float]] = [] # 16x16 chunks, each with avg height - pos = 0 - while pos < len(data) - 8: - chunk_id = data[pos:pos + 4] - chunk_size = struct.unpack_from("= 120: - # Base height at offset 112 in MCNK body - base_z = struct.unpack_from(" None: - canvas.delete("all") - cw = canvas.winfo_width() - ch = canvas.winfo_height() - if cw < 10 or ch < 10: - return - cell = min(cw, ch) // grid_size - - for i, h_list in enumerate(heights): - row = i // grid_size - col = i % grid_size - t = (h_list[0] - min_h) / h_range - # Green-brown colormap - r = int(50 + t * 150) - g = int(80 + (1 - t) * 120 + t * 50) - b = int(30 + t * 30) - color = f"#{r:02x}{g:02x}{b:02x}" - x1 = col * cell - y1 = row * cell - canvas.create_rectangle(x1, y1, x1 + cell, y1 + cell, fill=color, outline="") - - canvas.bind("", draw_heightmap) - canvas.after(50, draw_heightmap) - - # ── Text Preview ── - - def _browser_preview_text(self, path: str, entry: dict) -> None: - file_path = self._browser_resolve_path(path) - if file_path is None: - ttk.Label(self._browser_preview_frame, text="File not found on disk").pack(expand=True) - return - - try: - text = file_path.read_text(encoding="utf-8", errors="replace") - except Exception as exc: - ttk.Label(self._browser_preview_frame, text=f"Read error: {exc}").pack(expand=True) - return - - st = ScrolledText(self._browser_preview_frame, wrap="none", font=("Courier", 10)) - st.pack(fill="both", expand=True) - st.insert("1.0", text[:500000]) # Cap at 500k chars - st.configure(state="disabled") - - # ── Audio Preview ── - - def _browser_preview_audio(self, path: str, entry: dict) -> None: - file_path = self._browser_resolve_path(path) - if file_path is None: - ttk.Label(self._browser_preview_frame, text="File not found on disk").pack(expand=True) - return - - ext = self._browser_file_ext(path) - info_lines = [f"Audio file: {file_path.name}", f"Size: {self._format_size(entry.get('s', 0))}"] - - try: - data = file_path.read_bytes() - if ext == ".wav" and len(data) >= 44: - if data[:4] == b"RIFF" and data[8:12] == b"WAVE": - channels = struct.unpack_from("= 4: - info_lines.append("Format: MP3") - if data[:3] == b"ID3": - info_lines.append("Has ID3 tag") - elif ext == ".ogg" and len(data) >= 4: - if data[:4] == b"OggS": - info_lines.append("Format: Ogg Vorbis") - except Exception: - pass - - text = "\n".join(info_lines) - lbl = ttk.Label(self._browser_preview_frame, text=text, justify="left", anchor="nw") - lbl.pack(padx=20, pady=(20, 8)) - - # Audio playback controls - btn_frame = ttk.Frame(self._browser_preview_frame) - btn_frame.pack(padx=20, pady=4) - - self._audio_status_var = tk.StringVar(value="Stopped") - status_lbl = ttk.Label(self._browser_preview_frame, textvariable=self._audio_status_var) - status_lbl.pack(padx=20, pady=(4, 0)) - - def _play_audio(): - self._browser_stop_audio() - try: - import multiprocessing - ctx = multiprocessing.get_context("spawn") - self._audio_proc = ctx.Process( - target=_audio_subprocess, args=(str(file_path),), daemon=True) - self._audio_proc.start() - self._audio_status_var.set("Playing...") - except Exception as exc: - self._audio_status_var.set(f"Error: {exc}") - - def _stop_audio(): - self._browser_stop_audio() - self._audio_status_var.set("Stopped") - - ttk.Button(btn_frame, text="Play", command=_play_audio).pack(side="left", padx=4) - ttk.Button(btn_frame, text="Stop", command=_stop_audio).pack(side="left", padx=4) - - def _browser_stop_audio(self): - proc = getattr(self, "_audio_proc", None) - if proc and proc.is_alive(): - proc.terminate() - proc.join(timeout=0.5) - if proc.is_alive(): - proc.kill() - proc.join(timeout=0.5) - self._audio_proc = None - - # ── Hex Dump Preview ── - - def _browser_preview_hex(self, path: str, entry: dict) -> None: - file_path = self._browser_resolve_path(path) - if file_path is None: - ttk.Label(self._browser_preview_frame, text="File not found on disk").pack(expand=True) - return - - try: - data = file_path.read_bytes()[:512] - except Exception as exc: - ttk.Label(self._browser_preview_frame, text=f"Read error: {exc}").pack(expand=True) - return - - lines: list[str] = [] - for i in range(0, len(data), 16): - chunk = data[i:i + 16] - hex_part = " ".join(f"{b:02x}" for b in chunk) - ascii_part = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk) - lines.append(f"{i:08x} {hex_part:<48s} {ascii_part}") - - ttk.Label(self._browser_preview_frame, text=f"Hex dump (first {len(data)} bytes):").pack(pady=(4, 2)) - st = ScrolledText(self._browser_preview_frame, wrap="none", font=("Courier", 10)) - st.pack(fill="both", expand=True) - st.insert("1.0", "\n".join(lines)) - st.configure(state="disabled") - - # ── End Asset Browser ────────────────────────────────────────────── - - def _build_state_tab(self) -> None: - actions = ttk.Frame(self.state_tab) - actions.pack(fill="x") - ttk.Button(actions, text="Refresh", command=self.refresh_state_view).pack(side="left") - - self.state_text = ScrolledText(self.state_tab, wrap="word", state="disabled") - self.state_text.pack(fill="both", expand=True, pady=(10, 0)) - - def _build_logs_tab(self) -> None: - actions = ttk.Frame(self.logs_tab) - actions.pack(fill="x") - ttk.Button(actions, text="Clear Logs", command=self.clear_logs).pack(side="left") - - self.log_text = ScrolledText(self.logs_tab, wrap="none", state="disabled") - self.log_text.pack(fill="both", expand=True, pady=(10, 0)) - - def _path_row(self, frame: ttk.Frame, row: int, label: str, variable: tk.StringVar, browse_cmd) -> None: - ttk.Label(frame, text=label).grid(row=row, column=0, sticky="w", pady=6) - ttk.Entry(frame, textvariable=variable).grid(row=row, column=1, columnspan=2, sticky="ew", pady=6) - ttk.Button(frame, text="Browse", command=browse_cmd).grid(row=row, column=3, sticky="e", pady=6) - - def _pick_wow_data_dir(self) -> None: - picked = filedialog.askdirectory(title="Select WoW Data directory") - if picked: - self.var_wow_data.set(picked) - - def _pick_output_dir(self) -> None: - picked = filedialog.askdirectory(title="Select output Data directory") - if picked: - self.var_output_data.set(picked) - - def _pick_extractor(self) -> None: - picked = filedialog.askopenfilename(title="Select extractor binary or script") - if picked: - self.var_extractor.set(picked) - - def _load_vars_from_state(self) -> None: - st = self.manager.state - self.var_wow_data.set(st.wow_data_dir) - self.var_output_data.set(st.output_data_dir) - self.var_extractor.set(st.extractor_path) - self.var_expansion.set(st.expansion) - self.var_locale.set(st.locale) - self.var_skip_dbc.set(st.skip_dbc) - self.var_dbc_csv.set(st.dbc_csv) - self.var_verify.set(st.verify) - self.var_verbose.set(st.verbose) - self.var_threads.set(st.threads) - - def save_config(self) -> None: - st = self.manager.state - st.wow_data_dir = self.var_wow_data.get().strip() - st.output_data_dir = self.var_output_data.get().strip() - st.extractor_path = self.var_extractor.get().strip() - st.expansion = self.var_expansion.get().strip() or "auto" - st.locale = self.var_locale.get().strip() or "auto" - st.skip_dbc = bool(self.var_skip_dbc.get()) - st.dbc_csv = bool(self.var_dbc_csv.get()) - st.verify = bool(self.var_verify.get()) - st.verbose = bool(self.var_verbose.get()) - st.threads = int(self.var_threads.get()) - self.manager.save_state() - self.status_var.set("Configuration saved") - - def _selected_pack(self) -> PackInfo | None: - sel = self.pack_list.curselection() - if not sel: - return None - idx = int(sel[0]) - if idx < 0 or idx >= len(self.manager.state.packs): - return None - return self.manager.state.packs[idx] - - def refresh_pack_list(self) -> None: - prev_sel = self.pack_list.curselection() - active = self.manager.state.active_pack_ids - self.pack_list.delete(0, tk.END) - for pack in self.manager.state.packs: - marker = "" - if pack.pack_id in active: - marker = f"[active #{active.index(pack.pack_id) + 1}] " - self.pack_list.insert(tk.END, f"{marker}{pack.name}") - # Restore previous selection if still valid. - for idx in prev_sel: - if 0 <= idx < self.pack_list.size(): - self.pack_list.selection_set(idx) - self.pack_list.see(idx) - self._refresh_pack_detail() - - def _refresh_pack_detail(self) -> None: - pack = self._selected_pack() - self.pack_detail.configure(state="normal") - self.pack_detail.delete("1.0", tk.END) - if pack is None: - self.pack_detail.insert(tk.END, "Select a texture pack to see details.") - self.pack_detail.configure(state="disabled") - return - - active = "yes" if pack.pack_id in self.manager.state.active_pack_ids else "no" - order = "-" - if pack.pack_id in self.manager.state.active_pack_ids: - order = str(self.manager.state.active_pack_ids.index(pack.pack_id) + 1) - lines = [ - f"Name: {pack.name}", - f"Active: {active}", - f"Order: {order}", - f"Files: {pack.file_count}", - f"Installed at: {pack.installed_at}", - f"Installed dir: {pack.installed_dir}", - f"Source: {pack.source}", - ] - self.pack_detail.insert(tk.END, "\n".join(lines)) - self.pack_detail.configure(state="disabled") - - def install_zip(self) -> None: - zip_path = filedialog.askopenfilename( - title="Choose texture pack ZIP", - filetypes=[("ZIP archives", "*.zip"), ("All files", "*.*")], - ) - if not zip_path: - return - try: - info = self.manager.install_pack_from_zip(Path(zip_path)) - except Exception as exc: # pylint: disable=broad-except - messagebox.showerror("Install failed", str(exc)) - return - - self.refresh_pack_list() - self.refresh_state_view() - self.status_var.set(f"Installed pack: {info.name}") - - def install_folder(self) -> None: - folder = filedialog.askdirectory(title="Choose texture pack folder") - if not folder: - return - try: - info = self.manager.install_pack_from_folder(Path(folder)) - except Exception as exc: # pylint: disable=broad-except - messagebox.showerror("Install failed", str(exc)) - return - - self.refresh_pack_list() - self.refresh_state_view() - self.status_var.set(f"Installed pack: {info.name}") - - def activate_selected_pack(self) -> None: - pack = self._selected_pack() - if pack is None: - return - self.manager.set_pack_active(pack.pack_id, True) - self.refresh_pack_list() - self.refresh_state_view() - self.status_var.set(f"Activated pack: {pack.name}") - - def deactivate_selected_pack(self) -> None: - pack = self._selected_pack() - if pack is None: - return - self.manager.set_pack_active(pack.pack_id, False) - self.refresh_pack_list() - self.refresh_state_view() - self.status_var.set(f"Deactivated pack: {pack.name}") - - def move_selected_pack(self, delta: int) -> None: - pack = self._selected_pack() - if pack is None: - return - self.manager.move_active_pack(pack.pack_id, delta) - self.refresh_pack_list() - self.refresh_state_view() - self.status_var.set(f"Reordered active pack: {pack.name}") - - def uninstall_selected_pack(self) -> None: - pack = self._selected_pack() - if pack is None: - return - ok = messagebox.askyesno("Confirm uninstall", f"Uninstall texture pack '{pack.name}'?") - if not ok: - return - self.manager.uninstall_pack(pack.pack_id) - self.refresh_pack_list() - self.refresh_state_view() - self.status_var.set(f"Uninstalled pack: {pack.name}") - - def rebuild_override(self) -> None: - self.status_var.set("Rebuilding override...") - self.log_queue.put(f"[{self.manager.now_str()}] Starting override rebuild...") - - def worker() -> None: - try: - report = self.manager.rebuild_override() - msg = f"Override rebuilt: {report['copied']} files copied, {report['replaced']} replaced" - self.log_queue.put(f"[{self.manager.now_str()}] Override rebuild complete: {report['copied']} copied, {report['replaced']} replaced") - self.root.after(0, lambda: self.status_var.set(msg)) - except Exception as exc: # pylint: disable=broad-except - self.log_queue.put(f"[{self.manager.now_str()}] Override rebuild failed: {exc}") - self.root.after(0, lambda: self.status_var.set("Override rebuild failed")) - finally: - self.root.after(0, self.refresh_state_view) - - threading.Thread(target=worker, daemon=True).start() - - def clear_logs(self) -> None: - self.log_text.configure(state="normal") - self.log_text.delete("1.0", tk.END) - self.log_text.configure(state="disabled") - - def _append_log(self, line: str) -> None: - self.log_text.configure(state="normal") - self.log_text.insert(tk.END, line + "\n") - self.log_text.see(tk.END) - self.log_text.configure(state="disabled") - - def _poll_logs(self) -> None: - while True: - try: - line = self.log_queue.get_nowait() - except queue.Empty: - break - self._append_log(line) - self.root.after(120, self._poll_logs) - - def cancel_extraction(self) -> None: - if self.proc_process is not None: - self.proc_process.terminate() - self.log_queue.put(f"[{self.manager.now_str()}] Extraction cancelled by user") - self.status_var.set("Extraction cancelled") - - def run_extraction(self) -> None: - if self.proc_running: - messagebox.showinfo("Extraction running", "An extraction is already running.") - return - - self.save_config() - - try: - cmd = self.manager.build_extract_command() - except ValueError as exc: - messagebox.showerror("Cannot run extraction", str(exc)) - return - - self.cancel_btn.configure(state="normal") - - def worker() -> None: - self.proc_running = True - started = self.manager.now_str() - self.log_queue.put(f"[{started}] Running: {' '.join(cmd)}") - self.root.after(0, lambda: self.status_var.set("Extraction running...")) - - ok = False - try: - process = subprocess.Popen( - cmd, - cwd=str(ROOT_DIR), - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - bufsize=1, - ) - self.proc_process = process - assert process.stdout is not None - for line in process.stdout: - self.log_queue.put(line.rstrip()) - rc = process.wait() - ok = rc == 0 - if not ok: - self.log_queue.put(f"Extractor exited with status {rc}") - except Exception as exc: # pylint: disable=broad-except - self.log_queue.put(f"Extraction error: {exc}") - finally: - self.proc_process = None - self.manager.state.last_extract_at = self.manager.now_str() - self.manager.state.last_extract_ok = ok - self.manager.state.last_extract_command = " ".join(cmd) - self.manager.save_state() - self.proc_running = False - self.root.after(0, self.refresh_state_view) - self.root.after(0, lambda: self.cancel_btn.configure(state="disabled")) - self.root.after( - 0, lambda: self.status_var.set("Extraction complete" if ok else "Extraction failed") - ) - - self.proc_thread = threading.Thread(target=worker, daemon=True) - self.proc_thread.start() - - def refresh_state_view(self) -> None: - summary = self.manager.summarize_state() - - active_names = [] - pack_map = {p.pack_id: p.name for p in self.manager.state.packs} - for pid in self.manager.state.active_pack_ids: - active_names.append(pack_map.get(pid, f"")) - - lines = [ - "WoWee Asset Pipeline State", - "", - f"Output directory: {summary['output_dir']}", - f"Output exists: {summary['output_exists']}", - f"manifest.json present: {summary['manifest_exists']}", - f"Manifest entries: {summary['manifest_entries']}", - "", - f"Override folder present: {summary['override_exists']}", - f"Override file count: {summary['override_files']}", - f"Last override build: {summary['last_override_build_at']}", - "", - f"Installed texture packs: {summary['packs_installed']}", - f"Active texture packs: {summary['packs_active']}", - "Active order:", - ] - if active_names: - for i, name in enumerate(active_names, start=1): - lines.append(f" {i}. {name}") - else: - lines.append(" (none)") - - lines.extend( - [ - "", - f"Last extraction time: {summary['last_extract_at']}", - f"Last extraction success: {summary['last_extract_ok']}", - f"Last extraction command: {self.manager.state.last_extract_command or '(none)'}", - "", - "Pipeline files:", - f" State file: {STATE_FILE}", - f" Packs dir: {PIPELINE_DIR / 'packs'}", - ] - ) - - self.state_text.configure(state="normal") - self.state_text.delete("1.0", tk.END) - self.state_text.insert(tk.END, "\n".join(lines)) - self.state_text.configure(state="disabled") - - -def main() -> None: - root = tk.Tk() - AssetPipelineGUI(root) - root.mainloop() - - -if __name__ == "__main__": - main() diff --git a/tools/m2_viewer.py b/tools/m2_viewer.py deleted file mode 100644 index 8648948f..00000000 --- a/tools/m2_viewer.py +++ /dev/null @@ -1,2170 +0,0 @@ -#!/usr/bin/env python3 -"""Self-contained Pygame/OpenGL M2 model viewer. - -Launched as a subprocess from the asset pipeline GUI to avoid Tkinter/Pygame conflicts. -Supports textured rendering, skeletal animation playback, and orbit camera controls. -""" - -from __future__ import annotations - -import hashlib -import math -import multiprocessing -import os -import shutil -import struct -import subprocess -import sys -import time -from dataclasses import dataclass, field -from pathlib import Path -from typing import Any - -import numpy as np - -# --------------------------------------------------------------------------- -# Matrix math utilities (pure NumPy, no external 3D lib needed) -# --------------------------------------------------------------------------- - -def perspective(fov_deg: float, aspect: float, near: float, far: float) -> np.ndarray: - f = 1.0 / math.tan(math.radians(fov_deg) / 2.0) - m = np.zeros((4, 4), dtype=np.float32) - m[0, 0] = f / aspect - m[1, 1] = f - m[2, 2] = (far + near) / (near - far) - m[2, 3] = (2.0 * far * near) / (near - far) - m[3, 2] = -1.0 - return m - - -def look_at(eye: np.ndarray, target: np.ndarray, up: np.ndarray) -> np.ndarray: - f = target - eye - f = f / np.linalg.norm(f) - s = np.cross(f, up) - s = s / (np.linalg.norm(s) + 1e-12) - u = np.cross(s, f) - m = np.eye(4, dtype=np.float32) - m[0, :3] = s - m[1, :3] = u - m[2, :3] = -f - m[0, 3] = -np.dot(s, eye) - m[1, 3] = -np.dot(u, eye) - m[2, 3] = np.dot(f, eye) - return m - - -def translate(tx: float, ty: float, tz: float) -> np.ndarray: - m = np.eye(4, dtype=np.float32) - m[0, 3] = tx - m[1, 3] = ty - m[2, 3] = tz - return m - - -def scale_mat4(sx: float, sy: float, sz: float) -> np.ndarray: - m = np.eye(4, dtype=np.float32) - m[0, 0] = sx - m[1, 1] = sy - m[2, 2] = sz - return m - - -def quat_to_mat4(q: np.ndarray) -> np.ndarray: - """Quaternion (x,y,z,w) to 4x4 rotation matrix.""" - x, y, z, w = q - m = np.eye(4, dtype=np.float32) - m[0, 0] = 1 - 2 * (y * y + z * z) - m[0, 1] = 2 * (x * y - z * w) - m[0, 2] = 2 * (x * z + y * w) - m[1, 0] = 2 * (x * y + z * w) - m[1, 1] = 1 - 2 * (x * x + z * z) - m[1, 2] = 2 * (y * z - x * w) - m[2, 0] = 2 * (x * z - y * w) - m[2, 1] = 2 * (y * z + x * w) - m[2, 2] = 1 - 2 * (x * x + y * y) - return m - - -def slerp(q0: np.ndarray, q1: np.ndarray, t: float) -> np.ndarray: - dot = np.dot(q0, q1) - if dot < 0: - q1 = -q1 - dot = -dot - dot = min(dot, 1.0) - if dot > 0.9995: - result = q0 + t * (q1 - q0) - return result / np.linalg.norm(result) - theta = math.acos(dot) - sin_theta = math.sin(theta) - a = math.sin((1 - t) * theta) / sin_theta - b = math.sin(t * theta) / sin_theta - result = a * q0 + b * q1 - return result / np.linalg.norm(result) - - -# --------------------------------------------------------------------------- -# M2 Parser -# --------------------------------------------------------------------------- - -@dataclass -class M2Track: - """Parsed animation track with per-sequence timestamps and keyframes.""" - interp: int = 0 - global_sequence: int = -1 - timestamps: list[np.ndarray] = field(default_factory=list) # list of uint32 arrays per seq - keys: list[np.ndarray] = field(default_factory=list) # list of value arrays per seq - - -@dataclass -class M2Bone: - key_bone_id: int = -1 - flags: int = 0 - parent: int = -1 - pivot: np.ndarray = field(default_factory=lambda: np.zeros(3, dtype=np.float32)) - translation: M2Track = field(default_factory=M2Track) - rotation: M2Track = field(default_factory=M2Track) - scale: M2Track = field(default_factory=M2Track) - - -@dataclass -class M2Submesh: - vertex_start: int = 0 - vertex_count: int = 0 - index_start: int = 0 - index_count: int = 0 - - -@dataclass -class M2Batch: - submesh_index: int = 0 - texture_combo_index: int = 0 - - -@dataclass -class M2Animation: - anim_id: int = 0 - variation: int = 0 - duration: int = 0 - speed: float = 0.0 - flags: int = 0 - - -class M2Parser: - """Parse M2 binary data for rendering: vertices, UVs, normals, bones, skins, textures.""" - - def __init__(self, data: bytes): - self.data = data - self.version = struct.unpack_from(" int: - """Return header offset for a given field, version-gated.""" - offsets_wotlk = { - "nGlobalSeq": 20, "ofsGlobalSeq": 24, - "nAnims": 28, "ofsAnims": 32, - "nBones": 44, "ofsBones": 48, - "nVerts": 60, "ofsVerts": 64, - "nTextures": 80, "ofsTextures": 84, - "nTextureLookup": 128, "ofsTextureLookup": 132, - "nBoneLookup": 120, "ofsBoneLookup": 124, - } - offsets_vanilla = { - "nGlobalSeq": 20, "ofsGlobalSeq": 24, - "nAnims": 28, "ofsAnims": 32, - "nBones": 52, "ofsBones": 56, - "nVerts": 68, "ofsVerts": 72, - "nTextures": 92, "ofsTextures": 96, - "nTextureLookup": 148, "ofsTextureLookup": 152, - "nBoneLookup": 140, "ofsBoneLookup": 144, - } - table = offsets_vanilla if self.is_vanilla else offsets_wotlk - return table[field_name] - - def _read_u32(self, offset: int) -> int: - return struct.unpack_from(" tuple[int, int]: - """Read count, offset for an M2Array header field.""" - n_off = self._hdr(f"n{field_name}") - o_off = self._hdr(f"ofs{field_name}") - n = self._read_u32(n_off) - o = self._read_u32(o_off) - return n, o - - def _parse(self): - self._parse_global_sequences() - self._parse_vertices() - self._parse_textures() - self._parse_texture_lookup() - self._parse_bone_lookup() - self._parse_animations() - self._parse_bones() - self._parse_skin() - - def _parse_global_sequences(self): - n, ofs = self._read_m2array("GlobalSeq") - if n == 0 or n > 10000 or ofs + n * 4 > len(self.data): - return - self.global_sequences = list(struct.unpack_from(f"<{n}I", self.data, ofs)) - - def _parse_vertices(self): - n, ofs = self._read_m2array("Verts") - if n == 0 or n > 500000 or ofs + n * 48 > len(self.data): - return - - # Parse all vertex fields using numpy for speed - positions = np.empty((n, 3), dtype=np.float32) - normals = np.empty((n, 3), dtype=np.float32) - uvs = np.empty((n, 2), dtype=np.float32) - bone_weights = np.empty((n, 4), dtype=np.uint8) - bone_indices = np.empty((n, 4), dtype=np.uint8) - - for i in range(n): - base = ofs + i * 48 - positions[i] = struct.unpack_from("<3f", self.data, base) - bone_weights[i] = struct.unpack_from("<4B", self.data, base + 12) - bone_indices[i] = struct.unpack_from("<4B", self.data, base + 16) - normals[i] = struct.unpack_from("<3f", self.data, base + 20) - uvs[i] = struct.unpack_from("<2f", self.data, base + 32) - - self.positions = positions - self.normals = normals - self.uvs = uvs - self.bone_weights = bone_weights - self.bone_indices = bone_indices - - def _parse_textures(self): - n, ofs = self._read_m2array("Textures") - if n == 0 or n > 1000 or ofs + n * 16 > len(self.data): - return - for i in range(n): - base = ofs + i * 16 - tex_type, tex_flags = struct.unpack_from(" 1 and name_ofs + name_len <= len(self.data): - raw = self.data[name_ofs:name_ofs + name_len] - filename = raw.split(b"\x00", 1)[0].decode("ascii", errors="replace") - self.textures.append({"type": tex_type, "flags": tex_flags, "filename": filename}) - - def _parse_texture_lookup(self): - n, ofs = self._read_m2array("TextureLookup") - if n == 0 or n > 10000 or ofs + n * 2 > len(self.data): - return - self.texture_lookup = list(struct.unpack_from(f"<{n}H", self.data, ofs)) - - def _parse_bone_lookup(self): - n, ofs = self._read_m2array("BoneLookup") - if n == 0 or n > 10000 or ofs + n * 2 > len(self.data): - return - self.bone_lookup = list(struct.unpack_from(f"<{n}H", self.data, ofs)) - - def _parse_animations(self): - n, ofs = self._read_m2array("Anims") - if n == 0 or n > 5000: - return - seq_size = 68 if self.is_vanilla else 64 - if ofs + n * seq_size > len(self.data): - return - for i in range(n): - base = ofs + i * seq_size - anim_id, variation = struct.unpack_from(" M2Track: - """Parse a WotLK M2TrackDisk (20 bytes) at given offset.""" - track = M2Track() - if base + 20 > len(self.data): - return track - interp, global_seq = struct.unpack_from(" 5000 or n_keys > 5000: - return track - - # Each entry in n_ts is a sub-array header: {count(4), offset(4)} - for s in range(n_ts): - ts_hdr = ofs_ts + s * 8 - if ts_hdr + 8 > len(self.data): - track.timestamps.append(np.empty(0, dtype=np.uint32)) - continue - sub_count, sub_ofs = struct.unpack_from(" 50000 or sub_ofs + sub_count * 4 > len(self.data): - track.timestamps.append(np.empty(0, dtype=np.uint32)) - continue - ts_data = np.frombuffer(self.data, dtype=np.uint32, count=sub_count, offset=sub_ofs) - track.timestamps.append(ts_data.copy()) - - for s in range(n_keys): - key_hdr = ofs_keys + s * 8 - if key_hdr + 8 > len(self.data): - track.keys.append(np.empty(0, dtype=np.float32)) - continue - sub_count, sub_ofs = struct.unpack_from(" 50000 or sub_ofs + sub_count * key_size > len(self.data): - track.keys.append(np.empty(0, dtype=np.float32)) - continue - if key_dtype == "compressed_quat": - raw = np.frombuffer(self.data, dtype=np.int16, count=sub_count * 4, offset=sub_ofs) - raw = raw.reshape(sub_count, 4).astype(np.float32) - # Decompress: (v < 0 ? v+32768 : v-32767) / 32767.0 - result = np.where(raw < 0, raw + 32768.0, raw - 32767.0) / 32767.0 - # Normalize each quaternion - norms = np.linalg.norm(result, axis=1, keepdims=True) - norms = np.maximum(norms, 1e-10) - result = result / norms - track.keys.append(result) - elif key_dtype == "vec3": - vals = np.frombuffer(self.data, dtype=np.float32, count=sub_count * 3, offset=sub_ofs) - track.keys.append(vals.reshape(sub_count, 3).copy()) - elif key_dtype == "float": - vals = np.frombuffer(self.data, dtype=np.float32, count=sub_count, offset=sub_ofs) - track.keys.append(vals.copy()) - - return track - - def _parse_track_vanilla(self, base: int, key_size: int, key_dtype: str) -> M2Track: - """Parse a Vanilla M2TrackDiskVanilla (28 bytes) — flat arrays with M2Range indexing.""" - track = M2Track() - if base + 28 > len(self.data): - return track - interp, global_seq = struct.unpack_from(" 500000 or n_keys > 500000: - return track - - # Read flat timestamp array - all_ts = np.empty(0, dtype=np.uint32) - if n_ts > 0 and ofs_ts + n_ts * 4 <= len(self.data): - all_ts = np.frombuffer(self.data, dtype=np.uint32, count=n_ts, offset=ofs_ts).copy() - - # Read flat key array - if key_dtype == "c4quat": - all_keys_flat = np.empty(0, dtype=np.float32) - if n_keys > 0 and ofs_keys + n_keys * 16 <= len(self.data): - all_keys_flat = np.frombuffer(self.data, dtype=np.float32, count=n_keys * 4, offset=ofs_keys) - all_keys_flat = all_keys_flat.reshape(n_keys, 4).copy() - elif key_dtype == "vec3": - all_keys_flat = np.empty((0, 3), dtype=np.float32) - if n_keys > 0 and ofs_keys + n_keys * 12 <= len(self.data): - all_keys_flat = np.frombuffer(self.data, dtype=np.float32, count=n_keys * 3, offset=ofs_keys) - all_keys_flat = all_keys_flat.reshape(n_keys, 3).copy() - else: - all_keys_flat = np.empty(0, dtype=np.float32) - if n_keys > 0 and ofs_keys + n_keys * key_size <= len(self.data): - all_keys_flat = np.frombuffer(self.data, dtype=np.float32, count=n_keys, offset=ofs_keys).copy() - - # Read ranges and split into per-sequence arrays - if n_ranges > 0 and n_ranges < 5000 and ofs_ranges + n_ranges * 8 <= len(self.data): - for r in range(n_ranges): - rng_start, rng_end = struct.unpack_from(" rng_start and rng_end <= len(all_ts): - track.timestamps.append(all_ts[rng_start:rng_end]) - if key_dtype in ("c4quat", "vec3") and rng_end <= len(all_keys_flat): - track.keys.append(all_keys_flat[rng_start:rng_end]) - elif rng_end <= len(all_keys_flat): - track.keys.append(all_keys_flat[rng_start:rng_end]) - else: - track.keys.append(np.empty(0, dtype=np.float32)) - else: - track.timestamps.append(np.empty(0, dtype=np.uint32)) - track.keys.append(np.empty(0, dtype=np.float32)) - else: - # No ranges — treat entire array as single sequence - if len(all_ts) > 0: - track.timestamps.append(all_ts) - track.keys.append(all_keys_flat if len(all_keys_flat) > 0 else np.empty(0, dtype=np.float32)) - - return track - - def _parse_bones(self): - n, ofs = self._read_m2array("Bones") - if n == 0 or n > 5000: - return - - if self.is_vanilla: - bone_size = 108 # No boneNameCRC, 28-byte tracks: 4+4+2+2+3×28+12=108 - for i in range(n): - base = ofs + i * bone_size - if base + bone_size > len(self.data): - break - bone = M2Bone() - bone.key_bone_id = struct.unpack_from(" len(self.data): - break - bone = M2Bone() - bone.key_bone_id = struct.unpack_from(" 500000: - return - if n_tris == 0 or n_tris > 500000: - return - - # Vertex lookup - if ofs_indices + n_indices * 2 <= len(skin_data): - self.vertex_lookup = np.frombuffer(skin_data, dtype=np.uint16, - count=n_indices, offset=ofs_indices).copy() - - # Raw triangle indices (indices into vertex_lookup) - if ofs_tris + n_tris * 2 <= len(skin_data): - self.triangles = np.frombuffer(skin_data, dtype=np.uint16, - count=n_tris, offset=ofs_tris).copy() - - # Resolve two-level indirection: triangle idx -> vertex_lookup -> global vertex idx - # This matches the C++ approach: model.indices stores global vertex indices - if len(self.triangles) > 0 and len(self.vertex_lookup) > 0: - n_verts = len(self.positions) if len(self.positions) > 0 else 65536 - resolved = np.zeros(len(self.triangles), dtype=np.uint16) - for i, tri_idx in enumerate(self.triangles): - if tri_idx < len(self.vertex_lookup): - global_idx = self.vertex_lookup[tri_idx] - resolved[i] = global_idx if global_idx < n_verts else 0 - else: - resolved[i] = 0 - self.resolved_indices = resolved - - # Submeshes (WotLK: 48 bytes, Vanilla: 32 bytes) - submesh_size = 32 if self.is_vanilla else 48 - if n_submeshes > 0 and n_submeshes < 10000 and ofs_submeshes + n_submeshes * submesh_size <= len(skin_data): - for i in range(n_submeshes): - base = ofs_submeshes + i * submesh_size - sm = M2Submesh() - # WotLK M2SkinSection: +0=skinSectionId(2), +2=Level(2), - # +4=vertexStart(2), +6=vertexCount(2), +8=indexStart(2), +10=indexCount(2) - sm.vertex_start = struct.unpack_from(" 0 and n_batches < 10000 and ofs_batches + n_batches * 24 <= len(skin_data): - for i in range(n_batches): - base = ofs_batches + i * 24 - batch = M2Batch() - # M2Batch: flags(1) + priority(1) + shaderId(2) + skinSectionIndex(2) - # + geosetIndex(2) + colorIndex(2) + materialIndex(2) + materialLayer(2) - # + textureCount(2) + textureComboIndex(2) + ... - batch.submesh_index = struct.unpack_from(" 0: - self.time_ms += dt * 1000.0 * self.speed - self.time_ms = self.time_ms % anim.duration - - seq_idx = self.current_seq - t = self.time_ms - - for i, bone in enumerate(self.parser.bones): - local = self._eval_bone(bone, seq_idx, t) - if bone.parent >= 0 and bone.parent < n_bones: - self.bone_matrices[i] = self.bone_matrices[bone.parent] @ local - else: - self.bone_matrices[i] = local - - def _eval_bone(self, bone: M2Bone, seq_idx: int, time_ms: float) -> np.ndarray: - """Compute local bone transform for one bone at given time.""" - trans = self._interp_vec3(bone.translation, seq_idx, time_ms, np.zeros(3, dtype=np.float32)) - rot = self._interp_quat(bone.rotation, seq_idx, time_ms) - scl = self._interp_vec3(bone.scale, seq_idx, time_ms, np.ones(3, dtype=np.float32)) - - # local = T(pivot) * T(trans) * R(rot) * S(scl) * T(-pivot) - p = bone.pivot - m = translate(p[0], p[1], p[2]) - m = m @ translate(trans[0], trans[1], trans[2]) - m = m @ quat_to_mat4(rot) - m = m @ scale_mat4(scl[0], scl[1], scl[2]) - m = m @ translate(-p[0], -p[1], -p[2]) - return m - - def _get_time_and_seq(self, track: M2Track, seq_idx: int, time_ms: float) -> tuple[int, float]: - """Resolve sequence index and time, handling global sequences.""" - if track.global_sequence >= 0 and track.global_sequence < len(self.parser.global_sequences): - gs_dur = self.parser.global_sequences[track.global_sequence] - actual_seq = 0 - actual_time = time_ms % gs_dur if gs_dur > 0 else 0 - else: - actual_seq = seq_idx - actual_time = time_ms - return actual_seq, actual_time - - def _interp_vec3(self, track: M2Track, seq_idx: int, time_ms: float, - default: np.ndarray) -> np.ndarray: - si, t = self._get_time_and_seq(track, seq_idx, time_ms) - if si >= len(track.timestamps) or si >= len(track.keys): - return default - ts = track.timestamps[si] - keys = track.keys[si] - if len(ts) == 0 or len(keys) == 0: - return default - if len(keys.shape) == 1: - return default - - if t <= ts[0]: - return keys[0] - if t >= ts[-1]: - return keys[-1] - - # Binary search - idx = np.searchsorted(ts, t, side='right') - 1 - idx = max(0, min(idx, len(ts) - 2)) - t0, t1 = float(ts[idx]), float(ts[idx + 1]) - frac = (t - t0) / (t1 - t0) if t1 != t0 else 0.0 - - if track.interp == 0: - return keys[idx] - return keys[idx] * (1.0 - frac) + keys[idx + 1] * frac - - def _interp_quat(self, track: M2Track, seq_idx: int, time_ms: float) -> np.ndarray: - default = np.array([0, 0, 0, 1], dtype=np.float32) - si, t = self._get_time_and_seq(track, seq_idx, time_ms) - if si >= len(track.timestamps) or si >= len(track.keys): - return default - ts = track.timestamps[si] - keys = track.keys[si] - if len(ts) == 0 or len(keys) == 0: - return default - if len(keys.shape) == 1: - return default - - if t <= ts[0]: - return keys[0] - if t >= ts[-1]: - return keys[-1] - - idx = np.searchsorted(ts, t, side='right') - 1 - idx = max(0, min(idx, len(ts) - 2)) - t0, t1 = float(ts[idx]), float(ts[idx + 1]) - frac = (t - t0) / (t1 - t0) if t1 != t0 else 0.0 - - if track.interp == 0: - return keys[idx] - return slerp(keys[idx], keys[idx + 1], frac) - - def skin_vertices(self, positions: np.ndarray, bone_weights: np.ndarray, - bone_indices: np.ndarray, bone_lookup: list[int]) -> np.ndarray: - """CPU vertex skinning (NumPy vectorized). Returns transformed positions.""" - if len(self.bone_matrices) == 0 or len(bone_lookup) == 0: - return positions.copy() - - n = len(positions) - n_bones = len(self.bone_matrices) - n_lookup = len(bone_lookup) - lookup_arr = np.array(bone_lookup, dtype=np.int32) - - # Build homogeneous positions (n, 4) - pos4 = np.ones((n, 4), dtype=np.float32) - pos4[:, :3] = positions - - # Weights normalized to float (n, 4) - weights = bone_weights.astype(np.float32) / 255.0 - - result = np.zeros((n, 4), dtype=np.float32) - - for j in range(4): - w = weights[:, j] # (n,) - mask = w > 0.001 - if not np.any(mask): - continue - - bi = bone_indices[mask, j].astype(np.int32) - # Clamp bone lookup indices - valid = bi < n_lookup - bi = np.where(valid, bi, 0) - global_bones = lookup_arr[bi] - global_bones = np.where(valid, global_bones, 0) - valid2 = valid & (global_bones < n_bones) - global_bones = np.where(valid2, global_bones, 0) - - # Gather bone matrices for these vertices: (count, 4, 4) - mats = self.bone_matrices[global_bones] - # Transform: (count, 4, 4) @ (count, 4, 1) -> (count, 4, 1) - transformed = np.einsum('nij,nj->ni', mats, pos4[mask]) - # Apply weight and validity - weighted = transformed * w[mask, np.newaxis] - weighted[~valid2] = 0 - result[mask] += weighted - - # De-homogenize - w_col = result[:, 3:4] - w_col = np.where(np.abs(w_col) > 0.001, w_col, 1.0) - return (result[:, :3] / w_col).astype(np.float32) - - -# --------------------------------------------------------------------------- -# Orbit Camera -# --------------------------------------------------------------------------- - -class OrbitCamera: - def __init__(self): - self.azimuth: float = 0.0 - self.elevation: float = 0.3 - self.distance: float = 5.0 - self.target: np.ndarray = np.zeros(3, dtype=np.float32) - self.pan_x: float = 0.0 - self.pan_y: float = 0.0 - - def get_view_matrix(self) -> np.ndarray: - eye = self._eye_pos() - up = np.array([0, 0, 1], dtype=np.float32) - target = self.target + np.array([self.pan_x, self.pan_y, 0], dtype=np.float32) - return look_at(eye, target, up) - - def _eye_pos(self) -> np.ndarray: - x = self.distance * math.cos(self.elevation) * math.cos(self.azimuth) - y = self.distance * math.cos(self.elevation) * math.sin(self.azimuth) - z = self.distance * math.sin(self.elevation) - target = self.target + np.array([self.pan_x, self.pan_y, 0], dtype=np.float32) - return target + np.array([x, y, z], dtype=np.float32) - - def orbit(self, dx: float, dy: float): - self.azimuth += dx * 0.01 - self.elevation = max(-math.pi / 2 + 0.01, min(math.pi / 2 - 0.01, - self.elevation + dy * 0.01)) - - def zoom(self, delta: float): - self.distance = max(0.5, self.distance * (1.0 - delta * 0.1)) - - def pan(self, dx: float, dy: float): - self.pan_x += dx * self.distance * 0.002 - self.pan_y += dy * self.distance * 0.002 - - -# --------------------------------------------------------------------------- -# M2 Renderer (OpenGL 3.3) -# --------------------------------------------------------------------------- - -VERT_SHADER = """ -#version 330 core -layout(location=0) in vec3 aPos; -layout(location=1) in vec3 aNormal; -layout(location=2) in vec2 aUV; - -uniform mat4 uMVP; -uniform mat4 uModel; - -out vec3 vNormal; -out vec2 vUV; -out vec3 vWorldPos; - -void main() { - gl_Position = uMVP * vec4(aPos, 1.0); - vNormal = mat3(uModel) * aNormal; - vUV = aUV; - vWorldPos = (uModel * vec4(aPos, 1.0)).xyz; -} -""" - -FRAG_SHADER = """ -#version 330 core -in vec3 vNormal; -in vec2 vUV; -in vec3 vWorldPos; - -uniform sampler2D uTexture; -uniform int uHasTexture; -uniform vec3 uLightDir; - -out vec4 FragColor; - -void main() { - vec3 N = normalize(vNormal); - float NdotL = abs(dot(N, uLightDir)); - float ambient = 0.35; - float diffuse = 0.65 * NdotL; - float light = ambient + diffuse; - - vec4 texColor; - if (uHasTexture == 1) { - texColor = texture(uTexture, vUV); - if (texColor.a < 0.1) discard; - } else { - texColor = vec4(0.6, 0.6, 0.65, 1.0); - } - - FragColor = vec4(texColor.rgb * light, texColor.a); -} -""" - -WIRE_VERT = """ -#version 330 core -layout(location=0) in vec3 aPos; -uniform mat4 uMVP; -void main() { - gl_Position = uMVP * vec4(aPos, 1.0); -} -""" - -WIRE_FRAG = """ -#version 330 core -out vec4 FragColor; -void main() { - FragColor = vec4(0.0, 0.8, 1.0, 0.4); -} -""" - - -class M2Renderer: - """OpenGL 3.3 renderer for M2 models.""" - - def __init__(self, parser: M2Parser, blp_paths: dict[str, str], blp_convert: str): - self.parser = parser - self.blp_paths = blp_paths # texture filename -> filesystem path - self.blp_convert_path = blp_convert - - self.vao = 0 - self.vbo = 0 - self.ebo = 0 - self.wire_vao = 0 - self.wire_vbo = 0 - self.wire_ebo = 0 - self.shader = 0 - self.wire_shader = 0 - self.gl_textures: dict[int, int] = {} # batch index -> GL texture ID - self.batch_texture_map: dict[int, int] = {} # batch idx -> texture array index - - self.show_wireframe = False - self.n_indices = 0 - self.n_wire_indices = 0 - self.n_verts = 0 - - def init_gl(self): - import OpenGL.GL as gl - - self._gl = gl - - # Build shaders - self.shader = self._compile_program(VERT_SHADER, FRAG_SHADER) - self.wire_shader = self._compile_program(WIRE_VERT, WIRE_FRAG) - - p = self.parser - n_verts = len(p.positions) - if n_verts == 0: - return - self.n_verts = n_verts - - # VBO: ALL model vertices, interleaved pos(12) + normal(12) + uv(8) = 32 bytes - vbo_data = np.zeros((n_verts, 8), dtype=np.float32) - vbo_data[:, 0:3] = p.positions - vbo_data[:, 3:6] = p.normals if len(p.normals) == n_verts else np.zeros((n_verts, 3), dtype=np.float32) - vbo_data[:, 6:8] = p.uvs if len(p.uvs) == n_verts else np.zeros((n_verts, 2), dtype=np.float32) - - # EBO: resolved global vertex indices (after two-level skin indirection) - if len(p.resolved_indices) > 0: - idx_data = p.resolved_indices.astype(np.uint16) - elif len(p.triangles) > 0: - idx_data = p.triangles.astype(np.uint16) - else: - idx_data = np.empty(0, dtype=np.uint16) - - # Create main VAO/VBO/EBO - self.vao = gl.glGenVertexArrays(1) - self.vbo = gl.glGenBuffers(1) - self.ebo = gl.glGenBuffers(1) - - gl.glBindVertexArray(self.vao) - - gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self.vbo) - gl.glBufferData(gl.GL_ARRAY_BUFFER, vbo_data.nbytes, vbo_data, gl.GL_DYNAMIC_DRAW) - - if len(idx_data) > 0: - self.n_indices = len(idx_data) - gl.glBindBuffer(gl.GL_ELEMENT_ARRAY_BUFFER, self.ebo) - gl.glBufferData(gl.GL_ELEMENT_ARRAY_BUFFER, idx_data.nbytes, idx_data, gl.GL_STATIC_DRAW) - - stride = 32 - gl.glVertexAttribPointer(0, 3, gl.GL_FLOAT, gl.GL_FALSE, stride, gl.ctypes.c_void_p(0)) - gl.glEnableVertexAttribArray(0) - gl.glVertexAttribPointer(1, 3, gl.GL_FLOAT, gl.GL_FALSE, stride, gl.ctypes.c_void_p(12)) - gl.glEnableVertexAttribArray(1) - gl.glVertexAttribPointer(2, 2, gl.GL_FLOAT, gl.GL_FALSE, stride, gl.ctypes.c_void_p(24)) - gl.glEnableVertexAttribArray(2) - - gl.glBindVertexArray(0) - - # Wireframe VAO (positions only, same indices) - self.wire_vao = gl.glGenVertexArrays(1) - self.wire_vbo = gl.glGenBuffers(1) - self.wire_ebo = gl.glGenBuffers(1) - - gl.glBindVertexArray(self.wire_vao) - gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self.wire_vbo) - gl.glBufferData(gl.GL_ARRAY_BUFFER, p.positions.nbytes, p.positions, gl.GL_DYNAMIC_DRAW) - - if len(idx_data) > 0: - self.n_wire_indices = len(idx_data) - gl.glBindBuffer(gl.GL_ELEMENT_ARRAY_BUFFER, self.wire_ebo) - gl.glBufferData(gl.GL_ELEMENT_ARRAY_BUFFER, idx_data.nbytes, idx_data, gl.GL_STATIC_DRAW) - - gl.glVertexAttribPointer(0, 3, gl.GL_FLOAT, gl.GL_FALSE, 12, gl.ctypes.c_void_p(0)) - gl.glEnableVertexAttribArray(0) - gl.glBindVertexArray(0) - - # Load textures - self._load_textures() - - # Map batches to textures - self._map_batch_textures() - - def _compile_program(self, vert_src: str, frag_src: str) -> int: - gl = self._gl - vs = gl.glCreateShader(gl.GL_VERTEX_SHADER) - gl.glShaderSource(vs, vert_src) - gl.glCompileShader(vs) - if gl.glGetShaderiv(vs, gl.GL_COMPILE_STATUS) != gl.GL_TRUE: - log = gl.glGetShaderInfoLog(vs).decode() - print(f"Vertex shader error: {log}") - - fs = gl.glCreateShader(gl.GL_FRAGMENT_SHADER) - gl.glShaderSource(fs, frag_src) - gl.glCompileShader(fs) - if gl.glGetShaderiv(fs, gl.GL_COMPILE_STATUS) != gl.GL_TRUE: - log = gl.glGetShaderInfoLog(fs).decode() - print(f"Fragment shader error: {log}") - - prog = gl.glCreateProgram() - gl.glAttachShader(prog, vs) - gl.glAttachShader(prog, fs) - gl.glLinkProgram(prog) - if gl.glGetProgramiv(prog, gl.GL_LINK_STATUS) != gl.GL_TRUE: - log = gl.glGetProgramInfoLog(prog).decode() - print(f"Program link error: {log}") - - gl.glDeleteShader(vs) - gl.glDeleteShader(fs) - return prog - - def _load_textures(self): - """Load BLP textures via blp_convert → PIL → GL texture.""" - gl = self._gl - try: - from PIL import Image - except ImportError: - print("PIL not available, textures disabled") - return - - cache_dir = Path(os.path.expanduser("~/.cache/m2_viewer")) - cache_dir.mkdir(parents=True, exist_ok=True) - - for i, tex in enumerate(self.parser.textures): - if tex["type"] != 0 or not tex["filename"]: - continue - - fname = tex["filename"].replace("\\", "/") - blp_path = self.blp_paths.get(fname) or self.blp_paths.get(fname.lower()) - if not blp_path: - continue - - # Convert BLP to PNG - cache_key = hashlib.md5(blp_path.encode()).hexdigest() - cached_png = cache_dir / f"{cache_key}.png" - - if not cached_png.exists(): - try: - # Copy BLP to temp dir for conversion (avoids read-only source dirs) - import tempfile - with tempfile.TemporaryDirectory() as tmpdir: - tmp_blp = Path(tmpdir) / Path(blp_path).name - shutil.copy2(blp_path, str(tmp_blp)) - result = subprocess.run( - [self.blp_convert_path, "--to-png", str(tmp_blp)], - capture_output=True, text=True, timeout=10, - ) - output_png = tmp_blp.with_suffix(".png") - if result.returncode != 0 or not output_png.exists(): - print(f"blp_convert failed for {fname}: {result.stderr}") - continue - shutil.move(str(output_png), str(cached_png)) - except Exception as e: - print(f"BLP convert failed for {fname}: {e}") - continue - - try: - img = Image.open(cached_png) - img = img.transpose(Image.FLIP_TOP_BOTTOM) - if img.mode != "RGBA": - img = img.convert("RGBA") - img_data = np.array(img, dtype=np.uint8) - - tex_id = gl.glGenTextures(1) - gl.glBindTexture(gl.GL_TEXTURE_2D, tex_id) - gl.glTexImage2D(gl.GL_TEXTURE_2D, 0, gl.GL_RGBA, img.width, img.height, - 0, gl.GL_RGBA, gl.GL_UNSIGNED_BYTE, img_data) - gl.glGenerateMipmap(gl.GL_TEXTURE_2D) - gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MIN_FILTER, gl.GL_LINEAR_MIPMAP_LINEAR) - gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MAG_FILTER, gl.GL_LINEAR) - gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_WRAP_S, gl.GL_REPEAT) - gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_WRAP_T, gl.GL_REPEAT) - - self.gl_textures[i] = tex_id - except Exception as e: - print(f"Texture load failed for {fname}: {e}") - - def _map_batch_textures(self): - """Resolve batch → texture combo → texture lookup → GL texture mapping.""" - for bi, batch in enumerate(self.parser.batches): - tci = batch.texture_combo_index - if tci < len(self.parser.texture_lookup): - tex_idx = self.parser.texture_lookup[tci] - if tex_idx in self.gl_textures: - self.batch_texture_map[bi] = self.gl_textures[tex_idx] - - def update_vertices(self, skinned_positions: np.ndarray): - """Upload new skinned vertex positions to VBO.""" - gl = self._gl - if self.vao == 0 or len(skinned_positions) == 0: - return - - p = self.parser - n_verts = len(skinned_positions) - - # Rebuild interleaved VBO data with new positions - vbo_data = np.zeros((n_verts, 8), dtype=np.float32) - vbo_data[:, 0:3] = skinned_positions - vbo_data[:, 3:6] = p.normals if len(p.normals) == n_verts else np.zeros((n_verts, 3), dtype=np.float32) - vbo_data[:, 6:8] = p.uvs if len(p.uvs) == n_verts else np.zeros((n_verts, 2), dtype=np.float32) - - gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self.vbo) - gl.glBufferSubData(gl.GL_ARRAY_BUFFER, 0, vbo_data.nbytes, vbo_data) - - # Update wireframe VBO too - gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self.wire_vbo) - gl.glBufferSubData(gl.GL_ARRAY_BUFFER, 0, skinned_positions.nbytes, skinned_positions) - - def render(self, mvp: np.ndarray, model: np.ndarray): - gl = self._gl - if self.vao == 0 or self.n_indices == 0: - return - - gl.glEnable(gl.GL_DEPTH_TEST) - gl.glDisable(gl.GL_CULL_FACE) - - gl.glUseProgram(self.shader) - - mvp_loc = gl.glGetUniformLocation(self.shader, "uMVP") - model_loc = gl.glGetUniformLocation(self.shader, "uModel") - tex_loc = gl.glGetUniformLocation(self.shader, "uTexture") - has_tex_loc = gl.glGetUniformLocation(self.shader, "uHasTexture") - light_loc = gl.glGetUniformLocation(self.shader, "uLightDir") - - gl.glUniformMatrix4fv(mvp_loc, 1, gl.GL_TRUE, mvp) - gl.glUniformMatrix4fv(model_loc, 1, gl.GL_TRUE, model) - gl.glUniform1i(tex_loc, 0) - - # Light direction (normalized) - light_dir = np.array([0.5, 0.3, 0.8], dtype=np.float32) - light_dir /= np.linalg.norm(light_dir) - gl.glUniform3fv(light_loc, 1, light_dir) - - gl.glBindVertexArray(self.vao) - - if self.parser.batches and self.parser.submeshes: - # Per-batch rendering - for bi, batch in enumerate(self.parser.batches): - si = batch.submesh_index - if si >= len(self.parser.submeshes): - continue - sm = self.parser.submeshes[si] - - # Bind texture if available - gl_tex = self.batch_texture_map.get(bi) - if gl_tex: - gl.glActiveTexture(gl.GL_TEXTURE0) - gl.glBindTexture(gl.GL_TEXTURE_2D, gl_tex) - gl.glUniform1i(has_tex_loc, 1) - else: - gl.glUniform1i(has_tex_loc, 0) - - # Draw this submesh's triangles - idx_start = sm.index_start - idx_count = sm.index_count - if idx_start + idx_count <= self.n_indices: - gl.glDrawElements(gl.GL_TRIANGLES, idx_count, gl.GL_UNSIGNED_SHORT, - gl.ctypes.c_void_p(idx_start * 2)) - else: - # Fallback: draw all triangles with no texture - gl.glUniform1i(has_tex_loc, 0) - gl.glDrawElements(gl.GL_TRIANGLES, self.n_indices, gl.GL_UNSIGNED_SHORT, - gl.ctypes.c_void_p(0)) - - gl.glBindVertexArray(0) - - # Wireframe overlay - if self.show_wireframe and self.wire_vao and self.n_wire_indices > 0: - gl.glUseProgram(self.wire_shader) - wire_mvp_loc = gl.glGetUniformLocation(self.wire_shader, "uMVP") - gl.glUniformMatrix4fv(wire_mvp_loc, 1, gl.GL_TRUE, mvp) - - gl.glEnable(gl.GL_BLEND) - gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA) - gl.glPolygonMode(gl.GL_FRONT_AND_BACK, gl.GL_LINE) - gl.glDisable(gl.GL_CULL_FACE) - - gl.glBindVertexArray(self.wire_vao) - gl.glDrawElements(gl.GL_TRIANGLES, self.n_wire_indices, gl.GL_UNSIGNED_SHORT, - gl.ctypes.c_void_p(0)) - gl.glBindVertexArray(0) - - gl.glPolygonMode(gl.GL_FRONT_AND_BACK, gl.GL_FILL) - gl.glDisable(gl.GL_BLEND) - - -# --------------------------------------------------------------------------- -# M2 Viewer Window (Pygame main loop) -# --------------------------------------------------------------------------- - -class M2ViewerWindow: - """Pygame + OpenGL M2 model viewer window.""" - - def __init__(self, m2_path: str, blp_paths: dict[str, str], blp_convert: str): - self.m2_path = m2_path - self.blp_paths = blp_paths - self.blp_convert = blp_convert - self.parser: M2Parser | None = None - self.anim_system: AnimationSystem | None = None - self.renderer: M2Renderer | None = None - self.camera = OrbitCamera() - self.width = 1024 - self.height = 768 - self.running = True - self.fps_clock = None - self.font = None - - self._dragging = False - self._panning = False - self._last_mouse = (0, 0) - - def run(self): - """Main entry point — parse, init GL, run loop.""" - import pygame - from pygame.locals import ( - DOUBLEBUF, OPENGL, RESIZABLE, QUIT, KEYDOWN, MOUSEBUTTONDOWN, - MOUSEBUTTONUP, MOUSEMOTION, VIDEORESIZE, - K_SPACE, K_LEFT, K_RIGHT, K_PLUS, K_MINUS, K_EQUALS, K_r, K_w, - K_ESCAPE, - ) - - # Parse M2 - data = Path(self.m2_path).read_bytes() - if len(data) < 8 or data[:4] != b"MD20": - print(f"Not a valid M2 file: {self.m2_path}") - return - - self.parser = M2Parser(data) - - # Load skin file - m2_p = Path(self.m2_path) - skin_path = m2_p.with_name(m2_p.stem + "00.skin") - if skin_path.exists(): - self.parser.parse_skin_data(skin_path.read_bytes()) - elif self.parser.is_vanilla: - # Embedded skin at ofsViews - if self.parser.version <= 256: - # Read ofsViews from vanilla header - if len(data) > 108: - ofs_views = struct.unpack_from(" 0 and ofs_views < len(data): - self.parser.parse_skin_data(data[ofs_views:]) - - # Init animation - self.anim_system = AnimationSystem(self.parser) - if self.parser.animations: - self.anim_system.set_sequence(0) - - # Auto-fit camera - if len(self.parser.positions) > 0: - mins = self.parser.positions.min(axis=0) - maxs = self.parser.positions.max(axis=0) - center = (mins + maxs) / 2.0 - extent = np.linalg.norm(maxs - mins) - self.camera.target = center - self.camera.distance = max(extent * 1.2, 1.0) - - # Init Pygame + OpenGL - pygame.init() - pygame.display.set_caption(f"M2 Viewer — {Path(self.m2_path).name}") - pygame.display.gl_set_attribute(pygame.GL_CONTEXT_MAJOR_VERSION, 3) - pygame.display.gl_set_attribute(pygame.GL_CONTEXT_MINOR_VERSION, 3) - pygame.display.gl_set_attribute(pygame.GL_CONTEXT_PROFILE_MASK, - pygame.GL_CONTEXT_PROFILE_CORE) - pygame.display.set_mode((self.width, self.height), DOUBLEBUF | OPENGL | RESIZABLE) - - self.fps_clock = pygame.time.Clock() - self.font = pygame.font.SysFont("monospace", 14) - - import OpenGL.GL as gl - - # Init renderer - self.renderer = M2Renderer(self.parser, self.blp_paths, self.blp_convert) - self.renderer.init_gl() - - gl.glClearColor(0.12, 0.12, 0.18, 1.0) - gl.glEnable(gl.GL_DEPTH_TEST) - - # Main loop - while self.running: - dt = self.fps_clock.tick(60) / 1000.0 - - for event in pygame.event.get(): - if event.type == QUIT: - self.running = False - elif event.type == VIDEORESIZE: - self.width, self.height = event.w, event.h - pygame.display.set_mode((self.width, self.height), - DOUBLEBUF | OPENGL | RESIZABLE) - elif event.type == KEYDOWN: - self._handle_key(event.key) - elif event.type == MOUSEBUTTONDOWN: - if event.button == 1: - self._dragging = True - self._last_mouse = event.pos - elif event.button == 3: - self._panning = True - self._last_mouse = event.pos - elif event.button == 4: - self.camera.zoom(1) - elif event.button == 5: - self.camera.zoom(-1) - elif event.type == MOUSEBUTTONUP: - if event.button == 1: - self._dragging = False - elif event.button == 3: - self._panning = False - elif event.type == MOUSEMOTION: - if self._dragging: - dx = event.pos[0] - self._last_mouse[0] - dy = event.pos[1] - self._last_mouse[1] - self.camera.orbit(dx, dy) - self._last_mouse = event.pos - elif self._panning: - dx = event.pos[0] - self._last_mouse[0] - dy = event.pos[1] - self._last_mouse[1] - self.camera.pan(-dx, dy) - self._last_mouse = event.pos - - # Update animation + skinning - if self.anim_system: - self.anim_system.update(dt) - if (len(self.anim_system.bone_matrices) > 0 - and len(self.parser.bone_lookup) > 0): - skinned = self.anim_system.skin_vertices( - self.parser.positions, - self.parser.bone_weights, - self.parser.bone_indices, - self.parser.bone_lookup, - ) - self.renderer.update_vertices(skinned) - - # Render - gl.glViewport(0, 0, self.width, self.height) - gl.glClear(gl.GL_COLOR_BUFFER_BIT | gl.GL_DEPTH_BUFFER_BIT) - - aspect = self.width / max(self.height, 1) - proj = perspective(45.0, aspect, 0.01, 5000.0) - view = self.camera.get_view_matrix() - model = np.eye(4, dtype=np.float32) - mvp = proj @ view @ model - - self.renderer.render(mvp, model) - - # HUD overlay - self._draw_hud(pygame, gl) - - pygame.display.flip() - - pygame.quit() - - def _handle_key(self, key): - import pygame - if key == pygame.K_ESCAPE: - self.running = False - elif key == pygame.K_SPACE: - if self.anim_system: - self.anim_system.playing = not self.anim_system.playing - elif key == pygame.K_RIGHT: - if self.anim_system and self.parser.animations: - idx = (self.anim_system.current_seq + 1) % len(self.parser.animations) - self.anim_system.set_sequence(idx) - elif key == pygame.K_LEFT: - if self.anim_system and self.parser.animations: - idx = (self.anim_system.current_seq - 1) % len(self.parser.animations) - self.anim_system.set_sequence(idx) - elif key in (pygame.K_PLUS, pygame.K_EQUALS, pygame.K_KP_PLUS): - if self.anim_system: - self.anim_system.speed = min(self.anim_system.speed + 0.25, 5.0) - elif key in (pygame.K_MINUS, pygame.K_KP_MINUS): - if self.anim_system: - self.anim_system.speed = max(self.anim_system.speed - 0.25, 0.0) - elif key == pygame.K_r: - if self.anim_system: - self.anim_system.time_ms = 0.0 - self.anim_system.playing = False - self.anim_system.bone_matrices = np.empty(0) - elif key == pygame.K_w: - if self.renderer: - self.renderer.show_wireframe = not self.renderer.show_wireframe - - def _draw_hud(self, pygame, gl): - """Draw text overlay using Pygame font → texture approach.""" - if not self.font: - return - - lines = [Path(self.m2_path).name] - - n_verts = len(self.parser.positions) - n_tris = len(self.parser.triangles) // 3 - lines.append(f"{n_verts} verts, {n_tris} tris, {len(self.parser.textures)} tex") - - if self.parser.animations and self.anim_system: - anim = self.parser.animations[self.anim_system.current_seq] - name = _ANIM_NAMES.get(anim.anim_id, f"Anim {anim.anim_id}") - state = "Playing" if self.anim_system.playing else "Paused" - lines.append(f"[{self.anim_system.current_seq + 1}/{len(self.parser.animations)}] " - f"{name} ({anim.duration}ms) - {state} x{self.anim_system.speed:.1f}") - else: - lines.append("No animations") - - fps = self.fps_clock.get_fps() if self.fps_clock else 0 - lines.append(f"FPS: {fps:.0f}") - - lines.append("") - lines.append("LMB: orbit | RMB: pan | Scroll: zoom") - lines.append("Space: play/pause | Left/Right: anim | +/-: speed") - lines.append("W: wireframe | R: reset | Esc: quit") - - # Render text to surface, then blit via orthographic projection - # Use a simple texture-based approach - line_height = 18 - total_height = len(lines) * line_height + 8 - surf_width = 450 - surf = pygame.Surface((surf_width, total_height), pygame.SRCALPHA) - surf.fill((0, 0, 0, 160)) - - for i, line in enumerate(lines): - text_surf = self.font.render(line, True, (220, 220, 240)) - surf.blit(text_surf, (6, 4 + i * line_height)) - - # Convert to OpenGL texture and draw - text_data = pygame.image.tostring(surf, "RGBA", True) - tex_id = gl.glGenTextures(1) - gl.glBindTexture(gl.GL_TEXTURE_2D, tex_id) - gl.glTexImage2D(gl.GL_TEXTURE_2D, 0, gl.GL_RGBA, surf_width, total_height, - 0, gl.GL_RGBA, gl.GL_UNSIGNED_BYTE, text_data) - gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MIN_FILTER, gl.GL_NEAREST) - gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MAG_FILTER, gl.GL_NEAREST) - - # Draw fullscreen quad in ortho — use compatibility approach with glWindowPos + glDrawPixels - # Simpler: use a small shader-less blit via fixed function emulation - # Actually, let's just use the modern approach with a screen quad - self._blit_texture(gl, tex_id, 8, self.height - total_height - 8, surf_width, total_height) - - gl.glDeleteTextures(1, [tex_id]) - - def _blit_texture(self, gl, tex_id, x, y, w, h): - """Blit a texture to screen at (x,y) using a temporary screen-space quad.""" - # Simple blit using glBlitFramebuffer alternative: - # Create a minimal screen-space shader + quad - if not hasattr(self, '_blit_shader'): - blit_vert = """ -#version 330 core -layout(location=0) in vec2 aPos; -layout(location=1) in vec2 aUV; -out vec2 vUV; -void main() { - gl_Position = vec4(aPos, 0.0, 1.0); - vUV = aUV; -} -""" - blit_frag = """ -#version 330 core -in vec2 vUV; -uniform sampler2D uTex; -out vec4 FragColor; -void main() { - FragColor = texture(uTex, vUV); -} -""" - self._blit_shader = self.renderer._compile_program(blit_vert, blit_frag) - self._blit_vao = gl.glGenVertexArrays(1) - self._blit_vbo = gl.glGenBuffers(1) - - # Convert pixel coords to NDC - x0 = 2.0 * x / self.width - 1.0 - y0 = 2.0 * y / self.height - 1.0 - x1 = 2.0 * (x + w) / self.width - 1.0 - y1 = 2.0 * (y + h) / self.height - 1.0 - - quad = np.array([ - x0, y0, 0.0, 0.0, - x1, y0, 1.0, 0.0, - x1, y1, 1.0, 1.0, - x0, y0, 0.0, 0.0, - x1, y1, 1.0, 1.0, - x0, y1, 0.0, 1.0, - ], dtype=np.float32) - - gl.glBindVertexArray(self._blit_vao) - gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self._blit_vbo) - gl.glBufferData(gl.GL_ARRAY_BUFFER, quad.nbytes, quad, gl.GL_DYNAMIC_DRAW) - gl.glVertexAttribPointer(0, 2, gl.GL_FLOAT, gl.GL_FALSE, 16, gl.ctypes.c_void_p(0)) - gl.glEnableVertexAttribArray(0) - gl.glVertexAttribPointer(1, 2, gl.GL_FLOAT, gl.GL_FALSE, 16, gl.ctypes.c_void_p(8)) - gl.glEnableVertexAttribArray(1) - - gl.glDisable(gl.GL_DEPTH_TEST) - gl.glEnable(gl.GL_BLEND) - gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA) - - gl.glUseProgram(self._blit_shader) - gl.glActiveTexture(gl.GL_TEXTURE0) - gl.glBindTexture(gl.GL_TEXTURE_2D, tex_id) - gl.glUniform1i(gl.glGetUniformLocation(self._blit_shader, "uTex"), 0) - - gl.glDrawArrays(gl.GL_TRIANGLES, 0, 6) - - gl.glBindVertexArray(0) - gl.glEnable(gl.GL_DEPTH_TEST) - gl.glDisable(gl.GL_BLEND) - - -# --------------------------------------------------------------------------- -# WMO Parser -# --------------------------------------------------------------------------- - -@dataclass -class WMOBatch: - start_index: int = 0 - index_count: int = 0 - material_id: int = 0 - - -@dataclass -class WMOMaterial: - flags: int = 0 - shader: int = 0 - blend_mode: int = 0 - texture1_ofs: int = 0 - texture2_ofs: int = 0 - texture3_ofs: int = 0 - color1: int = 0 - color2: int = 0 - - -@dataclass -class WMOGroup: - positions: np.ndarray = field(default_factory=lambda: np.empty((0, 3), dtype=np.float32)) - normals: np.ndarray = field(default_factory=lambda: np.empty((0, 3), dtype=np.float32)) - uvs: np.ndarray = field(default_factory=lambda: np.empty((0, 2), dtype=np.float32)) - indices: np.ndarray = field(default_factory=lambda: np.empty(0, dtype=np.uint16)) - batches: list = field(default_factory=list) - - -class WMOParser: - """Parse WMO root + group files for rendering.""" - - def __init__(self): - self.textures: list[str] = [] - self.texture_offset_map: dict[int, int] = {} # MOTX byte offset -> texture index - self.materials: list[WMOMaterial] = [] - self.groups: list[WMOGroup] = [] - self.n_groups_expected: int = 0 - - def parse_root(self, data: bytes): - """Parse root WMO file for textures and materials.""" - pos = 0 - while pos + 8 <= len(data): - chunk_id = data[pos:pos + 4] - chunk_size = struct.unpack_from(" len(data): - break - - cid = chunk_id if chunk_id[:1] == b"M" else chunk_id[::-1] - - if cid == b"MOHD" and chunk_size >= 16: - # nTextures at +0, nGroups at +4 - self.n_groups_expected = struct.unpack_from(" WMOGroup: - """Parse a WMO group file for geometry.""" - group = WMOGroup() - pos = 0 - - # Scan for MOGP chunk which wraps all sub-chunks - mogp_start = -1 - mogp_end = len(data) - while pos + 8 <= len(data): - chunk_id = data[pos:pos + 4] - chunk_size = struct.unpack_from("= 0 else 0 - pos = scan_start - while pos + 8 <= mogp_end: - chunk_id = data[pos:pos + 4] - chunk_size = struct.unpack_from(" mogp_end: - break - - cid = chunk_id if chunk_id[:1] == b"M" else chunk_id[::-1] - - if cid == b"MOVT": - n = chunk_size // 12 - group.positions = np.zeros((n, 3), dtype=np.float32) - for i in range(n): - group.positions[i] = struct.unpack_from("<3f", data, chunk_start + i * 12) - - elif cid == b"MOVI": - n = chunk_size // 2 - group.indices = np.frombuffer(data, dtype=np.uint16, - count=n, offset=chunk_start).copy() - - elif cid == b"MONR": - n = chunk_size // 12 - group.normals = np.zeros((n, 3), dtype=np.float32) - for i in range(n): - group.normals[i] = struct.unpack_from("<3f", data, chunk_start + i * 12) - - elif cid == b"MOTV": - n = chunk_size // 8 - group.uvs = np.zeros((n, 2), dtype=np.float32) - for i in range(n): - group.uvs[i] = struct.unpack_from("<2f", data, chunk_start + i * 8) - - elif cid == b"MOBA": - n = chunk_size // 24 - for i in range(n): - base = chunk_start + i * 24 - batch = WMOBatch() - batch.start_index = struct.unpack_from(" str: - """Resolve a MOTX byte offset to a texture filename.""" - idx = self.texture_offset_map.get(motx_offset) - if idx is not None and idx < len(self.textures): - return self.textures[idx] - return "" - - -# --------------------------------------------------------------------------- -# WMO Renderer -# --------------------------------------------------------------------------- - -class WMORenderer: - """OpenGL 3.3 renderer for WMO models.""" - - def __init__(self, parser: WMOParser, blp_paths: dict[str, str], blp_convert: str): - self.parser = parser - self.blp_paths = blp_paths - self.blp_convert_path = blp_convert - self.show_wireframe = False - - # Per-group GL state - self._group_vaos: list[int] = [] - self._group_vbos: list[int] = [] - self._group_ebos: list[int] = [] - self._group_n_indices: list[int] = [] - self._group_batches: list[list[WMOBatch]] = [] - - self.shader = 0 - self.wire_shader = 0 - self._gl = None - - # material_id -> GL texture id - self._mat_textures: dict[int, int] = {} - - def init_gl(self): - import OpenGL.GL as gl - self._gl = gl - - self.shader = self._compile_program(VERT_SHADER, FRAG_SHADER) - self.wire_shader = self._compile_program(WIRE_VERT, WIRE_FRAG) - - self._load_textures() - - for group in self.parser.groups: - self._upload_group(group) - - def _upload_group(self, group: WMOGroup): - gl = self._gl - n_verts = len(group.positions) - if n_verts == 0: - self._group_vaos.append(0) - self._group_vbos.append(0) - self._group_ebos.append(0) - self._group_n_indices.append(0) - self._group_batches.append([]) - return - - # Interleaved: pos(12) + normal(12) + uv(8) = 32 bytes - vbo_data = np.zeros((n_verts, 8), dtype=np.float32) - vbo_data[:, 0:3] = group.positions - if len(group.normals) == n_verts: - vbo_data[:, 3:6] = group.normals - if len(group.uvs) == n_verts: - vbo_data[:, 6:8] = group.uvs - - vao = gl.glGenVertexArrays(1) - vbo = gl.glGenBuffers(1) - ebo = gl.glGenBuffers(1) - - gl.glBindVertexArray(vao) - gl.glBindBuffer(gl.GL_ARRAY_BUFFER, vbo) - gl.glBufferData(gl.GL_ARRAY_BUFFER, vbo_data.nbytes, vbo_data, gl.GL_STATIC_DRAW) - - n_idx = 0 - if len(group.indices) > 0: - idx_data = group.indices.astype(np.uint16) - n_idx = len(idx_data) - gl.glBindBuffer(gl.GL_ELEMENT_ARRAY_BUFFER, ebo) - gl.glBufferData(gl.GL_ELEMENT_ARRAY_BUFFER, idx_data.nbytes, idx_data, gl.GL_STATIC_DRAW) - - stride = 32 - gl.glVertexAttribPointer(0, 3, gl.GL_FLOAT, gl.GL_FALSE, stride, gl.ctypes.c_void_p(0)) - gl.glEnableVertexAttribArray(0) - gl.glVertexAttribPointer(1, 3, gl.GL_FLOAT, gl.GL_FALSE, stride, gl.ctypes.c_void_p(12)) - gl.glEnableVertexAttribArray(1) - gl.glVertexAttribPointer(2, 2, gl.GL_FLOAT, gl.GL_FALSE, stride, gl.ctypes.c_void_p(24)) - gl.glEnableVertexAttribArray(2) - gl.glBindVertexArray(0) - - self._group_vaos.append(vao) - self._group_vbos.append(vbo) - self._group_ebos.append(ebo) - self._group_n_indices.append(n_idx) - self._group_batches.append(group.batches) - - def _load_textures(self): - gl = self._gl - try: - from PIL import Image - except ImportError: - return - - cache_dir = Path(os.path.expanduser("~/.cache/m2_viewer")) - cache_dir.mkdir(parents=True, exist_ok=True) - - loaded: dict[str, int] = {} # filename -> GL tex id - - for mat_idx, mat in enumerate(self.parser.materials): - tex_name = self.parser.get_texture_name(mat.texture1_ofs) - if not tex_name: - continue - - if tex_name in loaded: - self._mat_textures[mat_idx] = loaded[tex_name] - continue - - norm = tex_name.replace("\\", "/") - blp_path = self.blp_paths.get(norm) or self.blp_paths.get(norm.lower()) - if not blp_path: - continue - - cache_key = hashlib.md5(blp_path.encode()).hexdigest() - cached_png = cache_dir / f"{cache_key}.png" - - if not cached_png.exists(): - try: - import tempfile - with tempfile.TemporaryDirectory() as tmpdir: - tmp_blp = Path(tmpdir) / Path(blp_path).name - shutil.copy2(blp_path, str(tmp_blp)) - result = subprocess.run( - [self.blp_convert_path, "--to-png", str(tmp_blp)], - capture_output=True, text=True, timeout=10, - ) - output_png = tmp_blp.with_suffix(".png") - if result.returncode != 0 or not output_png.exists(): - continue - shutil.move(str(output_png), str(cached_png)) - except Exception: - continue - - try: - img = Image.open(cached_png) - img = img.transpose(Image.FLIP_TOP_BOTTOM) - if img.mode != "RGBA": - img = img.convert("RGBA") - img_data = np.array(img, dtype=np.uint8) - - tex_id = gl.glGenTextures(1) - gl.glBindTexture(gl.GL_TEXTURE_2D, tex_id) - gl.glTexImage2D(gl.GL_TEXTURE_2D, 0, gl.GL_RGBA, img.width, img.height, - 0, gl.GL_RGBA, gl.GL_UNSIGNED_BYTE, img_data) - gl.glGenerateMipmap(gl.GL_TEXTURE_2D) - gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MIN_FILTER, gl.GL_LINEAR_MIPMAP_LINEAR) - gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MAG_FILTER, gl.GL_LINEAR) - gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_WRAP_S, gl.GL_REPEAT) - gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_WRAP_T, gl.GL_REPEAT) - - loaded[tex_name] = tex_id - self._mat_textures[mat_idx] = tex_id - except Exception: - continue - - def _compile_program(self, vert_src: str, frag_src: str) -> int: - gl = self._gl - vs = gl.glCreateShader(gl.GL_VERTEX_SHADER) - gl.glShaderSource(vs, vert_src) - gl.glCompileShader(vs) - fs = gl.glCreateShader(gl.GL_FRAGMENT_SHADER) - gl.glShaderSource(fs, frag_src) - gl.glCompileShader(fs) - prog = gl.glCreateProgram() - gl.glAttachShader(prog, vs) - gl.glAttachShader(prog, fs) - gl.glLinkProgram(prog) - gl.glDeleteShader(vs) - gl.glDeleteShader(fs) - return prog - - def render(self, mvp: np.ndarray, model: np.ndarray): - gl = self._gl - gl.glEnable(gl.GL_DEPTH_TEST) - gl.glDisable(gl.GL_CULL_FACE) - - gl.glUseProgram(self.shader) - mvp_loc = gl.glGetUniformLocation(self.shader, "uMVP") - model_loc = gl.glGetUniformLocation(self.shader, "uModel") - tex_loc = gl.glGetUniformLocation(self.shader, "uTexture") - has_tex_loc = gl.glGetUniformLocation(self.shader, "uHasTexture") - light_loc = gl.glGetUniformLocation(self.shader, "uLightDir") - - gl.glUniformMatrix4fv(mvp_loc, 1, gl.GL_TRUE, mvp) - gl.glUniformMatrix4fv(model_loc, 1, gl.GL_TRUE, model) - gl.glUniform1i(tex_loc, 0) - - light_dir = np.array([0.5, 0.3, 0.8], dtype=np.float32) - light_dir /= np.linalg.norm(light_dir) - gl.glUniform3fv(light_loc, 1, light_dir) - - for gi in range(len(self._group_vaos)): - vao = self._group_vaos[gi] - n_idx = self._group_n_indices[gi] - batches = self._group_batches[gi] - if vao == 0 or n_idx == 0: - continue - - gl.glBindVertexArray(vao) - - if batches: - for batch in batches: - gl_tex = self._mat_textures.get(batch.material_id) - if gl_tex: - gl.glActiveTexture(gl.GL_TEXTURE0) - gl.glBindTexture(gl.GL_TEXTURE_2D, gl_tex) - gl.glUniform1i(has_tex_loc, 1) - else: - gl.glUniform1i(has_tex_loc, 0) - - si = batch.start_index - ic = batch.index_count - if si + ic <= n_idx: - gl.glDrawElements(gl.GL_TRIANGLES, ic, gl.GL_UNSIGNED_SHORT, - gl.ctypes.c_void_p(si * 2)) - else: - gl.glUniform1i(has_tex_loc, 0) - gl.glDrawElements(gl.GL_TRIANGLES, n_idx, gl.GL_UNSIGNED_SHORT, - gl.ctypes.c_void_p(0)) - - gl.glBindVertexArray(0) - - # Wireframe overlay - if self.show_wireframe: - gl.glUseProgram(self.wire_shader) - wire_mvp_loc = gl.glGetUniformLocation(self.wire_shader, "uMVP") - gl.glUniformMatrix4fv(wire_mvp_loc, 1, gl.GL_TRUE, mvp) - gl.glEnable(gl.GL_BLEND) - gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA) - gl.glPolygonMode(gl.GL_FRONT_AND_BACK, gl.GL_LINE) - - for gi in range(len(self._group_vaos)): - vao = self._group_vaos[gi] - n_idx = self._group_n_indices[gi] - if vao == 0 or n_idx == 0: - continue - gl.glBindVertexArray(vao) - gl.glDrawElements(gl.GL_TRIANGLES, n_idx, gl.GL_UNSIGNED_SHORT, - gl.ctypes.c_void_p(0)) - gl.glBindVertexArray(0) - - gl.glPolygonMode(gl.GL_FRONT_AND_BACK, gl.GL_FILL) - gl.glDisable(gl.GL_BLEND) - - -# --------------------------------------------------------------------------- -# WMO Viewer Window -# --------------------------------------------------------------------------- - -class WMOViewerWindow: - """Pygame + OpenGL WMO model viewer window.""" - - def __init__(self, wmo_root_path: str, group_paths: list[str], - blp_paths: dict[str, str], blp_convert: str): - self.wmo_root_path = wmo_root_path - self.group_paths = group_paths - self.blp_paths = blp_paths - self.blp_convert = blp_convert - self.parser: WMOParser | None = None - self.renderer: WMORenderer | None = None - self.camera = OrbitCamera() - self.width = 1024 - self.height = 768 - self.running = True - self.fps_clock = None - self.font = None - self._dragging = False - self._panning = False - self._last_mouse = (0, 0) - - def run(self): - import pygame - from pygame.locals import ( - DOUBLEBUF, OPENGL, RESIZABLE, QUIT, KEYDOWN, MOUSEBUTTONDOWN, - MOUSEBUTTONUP, MOUSEMOTION, VIDEORESIZE, - ) - - # Parse WMO - self.parser = WMOParser() - - if self.wmo_root_path and Path(self.wmo_root_path).exists(): - self.parser.parse_root(Path(self.wmo_root_path).read_bytes()) - - total_verts = 0 - total_tris = 0 - for gp in self.group_paths: - if Path(gp).exists(): - group = self.parser.parse_group(Path(gp).read_bytes()) - self.parser.groups.append(group) - total_verts += len(group.positions) - total_tris += len(group.indices) // 3 - - if total_verts == 0: - print("No geometry found in WMO groups") - return - - # Auto-fit camera - all_pos = np.vstack([g.positions for g in self.parser.groups if len(g.positions) > 0]) - mins = all_pos.min(axis=0) - maxs = all_pos.max(axis=0) - center = (mins + maxs) / 2.0 - extent = np.linalg.norm(maxs - mins) - self.camera.target = center - self.camera.distance = max(extent * 1.2, 1.0) - - # Init Pygame - pygame.init() - name = Path(self.wmo_root_path or self.group_paths[0]).stem - pygame.display.set_caption(f"WMO Viewer — {name}") - pygame.display.gl_set_attribute(pygame.GL_CONTEXT_MAJOR_VERSION, 3) - pygame.display.gl_set_attribute(pygame.GL_CONTEXT_MINOR_VERSION, 3) - pygame.display.gl_set_attribute(pygame.GL_CONTEXT_PROFILE_MASK, - pygame.GL_CONTEXT_PROFILE_CORE) - pygame.display.set_mode((self.width, self.height), DOUBLEBUF | OPENGL | RESIZABLE) - - self.fps_clock = pygame.time.Clock() - self.font = pygame.font.SysFont("monospace", 14) - - import OpenGL.GL as gl - - self.renderer = WMORenderer(self.parser, self.blp_paths, self.blp_convert) - self.renderer.init_gl() - - gl.glClearColor(0.12, 0.12, 0.18, 1.0) - - while self.running: - self.fps_clock.tick(60) - - for event in pygame.event.get(): - if event.type == QUIT: - self.running = False - elif event.type == VIDEORESIZE: - self.width, self.height = event.w, event.h - pygame.display.set_mode((self.width, self.height), - DOUBLEBUF | OPENGL | RESIZABLE) - elif event.type == KEYDOWN: - if event.key == pygame.K_ESCAPE: - self.running = False - elif event.key == pygame.K_w: - self.renderer.show_wireframe = not self.renderer.show_wireframe - elif event.type == MOUSEBUTTONDOWN: - if event.button == 1: - self._dragging = True - self._last_mouse = event.pos - elif event.button == 3: - self._panning = True - self._last_mouse = event.pos - elif event.button == 4: - self.camera.zoom(1) - elif event.button == 5: - self.camera.zoom(-1) - elif event.type == MOUSEBUTTONUP: - if event.button == 1: - self._dragging = False - elif event.button == 3: - self._panning = False - elif event.type == MOUSEMOTION: - if self._dragging: - dx = event.pos[0] - self._last_mouse[0] - dy = event.pos[1] - self._last_mouse[1] - self.camera.orbit(dx, dy) - self._last_mouse = event.pos - elif self._panning: - dx = event.pos[0] - self._last_mouse[0] - dy = event.pos[1] - self._last_mouse[1] - self.camera.pan(-dx, dy) - self._last_mouse = event.pos - - gl.glViewport(0, 0, self.width, self.height) - gl.glClear(gl.GL_COLOR_BUFFER_BIT | gl.GL_DEPTH_BUFFER_BIT) - - aspect = self.width / max(self.height, 1) - proj = perspective(45.0, aspect, 0.1, 10000.0) - view = self.camera.get_view_matrix() - model_mat = np.eye(4, dtype=np.float32) - mvp = proj @ view @ model_mat - - self.renderer.render(mvp, model_mat) - - # HUD - self._draw_hud(pygame, gl, total_verts, total_tris) - - pygame.display.flip() - - pygame.quit() - - def _draw_hud(self, pygame, gl, total_verts, total_tris): - if not self.font: - return - name = Path(self.wmo_root_path or self.group_paths[0]).name - lines = [ - name, - f"{len(self.parser.groups)} groups, {total_verts} verts, {total_tris} tris", - f"{len(self.parser.materials)} materials, {len(self.parser.textures)} textures", - f"FPS: {self.fps_clock.get_fps():.0f}", - "", - "LMB: orbit | RMB: pan | Scroll: zoom", - "W: wireframe | Esc: quit", - ] - - line_height = 18 - total_height = len(lines) * line_height + 8 - surf_width = 420 - surf = pygame.Surface((surf_width, total_height), pygame.SRCALPHA) - surf.fill((0, 0, 0, 160)) - for i, line in enumerate(lines): - text_surf = self.font.render(line, True, (220, 220, 240)) - surf.blit(text_surf, (6, 4 + i * line_height)) - - text_data = pygame.image.tostring(surf, "RGBA", True) - tex_id = gl.glGenTextures(1) - gl.glBindTexture(gl.GL_TEXTURE_2D, tex_id) - gl.glTexImage2D(gl.GL_TEXTURE_2D, 0, gl.GL_RGBA, surf_width, total_height, - 0, gl.GL_RGBA, gl.GL_UNSIGNED_BYTE, text_data) - gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MIN_FILTER, gl.GL_NEAREST) - gl.glTexParameteri(gl.GL_TEXTURE_2D, gl.GL_TEXTURE_MAG_FILTER, gl.GL_NEAREST) - - # Blit using the same approach as M2ViewerWindow - if not hasattr(self, '_blit_shader'): - blit_vert = """ -#version 330 core -layout(location=0) in vec2 aPos; -layout(location=1) in vec2 aUV; -out vec2 vUV; -void main() { gl_Position = vec4(aPos, 0.0, 1.0); vUV = aUV; } -""" - blit_frag = """ -#version 330 core -in vec2 vUV; -uniform sampler2D uTex; -out vec4 FragColor; -void main() { FragColor = texture(uTex, vUV); } -""" - self._blit_shader = self.renderer._compile_program(blit_vert, blit_frag) - self._blit_vao = gl.glGenVertexArrays(1) - self._blit_vbo = gl.glGenBuffers(1) - - x, y, w, h = 8, self.height - total_height - 8, surf_width, total_height - x0 = 2.0 * x / self.width - 1.0 - y0 = 2.0 * y / self.height - 1.0 - x1 = 2.0 * (x + w) / self.width - 1.0 - y1 = 2.0 * (y + h) / self.height - 1.0 - - quad = np.array([ - x0, y0, 0, 0, x1, y0, 1, 0, x1, y1, 1, 1, - x0, y0, 0, 0, x1, y1, 1, 1, x0, y1, 0, 1, - ], dtype=np.float32) - - gl.glBindVertexArray(self._blit_vao) - gl.glBindBuffer(gl.GL_ARRAY_BUFFER, self._blit_vbo) - gl.glBufferData(gl.GL_ARRAY_BUFFER, quad.nbytes, quad, gl.GL_DYNAMIC_DRAW) - gl.glVertexAttribPointer(0, 2, gl.GL_FLOAT, gl.GL_FALSE, 16, gl.ctypes.c_void_p(0)) - gl.glEnableVertexAttribArray(0) - gl.glVertexAttribPointer(1, 2, gl.GL_FLOAT, gl.GL_FALSE, 16, gl.ctypes.c_void_p(8)) - gl.glEnableVertexAttribArray(1) - - gl.glDisable(gl.GL_DEPTH_TEST) - gl.glEnable(gl.GL_BLEND) - gl.glBlendFunc(gl.GL_SRC_ALPHA, gl.GL_ONE_MINUS_SRC_ALPHA) - gl.glUseProgram(self._blit_shader) - gl.glUniform1i(gl.glGetUniformLocation(self._blit_shader, "uTex"), 0) - gl.glDrawArrays(gl.GL_TRIANGLES, 0, 6) - gl.glBindVertexArray(0) - gl.glEnable(gl.GL_DEPTH_TEST) - gl.glDisable(gl.GL_BLEND) - gl.glDeleteTextures(1, [tex_id]) - - -# --------------------------------------------------------------------------- -# Launch entry points (multiprocessing-safe) -# --------------------------------------------------------------------------- - -def _viewer_main(m2_path: str, blp_paths: dict[str, str], blp_convert: str): - """Entry point for M2 viewer subprocess.""" - viewer = M2ViewerWindow(m2_path, blp_paths, blp_convert) - viewer.run() - - -def _wmo_viewer_main(wmo_root: str, group_paths: list[str], - blp_paths: dict[str, str], blp_convert: str): - """Entry point for WMO viewer subprocess.""" - viewer = WMOViewerWindow(wmo_root, group_paths, blp_paths, blp_convert) - viewer.run() - - -def launch_m2_viewer(m2_path: str, blp_paths: dict[str, str], blp_convert: str): - """Launch M2 viewer in a separate process to avoid Tkinter/Pygame conflicts.""" - p = multiprocessing.Process(target=_viewer_main, args=(m2_path, blp_paths, blp_convert), - daemon=True) - p.start() - return p - - -def launch_wmo_viewer(wmo_root: str, group_paths: list[str], - blp_paths: dict[str, str], blp_convert: str): - """Launch WMO viewer in a separate process.""" - p = multiprocessing.Process(target=_wmo_viewer_main, - args=(wmo_root, group_paths, blp_paths, blp_convert), - daemon=True) - p.start() - return p - - -if __name__ == "__main__": - if len(sys.argv) < 2: - print("Usage: python m2_viewer.py [blp_convert_path]") - sys.exit(1) - - file_path = sys.argv[1] - blp_conv = sys.argv[2] if len(sys.argv) > 2 else "" - - if file_path.lower().endswith(".wmo"): - # Detect root vs group and find all group files - p = Path(file_path) - name = p.name.lower() - is_group = len(name) > 8 and name[-8:-4].isdigit() and name[-9] == "_" - - if is_group: - # Derive root from group - stem = p.stem - root_stem = stem.rsplit("_", 1)[0] - root_path = p.parent / f"{root_stem}.wmo" - groups = sorted(p.parent.glob(f"{root_stem}_*.wmo")) - else: - root_path = p - stem = p.stem - groups = sorted(p.parent.glob(f"{stem}_*.wmo")) - - root_str = str(root_path) if root_path.exists() else "" - group_strs = [str(g) for g in groups] - if not group_strs and is_group: - group_strs = [file_path] - - viewer = WMOViewerWindow(root_str, group_strs, {}, blp_conv) - viewer.run() - else: - viewer = M2ViewerWindow(file_path, {}, blp_conv) - viewer.run()