From bba203aa536921c6ad5515cda98fe15ad4f61a50 Mon Sep 17 00:00:00 2001 From: sindoring Date: Sat, 23 May 2026 00:08:32 +0400 Subject: [PATCH] =?UTF-8?q?=D0=BF=D0=B5=D1=80=D0=B2=D1=8B=D0=B5=20=D0=BD?= =?UTF-8?q?=D0=B0=D1=80=D0=B0=D0=B1=D0=BE=D1=82=D0=BA=D0=B8=20=D0=BF=D0=BE?= =?UTF-8?q?=20=D1=80=D0=B5=D0=BD=D0=B4=D0=B5=D1=80=D1=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/tasks.json | 72 ++++ README.md | 83 +++- _godot_bake_stderr.log | 0 _godot_bake_stdout.log | 392 ++++++++++++++++++ _godot_m2_stderr.log | 0 _godot_m2_stdout.log | 46 ++ addons/mpq_extractor/loaders/adt_builder.gd | 96 ++++- addons/mpq_extractor/loaders/wmo_builder.gd | 224 +++++++++- data/cache/bake_progress/adt_worker_0.json | 1 + data/cache/bake_progress/adt_worker_1.json | 1 + data/cache/bake_progress/adt_worker_2.json | 1 + data/cache/bake_progress/adt_worker_3.json | 1 + src/native/src/adt_loader.cpp | 30 +- src/native/src/adt_loader.h | 12 +- src/native/src/m2_loader.cpp | 6 +- src/native/src/register_types.cpp | 2 + src/native/src/wdt_loader.cpp | 156 +++++++ src/native/src/wdt_loader.h | 55 +++ src/native/src/wmo_loader.cpp | 140 ++++++- src/native/src/wow_chunk_reader.h | 10 +- .../streaming/eastern_kingdoms_streaming.tscn | 6 +- .../streaming/streaming_world_loader.gd | 370 ++++++++++++++--- src/tools/bake_adt_terrain_cache.gd | 245 ++++++++++- src/tools/bake_all.gd | 1 + src/tools/bake_wmo_cache.gd | 1 + 25 files changed, 1841 insertions(+), 110 deletions(-) create mode 100644 .vscode/tasks.json create mode 100644 _godot_bake_stderr.log create mode 100644 _godot_bake_stdout.log create mode 100644 _godot_m2_stderr.log create mode 100644 _godot_m2_stdout.log create mode 100644 data/cache/bake_progress/adt_worker_0.json create mode 100644 data/cache/bake_progress/adt_worker_1.json create mode 100644 data/cache/bake_progress/adt_worker_2.json create mode 100644 data/cache/bake_progress/adt_worker_3.json create mode 100644 src/native/src/wdt_loader.cpp create mode 100644 src/native/src/wdt_loader.h diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..a81cce1 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,72 @@ +{ + "version": "2.0.0", + "presentation": { + "echo": true, + "reveal": "always", + "panel": "shared", + "clear": false, + "showReuseMessage": false + }, + "tasks": [ + { + "label": "Bake: WMO cache (force)", + "type": "process", + "command": "c:\\Godot 4.6\\Godot_v4.6.1-stable_win64.exe", + "args": [ + "--headless", + "--path", "${workspaceFolder}", + "--script", "res://src/tools/bake_wmo_cache.gd", + "--", "--map", "Azeroth", "--force" + ], + "problemMatcher": [] + }, + { + "label": "Bake: M2 cache (force)", + "type": "process", + "command": "c:\\Godot 4.6\\Godot_v4.6.1-stable_win64.exe", + "args": [ + "--headless", + "--path", "${workspaceFolder}", + "--script", "res://src/tools/bake_m2_cache.gd", + "--", "--map", "Azeroth", "--force" + ], + "problemMatcher": [] + }, + { + "label": "Bake: terrain cache (force)", + "type": "process", + "command": "c:\\Godot 4.6\\Godot_v4.6.1-stable_win64.exe", + "args": [ + "--headless", + "--path", "${workspaceFolder}", + "--script", "res://src/tools/bake_adt_terrain_cache.gd", + "--", "--map", "Azeroth", "--force" + ], + "problemMatcher": [] + }, + { + "label": "Bake: all (skip existing)", + "type": "process", + "command": "c:\\Godot 4.6\\Godot_v4.6.1-stable_win64.exe", + "args": [ + "--headless", + "--path", "${workspaceFolder}", + "--script", "res://src/tools/bake_all.gd", + "--", "--map", "Azeroth" + ], + "problemMatcher": [] + }, + { + "label": "Bake: all (force)", + "type": "process", + "command": "c:\\Godot 4.6\\Godot_v4.6.1-stable_win64.exe", + "args": [ + "--headless", + "--path", "${workspaceFolder}", + "--script", "res://src/tools/bake_all.gd", + "--", "--map", "Azeroth", "--force" + ], + "problemMatcher": [] + } + ] +} diff --git a/README.md b/README.md index 5dfe064..0243110 100644 --- a/README.md +++ b/README.md @@ -15,13 +15,15 @@ - читать и рендерить воду из `MH2O`; - стримить мир чанками с LOD в runtime; - стримить мир в **editor preview** по мере движения камеры в 3D viewport; -- читать `WMO` в отдельный `Dictionary` и собирать его в `Node3D` вручную. +- читать `WDT` и собирать состав мира по данным карты, а не по сканированию папки; +- читать `WMO` и автоматически расставлять его в мире через world-level registry с дедупом по `unique_id` (WMO на границе тайлов не дублируется); +- подхватывать глобальный WMO из `WDT.MWMO/MODF` для WMO-only карт (инстансы); +- читать `M2` (MD20 + SKIN00) и ставить его через `MultiMesh` из ADT doodad placements. Что пока не доведено до полноценного world renderer: -- автоматическая подстановка `WMO` и `M2` в мир из `ADT/WDT`; - вода без простого базового материала: нет нормального water shader, depth fade, shoreline blending, анимации; -- world-level `WDT` loader; +- M2 renderer статический: material flags (cull/unshaded), blend modes 2+, billboards для листвы и UV-transform'ы не применяются; анимация/скиннинг не парсятся; - импорт обратно в форматы WoW; - полноценные инструменты редактирования мира в editor. @@ -38,10 +40,14 @@ - поддержка основных сжатых форматов, достаточная для terrain textures. - `ADTLoader` - `MTEX`, `MMDX/MMID`, `MWMO/MWID`; - - `MDDF`, `MODF`; + - `MDDF`, `MODF` (с `unique_id`, `flags`, `doodad_set`, `name_set`); - `MCVT`, `MCNR`, `MCLY`, `MCAL`; - `holes`; - `MH2O`. +- `WDTLoader` + - `MVER`, `MPHD` (flags карты: global_wmo / big_alpha / sort_by_size_class); + - `MAIN` — таблица присутствия 64×64 ADT-тайлов; + - `MWMO` + `MODF` — глобальный WMO для инстансных/WMO-only карт. - `ADTBuilder` - сборка terrain в `Node3D`; - terrain material с 4 слоями; @@ -51,19 +57,32 @@ - `StreamingWorld` - runtime streaming тайлов и чанков; - editor streaming вокруг камеры viewport; - - отдельные `ADT`-узлы под `Terrain` в дереве сцены. + - состав мира берётся из `WDT` (fallback на папочный скан, если `.wdt` отсутствует); + - отдельные `ADT`-узлы под `Terrain` в дереве сцены; + - world-level WMO registry с refcount-дедупом по `unique_id`: WMO живёт пока на него ссылается хотя бы один загруженный тайл. +- `WMOLoader` / `WMOBuilder` + - root/group parsing; + - `WMOBuilder` собирает mesh по группам; + - интегрирован в `StreamingWorld` через world-level registry; + - глобальный WMO из `WDT` ставится один раз при загрузке карты. +- `M2Loader` / `M2Builder` + - парсинг `MD20` + `SKIN00`: vertices/normals/UVs, materials (flags + blend_mode), texture combos, batches; + - `M2Builder` собирает `ArrayMesh` + `StandardMaterial3D`; + - в `StreamingWorld` расставляется через `MultiMesh` из `ADT.MDDF`. ### Частично реализовано -- `WMOLoader` - - root/group parsing есть; - - `WMOBuilder` собирает mesh по группам; - - в общий world streaming пока не интегрировано. +- `M2` renderer + - статическая геометрия рендерится корректно; + - material flags пока не применяются (cull_mode, unshaded, depth_write); + - blend modes 2+ (additive / modulate) схлопнуты в обычный alpha; + - билборды (leaf bones) не распознаются — листва деревьев плоская; + - UV-трансформы (waterfalls, флаги, порталы) не применяются. ### Не реализовано -- `M2` renderer; -- `WDT` streaming metadata; +- скиннинг и анимации `M2` (bones, key frames); +- particles / ribbons; - сохранение terrain/liquid/object placement обратно в WoW-форматы; - редакторские инструменты изменения ADT/WMO данных. @@ -197,7 +216,28 @@ res://data/extracted Читает root/group файлы `WMO` и возвращает `Dictionary` с `materials` и `groups`. -Это уже можно использовать вручную, но в world streaming оно пока не подключено. +Подключён в `StreamingWorld` через world-level registry. Дедуп по `unique_id` из `ADT.MODF`, refcount по тайлам. + +### `WDTLoader` + +Читает `WDT` карты и возвращает `Dictionary`: + +- `version` — `MVER` (ожидается 18); +- `flags`, `has_global_wmo`, `big_alpha`, `sort_by_size_class` — из `MPHD`; +- `tiles: Array[Dictionary]` — присутствующие тайлы `{x, y, flags}` из `MAIN`; +- `global_wmo_name: String` + `global_wmo_placement: Dictionary` — если карта WMO-only. + +`StreamingWorld` по умолчанию строит мир из этого списка. Папочный скан остаётся fallback'ом. + +### `M2Loader` + +Читает `.m2` (`MD20`) + `00.skin` и возвращает `Dictionary` с геометрией: + +- `textures`, `materials` (`flags`, `blend_mode`), `texture_combos`; +- `vertices`, `normals`, `uvs`, `indices`; +- `batches: Array[Dictionary]` — `{index_start, index_count, material_id, texture_combo_index}`. + +Кости, анимации и частицы пока не читаются. ## Export/bake scripts @@ -481,19 +521,20 @@ Preview одного `ADT` прямо в editor. - terrain текстурирование уже рабочее, но ещё не 1:1 как в клиенте/Noggit во всех edge cases; - water renderer сейчас базовый, без настоящих WoW water materials; -- `WMO` читается отдельно, но world placement пока не подключён; -- `M2` placements из `ADT` парсятся, но renderer нет; -- `WDT` ещё не управляет составом мира; +- `M2` рендерится статикой без билбордов, UV-анимаций и скелетной анимации; +- старые `baked_terrain_v2` tile-кэши (до добавления `unique_id` в `MODF`) не дедупят WMO на границах тайлов до перезапечки через `bake_all.gd`; - проект сейчас ориентирован на просмотр и исследование данных, а не на полный round-trip import/export. ## Ближайшие логичные шаги -1. Подключить `WDT`, чтобы мир собирался не по сканированию папки, а по данным карты. -2. Интегрировать `WMO` placement в `StreamingWorld`. -3. Добавить `M2` loader/renderer. -4. Довести water shader. -5. Начать editor-side инструменты для редактирования terrain/liquid/object placement. -6. Подготовить формат сохранения обратно в WoW-совместимые структуры. +1. Довести M2 renderer: + - **A.** Применять material flags: корректный `cull_mode`, `unshaded`, `depth_write`; blend modes 2+ (`additive`, `modulate`) вместо плоского alpha. + - **B.** Билборды: парсить bones из `M2`, помечать billboard-флаги, для батчей ссылающихся на billboard-bone выдавать отдельные ноды с `BillboardMode = BILLBOARD_ENABLED`. Листва деревьев перестанет быть плоской. + - **C.** UV-трансформы и vertex colors — custom shader, per-instance UV offset в `MultiMesh`. Waterfalls, порталы, флаги. +2. Скелетная анимация и скиннинг `M2` (bones, key frames, attachments) — отдельный крупный кусок. +3. Довести water shader (depth fade, shoreline blending, анимация). +4. Editor-side инструменты редактирования terrain/liquid/object placement. +5. Подготовить формат сохранения обратно в WoW-совместимые структуры. ## Быстрый старт diff --git a/_godot_bake_stderr.log b/_godot_bake_stderr.log new file mode 100644 index 0000000..e69de29 diff --git a/_godot_bake_stdout.log b/_godot_bake_stdout.log new file mode 100644 index 0000000..be7506e --- /dev/null +++ b/_godot_bake_stdout.log @@ -0,0 +1,392 @@ +Godot Engine v4.6.1.stable.official.14d19694e - https://godotengine.org + +Scanning ADT tiles... +Found 368 unique WMO models from 687 tiles. +WMOLoader: loaded 6 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_shipwreck/transport_shipwreck.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/collidabledoodads/darkshore/wreckedelvendestroyer/elvendestroyerwreckback.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/collidabledoodads/darkshore/wreckedelvendestroyer/elvendestroyerwreckfront.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/guardtower/guardtower.wmo +WMOLoader: loaded 6 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwoodabandoned_humantwostory/duskwoodabandoned_humantwostory.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/chapel/duskwoodchapel.wmo +WMOLoader: loaded 12 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_spidermine/md_spidermine_nokobolds.wmo +WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_cryptonerm/md_cryptonerm.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwoodabandoned_human_farm/duskwoodabandoned_human_farm.wmo +WMOLoader: loaded 12 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_spidermine/md_spidermine.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_shipwreck/shipwreck_a.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwood_human_farm/duskwood_human_farm.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwood_stable/duskwood_stable.wmo +WMOLoader: loaded 11 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwood_inn/duskwood_inn.wmo +WMOLoader: loaded 10 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/ld_shadowfang/ld_shadowfang.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/greatwall/greatwall_wall_04.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/greatwall/greatwall_gate.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stormwind/sw_harbor_lighthouse.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_shipwreck/shipwreck_b.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/westfall_human_farm_burnt/westfallfarmhouseburnt.wmo +[20/368] baked=20 skipped=0 failed=0 +WMOLoader: loaded 8 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_mountaincave/md_mountaincave_medium.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/human_barn_silo/silo.wmo +WMOLoader: loaded 3 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/westfall_barn/westfall_barn.wmo +WMOLoader: loaded 38 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/az_deadmines/az_deadmines_a.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/wetlands/wetlandshumandock01/wetlandshumandock01.wmo +WMOLoader: loaded 9 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/az_deadmines/az_deadmines_c.wmo +WMOLoader: loaded 3 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwoodabandoned_barn/duskwoodabandoned_barn.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/westfall/westfallgrainsilo01/westfallgrainsilo01.wmo +WMOLoader: loaded 6 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwood_humantwostory/duskwood_humantwostory.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwoodabandoned_blacksmith/duskwoodabandoned_blacksmith.wmo +WMOLoader: loaded 3 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwood_barn/duskwood_barn.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/duskwood/chasmbridge/duskwoodchasmbridge.wmo +WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_goldmine/md_goldmine_1room.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwood_blacksmith/duskwood_blacksmith.wmo +WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwood_townhall_nowall/duskwood_townhall_nowall.wmo +WMOLoader: loaded 27 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/kz_gnomeragon/kz_gnomeragon.wmo +WMOLoader: loaded 286 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stormwind/stormwind.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/keepwall/wallpiece01.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/keepwall/wallpost01.wmo +WMOLoader: loaded 4 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/transports/transport_ship/transportship.wmo +[40/368] baked=40 skipped=0 failed=0 +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stormwind/sw_harbor_lowmain.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stormwind/sw_harbor_lowstair.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stormwind/sw_harbor_lowtwr.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stormwind/sw_harbor_shipshelter.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stormwind/sw_harbor_docks.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stormwind/sw_harbor_lowstr.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/northrend/buildings/human/icebreaker/transport_icebreaker_ship_nomasts.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stormwind/stormwindhangar.wmo +WMOLoader: loaded 10 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_goldmine/md_goldmine.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/westfall_human_farm/westfall_human_farm.wmo +WMOLoader: loaded 6 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/westfall_humantwostory/westfall_humantwostory.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/moonbrook_human_farm/moonbrook_human_farm.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/westfall_stable/westfall_stable.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/westfall_lumbermill/westfall_lumbermill.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/prison_camp/prisonlonghouse.wmo +WMOLoader: loaded 6 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/moonbrook_humantwostory/moonbrook_humantwostory.wmo +WMOLoader: loaded 11 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/moonbrook_inn/moonbrook_inn.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/moonbrook_blacksmith/moonbrook_blacksmith.wmo +WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/moonbrook_townhall_nowall/moonbrook_townhall_nowall.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/greatwall/greatwall_tower_01.wmo +[60/368] baked=60 skipped=0 failed=0 +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/swampofsorrows/swampdock01/swampdock01.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/trollshop/trollshop01.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/swampofsorrows/swampbridge01/swampbridge01.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/trollshoppingmall/trollshoppingmall01.wmo +WMOLoader: loaded 10 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_crypt/md_crypt.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/duskwood/coveredbridge/duskwoodcoveredbridge.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/guardtower_ruined/ruinedhumanguardtower01.wmo +WMOLoader: loaded 206 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/undercity/undercity.wmo +WMOLoader: loaded 26 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwood_barracks/duskwood_barracks.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/buildings/tirisfalglade/fenriskeep/fenriskeep.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwood_human_farm_burnt/duskwoodfarmhouseburnt.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwoodabandoned_lumbermill/duskwoodabandoned_lumbermill.wmo +WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwood_townhall/duskwood_townhall.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/gnomehut/gnomehut.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/collidabledoodads/stonetalon/landingpad/landingpad01.wmo +WMOLoader: loaded 7 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_mountaincave/md_icecave02.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/collidabledoodads/tanaris/trolltent/trolltentsand01.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/human_farm/farm.wmo +WMOLoader: loaded 28 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/human_barracks/human_barracks.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/elwynn/widebridge/elwynnwidebridge.wmo +[80/368] baked=80 skipped=0 failed=0 +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/elwynn/lionbridge/elwynnlionbridge.wmo +WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/westfall_magetower/westfall_magetower.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/stranglethorn/stranglethornruins16/stranglethornruins16.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/stranglethorn/stranglethornruins12/stranglethornruins12.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/stranglethorn/stranglethornruins11/stranglethornruins11.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/stranglethorn/stranglethornruins04/stranglethornruins04.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/collidabledoodads/darkshore/giantseaturtle02/giantseaturtle02.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stranglethorn_ruins/stranglethorn_ruins12.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/collidabledoodads/darkshore/snakespine/snakespinegiantarch.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stranglethorn_ruins/stranglethorn_ruins05.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stranglethorn_ruins/stranglethorn_ruins16.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stranglethorn_bootybay/bootybay_fortress2.wmo +WMOLoader: loaded 46 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stranglethorn_bootybay/bootybay.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/buildings/tirisfalglade/mausoleum/mausoleumactive.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/undercity/undercitywall01.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/buildings/tirisfalglade/lorderonzeppelintower/lorderonzeppelin.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/buildings/tirisfalglade/lorderonzeppelintower/lorderonzeppelin_nd_transport.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/keepwall/wallpieceruin02.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/keepwall/wallpostruined01.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/keepwall/wallposttunnel01.wmo +[100/368] baked=100 skipped=0 failed=0 +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/arathihighlands/humanruinedbuildings/huruinedbuildingsmed01.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/arathihighlands/humanruinedbuildings/huruinedbuildingssmall01.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/arathihighlands/humanruinedbuildings/huruinedbuildingsmed02.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/keepwall/wallpiecestairs01.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/arathihighlands/humanruinedbuildings/huruinedbuildingssmall02.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/arathihighlands/humanruinedbuildings/huruinedbuildingsbig03.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/arathihighlands/humanruinedbuildings/huruinedbuildingsbig02.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/northrend/dalaran/dalaran_sewer_oldworld_architecture.wmo +WMOLoader: loaded 6 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/humantwostory/humantwostory.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/prison_camp/prisonguardtower.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/buildings/alterac/greenhouse01.wmo +WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/townhall/townhall.wmo +WMOLoader: loaded 3 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/prison_camp/prisonhq.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/goldshireblacksmith/goldshireblacksmith.wmo +WMOLoader: loaded 11 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_goldmine/md_goldmine_variantb.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_animalden/animalden.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/gnomehut/gnomehuttwostory.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_caveden/md_iceden.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/trollhut/trollhut_desert.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/collidabledoodads/tanaris/trolltent/trolltentdesert01.wmo +[120/368] baked=120 skipped=0 failed=0 +WMOLoader: loaded 13 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_anvilmarpass/anvilmarpass.wmo +WMOLoader: loaded 6 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/anvilmar/anvilmar.wmo +WMOLoader: loaded 13 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_mountaincave/md_icecave.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/elwynn/abbeygate/abbeygate01.wmo +WMOLoader: loaded 12 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/goldshireinn/goldshireinn.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stable/stable.wmo +WMOLoader: loaded 3 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/human_barn_silo/barn.wmo +WMOLoader: loaded 22 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_goldmine/md_goldmine_varianta.wmo +WMOLoader: loaded 13 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_crypt/md_crypt_c.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/duskwood/opengrave/duskwoodopengrave.wmo +WMOLoader: loaded 11 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwoodabandoned_inn/duskwoodabandoned_inn.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/collidabledoodads/darkshore/darkshoredocks/darkshoredocks01.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/orczeppelinhouse/orczeppelinhouse.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/orctower/orctower.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/orcblacksmith/orcblacksmith.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/stranglethorn/stranglethornarena/stranglegladiatorarena.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_caveden/md_trollden.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/stranglethorn/stranglethornarenagate/stranglearenagate01.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/stranglethorn/ropebridge/ropebridgesmall.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stranglethorn_bootybay/bootybay_railing.wmo +[140/368] baked=140 skipped=0 failed=0 +WMOLoader: loaded 3 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stranglethorn_bootybay/bootybay_house1.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stranglethorn_bootybay/bootybay_house2.wmo +WMOLoader: loaded 10 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_pirateship/pirateship_plank.wmo +WMOLoader: loaded 6 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/redridge_humantwostory/redridge_humantwostory.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/redridge/redridgedocks02/redridgedocks02.wmo +WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwoodabandoned_townhall_nowall/duskwoodabandoned_townhall_nowall.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/keepwall/wallpieceruin01.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/keepwall/gateruined01.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/redridge_human_farm/redridge_human_farm.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_ogremound/md_snogremound1room.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_caveden/md_mushroomden.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/intactprisontower/intactprisontower.wmo +WMOLoader: loaded 3 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_cavetunnels/pvp_alterac_ent01.wmo +WMOLoader: loaded 4 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_mountaincave/md_mountaincave_medium_e.wmo +WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/westfall_townhall_nowall/westfall_townhall_nowall.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/collidabledoodads/menethildocks/menethildocks.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/ironforge/footbridge/ironfootbridge.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/ironforge/ironforgeropebridge01/ironforgeropebridge01.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/dwarven_blacksmith/snow_blacksmith/snow_blacksmith.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/dwarven_1story_hovel/snow_hovel/hovel_machine.wmo +[160/368] baked=160 skipped=0 failed=0 +WMOLoader: loaded 8 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_mountaincave/md_icecave03.wmo +WMOLoader: loaded 7 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/dwarven_inn/snow_inn/snow_inn.wmo +WMOLoader: loaded 7 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_mountaincave/md_mushroomcave02.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/redridge_stable/redridge_stable.wmo +WMOLoader: loaded 14 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/nsabbey/nsabbey.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/elwynn/abbeygate02/abbeygate02.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/worldtree/duskworldtree.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/nightelfmoonwell/dsnightelfmoonwellornate.wmo +WMOLoader: loaded 9 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_ogremound/md_ogremound.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/stranglethorn/ropebridge/ropebridge.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/oilplatform/oilplatformsmall.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/trollhut/trollhut01.wmo +WMOLoader: loaded 7 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_mountaincave/md_mushroomcave03.wmo +WMOLoader: loaded 13 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/scarlet_monestary/scarlet_monestary.wmo +WMOLoader: loaded 9 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_ogremound/md_snogremound.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/collidabledoodads/wetlandsfootbridge/wetlandsfootbridge01.wmo +WMOLoader: loaded 11 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/westfall_inn/westfall_inn.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/buildings/wetlands/bridgebigwhite/bridgebigwhite.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/keepwall/wallpiece03.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/keepwall/wallpiece02.wmo +[180/368] baked=180 skipped=0 failed=0 +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/keepwall/gate03.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/keepwall/wallpost02.wmo +WMOLoader: loaded 4 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/transports/passengership/transportship_a.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/dwarven_stable/dwarven_stable.wmo +WMOLoader: loaded 104 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/cities/ironforge/ironforge.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/dwarven_1story_hovel/snow_hovel/snow_hovel.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/dwarven_2story_hovel/snow_2story/snow_2story.wmo +WMOLoader: loaded 13 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_goldmine/md_goldmine_variante.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/searinggorge/darkirontower.wmo +WMOLoader: loaded 45 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/az_blackrock/blackrock.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/altarofstorms/altarofstorms.wmo +WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/magetower/magetower.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/collidabledoodads/mulgore/taurentent01/taurentent01.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwood_lumbermill/duskwood_lumbermill.wmo +WMOLoader: loaded 9 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_mountaincave/md_mushroomcave05.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/stranglethorn/holdingpen/holdingpenbamboo.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/orctower/swamporctower.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/zulgurub/zulgurubentrance.wmo +WMOLoader: loaded 9 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_ogremound/md_grogremound.wmo +WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/redridge_townhall_nowall/redridge_townhall_nowall.wmo +[200/368] baked=200 skipped=0 failed=0 +WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/redridge_magetower/redridge_magetower.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/redridge_lumbermill/redridge_lumbermill.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/westfall_guardtower/westfall_guardtower.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/redridge_blacksmith/redridge_blacksmith.wmo +WMOLoader: loaded 26 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/redridge_barracks/redridge_barracks.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/keepwall/gate01.wmo +WMOLoader: loaded 11 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwoodabandoned_inn/andorhalabandoned_inn.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/arathihighlands/humanruinedbuildings/huruinedbuildingsbig01.wmo +WMOLoader: loaded 4 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_cavetunnels/md_cavetunnel01.wmo +WMOLoader: loaded 3 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/guildhouses/guildhouseb.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/hillsbradterracewall/hillsbradterracewall.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/outland/humanbuildings/outlandruinedmagetower.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/outland/humanbuildings/outlandruinhumanbarracks01.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/arathihighlands/durnholdedestroyedwalls/durnholdedestroyedwall.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/greatwall/greatwall_wall_02.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/greatwall/greatwall_wall_01.wmo +WMOLoader: loaded 12 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/dwarven_barracks/wetlands_barracks/wet_dwarfbarracks.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/greatwall/greatwall_tower_02.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/arathihighlands/stromguard/stromguard.wmo +WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwood_magetower/duskwood_magetower.wmo +[220/368] baked=220 skipped=0 failed=0 +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/dwarven_1story_hovel/mud_hovel/mud_hovel.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/dwarven_2story_hovel/mud_2story/mud_2story.wmo +WMOLoader: loaded 8 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_dwarven tunnels/smalltunnel.wmo +WMOLoader: loaded 10 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_dwarven tunnels/mediumtunnel.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/dwarven_snowstable/dwarven_snowstable.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/collidable doodads/golbolarplatform/golbolarplatform.wmo +WMOLoader: loaded 4 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_mountaincave/md_icecave04.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/searinggorge/darkironcrane.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/collidable doodads/searinggorge/searinggorgescaffold/searinggorgescaffold.wmo +WMOLoader: loaded 4 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/az_blackrock/blackrock2.wmo +WMOLoader: loaded 21 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/orcfortress/blackrockorcfortress.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/trollburrow/trollburrow.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/lumbermill/lumbermill.wmo +WMOLoader: loaded 19 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_crypt/md_crypt_d.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/trollhut/trollhutsmall01.wmo +WMOLoader: loaded 3 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/zulgurub/zulgurubcity.wmo +WMOLoader: loaded 4 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stranglethorn_ruins/stranglethorn_ruins02.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/ziggurat/trollziggurat.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stranglethorn_ruins/stranglethorn_ruins04.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stranglethorn_ruins/stranglethorn_ruins08.wmo +[240/368] baked=240 skipped=0 failed=0 +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stranglethorn_ruins/stranglethorn_ruins09.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stranglethorn_ruins/stranglethorn_ruins11.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/stranglethorn/ropebridge/ropebridgeshort.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stranglethorn_ruins/stranglethorn_ruins15.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_ogremound/md_ogremound1room.wmo +WMOLoader: loaded 12 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_cryptschool/md_cryptschool.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/buildings/tirisfalglade/uthershrine/uthershrine.wmo +WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/dwarven_griffonaviary/dwarven_griffonaviary.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/dwarven_blacksmith/wetland_blacksmith/wetland_blacksmith.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/greatwall/greatwall_wall_03.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/arathihighlands/wreckedbuildinghcorner02/wreckedbuildinghcorner02.wmo +WMOLoader: loaded 16 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_ruinedkeep/ruinedkeep.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/arathihighlands/wreckedbuildinghcorner01/wreckedbuildinghcorner01.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/redridge/redridgedocks01/redridgedocks01.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/redridge/redridgedocks03/redridgedocks03.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/dwarven_snowtower/dwarven_snowtower.wmo +WMOLoader: loaded 22 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_dwarven tunnels/largetunnel.wmo +WMOLoader: loaded 3 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/orcmedium/orcmedium.wmo +WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/orcmagetower/orcmagetower.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/orckennel/orckennel.wmo +[260/368] baked=260 skipped=0 failed=0 +WMOLoader: loaded 13 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_mountaincave/md_mushroomcave.wmo +WMOLoader: loaded 12 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/az_karazahn/kharazan.wmo +WMOLoader: loaded 7 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/arathihighlands/humanruinedbuildings/huruinedbuildingssm01_winecellar.wmo +WMOLoader: loaded 4 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/arathihighlands/humanruinedbuildings/huruinedbuilding_collapsed.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/stranglethorn/strangleruinstair/strangleruinstair.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stranglethorn_ruins/stranglethorn_ruins17.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/trollhotel/troll_hotel01.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stranglethorn_ruins/stranglethorn_ruins14.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/stranglethorn/stranglethornruins01/stranglethornruins01.wmo +WMOLoader: loaded 25 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_stratholme_plaguewood_tunnel/md_stratholme_plaguewood_tunnel.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/plaguelandbridge/plaguelandsbridge.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/redridge/redridgedocks/redridgesmalldock01.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/keepwall/lowwallpost01.wmo +WMOLoader: loaded 15 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_ruinedkeep/ruinedkeep_crypt.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_ruinedkeep/ruinedkeep_crypt_facade.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/dwarven_guardtower/dwarven_guardtower.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/arathihighlands/bridge/arathistonebridge02.wmo +WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/dwarven_tavern/wetlands_tavern/wet_tavern.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/collidabledoodads/dustwallowmarsh/crashedzippelin/final/crashedzippelin.wmo +WMOLoader: loaded 7 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/thandolspan_bridge/thandolspan.wmo +[280/368] baked=280 skipped=0 failed=0 +WMOLoader: loaded 8 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_mountaincave/md_mushroomcave04.wmo +WMOLoader: loaded 8 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_dwarven tunnels/smalltunnel_snow.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/collidable doodads/lochmodan/dwarvensentinels/dwarvensentinel01.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/collidable doodads/lochmodan/dwarvensentinels/dwarvensentinel02.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_caveden/md_warmden.wmo +WMOLoader: loaded 9 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_ogremound/md_brogremound.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/orctower/blackrockorctower.wmo +WMOLoader: loaded 11 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/redridge_inn/redridge_inn.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/redridge/redridgedamagedbridge/redridgedamagedbridge.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/redridge/redridgedocks/redridgesmalldock02.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_ogremound/md_grogremound1room.wmo +WMOLoader: loaded 4 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/orcbarracks/abandonedorcbarracks.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/classicalelfruins/azrclassicalelfruin10.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/classicalelfruins/azrclassicalelfruin01.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/buildings/easternplaguelands/undeadziggurat/undeadziggurat.wmo +WMOLoader: loaded 4 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_cavetunnels/md_warmcavetunnel01.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/nightelfhunterslodge/nightelfhunterslodge.wmo +WMOLoader: loaded 3 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/nightelfsmallhouse/nightelfsmallhouse.wmo +WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_ogremound/md_ogremound_medium.wmo +WMOLoader: loaded 3 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_mountaincave/md_mountaincave_medium_d.wmo +[300/368] baked=300 skipped=0 failed=0 +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/wetlands/wetlandswagon/final/wetlandsbustedwagon.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/collidable doodads/lochmodan/dwarvendam/dwarvendam01.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/collidable doodads/lochmodan/dwarvendock/dwarvendock01.wmo +WMOLoader: loaded 8 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/dwarven_inn/wetlands_inn/wet_inn.wmo +WMOLoader: loaded 23 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/kz_uldaman/kz_uldaman_a.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/redridge/redridgefootbridge/redridgefootbridge.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/outland/draenibuildings/outland_draeni_hut_1.wmo +WMOLoader: loaded 3 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/outland/draenibuildings/draenihuthooded01.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/trollburrow/swamptrollburrow.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/collidable doodads/swampofsorrows/bridgestranglethorn/bridgestranglethorn.wmo +WMOLoader: loaded 13 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_goldmine/md_goldmine_variantd.wmo +WMOLoader: loaded 3 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/orctower/abandonedorctower.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/outland/orcbuildings/outlandorctower04.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_ogremound/md_brogremound1room.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/outland/darkportal/darkportal.wmo +WMOLoader: loaded 12 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/buildings/easternplaguelands/undeadslaughterhouse/slaughterhouse.wmo +WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/ld_stratholme/stratholme_a.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/buildings/easternplaguelands/naxportal/epl_portal.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/worldtree/worldtree.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/trollhut/trolltent.wmo +[320/368] baked=320 skipped=0 failed=0 +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/hinterlands/trollcitypieces/aerietrollcitylevel7.wmo +WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_mountaincave/md_mushroomcave08.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/buildings/wetlands_grimbatol/wetlands_grimbatol.wmo +WMOLoader: loaded 5 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_cryptsimpleent/md_cryptsimpleent.wmo +WMOLoader: loaded 6 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/kz_uldaman/kz_uldaman_c.wmo +WMOLoader: loaded 36 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/sunkentemple/az_sunkentemple.wmo +WMOLoader: loaded 3 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/orcmedium/swamporcmedium.wmo +WMOLoader: loaded 4 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/orcgreathall/swamporcgreathall.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/orcblacksmith/swamporcblacksmith.wmo +WMOLoader: loaded 6 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_mountaincave/md_mushroomcave07.wmo +WMOLoader: loaded 26 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/westfall_barracks/westfall_barracks.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/westfall_blacksmith/westfall_blacksmith.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/prison_camp/prisonoubliette.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/westfall_stable/westfall_stableb.wmo +WMOLoader: loaded 13 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_hordemine/md_hordemine.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/outland/orcbuildings/outlandorctower02.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/outland/orcbuildings/outlandorctower01.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/outland/orcbuildings/outlandorcpigfarm01.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/pvp/collidabledoodads/netents/nightelftent02_pvp.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/nightelfhunterslodge/dsnightelfhunterslodge.wmo +[340/368] baked=340 skipped=0 failed=0 +WMOLoader: loaded 7 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/ld_stratholme/stratholme_c.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/classicalelfruins/classicalelfruin10.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/classicalelfruins/classicalelfruin01.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/classicalelfruins/classicalelfruin02.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/classicalelfruins/classicalelfruin03.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/classicalelfruins/classicalelfruin05.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/hinterlands/trollcitypieces/aerietrollcitylevel1.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/hinterlands/trollcitypieces/aerietrollcitylevel2.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/hinterlands/trollcitypieces/aerietrollcitylevel3.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/hinterlands/trollcitypieces/aerietrollcitylevel4.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/hinterlands/trollcitypieces/aerietrollcitylevel5.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/hinterlands/trollcitypieces/aerietrollcitylevel6.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/hinterlands/trollcitypieces/aerietrollcityarena.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/collidabledoodads/hinterlands/trollcitypieces/aerietrollcityampatheater.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/collidable doodads/lochmodan/excavationsite/excavationplatform02.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/collidable doodads/lochmodan/excavationsite/excavationplatform01.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/khazmodan/collidable doodads/lochmodan/excavationsite/excavationplatform03.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/collidabledoodads/darkshore/giantseaturtle01/giantseaturtle01.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/kalimdor/buildings/trollhut/trollhut_burn01.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/stranglethorn_bootybay/ratchet_dock.wmo +[360/368] baked=360 skipped=0 failed=0 +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/md_shipwreck/shipwreck_c.wmo +WMOLoader: loaded 6 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/nd_necropolis/deathknightnecropolis.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwood_human_farm/duskwood_human_farm_closed.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/dungeon/nd_necropolis/nd_necropolisteleport02.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/duskwood_barn/duskwood_barn_closed.wmo +WMOLoader: loaded 7 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/lorderon/buildings/easternplaguelands/cathedral/cathedral.wmo +WMOLoader: loaded 2 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/chapel/redridgechapel.wmo +WMOLoader: loaded 1 groups from C:/Users/sindo/open-wc/data/extracted/world/wmo/azeroth/buildings/guardtower/guardtower_damaged.wmo +[368/368] baked=368 skipped=0 failed=0 +Done. baked=368 skipped=0 failed=0 diff --git a/_godot_m2_stderr.log b/_godot_m2_stderr.log new file mode 100644 index 0000000..e69de29 diff --git a/_godot_m2_stdout.log b/_godot_m2_stdout.log new file mode 100644 index 0000000..bdc4114 --- /dev/null +++ b/_godot_m2_stdout.log @@ -0,0 +1,46 @@ +Godot Engine v4.6.1.stable.official.14d19694e - https://godotengine.org + +Scanning ADT tiles... +Found 2012 unique M2 models from 687 tiles. +[50/2012] baked=50 skipped=0 failed=0 +[100/2012] baked=100 skipped=0 failed=0 +[150/2012] baked=150 skipped=0 failed=0 +[200/2012] baked=200 skipped=0 failed=0 +[250/2012] baked=250 skipped=0 failed=0 +[300/2012] baked=300 skipped=0 failed=0 +[350/2012] baked=350 skipped=0 failed=0 +[400/2012] baked=400 skipped=0 failed=0 +[450/2012] baked=450 skipped=0 failed=0 +[500/2012] baked=500 skipped=0 failed=0 +[550/2012] baked=550 skipped=0 failed=0 +[600/2012] baked=600 skipped=0 failed=0 +[650/2012] baked=650 skipped=0 failed=0 +[700/2012] baked=700 skipped=0 failed=0 +[750/2012] baked=750 skipped=0 failed=0 +[800/2012] baked=800 skipped=0 failed=0 +[850/2012] baked=850 skipped=0 failed=0 +[900/2012] baked=899 skipped=0 failed=1 +[950/2012] baked=949 skipped=0 failed=1 +[1000/2012] baked=999 skipped=0 failed=1 +[1050/2012] baked=1049 skipped=0 failed=1 +[1100/2012] baked=1099 skipped=0 failed=1 +[1150/2012] baked=1149 skipped=0 failed=1 +[1200/2012] baked=1199 skipped=0 failed=1 +[1250/2012] baked=1248 skipped=0 failed=2 +[1300/2012] baked=1298 skipped=0 failed=2 +[1350/2012] baked=1348 skipped=0 failed=2 +[1400/2012] baked=1398 skipped=0 failed=2 +[1450/2012] baked=1447 skipped=0 failed=3 +[1500/2012] baked=1497 skipped=0 failed=3 +[1550/2012] baked=1547 skipped=0 failed=3 +[1600/2012] baked=1597 skipped=0 failed=3 +[1650/2012] baked=1647 skipped=0 failed=3 +[1700/2012] baked=1697 skipped=0 failed=3 +[1750/2012] baked=1747 skipped=0 failed=3 +[1800/2012] baked=1797 skipped=0 failed=3 +[1850/2012] baked=1847 skipped=0 failed=3 +[1900/2012] baked=1897 skipped=0 failed=3 +[1950/2012] baked=1947 skipped=0 failed=3 +[2000/2012] baked=1997 skipped=0 failed=3 +[2012/2012] baked=2009 skipped=0 failed=3 +Done. baked=2009 skipped=0 failed=3 diff --git a/addons/mpq_extractor/loaders/adt_builder.gd b/addons/mpq_extractor/loaders/adt_builder.gd index b86a476..7941507 100644 --- a/addons/mpq_extractor/loaders/adt_builder.gd +++ b/addons/mpq_extractor/loaders/adt_builder.gd @@ -520,6 +520,7 @@ static func _build_mesh_instance_from_payload(payload: Dictionary) -> MeshInstan static func _build_tile_water_root(data: Dictionary, origin_offset: Vector3) -> Node3D: var root := Node3D.new() root.name = "Water" + var surfaces: Dictionary = {} for chunk in data.get("chunks", []) as Array: if chunk.is_empty(): @@ -527,13 +528,102 @@ static func _build_tile_water_root(data: Dictionary, origin_offset: Vector3) -> for liquid in chunk.get("liquids", []) as Array: if liquid.is_empty(): continue - var water_mesh := _build_liquid_mesh(chunk, liquid, origin_offset) - if water_mesh: - root.add_child(water_mesh) + var liquid_id := int(liquid.get("liquid_id", 0)) + if not surfaces.has(liquid_id): + surfaces[liquid_id] = { + "verts": PackedVector3Array(), + "nrms": PackedVector3Array(), + "uvs": PackedVector2Array(), + "indices": PackedInt32Array(), + } + _append_liquid_geometry(chunk, liquid, origin_offset, surfaces[liquid_id]) + + for liquid_id in surfaces.keys(): + var surface: Dictionary = surfaces[liquid_id] + var verts: PackedVector3Array = surface["verts"] + if verts.is_empty(): + continue + + var arrays := [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = verts + arrays[Mesh.ARRAY_NORMAL] = surface["nrms"] + arrays[Mesh.ARRAY_TEX_UV] = surface["uvs"] + arrays[Mesh.ARRAY_INDEX] = surface["indices"] + + var mesh := ArrayMesh.new() + mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + mesh.surface_set_material(0, _build_liquid_material(int(liquid_id))) + + var mi := MeshInstance3D.new() + mi.mesh = mesh + mi.name = "Liquid_%d" % int(liquid_id) + root.add_child(mi) return root if root.get_child_count() > 0 else null +static func _append_liquid_geometry( + chunk: Dictionary, + liquid: Dictionary, + origin_offset: Vector3, + surface: Dictionary) -> void: + + var liquid_mask: PackedByteArray = liquid.get("mask", PackedByteArray()) + var liquid_heights: PackedFloat32Array = liquid.get("heights", PackedFloat32Array()) + if liquid_mask.size() < 8 * 8 or liquid_heights.size() < 9 * 9: + return + + var chunk_origin: Vector3 = chunk.get("origin", Vector3.ZERO) + var tile_local_origin := chunk_origin - origin_offset + var verts: PackedVector3Array = surface["verts"] + var nrms: PackedVector3Array = surface["nrms"] + var uvs_arr: PackedVector2Array = surface["uvs"] + var indices: PackedInt32Array = surface["indices"] + + for z in range(8): + for x in range(8): + if liquid_mask[z * 8 + x] == 0: + continue + + var base := verts.size() + var x0 := tile_local_origin.x + x * UNIT_SIZE + var x1 := tile_local_origin.x + (x + 1) * UNIT_SIZE + var z0 := tile_local_origin.z + z * UNIT_SIZE + var z1 := tile_local_origin.z + (z + 1) * UNIT_SIZE + var y00 := liquid_heights[z * 9 + x] - origin_offset.y + var y10 := liquid_heights[z * 9 + x + 1] - origin_offset.y + var y01 := liquid_heights[(z + 1) * 9 + x] - origin_offset.y + var y11 := liquid_heights[(z + 1) * 9 + x + 1] - origin_offset.y + + verts.append(Vector3(x0, y00, z0)) + verts.append(Vector3(x0, y01, z1)) + verts.append(Vector3(x1, y11, z1)) + verts.append(Vector3(x1, y10, z0)) + + nrms.append(Vector3.UP) + nrms.append(Vector3.UP) + nrms.append(Vector3.UP) + nrms.append(Vector3.UP) + + uvs_arr.append(Vector2(float(x) / 8.0, float(z) / 8.0)) + uvs_arr.append(Vector2(float(x) / 8.0, float(z + 1) / 8.0)) + uvs_arr.append(Vector2(float(x + 1) / 8.0, float(z + 1) / 8.0)) + uvs_arr.append(Vector2(float(x + 1) / 8.0, float(z) / 8.0)) + + indices.append(base) + indices.append(base + 1) + indices.append(base + 2) + indices.append(base) + indices.append(base + 2) + indices.append(base + 3) + + surface["verts"] = verts + surface["nrms"] = nrms + surface["uvs"] = uvs_arr + surface["indices"] = indices + + static func _build_liquid_mesh( chunk: Dictionary, liquid: Dictionary, diff --git a/addons/mpq_extractor/loaders/wmo_builder.gd b/addons/mpq_extractor/loaders/wmo_builder.gd index 0e9cd91..e6a1eb4 100644 --- a/addons/mpq_extractor/loaders/wmo_builder.gd +++ b/addons/mpq_extractor/loaders/wmo_builder.gd @@ -5,7 +5,27 @@ ## add_child(node) class_name WMOBuilder +const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd") +const M2_RIGHT_YAW_OFFSET := PI * 0.5 + static var _texture_cache: Dictionary = {} +# rel_path → Node3D prototype (built once, duplicated per usage) +static var _m2_prototype_cache: Dictionary = {} +# rel_path → true (resolved missing — skip) +static var _m2_missing_cache: Dictionary = {} + + +# Bake scripts should call this before SceneTree.quit() to release cached +# Node3D prototypes (Node-derived objects aren't RefCounted, so the static +# dict alone won't free them at exit). +static func clear_caches() -> void: + for proto in _m2_prototype_cache.values(): + if is_instance_valid(proto): + (proto as Node).free() + _m2_prototype_cache.clear() + _m2_missing_cache.clear() + _texture_cache.clear() + _wmo_liquid_material_cache.clear() # Returns a Node3D containing one MeshInstance3D per WMO group. static func build(data: Dictionary, extracted_dir: String = "") -> Node3D: @@ -23,7 +43,7 @@ static func build(data: Dictionary, extracted_dir: String = "") -> Node3D: for mat_def in materials: godot_materials.append(_build_material(mat_def, textures, extracted_dir)) - # Build one MeshInstance3D per group + # Build one MeshInstance3D per group, plus optional liquid mesh as sibling var groups: Array = data.get("groups", []) for gi in groups.size(): var g: Dictionary = groups[gi] @@ -33,9 +53,117 @@ static func build(data: Dictionary, extracted_dir: String = "") -> Node3D: mesh_inst.name = "Group_%d" % gi root.add_child(mesh_inst) + var liquid_dict: Variant = g.get("liquid", null) + if liquid_dict is Dictionary and not (liquid_dict as Dictionary).is_empty(): + var liquid_inst := _build_group_liquid(liquid_dict) + if liquid_inst != null: + liquid_inst.name = "Group_%d_Liquid" % gi + root.add_child(liquid_inst) + + # Doodad set 0 ($DefaultGlobal) — global decorations always active. + # Non-default sets (Day/Night/etc) are not baked yet — see TODO below. + _build_default_doodads(root, data, extracted_dir) + return root +# Instantiates M2 doodads for set 0. Each placement is a M2 prototype +# duplicated under the WMO root with a local Transform3D. +static func _build_default_doodads(root: Node3D, data: Dictionary, + extracted_dir: String) -> void: + + var sets: Array = data.get("doodad_sets", []) + var placements: Array = data.get("doodad_placements", []) + if placements.is_empty() or sets.is_empty(): + return + + var first_set: Dictionary = sets[0] + var start: int = int(first_set.get("start", 0)) + var count: int = int(first_set.get("count", 0)) + if count <= 0: + return + + var doodad_root := Node3D.new() + doodad_root.name = "Doodads" + root.add_child(doodad_root) + + var end: int = mini(start + count, placements.size()) + for i in range(start, end): + var p: Dictionary = placements[i] + var rel_path: String = str(p.get("name", "")).replace("\\", "/").to_lower() + if rel_path.is_empty(): + continue + # WoW 3.3.5 stores .mdx/.mdl in MODN; the actual file on disk is .m2. + if rel_path.ends_with(".mdx") or rel_path.ends_with(".mdl"): + rel_path = rel_path.get_basename() + ".m2" + + var prototype: Node3D = _get_or_load_m2_prototype(rel_path, extracted_dir) + if prototype == null: + continue + + var inst := prototype.duplicate() as Node3D + if inst == null: + continue + inst.name = "%s_%d" % [rel_path.get_file().get_basename(), i] + + var pos: Vector3 = p.get("pos", Vector3.ZERO) + var rot_q: Quaternion = p.get("rot", Quaternion.IDENTITY) + var scl: float = float(p.get("scale", 1.0)) + var basis := (Basis(rot_q) * Basis(Vector3.UP, M2_RIGHT_YAW_OFFSET)).scaled(Vector3.ONE * maxf(scl, 0.001)) + inst.transform = Transform3D(basis, pos) + doodad_root.add_child(inst) + + +static func _get_or_load_m2_prototype(rel_path: String, + extracted_dir: String) -> Node3D: + + if _m2_prototype_cache.has(rel_path): + return _m2_prototype_cache[rel_path] + if _m2_missing_cache.has(rel_path): + return null + + var prototype: Node3D = _build_raw_m2_prototype(rel_path, extracted_dir) + if prototype != null: + _m2_prototype_cache[rel_path] = prototype + return prototype + + # Fall back to pre-baked cache when the raw M2 is unavailable. + var cache_paths := [ + "res://data/cache/m2_glb".path_join(rel_path.get_basename() + ".tscn"), + "res://data/cache/m2_glb".path_join(rel_path.get_basename() + ".glb"), + ] + for cache_path in cache_paths: + if not ResourceLoader.exists(cache_path): + continue + var resource: Resource = load(cache_path) + if resource is PackedScene: + var node = (resource as PackedScene).instantiate() + if node is Node3D: + _m2_prototype_cache[rel_path] = node as Node3D + return node as Node3D + break + + _m2_missing_cache[rel_path] = true + return null + + +static func _build_raw_m2_prototype(rel_path: String, extracted_dir: String) -> Node3D: + if not ClassDB.class_exists("M2Loader"): + return null + + var abs_path := ProjectSettings.globalize_path(extracted_dir.path_join(rel_path)) + if not FileAccess.file_exists(abs_path): + return null + + var loader = ClassDB.instantiate("M2Loader") + var m2_data: Dictionary = loader.call("load_m2", abs_path) + if m2_data.is_empty() or (m2_data.get("vertices", PackedVector3Array()) as PackedVector3Array).is_empty(): + return null + + var prototype: Node3D = M2_BUILDER_SCRIPT.build(m2_data, extracted_dir) + return prototype + + static func _build_group_mesh( g: Dictionary, godot_mats: Array[StandardMaterial3D]) -> MeshInstance3D: @@ -138,6 +266,100 @@ static func _build_material( return mat +static var _wmo_liquid_material_cache: Dictionary = {} + +# WMO MLIQ → MeshInstance3D in WMO-local coordinates. +# Corner & vertex grid are in WMO-local (Z-up); convert to Godot Y-up using the +# same basis change as the WMO geometry: (x, y, z)_local → (-y, z, -x)_godot. +static func _build_group_liquid(liquid: Dictionary) -> MeshInstance3D: + var xverts: int = int(liquid.get("xverts", 0)) + var yverts: int = int(liquid.get("yverts", 0)) + var xtiles: int = int(liquid.get("xtiles", 0)) + var ytiles: int = int(liquid.get("ytiles", 0)) + if xverts <= 1 or yverts <= 1 or xtiles <= 0 or ytiles <= 0: + return null + + var corner: Vector3 = liquid.get("corner", Vector3.ZERO) + var unit: float = float(liquid.get("unit_size", 4.1666666)) + var heights: PackedFloat32Array = liquid.get("heights", PackedFloat32Array()) + var tiles: PackedByteArray = liquid.get("tiles", PackedByteArray()) + if heights.size() < xverts * yverts: + return null + + var verts := PackedVector3Array() + var indices := PackedInt32Array() + + # Pre-compute Godot-space position for each grid vertex. + # WMO local (lx, ly, lz) → Godot (-ly, lz, -lx). + var pos := PackedVector3Array() + pos.resize(xverts * yverts) + for vy in yverts: + for vx in xverts: + var lx := corner.x + float(vx) * unit + var ly := corner.y + float(vy) * unit + var lz := heights[vy * xverts + vx] + pos[vy * xverts + vx] = Vector3(-ly, lz, -lx) + + for ty in ytiles: + for tx in xtiles: + var flag: int = 0 + if tiles.size() > ty * xtiles + tx: + flag = int(tiles[ty * xtiles + tx]) + # wowdev: high bit (0x80) marks "don't render". Some sources also + # treat 0x0F lower-nibble as "no liquid" — skip both cases. + if (flag & 0x80) != 0: + continue + if (flag & 0x0F) == 0x0F: + continue + var i00 := ty * xverts + tx + var i10 := ty * xverts + tx + 1 + var i01 := (ty + 1) * xverts + tx + var i11 := (ty + 1) * xverts + tx + 1 + var base := verts.size() + verts.append(pos[i00]) + verts.append(pos[i10]) + verts.append(pos[i11]) + verts.append(pos[i01]) + # Two triangles, winding chosen to face up after the (-y, z, -x) flip. + indices.append(base + 0) + indices.append(base + 1) + indices.append(base + 2) + indices.append(base + 0) + indices.append(base + 2) + indices.append(base + 3) + + if verts.is_empty() or indices.is_empty(): + return null + + var arrays := [] + arrays.resize(Mesh.ARRAY_MAX) + arrays[Mesh.ARRAY_VERTEX] = verts + arrays[Mesh.ARRAY_INDEX] = indices + + var mesh := ArrayMesh.new() + mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays) + mesh.surface_set_material(0, _wmo_liquid_material(int(liquid.get("material_id", 0)))) + + var mi := MeshInstance3D.new() + mi.mesh = mesh + return mi + + +static func _wmo_liquid_material(material_id: int) -> StandardMaterial3D: + if _wmo_liquid_material_cache.has(material_id): + return _wmo_liquid_material_cache[material_id] + + var mat := StandardMaterial3D.new() + mat.cull_mode = BaseMaterial3D.CULL_DISABLED + mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + # Default: bluish water. Without DBC liquid_type lookup we can't pick magma/slime + # precisely yet — material_id from MLIQ is the WMO MOMT slot, not the DBC id. + mat.albedo_color = Color(0.2, 0.4, 0.7, 0.6) + _wmo_liquid_material_cache[material_id] = mat + return mat + + static func _load_texture(rel_path: String, extracted_dir: String) -> Texture2D: if rel_path.is_empty() or extracted_dir.is_empty(): return null diff --git a/data/cache/bake_progress/adt_worker_0.json b/data/cache/bake_progress/adt_worker_0.json new file mode 100644 index 0000000..b9d4edc --- /dev/null +++ b/data/cache/bake_progress/adt_worker_0.json @@ -0,0 +1 @@ +{"baked":77,"done":false,"failed":0,"last_tile":"33_26","processed":77,"skipped":0,"total":172,"worker":0,"workers":4} \ No newline at end of file diff --git a/data/cache/bake_progress/adt_worker_1.json b/data/cache/bake_progress/adt_worker_1.json new file mode 100644 index 0000000..7d50ddf --- /dev/null +++ b/data/cache/bake_progress/adt_worker_1.json @@ -0,0 +1 @@ +{"baked":79,"done":false,"failed":0,"last_tile":"33_35","processed":79,"skipped":0,"total":172,"worker":1,"workers":4} \ No newline at end of file diff --git a/data/cache/bake_progress/adt_worker_2.json b/data/cache/bake_progress/adt_worker_2.json new file mode 100644 index 0000000..d16374d --- /dev/null +++ b/data/cache/bake_progress/adt_worker_2.json @@ -0,0 +1 @@ +{"baked":80,"done":false,"failed":0,"last_tile":"33_40","processed":80,"skipped":0,"total":172,"worker":2,"workers":4} \ No newline at end of file diff --git a/data/cache/bake_progress/adt_worker_3.json b/data/cache/bake_progress/adt_worker_3.json new file mode 100644 index 0000000..d43e51d --- /dev/null +++ b/data/cache/bake_progress/adt_worker_3.json @@ -0,0 +1 @@ +{"baked":78,"done":false,"failed":0,"last_tile":"33_33","processed":78,"skipped":0,"total":171,"worker":3,"workers":4} \ No newline at end of file diff --git a/src/native/src/adt_loader.cpp b/src/native/src/adt_loader.cpp index 11af3ce..814c835 100644 --- a/src/native/src/adt_loader.cpp +++ b/src/native/src/adt_loader.cpp @@ -270,16 +270,17 @@ inline void adt_placement_pos_to_godot(float px, float pz, float py, gz = py; } -// MDDF/MODF Euler angles are stored in degrees using the same axis layout as -// placement positions: Y-up, so rot[1] is yaw. Map each axis straight through -// to Godot's Y-up Euler space — the position conversion is identity, so the -// rotation conversion is identity too. rot[1] gets +180° later for WMOs. +// MDDF/MODF Euler angles are stored in degrees in WoW render space (Y-up): +// rot[0] — pitch around X, rot[1] — yaw around Y, rot[2] — roll around Z. +// WoW's "0 yaw" faces a different direction than Godot's, so rot[1] needs +// a fixed +90° offset (matches Blender wow.export importer). Other axes +// map straight through. inline void adt_placement_rot_to_godot(float rx, float ry_yaw, float rz, float &gx, float &gy, float &gz) { constexpr float D2R = (float)(M_PI / 180.0); - gx = rx * D2R; - gy = ry_yaw * D2R; - gz = rz * D2R; + gx = rx * D2R; + gy = (ry_yaw - 90.0f) * D2R; + gz = rz * D2R; } } // namespace @@ -382,12 +383,19 @@ void ADTLoader::_parse_adt(const uint8_t *raw, size_t len, Dictionary &result) { float gx, gy, gz, rx, ry, rz; adt_placement_pos_to_godot(od[i].pos[0], od[i].pos[1], od[i].pos[2], gx, gy, gz); adt_placement_rot_to_godot(od[i].rot[0], od[i].rot[1], od[i].rot[2], rx, ry, rz); + // MODF (WMO) needs an additional +90° yaw on top of the M2 offset + // (so total WMO yaw offset = 0°). WMO local-forward differs from M2. + ry -= (float)(M_PI * 0.5); Dictionary p; - p["name_id"] = (int)od[i].nameId; - p["pos"] = Vector3(gx, gy, gz); - p["rot"] = Vector3(rx, ry, rz); - p["scale"] = 1.0f; + p["name_id"] = (int)od[i].nameId; + p["unique_id"] = (int)od[i].uniqueId; + p["pos"] = Vector3(gx, gy, gz); + p["rot"] = Vector3(rx, ry, rz); + p["scale"] = 1.0f; + p["flags"] = (int)od[i].flags; + p["doodad_set"] = (int)od[i].doodadSet; + p["name_set"] = (int)od[i].nameSet; wmo_placements.push_back(p); } } diff --git a/src/native/src/adt_loader.h b/src/native/src/adt_loader.h index d3b9d89..6a0e5f5 100644 --- a/src/native/src/adt_loader.h +++ b/src/native/src/adt_loader.h @@ -37,10 +37,14 @@ namespace godot { // // Placement Dictionary (both M2 and WMO): // { -// "name_id": int, # index into m2_names / wmo_names -// "pos": Vector3, # Godot world coords -// "rot": Vector3, # Euler angles (radians) -// "scale": float, # M2 only (WMO always 1.0) +// "name_id": int, # index into m2_names / wmo_names +// "unique_id": int, # WMO only — MODF uniqueId (dedupe key across tiles) +// "pos": Vector3, # Godot world coords +// "rot": Vector3, # Euler angles (radians) +// "scale": float, # M2 only (WMO always 1.0) +// "flags": int, # WMO only +// "doodad_set": int, # WMO only +// "name_set": int, # WMO only // } // // Chunk Dictionary: diff --git a/src/native/src/m2_loader.cpp b/src/native/src/m2_loader.cpp index be58600..2ddcf2a 100644 --- a/src/native/src/m2_loader.cpp +++ b/src/native/src/m2_loader.cpp @@ -190,8 +190,10 @@ Dictionary M2Loader::parse_m2(const std::vector &buf, const std::string // WoW model space (X right, Y forward, Z up) → Godot (X right, Y up, Z back) vertices[i] = Vector3( v.pos[0], v.pos[2], -v.pos[1]); normals[i] = Vector3( v.normal[0], v.normal[2], -v.normal[1]); - // WoW V origin is top-left; Godot expects bottom-left → flip V - uvs[i] = Vector2(v.texCoords[0], 1.0f - v.texCoords[1]); + // WoW BLP textures and Godot 4 sampling both use V=0 at top + // (D3D/Vulkan convention) — pass UVs through unchanged. Same as + // wmo_loader's MOTV handling. + uvs[i] = Vector2(v.texCoords[0], v.texCoords[1]); } } diff --git a/src/native/src/register_types.cpp b/src/native/src/register_types.cpp index 47acadc..ca56078 100644 --- a/src/native/src/register_types.cpp +++ b/src/native/src/register_types.cpp @@ -4,6 +4,7 @@ #include "adt_loader.h" #include "blp_loader.h" #include "m2_loader.h" +#include "wdt_loader.h" #include #include @@ -17,6 +18,7 @@ void initialize_mpq_extractor_module(ModuleInitializationLevel p_level) { ClassDB::register_class(); ClassDB::register_class(); ClassDB::register_class(); + ClassDB::register_class(); } void uninitialize_mpq_extractor_module(ModuleInitializationLevel p_level) { diff --git a/src/native/src/wdt_loader.cpp b/src/native/src/wdt_loader.cpp new file mode 100644 index 0000000..ac936d8 --- /dev/null +++ b/src/native/src/wdt_loader.cpp @@ -0,0 +1,156 @@ +#include "wdt_loader.h" +#include "wow_chunk_reader.h" + +#include +#include +#include + +#include +#include + +using namespace godot; + +#pragma pack(push, 1) + +struct MPHD { + uint32_t flags; + uint32_t something; + uint32_t unused[6]; +}; + +struct MAINEntry { + uint32_t flags; // bit 0 = has_adt + uint32_t async_id; +}; + +struct WDTMODFEntry { + uint32_t nameId; + uint32_t uniqueId; + float pos[3]; + float rot[3]; + float bboxMin[3]; + float bboxMax[3]; + uint16_t flags; + uint16_t doodadSet; + uint16_t nameSet; + uint16_t padding; +}; + +#pragma pack(pop) + +std::vector WDTLoader::read_file(const std::string &path) { + std::ifstream f(path, std::ios::binary | std::ios::ate); + if (!f) return {}; + auto sz = f.tellg(); f.seekg(0); + std::vector buf(sz); + f.read(reinterpret_cast(buf.data()), sz); + return buf; +} + +std::string WDTLoader::to_std(const String &s) { + return std::string(s.utf8().get_data()); +} + +String WDTLoader::to_godot(const std::string &s) { + return String(s.c_str()); +} + +Dictionary WDTLoader::load_wdt(const String &path) { + Dictionary result; + const std::string p = to_std(path); + std::vector buf = read_file(p); + if (buf.empty()) { + UtilityFunctions::push_warning("WDTLoader: cannot open file: ", path); + return result; + } + _parse_wdt(buf.data(), buf.size(), result); + return result; +} + +Dictionary WDTLoader::load_wdt_from_bytes(const PackedByteArray &bytes) { + Dictionary result; + if (bytes.size() <= 0) return result; + _parse_wdt(bytes.ptr(), (size_t)bytes.size(), result); + return result; +} + +void WDTLoader::_parse_wdt(const uint8_t *raw, size_t len, Dictionary &result) { + ChunkReader reader(raw, len); + + uint32_t version = 0; + uint32_t flags = 0; + bool found_mphd = false; + Array tiles; + + WoWChunk c; + while (reader.next(c)) { + if (c.is("MVER") && c.size >= 4) { + std::memcpy(&version, c.data, 4); + } else if (c.is("MPHD") && c.size >= sizeof(MPHD)) { + const MPHD &mphd = c.as(); + flags = mphd.flags; + found_mphd = true; + } else if (c.is("MAIN")) { + const uint32_t expected = 64 * 64 * (uint32_t)sizeof(MAINEntry); + if (c.size < expected) { + UtilityFunctions::push_warning("WDTLoader: MAIN chunk too small: ", (int)c.size); + continue; + } + const MAINEntry *entries = c.array(); + for (int y = 0; y < 64; ++y) { + for (int x = 0; x < 64; ++x) { + const MAINEntry &e = entries[y * 64 + x]; + if (!(e.flags & 0x1)) continue; + Dictionary td; + td["x"] = (int)x; + td["y"] = (int)y; + td["flags"] = (int)e.flags; + tiles.push_back(td); + } + } + } else if (c.is("MWMO") && c.size > 0) { + // Global WMO filename (null-terminated). + const char *s = reinterpret_cast(c.data); + size_t n = c.size; + while (n > 0 && s[n - 1] == '\0') --n; + result["global_wmo_name"] = String(std::string(s, n).c_str()); + } else if (c.is("MODF") && c.size >= sizeof(WDTMODFEntry)) { + const WDTMODFEntry &e = c.as(); + + float gx, gy, gz; + wow_to_godot(e.pos[0], e.pos[1], e.pos[2], gx, gy, gz); + + float rx, ry, rz; + wow_rot_to_godot(e.rot[0], e.rot[1], e.rot[2], rx, ry, rz); + // Global WMOs (MODF) get the same extra yaw as tile WMOs — see + // the matching comment in adt_loader.cpp MODF loop. + ry -= (float)(M_PI * 0.5); + + Dictionary placement; + placement["name_id"] = (int)e.nameId; + placement["unique_id"] = (int)e.uniqueId; + placement["pos"] = Vector3(gx, gy, gz); + placement["rot"] = Vector3(rx, ry, rz); + placement["flags"] = (int)e.flags; + placement["doodad_set"] = (int)e.doodadSet; + placement["name_set"] = (int)e.nameSet; + result["global_wmo_placement"] = placement; + } + } + + if (!found_mphd) { + UtilityFunctions::push_warning("WDTLoader: MPHD chunk missing"); + } + + result["version"] = (int)version; + result["flags"] = (int)flags; + result["has_global_wmo"] = (flags & 0x1) != 0; + result["big_alpha"] = (flags & 0x4) != 0; + result["sort_by_size_class"] = (flags & 0x8) != 0; + result["tiles"] = tiles; +} + +void WDTLoader::_bind_methods() { + ClassDB::bind_method(D_METHOD("load_wdt", "path"), &WDTLoader::load_wdt); + ClassDB::bind_method(D_METHOD("load_wdt_from_bytes", "bytes"), &WDTLoader::load_wdt_from_bytes); +} diff --git a/src/native/src/wdt_loader.h b/src/native/src/wdt_loader.h new file mode 100644 index 0000000..4f3580c --- /dev/null +++ b/src/native/src/wdt_loader.h @@ -0,0 +1,55 @@ +#pragma once +#include +#include +#include + +#include +#include +#include +#include +#include + +namespace godot { + +// ───────────────────────────────────────────────────────────────────────────── +// WDTLoader +// +// Loads WoW 3.3.5a WDT map descriptors. +// +// Usage (GDScript): +// var wdt = WDTLoader.new() +// var data = wdt.load_wdt("C:/wow/Data/World/Maps/Azeroth/Azeroth.wdt") +// +// Return Dictionary: +// { +// "version": int, # MVER (expected 18) +// "flags": int, # MPHD.flags +// "has_global_wmo": bool, # flags & 0x1 +// "big_alpha": bool, # flags & 0x4 +// "sort_by_size_class": bool, # flags & 0x8 +// "tiles": Array[Dictionary], # one entry per ADT present (flag 0x1 in MAIN) +// # { "x": int, "y": int, "flags": int } +// # optional, only when has_global_wmo: +// "global_wmo_name": String, +// "global_wmo_placement": Dictionary, # MODF-like entry +// } +// ───────────────────────────────────────────────────────────────────────────── +class WDTLoader : public RefCounted { + GDCLASS(WDTLoader, RefCounted) + +public: + Dictionary load_wdt(const String &path); + Dictionary load_wdt_from_bytes(const PackedByteArray &bytes); + +protected: + static void _bind_methods(); + +private: + static std::vector read_file(const std::string &path); + static std::string to_std(const String &s); + static String to_godot(const std::string &s); + + static void _parse_wdt(const uint8_t *raw, size_t len, Dictionary &result); +}; + +} // namespace godot diff --git a/src/native/src/wmo_loader.cpp b/src/native/src/wmo_loader.cpp index 2283150..1a2cee2 100644 --- a/src/native/src/wmo_loader.cpp +++ b/src/native/src/wmo_loader.cpp @@ -4,6 +4,7 @@ #include #include #include +#include #include #include @@ -85,8 +86,40 @@ struct MONREntry { float x, y, z; }; struct MOTVEntry { float u, v; }; struct MOCVEntry { uint8_t b, g, r, a; }; +struct MLIQHeader { // Liquid header inside MLIQ chunk + uint32_t xverts; // = xtiles + 1 + uint32_t yverts; // = ytiles + 1 + uint32_t xtiles; + uint32_t ytiles; + float corner[3]; // base position (WMO local, Z-up) + uint16_t materialId; +}; + +struct MLIQVertex { // 8 bytes per vertex (interpretation depends on liquid type) + uint32_t data; // flow info or s,t for magma — we ignore + float height; +}; + +struct MODSEntry { // Doodad set: 32 bytes + char name[20]; + uint32_t startDoodad; // first MODD index + uint32_t nDoodads; + uint32_t unused; +}; + +struct MODDEntry { // Doodad placement: 40 bytes + uint32_t nameOfsAndFlags; // low 24 = MODN byte offset, high 8 = flags + float pos[3]; // WMO local (Z-up) + float rot[4]; // quaternion (qx, qy, qz, qw) in WMO local + float scale; + uint8_t color[4]; // BGRA tint +}; + #pragma pack(pop) +// MLIQ tile size in WoW yards (same constant as ADT MCNK unit cells). +static constexpr float WMO_LIQUID_UNIT_SIZE = 4.1666666f; + // ───────────────────────────────────────────────────────────────────────────── // Helpers // ───────────────────────────────────────────────────────────────────────────── @@ -122,6 +155,14 @@ Dictionary WMOLoader::parse_root(const std::vector &buf) { Array materials; std::vector tex_strings; + // Two-pass: first collect raw chunks, then build (MODD/MODN/MODS may appear in any order). + const uint8_t *modn_data = nullptr; + size_t modn_size = 0; + const MODSEntry *mods = nullptr; + uint32_t mods_count = 0; + const MODDEntry *modd = nullptr; + uint32_t modd_count = 0; + while (reader.next(chunk)) { if (chunk.is("MOTX")) { @@ -152,11 +193,69 @@ Dictionary WMOLoader::parse_root(const std::vector &buf) { mat["texture1"] = tex_idx(mt[i].texUnit1); materials.push_back(mat); } + } else if (chunk.is("MODS")) { + mods = chunk.array(); + mods_count = chunk.count_of(sizeof(MODSEntry)); + } else if (chunk.is("MODN")) { + modn_data = chunk.data; + modn_size = chunk.size; + } else if (chunk.is("MODD")) { + modd = chunk.array(); + modd_count = chunk.count_of(sizeof(MODDEntry)); } } root["textures"] = textures; root["materials"] = materials; + + // ── Doodad sets ───────────────────────────────────────────────────────── + Array doodad_sets; + for (uint32_t i = 0; i < mods_count; ++i) { + Dictionary s; + char buf[21]; std::memcpy(buf, mods[i].name, 20); buf[20] = 0; + s["name"] = String(buf); + s["start"] = (int)mods[i].startDoodad; + s["count"] = (int)mods[i].nDoodads; + doodad_sets.push_back(s); + } + root["doodad_sets"] = doodad_sets; + + // ── Doodad placements ─────────────────────────────────────────────────── + // Position/rotation come in WMO local (Z-up). Convert to Godot Y-up the + // same way as MOVT vertices: (lx, ly, lz) → (-ly, lz, -lx). Quaternion + // vector part transforms as a vector under that basis change; the scalar + // part is kept (signed `qw` → `qw`). The basis change has det = -1 which + // reverses winding; for rotations of M2 prototypes that already live in + // Godot-space (built via M2Builder), this gives the visually correct + // orientation. + Array doodad_placements; + for (uint32_t i = 0; i < modd_count; ++i) { + const MODDEntry &d = modd[i]; + + // Resolve doodad name from MODN by byte offset. + uint32_t name_ofs = d.nameOfsAndFlags & 0x00FFFFFFu; + String name; + if (modn_data && name_ofs < modn_size) { + const char *s = reinterpret_cast(modn_data + name_ofs); + // Bound by remaining MODN bytes. + size_t max_len = modn_size - name_ofs; + size_t len = 0; + while (len < max_len && s[len] != 0) ++len; + name = String(std::string(s, len).c_str()); + } + + Dictionary p; + p["name"] = name; + p["flags"] = (int)((d.nameOfsAndFlags >> 24) & 0xFFu); + p["pos"] = Vector3(-d.pos[1], d.pos[2], -d.pos[0]); + p["rot"] = Quaternion(-d.rot[1], d.rot[2], -d.rot[0], d.rot[3]); + p["scale"] = d.scale; + p["color"] = Color(d.color[2] / 255.f, d.color[1] / 255.f, + d.color[0] / 255.f, d.color[3] / 255.f); + doodad_placements.push_back(p); + } + root["doodad_placements"] = doodad_placements; + return root; } @@ -214,7 +313,7 @@ Dictionary WMOLoader::parse_group(const std::vector &buf) { auto *v = sc.array(); uvs.resize(n); for (uint32_t i = 0; i < n; ++i) - uvs[i] = Vector2(v[i].u, v[i].v); + uvs[i] = Vector2(v[i].u, 1.0f - v[i].v); } else if (sc.is("MOVI")) { uint32_t n = sc.size / 2; @@ -244,6 +343,45 @@ Dictionary WMOLoader::parse_group(const std::vector &buf) { b["flags"] = (int)ba[i].flags; batches.push_back(b); } + } else if (sc.is("MLIQ") && sc.size >= sizeof(MLIQHeader)) { + const auto &h = *reinterpret_cast(sc.data); + + size_t need = sizeof(MLIQHeader) + + (size_t)h.xverts * h.yverts * sizeof(MLIQVertex) + + (size_t)h.xtiles * h.ytiles; + if (need <= sc.size && h.xverts > 0 && h.yverts > 0 + && h.xtiles > 0 && h.ytiles > 0) { + auto *verts = reinterpret_cast( + sc.data + sizeof(MLIQHeader)); + auto *tiles = sc.data + sizeof(MLIQHeader) + + (size_t)h.xverts * h.yverts * sizeof(MLIQVertex); + + PackedFloat32Array heights; + heights.resize((int)(h.xverts * h.yverts)); + { + float *dst = heights.ptrw(); + for (uint32_t i = 0; i < h.xverts * h.yverts; ++i) + dst[i] = verts[i].height; + } + + PackedByteArray tile_flags; + tile_flags.resize((int)(h.xtiles * h.ytiles)); + std::memcpy(tile_flags.ptrw(), tiles, + (size_t)h.xtiles * h.ytiles); + + Dictionary liquid; + liquid["xverts"] = (int)h.xverts; + liquid["yverts"] = (int)h.yverts; + liquid["xtiles"] = (int)h.xtiles; + liquid["ytiles"] = (int)h.ytiles; + // Corner in WMO local (Z-up). Builder converts to Godot. + liquid["corner"] = Vector3(h.corner[0], h.corner[1], h.corner[2]); + liquid["material_id"] = (int)h.materialId; + liquid["unit_size"] = WMO_LIQUID_UNIT_SIZE; + liquid["heights"] = heights; + liquid["tiles"] = tile_flags; + group["liquid"] = liquid; + } } } diff --git a/src/native/src/wow_chunk_reader.h b/src/native/src/wow_chunk_reader.h index e925287..ba8d44d 100644 --- a/src/native/src/wow_chunk_reader.h +++ b/src/native/src/wow_chunk_reader.h @@ -86,7 +86,9 @@ inline void wow_to_godot(float wx, float wy, float wz, gz = -(wx - 17066.666f); } -// WoW rotation (degrees, ZXY order) → Godot Euler (radians, XYZ) +// WoW MDDF/MODF rotation (degrees, render-space Y-up: rx=pitch, ry=yaw, rz=roll) +// → Godot Euler (radians). WoW's "0 yaw" differs from Godot's, so ry needs +// a fixed +90° offset (matches Blender wow.export importer). #ifndef M_PI #define M_PI 3.14159265358979323846 #endif @@ -94,9 +96,9 @@ inline void wow_rot_to_godot(float rx, float ry, float rz, float &gx, float &gy, float &gz) { constexpr float D2R = (float)(M_PI / 180.0); - gx = rx * D2R; - gy = (rz - 90.0f) * D2R; // WoW Z-rot maps to Godot Y - gz = -ry * D2R; + gx = rx * D2R; + gy = (ry - 90.0f) * D2R; + gz = rz * D2R; } // Collect null-separated strings from a chunk into a vector diff --git a/src/scenes/streaming/eastern_kingdoms_streaming.tscn b/src/scenes/streaming/eastern_kingdoms_streaming.tscn index 780ce84..f7157af 100644 --- a/src/scenes/streaming/eastern_kingdoms_streaming.tscn +++ b/src/scenes/streaming/eastern_kingdoms_streaming.tscn @@ -19,10 +19,9 @@ update_interval = 0.1 max_concurrent_tile_tasks = 4 chunk_ops_per_tick = 64 cached_tile_mesh_limit = 48 -use_baked_tile_cache = false +editor_follow_view_camera = false enable_water = true terrain_cast_shadows = true -m2_cast_shadows = null [node name="Camera3D" type="Camera3D" parent="." unique_id=502573687] transform = Transform3D(1, 0, 0, 0, 0.707107, 0.707107, 0, -0.707107, 0.707107, 0, 300, 300) @@ -36,6 +35,9 @@ fast_mult = 8.0 transform = Transform3D(0.866025, -0.353553, 0.353553, 0, 0.707107, 0.707107, -0.5, -0.612372, 0.612372, 0, 0, 0) light_energy = 1.5 shadow_enabled = true +directional_shadow_mode = 1 +directional_shadow_fade_start = 0.85 +directional_shadow_max_distance = 2200.0 [node name="WorldEnvironment" type="WorldEnvironment" parent="." unique_id=12906896] environment = SubResource("Environment_1") diff --git a/src/scenes/streaming/streaming_world_loader.gd b/src/scenes/streaming/streaming_world_loader.gd index f92eb38..38e3d58 100644 --- a/src/scenes/streaming/streaming_world_loader.gd +++ b/src/scenes/streaming/streaming_world_loader.gd @@ -10,6 +10,7 @@ const REQUIRED_BAKED_TILE_FORMAT_VERSION := 4 const TILE_SIZE := 533.33333 const CHUNK_SIZE := TILE_SIZE / 16.0 +const M2_RIGHT_YAW_OFFSET := 0.0 @export var extracted_dir: String = "res://data/extracted" @export var map_name: String = "Azeroth" @@ -51,6 +52,14 @@ const CHUNK_SIZE := TILE_SIZE / 16.0 @export var enable_m2_assets: bool = true @export var enable_m2_placeholders: bool = false @export var m2_placeholder_only_trees: bool = true +## Dynamic doodad render radius in ADT tiles. Negative keeps all loaded M2s active. +@export var m2_tile_radius: int = 2 +## Dynamic WMO render radius in ADT tiles. Negative keeps all loaded WMOs active. +@export var wmo_tile_radius: int = 4 +## Godot visibility range applied to instantiated M2 MultiMesh nodes. <= 0 disables it. +@export var m2_visibility_range: float = TILE_SIZE * 3.0 +## Godot visibility range applied to instantiated WMO trees. <= 0 disables it. +@export var wmo_visibility_range: float = TILE_SIZE * 5.0 @export var m2_cache_dir: String = "res://data/cache/m2_glb" @export var wmo_cache_dir: String = "res://data/cache/wmo_tscn" @export var terrain_cast_shadows: bool = false @@ -61,6 +70,7 @@ const CHUNK_SIZE := TILE_SIZE / 16.0 var _builder var _map_dir := "" var _abs_map_dir := "" +var _wdt_info: Dictionary = {} var _available_tiles: Dictionary = {} var _tile_states: Dictionary = {} var _tile_load_queue: Array = [] @@ -91,6 +101,8 @@ var _wmo_prototype_cache: Dictionary = {} var _wmo_missing_cache: Dictionary = {} var _m2_scene_cache: Dictionary = {} var _m2_missing_cache: Dictionary = {} +var _world_wmo_root: Node3D +var _wmo_registry: Dictionary = {} func _ready() -> void: @@ -110,10 +122,11 @@ func _ready() -> void: _terrain_root.name = "Terrain" add_child(_terrain_root) _reset_terrain_root_children() + _ensure_world_wmo_root() _set_editor_owner_recursive(_terrain_root) _scan_available_tiles() - if _available_tiles.is_empty(): + if _available_tiles.is_empty() and not _wdt_has_global_wmo(): push_error("No ADT tiles found in %s" % _map_dir) return if _can_use_baked_tile_cache(): @@ -121,6 +134,9 @@ func _ready() -> void: if not DirAccess.dir_exists_absolute(baked_dir): push_warning("Baked terrain cache not found: %s. Runtime will fall back to raw ADT builds." % baked_dir) + if enable_wmo: + _place_global_wmo() + if Engine.is_editor_hint(): _tick_editor_streaming() elif auto_position_camera: @@ -185,6 +201,64 @@ func _tick_editor_streaming() -> void: func _scan_available_tiles() -> void: + _available_tiles.clear() + _wdt_info = {} + + if _load_tiles_from_wdt(): + print("StreamingWorld: loaded %d ADT tiles from WDT for %s" % [_available_tiles.size(), map_name]) + return + + push_warning("StreamingWorld: falling back to directory scan for %s (no WDT)" % map_name) + _load_tiles_from_directory() + print("StreamingWorld: found %d ADT tiles by directory scan for %s" % [_available_tiles.size(), map_name]) + + +func _load_tiles_from_wdt() -> bool: + if not ClassDB.class_exists("WDTLoader"): + return false + + var wdt_path_abs := _abs_map_dir.path_join("%s.wdt" % map_name) + if not FileAccess.file_exists(wdt_path_abs): + return false + + var loader = ClassDB.instantiate("WDTLoader") + if loader == null: + return false + + var data: Dictionary = loader.call("load_wdt", wdt_path_abs) + if data.is_empty(): + push_warning("StreamingWorld: WDTLoader returned empty data for %s" % wdt_path_abs) + return false + + var tiles: Array = data.get("tiles", []) + for tile_variant in tiles: + if not (tile_variant is Dictionary): + continue + var entry: Dictionary = tile_variant + var tx: int = int(entry.get("x", -1)) + var ty: int = int(entry.get("y", -1)) + if tx < 0 or tx > 63 or ty < 0 or ty > 63: + continue + + var adt_file := "%s_%d_%d.adt" % [map_name, tx, ty] + var adt_abs := _abs_map_dir.path_join(adt_file) + # WDT flags the tile as present, but the ADT may still be missing in + # a partial extraction. Skip so downstream code never chases a ghost. + if not FileAccess.file_exists(adt_abs): + continue + + var key := _tile_key(tx, ty) + _available_tiles[key] = adt_abs + _tile_min.x = min(_tile_min.x, tx) + _tile_min.y = min(_tile_min.y, ty) + _tile_max.x = max(_tile_max.x, tx) + _tile_max.y = max(_tile_max.y, ty) + + _wdt_info = data + return not _available_tiles.is_empty() + + +func _load_tiles_from_directory() -> void: var dir := DirAccess.open(_map_dir) if dir == null: push_error("Cannot open map directory: %s" % _map_dir) @@ -210,8 +284,6 @@ func _scan_available_tiles() -> void: _tile_max.x = max(_tile_max.x, tx) _tile_max.y = max(_tile_max.y, ty) - print("StreamingWorld: found %d ADT tiles for %s" % [_available_tiles.size(), map_name]) - func _refresh_streaming_targets(force: bool) -> void: var camera := _get_stream_camera() @@ -312,6 +384,7 @@ func _apply_streaming_target(wanted_tiles: Dictionary, focus_pos: Vector3) -> vo else: state["desired_lods"] = {} state["desired_tile_lod"] = -1 + state = _sync_detail_assets(state, focus_pos) _tile_states[key] = state _rebuild_chunk_queues() @@ -592,25 +665,12 @@ func _finalize_loaded_tile(request: Dictionary, data: Dictionary) -> void: if water_root: tile_root.add_child(water_root) _set_editor_owner_recursive(water_root) - if enable_m2_assets: - _build_tile_m2_assets( - tile_root, - tile_origin, - data.get("m2_names", PackedStringArray()), - data.get("m2_placements", [])) if enable_m2_placeholders: _build_tile_m2_placeholders( tile_root, tile_origin, data.get("m2_names", PackedStringArray()), data.get("m2_placements", [])) - if enable_wmo: - _build_tile_wmos( - tile_root, - tile_origin, - data.get("wmo_names", PackedStringArray()), - data.get("wmo_placements", [])) - var state := { "key": key, "tx": request["tx"], @@ -629,11 +689,18 @@ func _finalize_loaded_tile(request: Dictionary, data: Dictionary) -> void: "desired_tile_lod": -1, "wanted": true, "root": tile_root, + "m2_names": data.get("m2_names", PackedStringArray()), + "m2_placements": data.get("m2_placements", []), + "m2_built": false, + "wmo_names": data.get("wmo_names", PackedStringArray()), + "wmo_placements": data.get("wmo_placements", []), + "wmo_refs": [], } # Use _last_focus_pos — already set correctly for both editor and game. state["desired_lods"] = _compute_desired_lods(state, _last_focus_pos) state["desired_tile_lod"] = -1 if not state["desired_lods"].is_empty() else _compute_desired_tile_lod(state, _last_focus_pos) + state = _sync_detail_assets(state, _last_focus_pos) _tile_states[key] = state _rebuild_chunk_queues() @@ -663,27 +730,14 @@ func _finalize_loaded_baked_tile(request: Dictionary, baked_tile: Resource) -> v var tile_origin: Vector3 = baked_tile.get("tile_origin") tile_root.position = tile_origin - if enable_m2_assets: - var baked_m2_names_value: Variant = baked_tile.get("m2_names") - var baked_m2_placements_value: Variant = baked_tile.get("m2_placements") - var baked_m2_names: PackedStringArray = baked_m2_names_value if baked_m2_names_value is PackedStringArray else PackedStringArray() - var baked_m2_placements: Array = baked_m2_placements_value if baked_m2_placements_value is Array else [] - _build_tile_m2_assets( - tile_root, - tile_origin, - baked_m2_names, - baked_m2_placements) - if enable_wmo: - var baked_wmo_names_value: Variant = baked_tile.get("wmo_names") - var baked_wmo_placements_value: Variant = baked_tile.get("wmo_placements") - var baked_wmo_names: PackedStringArray = baked_wmo_names_value if baked_wmo_names_value is PackedStringArray else PackedStringArray() - var baked_wmo_placements: Array = baked_wmo_placements_value if baked_wmo_placements_value is Array else [] - _build_tile_wmos( - tile_root, - tile_origin, - baked_wmo_names, - baked_wmo_placements) - + var baked_m2_names_value: Variant = baked_tile.get("m2_names") + var baked_m2_placements_value: Variant = baked_tile.get("m2_placements") + var baked_m2_names: PackedStringArray = baked_m2_names_value if baked_m2_names_value is PackedStringArray else PackedStringArray() + var baked_m2_placements: Array = baked_m2_placements_value if baked_m2_placements_value is Array else [] + var baked_wmo_names_value: Variant = baked_tile.get("wmo_names") + var baked_wmo_placements_value: Variant = baked_tile.get("wmo_placements") + var baked_wmo_names: PackedStringArray = baked_wmo_names_value if baked_wmo_names_value is PackedStringArray else PackedStringArray() + var baked_wmo_placements: Array = baked_wmo_placements_value if baked_wmo_placements_value is Array else [] var state := { "key": key, "tx": request["tx"], @@ -702,9 +756,16 @@ func _finalize_loaded_baked_tile(request: Dictionary, baked_tile: Resource) -> v "desired_tile_lod": -1, "wanted": true, "root": tile_root, + "m2_names": baked_m2_names, + "m2_placements": baked_m2_placements, + "m2_built": false, + "wmo_names": baked_wmo_names, + "wmo_placements": baked_wmo_placements, + "wmo_refs": [], } state["desired_tile_lod"] = _compute_desired_tile_lod(state, _last_focus_pos) + state = _sync_detail_assets(state, _last_focus_pos) _tile_states[key] = state _rebuild_chunk_queues() if debug_streaming: @@ -910,6 +971,7 @@ func _release_tile(key: String) -> void: return var state: Dictionary = _tile_states[key] + _unregister_tile_wmos(state) var tile_lod_rid: RID = state.get("tile_lod_rid", RID()) if tile_lod_rid.is_valid(): _free_render_instance(tile_lod_rid) @@ -949,6 +1011,10 @@ func _clear_streamed_world() -> void: _tile_states.clear() _clear_tile_mesh_cache() + _clear_wmo_registry() + _ensure_world_wmo_root() + if enable_wmo: + _place_global_wmo() func _reset_terrain_root_children() -> void: @@ -1121,16 +1187,110 @@ func _tile_mesh_cache_key(tile_key: String, lod: int) -> String: return "%s|%d" % [tile_key, lod] -func _build_tile_wmos(tile_root: Node3D, tile_origin: Vector3, wmo_names: PackedStringArray, wmo_placements: Array) -> void: - if wmo_names.is_empty() or wmo_placements.is_empty(): +func _ensure_world_wmo_root() -> void: + if _world_wmo_root != null and is_instance_valid(_world_wmo_root): + return + _world_wmo_root = Node3D.new() + _world_wmo_root.name = "WMOs" + _terrain_root.add_child(_world_wmo_root) + _set_editor_owner_recursive(_world_wmo_root) + + +func _wdt_has_global_wmo() -> bool: + return bool(_wdt_info.get("has_global_wmo", false)) and _wdt_info.has("global_wmo_name") + + +func _place_global_wmo() -> void: + if not _wdt_has_global_wmo(): + return + if _wmo_registry.has("global"): return - var wmo_root := Node3D.new() - wmo_root.name = "WMOs" - tile_root.add_child(wmo_root) - _set_editor_owner_recursive(wmo_root) + _ensure_world_wmo_root() - for placement_variant in wmo_placements: + var rel_path := String(_wdt_info.get("global_wmo_name", "")).replace("\\", "/") + if rel_path.is_empty(): + return + + var placement: Dictionary = _wdt_info.get("global_wmo_placement", {}) + if placement.is_empty(): + placement = { + "pos": Vector3.ZERO, + "rot": Vector3.ZERO, + "scale": 1.0, + } + + var instance := _instantiate_wmo_world(rel_path, placement) + if instance == null: + push_warning("StreamingWorld: failed to load global WMO: %s" % rel_path) + return + + _world_wmo_root.add_child(instance) + _apply_visibility_range_recursive(instance, wmo_visibility_range) + _set_editor_owner_recursive(instance) + _wmo_registry["global"] = { + "node": instance, + "refs": {"__global__": true}, + } + + +func _sync_detail_assets(state: Dictionary, focus_pos: Vector3) -> Dictionary: + if not bool(state.get("wanted", false)): + _disable_tile_detail_assets(state) + return state + + var tile_root: Node = state.get("root", null) + if tile_root == null or not is_instance_valid(tile_root): + return state + + if enable_wmo and _tile_within_detail_radius(state, focus_pos, wmo_tile_radius): + var refs: Array = state.get("wmo_refs", []) + if refs.is_empty(): + _register_tile_wmos( + state, + state.get("wmo_names", PackedStringArray()), + state.get("wmo_placements", [])) + else: + _unregister_tile_wmos(state) + + if enable_m2_assets and _tile_within_detail_radius(state, focus_pos, m2_tile_radius): + if not bool(state.get("m2_built", false)): + state["m2_built"] = _build_tile_m2_assets( + tile_root as Node3D, + state.get("origin", Vector3.ZERO), + state.get("m2_names", PackedStringArray()), + state.get("m2_placements", [])) + else: + _remove_tile_m2_assets(state) + state["m2_built"] = false + + return state + + +func _disable_tile_detail_assets(state: Dictionary) -> void: + _unregister_tile_wmos(state) + _remove_tile_m2_assets(state) + state["m2_built"] = false + + +func _tile_within_detail_radius(state: Dictionary, focus_pos: Vector3, radius_tiles: int) -> bool: + if radius_tiles < 0: + return true + var radius_sq := (float(radius_tiles) * TILE_SIZE) ** 2 + return _tile_dist_sq(state, focus_pos) <= radius_sq + + +func _register_tile_wmos(state: Dictionary, wmo_names: PackedStringArray, wmo_placements: Array) -> void: + var refs: Array = state.get("wmo_refs", []) + if wmo_names.is_empty() or wmo_placements.is_empty(): + state["wmo_refs"] = refs + return + + _ensure_world_wmo_root() + var tile_key: String = state["key"] + + for i in wmo_placements.size(): + var placement_variant = wmo_placements[i] if not (placement_variant is Dictionary): continue var placement: Dictionary = placement_variant @@ -1138,17 +1298,83 @@ func _build_tile_wmos(tile_root: Node3D, tile_origin: Vector3, wmo_names: Packed if name_id < 0 or name_id >= wmo_names.size(): continue + var unique_key := _wmo_unique_key(placement, tile_key, i) + + if _wmo_registry.has(unique_key): + var entry: Dictionary = _wmo_registry[unique_key] + var entry_refs: Dictionary = entry["refs"] + entry_refs[tile_key] = true + refs.append(unique_key) + continue + var rel_path: String = str(wmo_names[name_id]).replace("\\", "/") - var instance := _instantiate_wmo(rel_path, placement, tile_origin) + var instance := _instantiate_wmo_world(rel_path, placement) if instance == null: continue - wmo_root.add_child(instance) + + _world_wmo_root.add_child(instance) + _apply_visibility_range_recursive(instance, wmo_visibility_range) _set_editor_owner_recursive(instance) + _wmo_registry[unique_key] = { + "node": instance, + "refs": {tile_key: true}, + } + refs.append(unique_key) + + state["wmo_refs"] = refs -func _build_tile_m2_assets(tile_root: Node3D, tile_origin: Vector3, m2_names: PackedStringArray, m2_placements: Array) -> void: - if m2_names.is_empty() or m2_placements.is_empty(): +func _unregister_tile_wmos(state: Dictionary) -> void: + var refs: Array = state.get("wmo_refs", []) + if refs.is_empty(): return + var tile_key: String = state["key"] + + for unique_key in refs: + if not _wmo_registry.has(unique_key): + continue + var entry: Dictionary = _wmo_registry[unique_key] + var entry_refs: Dictionary = entry["refs"] + entry_refs.erase(tile_key) + if entry_refs.is_empty(): + var node: Node = entry.get("node", null) + if node: + node.queue_free() + _wmo_registry.erase(unique_key) + + state["wmo_refs"] = [] + + +func _wmo_unique_key(placement: Dictionary, tile_key: String, idx: int) -> String: + var uid: int = int(placement.get("unique_id", -1)) + # uniqueId is always set in vanilla/3.3.5a MODF data, but older baked caches + # predating the ADTLoader unique_id field will miss it. Fall back to a + # per-tile synthetic key — no cross-tile dedup, but also no collisions. + if uid > 0: + return "uid:%d" % uid + return "tile:%s:%d" % [tile_key, idx] + + +func _clear_wmo_registry() -> void: + for entry_variant in _wmo_registry.values(): + if not (entry_variant is Dictionary): + continue + var entry: Dictionary = entry_variant + var node: Node = entry.get("node", null) + if node and is_instance_valid(node): + node.queue_free() + _wmo_registry.clear() + if _world_wmo_root and is_instance_valid(_world_wmo_root): + _world_wmo_root.queue_free() + _world_wmo_root = null + + +func _build_tile_m2_assets(tile_root: Node3D, tile_origin: Vector3, m2_names: PackedStringArray, m2_placements: Array) -> bool: + if m2_names.is_empty() or m2_placements.is_empty(): + return false + var existing_m2_root := tile_root.get_node_or_null("M2s") + if existing_m2_root != null: + return not existing_m2_root.is_queued_for_deletion() var groups: Dictionary = {} for placement_variant in m2_placements: @@ -1169,14 +1395,15 @@ func _build_tile_m2_assets(tile_root: Node3D, tile_origin: Vector3, m2_names: Pa var pos: Vector3 = placement.get("pos", Vector3.ZERO) - tile_origin var rot: Vector3 = placement.get("rot", Vector3.ZERO) var scale_value: float = float(placement.get("scale", 1.0)) - var xform := Transform3D(Basis.from_euler(rot).scaled(Vector3.ONE * maxf(scale_value, 0.0001)), pos) + var basis := Basis.from_euler(rot) * Basis(Vector3.UP, M2_RIGHT_YAW_OFFSET) + var xform := Transform3D(basis.scaled(Vector3.ONE * maxf(scale_value, 0.0001)), pos) if not groups.has(normalized): groups[normalized] = [] (groups[normalized] as Array).append(xform) if groups.is_empty(): - return + return false var m2_root := Node3D.new() m2_root.name = "M2s" @@ -1200,15 +1427,40 @@ func _build_tile_m2_assets(tile_root: Node3D, tile_origin: Vector3, m2_names: Pa mmi.name = rel_path.get_file().get_basename() mmi.multimesh = mm mmi.cast_shadow = ( - GeometryInstance3D.SHADOW_CASTING_SETTING_ON - if m2_cast_shadows - else GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + GeometryInstance3D.SHADOW_CASTING_SETTING_ON + if m2_cast_shadows + else GeometryInstance3D.SHADOW_CASTING_SETTING_OFF ) + if m2_visibility_range > 0.0: + mmi.visibility_range_end = m2_visibility_range + mmi.visibility_range_end_margin = CHUNK_SIZE m2_root.add_child(mmi) _set_editor_owner_recursive(mmi) if m2_root.get_child_count() == 0: m2_root.queue_free() + return false + return true + + +func _remove_tile_m2_assets(state: Dictionary) -> void: + var tile_root: Node = state.get("root", null) + if tile_root == null or not is_instance_valid(tile_root): + return + var m2_root := tile_root.get_node_or_null("M2s") + if m2_root: + m2_root.queue_free() + + +func _apply_visibility_range_recursive(node: Node, range_end: float) -> void: + if range_end <= 0.0: + return + if node is GeometryInstance3D: + var geometry := node as GeometryInstance3D + geometry.visibility_range_end = range_end + geometry.visibility_range_end_margin = CHUNK_SIZE + for child in node.get_children(): + _apply_visibility_range_recursive(child, range_end) func _get_or_load_m2_mesh(rel_path: String) -> Mesh: @@ -1317,23 +1569,23 @@ func _instantiate_m2(rel_path: String, placement: Dictionary, tile_origin: Vecto node.name = rel_path.get_file().get_basename() node.position = placement.get("pos", Vector3.ZERO) - tile_origin - node.rotation = placement.get("rot", Vector3.ZERO) + node.rotation = placement.get("rot", Vector3.ZERO) + Vector3(0.0, M2_RIGHT_YAW_OFFSET, 0.0) var scale_value: float = float(placement.get("scale", 1.0)) node.scale = Vector3.ONE * scale_value return node -func _instantiate_wmo(rel_path: String, placement: Dictionary, tile_origin: Vector3) -> Node3D: +func _instantiate_wmo_world(rel_path: String, placement: Dictionary) -> Node3D: var prototype := _get_or_load_wmo_prototype(rel_path) if prototype == null: return null var instance := prototype.duplicate() instance.name = rel_path.get_file().get_basename() - instance.position = placement.get("pos", Vector3.ZERO) - tile_origin - var rot: Vector3 = placement.get("rot", Vector3.ZERO) - rot.y += PI - instance.rotation = rot + # WMOs are parented to _terrain_root, whose position absorbs the editor + # offset — use world-space placement.pos directly (no tile_origin subtraction). + instance.position = placement.get("pos", Vector3.ZERO) + instance.rotation = placement.get("rot", Vector3.ZERO) var scale_value: float = float(placement.get("scale", 1.0)) instance.scale = Vector3.ONE * scale_value return instance diff --git a/src/tools/bake_adt_terrain_cache.gd b/src/tools/bake_adt_terrain_cache.gd index 08c05bd..8fdc8c4 100644 --- a/src/tools/bake_adt_terrain_cache.gd +++ b/src/tools/bake_adt_terrain_cache.gd @@ -1,11 +1,29 @@ extends SceneTree +## Usage: +## godot --headless --path --script res://src/tools/bake_adt_terrain_cache.gd -- \ +## --map Azeroth --jobs 4 --force +## +## `--jobs N` starts N separate Godot worker processes. Process isolation keeps +## ResourceSaver/Image/loader state independent while using multiple CPU cores. + const ADT_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/adt_builder.gd") const BAKED_TILE_SCRIPT := preload("res://src/resources/baked_adt_tile.gd") +const PROGRESS_DIR := "res://data/cache/bake_progress" +const PROGRESS_REPORT_INTERVAL_MS := 2000 var _builder var _loader var _image_cache: Dictionary = {} +var _progress_path := "" +var _progress_worker_index := -1 +var _progress_worker_count := 1 +var _progress_total := 0 +var _progress_processed := 0 +var _progress_baked := 0 +var _progress_skipped := 0 +var _progress_failed := 0 +var _progress_last_tile := "" func _initialize() -> void: @@ -13,13 +31,13 @@ func _initialize() -> void: var map_name := _get_arg_value(args, "--map", "Azeroth") var extracted_dir := _normalize_res_path(_get_arg_value(args, "--extracted", "res://data/extracted")) var output_dir := _normalize_res_path(_get_arg_value(args, "--output", "res://data/cache/baked_terrain_v2")) - var legacy_texture_size = _get_optional_int_arg(args, "--texture-size") + var legacy_texture_size: Variant = _get_optional_int_arg(args, "--texture-size") var full_texture_size := maxi( 256, _get_arg_value( args, "--full-texture-size", - str(legacy_texture_size if legacy_texture_size != null else 2048) + str(legacy_texture_size if legacy_texture_size != null else 4196) ).to_int() ) var coarse_texture_size := maxi( @@ -32,8 +50,15 @@ func _initialize() -> void: ) var tile_x: Variant = _get_optional_int_arg(args, "--tile-x") var tile_y: Variant = _get_optional_int_arg(args, "--tile-y") + var jobs := maxi(1, _get_arg_value(args, "--jobs", "1").to_int()) + var worker_index: Variant = _get_optional_int_arg(args, "--worker-index") + var worker_count := maxi(1, _get_arg_value(args, "--worker-count", "1").to_int()) var force := args.has("--force") + if jobs > 1 and worker_index == null: + _run_parallel_controller(args, jobs) + return + _builder = ADT_BUILDER_SCRIPT.new() if not ClassDB.class_exists("ADTLoader"): push_error("ADTLoader not found. Rebuild GDExtension first.") @@ -59,6 +84,9 @@ func _initialize() -> void: var skipped := 0 var failed := 0 var started_ms := Time.get_ticks_msec() + var tile_index := 0 + var worker_total := _count_worker_tiles(dir.get_files(), map_name, tile_x, tile_y, worker_index, worker_count) + _progress_setup(worker_index, worker_count, worker_total) for file_name in dir.get_files(): if not file_name.ends_with(".adt"): @@ -77,6 +105,11 @@ func _initialize() -> void: continue if tile_y != null and ty != tile_y: continue + if worker_index != null: + if tile_index % worker_count != int(worker_index): + tile_index += 1 + continue + tile_index += 1 var source_res_path := map_dir.path_join(file_name) var source_abs_path := ProjectSettings.globalize_path(source_res_path) var output_res_path := output_dir.path_join(map_name).path_join("%s_%d_%d.res" % [map_name, tx, ty]) @@ -84,12 +117,14 @@ func _initialize() -> void: if not force and FileAccess.file_exists(output_abs_path): skipped += 1 + _progress_update(tx, ty, baked, skipped, failed) continue var data: Dictionary = _loader.call("load_adt", source_abs_path) if data.is_empty() or not data.has("chunks"): push_warning("Bake failed to parse ADT: %s" % source_res_path) failed += 1 + _progress_update(tx, ty, baked, skipped, failed) continue var full_payload: Dictionary = _builder.build_baked_tile_render_payload( @@ -108,6 +143,7 @@ func _initialize() -> void: if full_payload.is_empty(): push_warning("Bake produced empty full mesh: %s" % source_res_path) failed += 1 + _progress_update(tx, ty, baked, skipped, failed) continue var baked_tile: Resource = BAKED_TILE_SCRIPT.new() @@ -129,18 +165,223 @@ func _initialize() -> void: if save_err != OK: push_warning("Failed to save baked tile: %s (err=%d)" % [output_res_path, save_err]) failed += 1 + _progress_update(tx, ty, baked, skipped, failed) continue baked += 1 + _progress_update(tx, ty, baked, skipped, failed) if baked % 25 == 0: print("Baked %d tiles..." % baked) var elapsed_ms := Time.get_ticks_msec() - started_ms print("Bake finished. baked=%d skipped=%d failed=%d elapsed=%.2fs" % [ baked, skipped, failed, float(elapsed_ms) / 1000.0]) + _progress_finish(baked, skipped, failed) quit(0 if failed == 0 else 2) +func _run_parallel_controller(args: PackedStringArray, jobs: int) -> void: + var started_ms := Time.get_ticks_msec() + var last_report_ms := 0 + var threads: Array[Thread] = [] + _prepare_progress_dir() + + print("Starting parallel ADT bake: jobs=%d" % jobs) + for worker_index in jobs: + var thread := Thread.new() + var err := thread.start(_run_worker_process.bind(args, worker_index, jobs)) + if err != OK: + push_error("Failed to start bake worker thread %d (err=%d)" % [worker_index, err]) + quit(1) + return + threads.append(thread) + + var finished := false + while not finished: + OS.delay_msec(250) + var now := Time.get_ticks_msec() + if now - last_report_ms >= PROGRESS_REPORT_INTERVAL_MS: + _print_parallel_progress(jobs, started_ms) + last_report_ms = now + finished = true + for thread in threads: + if thread.is_alive(): + finished = false + break + + var failed := 0 + for thread in threads: + var exit_code: int = int(thread.wait_to_finish()) + if exit_code != 0: + failed += 1 + + var elapsed_ms := Time.get_ticks_msec() - started_ms + _print_parallel_progress(jobs, started_ms) + print("Parallel bake finished. jobs=%d failed_workers=%d elapsed=%.2fs" % [ + jobs, failed, float(elapsed_ms) / 1000.0]) + quit(0 if failed == 0 else 2) + + +func _run_worker_process(args: PackedStringArray, worker_index: int, worker_count: int) -> int: + var worker_args := PackedStringArray([ + "--headless", + "--path", + ProjectSettings.globalize_path("res://"), + "--script", + "res://src/tools/bake_adt_terrain_cache.gd", + "--", + ]) + + var skip_next := false + for i in args.size(): + if skip_next: + skip_next = false + continue + var arg := args[i] + if arg == "--jobs": + skip_next = i + 1 < args.size() + continue + if arg == "--worker-index" or arg == "--worker-count": + skip_next = i + 1 < args.size() + continue + worker_args.append(arg) + + worker_args.append("--jobs") + worker_args.append("1") + worker_args.append("--worker-index") + worker_args.append(str(worker_index)) + worker_args.append("--worker-count") + worker_args.append(str(worker_count)) + + print("Worker %d/%d started" % [worker_index + 1, worker_count]) + var exit_code := OS.execute(OS.get_executable_path(), worker_args) + print("Worker %d/%d finished with exit=%d" % [worker_index + 1, worker_count, exit_code]) + return exit_code + + +func _prepare_progress_dir() -> void: + DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(PROGRESS_DIR)) + var dir := DirAccess.open(PROGRESS_DIR) + if dir == null: + return + for file_name in dir.get_files(): + if file_name.begins_with("adt_worker_") and file_name.ends_with(".json"): + dir.remove(file_name) + + +func _progress_setup(worker_index: Variant, worker_count: int, total: int) -> void: + _progress_worker_index = int(worker_index) if worker_index != null else 0 + _progress_worker_count = worker_count + _progress_total = total + _progress_processed = 0 + _progress_baked = 0 + _progress_skipped = 0 + _progress_failed = 0 + _progress_last_tile = "" + _progress_path = PROGRESS_DIR.path_join("adt_worker_%d.json" % _progress_worker_index) + _progress_write(false) + + +func _progress_update(tx: int, ty: int, baked: int, skipped: int, failed: int) -> void: + _progress_processed += 1 + _progress_baked = baked + _progress_skipped = skipped + _progress_failed = failed + _progress_last_tile = "%d_%d" % [tx, ty] + _progress_write(false) + + +func _progress_finish(baked: int, skipped: int, failed: int) -> void: + _progress_baked = baked + _progress_skipped = skipped + _progress_failed = failed + _progress_processed = _progress_total + _progress_write(true) + + +func _progress_write(done: bool) -> void: + if _progress_path.is_empty(): + return + var file := FileAccess.open(_progress_path, FileAccess.WRITE) + if file == null: + return + file.store_string(JSON.stringify({ + "worker": _progress_worker_index, + "workers": _progress_worker_count, + "total": _progress_total, + "processed": _progress_processed, + "baked": _progress_baked, + "skipped": _progress_skipped, + "failed": _progress_failed, + "last_tile": _progress_last_tile, + "done": done, + })) + + +func _print_parallel_progress(jobs: int, started_ms: int) -> void: + var now := Time.get_ticks_msec() + var total := 0 + var processed := 0 + var baked := 0 + var skipped := 0 + var failed := 0 + var done := 0 + for worker_index in jobs: + var path := PROGRESS_DIR.path_join("adt_worker_%d.json" % worker_index) + if not FileAccess.file_exists(path): + continue + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + continue + var parsed: Variant = JSON.parse_string(file.get_as_text()) + if not (parsed is Dictionary): + continue + var row: Dictionary = parsed + total += int(row.get("total", 0)) + processed += int(row.get("processed", 0)) + baked += int(row.get("baked", 0)) + skipped += int(row.get("skipped", 0)) + failed += int(row.get("failed", 0)) + if bool(row.get("done", false)): + done += 1 + + var elapsed := maxf(0.001, float(now - started_ms) / 1000.0) + var rate := float(processed) / elapsed + print("Progress: %d/%d tiles, baked=%d skipped=%d failed=%d, workers done=%d/%d, %.2f tiles/s" % [ + processed, total, baked, skipped, failed, done, jobs, rate]) + + +func _count_worker_tiles( + files: PackedStringArray, + map_name: String, + tile_x: Variant, + tile_y: Variant, + worker_index: Variant, + worker_count: int) -> int: + var count := 0 + var tile_index := 0 + for file_name in files: + if not file_name.ends_with(".adt"): + continue + var parts := file_name.trim_suffix(".adt").split("_") + if parts.size() != 3 or parts[0] != map_name: + continue + if not parts[1].is_valid_int() or not parts[2].is_valid_int(): + continue + var tx := parts[1].to_int() + var ty := parts[2].to_int() + if tile_x != null and tx != tile_x: + continue + if tile_y != null and ty != tile_y: + continue + if worker_index != null and tile_index % worker_count != int(worker_index): + tile_index += 1 + continue + tile_index += 1 + count += 1 + return count + + func _get_arg_value(args: PackedStringArray, name: String, default_value: String) -> String: var index := args.find(name) if index >= 0 and index + 1 < args.size(): diff --git a/src/tools/bake_all.gd b/src/tools/bake_all.gd index a05c69f..cf1f50b 100644 --- a/src/tools/bake_all.gd +++ b/src/tools/bake_all.gd @@ -81,6 +81,7 @@ func _initialize() -> void: var elapsed := float(Time.get_ticks_msec() - total_start) / 1000.0 print("\nAll done in %.1fs." % elapsed) + WMO_BUILDER_SCRIPT.clear_caches() quit(0) diff --git a/src/tools/bake_wmo_cache.gd b/src/tools/bake_wmo_cache.gd index c0cf364..9c5c299 100644 --- a/src/tools/bake_wmo_cache.gd +++ b/src/tools/bake_wmo_cache.gd @@ -104,6 +104,7 @@ func _initialize() -> void: print("[%d/%d] baked=%d skipped=%d failed=%d" % [i, total, baked, skipped, failed]) print("Done. baked=%d skipped=%d failed=%d" % [baked, skipped, failed]) + WMO_BUILDER_SCRIPT.clear_caches() quit(0)