From 93bfe114c01b4778212c51d5010a98176e96c947 Mon Sep 17 00:00:00 2001 From: sindoring Date: Fri, 10 Jul 2026 10:28:37 +0400 Subject: [PATCH] =?UTF-8?q?=D0=B0=D1=80=D1=85=D0=B8=D1=82=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=D1=83=D1=80=D0=B0=20=D0=B8=20=D1=86=D0=B5=D0=BB=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=BB=D1=8F=20=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 65 ++++++ README.md | 4 + RENDER.md | 81 ++++++- addons/mpq_extractor/loaders/m2_builder.gd | 42 +++- .../mpq_extractor/loaders/wow_m2_material.gd | 51 ++++- .../mpq_extractor/loaders/wow_wmo_material.gd | 76 +++++-- docs/ARCHITECTURE.md | 170 ++++++++++++++ docs/CLIENT_FEATURES.md | 117 ++++++++++ docs/CONTENT_AND_SERVER_DATA.md | 126 +++++++++++ docs/GAME_DESIGN.md | 100 ++++++++ docs/GODOT_EDITOR.md | 107 +++++++++ docs/README.md | 45 ++++ docs/REFERENCES.md | 59 +++++ docs/ROADMAP.md | 112 +++++++++ docs/TESTING.md | 152 +++++++++++++ project.godot | 2 +- src/native/src/adt_loader.cpp | 9 +- src/native/src/adt_loader.h | 8 +- src/native/src/m2_loader.cpp | 66 ++++++ src/scenes/streaming/kalimdor_streaming.tscn | 132 +++++++++++ .../streaming/streaming_world_loader.gd | 214 ++++++++++++++++-- src/tools/capture_render_checkpoints.gd | 136 +++++++++++ src/tools/capture_render_checkpoints.gd.uid | 1 + src/tools/find_adt_placements.gd | 80 +++++++ src/tools/find_adt_placements.gd.uid | 1 + src/tools/verify_adt_m2_placements.gd | 145 ++++++++++++ src/tools/verify_adt_m2_placements.gd.uid | 1 + src/tools/verify_m2_unique_dedupe.gd | 54 +++++ src/tools/verify_m2_unique_dedupe.gd.uid | 1 + src/tools/verify_render_materials.gd | 142 ++++++++++++ src/tools/verify_render_materials.gd.uid | 1 + targets/00-render-baseline.md | 47 ++++ targets/01-coordinates-and-seams.md | 37 +++ targets/02-player-decomposition.md | 36 +++ targets/03-renderer-facade.md | 35 +++ targets/04-editor-shell.md | 35 +++ targets/05-content-project.md | 32 +++ targets/06-server-adapters.md | 36 +++ targets/07-world-editor-slice.md | 32 +++ targets/08-network-foundation.md | 37 +++ targets/09-gameplay-domain.md | 36 +++ targets/10-playable-client.md | 37 +++ targets/11-quest-end-to-end.md | 37 +++ targets/12-client-completeness.md | 37 +++ targets/13-dungeon-authoring.md | 36 +++ targets/README.md | 71 ++++++ 46 files changed, 2826 insertions(+), 55 deletions(-) create mode 100644 AGENTS.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/CLIENT_FEATURES.md create mode 100644 docs/CONTENT_AND_SERVER_DATA.md create mode 100644 docs/GAME_DESIGN.md create mode 100644 docs/GODOT_EDITOR.md create mode 100644 docs/README.md create mode 100644 docs/REFERENCES.md create mode 100644 docs/ROADMAP.md create mode 100644 docs/TESTING.md create mode 100644 src/scenes/streaming/kalimdor_streaming.tscn create mode 100644 src/tools/capture_render_checkpoints.gd create mode 100644 src/tools/capture_render_checkpoints.gd.uid create mode 100644 src/tools/find_adt_placements.gd create mode 100644 src/tools/find_adt_placements.gd.uid create mode 100644 src/tools/verify_adt_m2_placements.gd create mode 100644 src/tools/verify_adt_m2_placements.gd.uid create mode 100644 src/tools/verify_m2_unique_dedupe.gd create mode 100644 src/tools/verify_m2_unique_dedupe.gd.uid create mode 100644 src/tools/verify_render_materials.gd create mode 100644 src/tools/verify_render_materials.gd.uid create mode 100644 targets/00-render-baseline.md create mode 100644 targets/01-coordinates-and-seams.md create mode 100644 targets/02-player-decomposition.md create mode 100644 targets/03-renderer-facade.md create mode 100644 targets/04-editor-shell.md create mode 100644 targets/05-content-project.md create mode 100644 targets/06-server-adapters.md create mode 100644 targets/07-world-editor-slice.md create mode 100644 targets/08-network-foundation.md create mode 100644 targets/09-gameplay-domain.md create mode 100644 targets/10-playable-client.md create mode 100644 targets/11-quest-end-to-end.md create mode 100644 targets/12-client-completeness.md create mode 100644 targets/13-dungeon-authoring.md create mode 100644 targets/README.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..73c3628 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,65 @@ +# OpenWC Agent Guide + +Эти инструкции действуют для всего репозитория, кроме поддеревьев с собственным `AGENTS.md`. + +## Миссия + +Реализовать клиент World of Warcraft 3.3.5a build 12340 с максимально точным поведением оригинального клиента (`1:1`) и расширяемый набор инструментов авторинга внутри Godot Editor. TrinityCore/AzerothCore являются целевыми server backends. Улучшения допускаются как отдельные настраиваемые режимы; они НЕ ДОЛЖНЫ незаметно менять профиль совместимости `Blizzlike335`. + +## Обязательное чтение перед работой + +Каждый агент ДОЛЖЕН: + +1. Прочитать [`targets/README.md`](targets/README.md). +2. Прочитать файл текущей цели, указанный там как `Current target`. +3. Прочитать [`docs/README.md`](docs/README.md) и связанные с задачей нормативные документы. +4. Для renderer-задач прочитать [`RENDER.md`](RENDER.md). +5. Проверить рабочее дерево и не перезаписывать чужие изменения. + +Если задача пользователя противоречит target-порядку, прямое указание пользователя имеет приоритет, но агент обязан сохранить архитектурные границы и отметить влияние на roadmap. + +## Правила выполнения целей + +- Работать только над текущей целью или явно указанной пользователем задачей. +- Не начинать следующий milestone, пока текущий не имеет доказательств завершения. +- Не объявлять совместимость `1:1` без fixture, сравнения с оригинальным клиентом или подтверждённого protocol/server behavior. +- Не смешивать render, gameplay, UI, network и server-data adapters. +- Не добавлять прямую запись в production DB. +- Интерактивный authoring UI реализуется только через Godot `EditorPlugin`. +- Любой новый формат, публичный контракт или несовместимое решение требует обновления документации и при необходимости ADR. +- Исправление дефекта должно получать regression test, когда это технически возможно. + +## Метки состояния + +В каждом `targets/*.md` присутствует ровно одна метка: + +- `` — цель ещё не начата. +- `` — единственная текущая цель. +- `` — продолжение невозможно; причина записана в Evidence/Blockers. +- `` — все критерии и проверки выполнены. + +Завершение подтверждается второй меткой: + +```text + +``` + +Правила метки `DONE`: + +1. Все обязательные checklist-пункты отмечены. +2. Выполнены команды Verification. +3. В Evidence записаны команды, результаты и изменённые файлы. +4. Не осталось обязательных незакрытых пунктов или скрытых заглушек. +5. Обновлены `targets/README.md` и матрица функций, если применимо. + +Агент НЕ ДОЛЖЕН ставить `DONE` только потому, что код компилируется. Если пользователь явно принял частичный результат, статус остаётся `ACTIVE`, а принятое ограничение записывается в Evidence. + +## Формат отчёта агента + +Финальный отчёт должен содержать: + +- Target ID и статус; +- что изменено; +- какие проверки выполнены; +- fidelity evidence для поведения 3.3.5a; +- оставшиеся риски или следующий незакрытый checklist item. diff --git a/README.md b/README.md index 0243110..68ab8c3 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,10 @@ Текущий фокус проекта: загрузка данных из клиента, разбор форматов WoW и отображение мира в Godot так, чтобы его можно было смотреть и дальше редактировать уже средствами движка. +Целевая архитектура клиента, Godot Editor-инструментов, Content Project, интеграции с TrinityCore/AzerothCore и тестовой стратегии собрана в [`docs/README.md`](docs/README.md). Фактическое состояние renderer-подсистемы ведётся в [`RENDER.md`](RENDER.md). + +Текущая последовательность реализации и критерии завершения находятся в [`targets/README.md`](targets/README.md); правила работы агентов — в [`AGENTS.md`](AGENTS.md). + Сейчас проект уже умеет: - открывать MPQ-архивы клиента через editor plugin; diff --git a/RENDER.md b/RENDER.md index a6301a2..7e67704 100644 --- a/RENDER.md +++ b/RENDER.md @@ -580,7 +580,7 @@ Please increase rendering/rendering_device/d3d12/max_resource_descriptors. - ограничивать активные high-quality terrain tiles; - избегать screen/refraction textures для water. -Если ошибка возвращается, увеличивать project setting `rendering/rendering_device/d3d12/max_resource_descriptors` и одновременно искать, где плодятся уникальные материалы. +Если ошибка возвращается, увеличивать project setting `rendering/rendering_device/d3d12/max_resource_descriptors` и одновременно искать, где плодятся уникальные материалы. В `project.godot` это должно быть записано внутри секции `[rendering]` как `rendering_device/d3d12/max_resource_descriptors`; вариант с повторным префиксом `rendering/rendering_device/...` внутри секции игнорируется. ## Caches @@ -607,7 +607,8 @@ res://data/cache/wmo_render_v1 Версии: ```text -Baked terrain required: 4 +Baked terrain required: 5 +Streaming terrain required: 2 Splat terrain required: 1 Control splat required: 3 WMO streaming resource: 2 @@ -659,6 +660,41 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' & $exe --headless --path . --quit ``` +Renderer material regression smoke: + +```powershell +$exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' +& $exe --headless --path . --script res://src/tools/verify_render_materials.gd +``` + +ADT M2 placement probe: + +```powershell +$exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' +& $exe --headless --path . --script res://src/tools/verify_adt_m2_placements.gd -- --map Azeroth --uid 11785 --path-contains waterfall +``` + +M2 uniqueId dedupe smoke: + +```powershell +$exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' +& $exe --headless --path . --script res://src/tools/verify_m2_unique_dedupe.gd +``` + +Renderer checkpoint capture: + +```powershell +$exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' +& $exe --path . --script res://src/tools/capture_render_checkpoints.gd -- --wait 8 --output user://render_checkpoints +``` + +Headless checkpoint dry-run: + +```powershell +$exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' +& $exe --headless --path . --script res://src/tools/capture_render_checkpoints.gd -- --dry-run --wait 0.1 +``` + Run scene headless: ```powershell @@ -697,7 +733,7 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' - Liquid depth fade/shore blending. - LiquidType.dbc based water textures. - Better skybox transition validation per zone. -- Automated renderer regression scenes/screenshots. +- Automated renderer screenshot scenes. A cheap material regression smoke exists at `src/tools/verify_render_materials.gd`. ## 2026-07-04 WMO Shader Pass @@ -722,7 +758,7 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' - `.skin` texture unit `priority` is exposed as `priority_plane` for future transparent-pass ordering. This needs the native GDExtension DLL to be rebuilt when the editor is not holding it open. - Waterfalls seen near terrain cliffs are usually WMO embedded doodads rather than ADT `MDDF` placements. WMO doodad transforms now use the converted MODD quaternion directly, without the old extra `+90 deg` M2 yaw offset. - WMO render/scene caches now carry version guards for this transform path. Rebuild `wmo_render_v1`/`wmo_tscn` after changing WMO doodad placement code; old caches are ignored by runtime. -- Elwynn/Thunder Falls style outdoor waterfalls are ADT `MDDF` M2 placements. Regular ADT M2 doodads use the native cached Godot-space Euler directly via `Basis.from_euler(rot)`; do not apply a global yaw/order remap, because it flips ordinary props such as wagons, stalls, barrels, fences and trees. `ElwynnCliffRock01/02` are a model-specific exception: these are open-backed shell meshes, so they use a narrow `YZX` placement correction with `rot.y + 180 deg` to push the hollow side into the terrain. The waterfall transform correction is also filename-allowlisted to the actual sheet M2s (`NewWaterfall`, `ElwynnTallWaterfall01`). Sheet M2s keep the calibrated world-space `+90 deg` Godot-Y yaw to anchor the top to the upper pool, plus model-specific twist where needed. Tall waterfall sheets twist around their local fall line with an origin offset so top/bottom anchors stay fixed instead of rotating around the model pivot; `ElwynnTallWaterfall01` uses the opposite twist sign from `NewWaterfall` to face the correct side. Do not apply the raw-M2/WoWee render-space basis here; that double-converts M2 vertices. Do not cull boundary waterfall placements by `floor(world_pos / TILE_SIZE)`: tiles can carry a small cross-border placement that is still the active streamed instance at low M2 radius. Future cross-tile dedupe should use `MDDF.uniqueId` plus an ADT cache format bump/rebake. +- Elwynn/Thunder Falls style outdoor waterfalls are ADT `MDDF` M2 placements. Regular ADT M2 doodads use the native cached Godot-space Euler directly via `Basis.from_euler(rot)`; do not apply a global yaw/order remap, because it flips ordinary props such as wagons, stalls, barrels, fences and trees. `ElwynnCliffRock01/02` are a model-specific exception: these are open-backed shell meshes, so they use a narrow `YZX` placement correction with `rot.y + 180 deg` to push the hollow side into the terrain. The waterfall transform correction is also filename-allowlisted to the actual sheet M2s (`NewWaterfall`, `ElwynnTallWaterfall01`). Sheet M2s keep the calibrated world-space `+90 deg` Godot-Y yaw to anchor the top to the upper pool, plus model-specific twist where needed. Tall waterfall sheets twist around their local fall line with an origin offset so top/bottom anchors stay fixed instead of rotating around the model pivot; `ElwynnTallWaterfall01` uses the opposite twist sign from `NewWaterfall` to face the correct side. Do not apply the raw-M2/WoWee render-space basis here; that double-converts M2 vertices. Do not cull boundary waterfall placements by `floor(world_pos / TILE_SIZE)`: tiles can carry a small cross-border placement that is still the active streamed instance at low M2 radius. Cross-tile dedupe uses `MDDF.uniqueId`, not geometry ownership. - Still missing for true parity: exact shader array entries beyond the common WotLK set, particles/ribbons, full edge-scan tuning, exact guild/color constants and specialized blend-state edge cases. ## 2026-07-07 World Shader Light Stabilization @@ -733,6 +769,11 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' - M2/WMO shaders now reduce zone-color strength and clamp final albedo/specular/emissive contribution. Additive/parity edge cases still need exact WoW pass ordering later, but ordinary Elwynn props should no longer produce overexposed or acid-green surfaces. - Human building WMO materials such as `HumanTwoStory` and `GoldshireInn` carry strong painted `MOMT.diffColor` values, including green trim colors. Textured WMO surfaces now use this as a soft hue tint instead of multiplying texture albedo by the raw RGB value; untextured WMO surfaces still use a muted material paint color. - WMO window materials such as `MM_ELWYNN_WND_EXT__01.BLP` carry high `MOMT.sidnColor`/emissive values. Emissive is texture-masked and capped for window paths so it brightens the pane pattern instead of flooding the whole quad white. +- WMO window atlas textures use a stable albedo sampler (`filter_linear`, `repeat_disable`) instead of generated-mipmap anisotropic sampling. These textures pack several window variants into one BLP; generated mipmaps can bleed between atlas cells and make the upper-house window shimmer/change along diagonal texture features while the camera moves. +- WMO window atlas materials use `cull_back` even when they are opaque `blendMode == 0`. The human-house exterior window texture is a real WMO material (`MM_ELWYNN_WND_EXT__01.BLP`, `flags=0x11`), not a transparent glass pass, and two-sided rendering can make nearby interior/exterior pane faces compete as the view angle changes. +- WMO material flag `0x01` is treated as unlit in the WMO shader. Elwynn window materials use this flag together with emissive color, so they should not receive ordinary per-normal directional lighting across their triangulated panes. +- WMO alpha discard is now restricted to `blendMode == 1` alpha-key materials. `blendMode == 2` is true alpha blend; treating it as alpha-key caused windows/glass and other translucent WMO surfaces to become cut-out. +- WMO window/glass texture paths (`/windows/`, `/glass/`, `window`, `glass`, exact `_wnd_` in the file name) are metadata only for opaque materials. Do not use a broad `wnd_` substring: Stormwind texture names such as `MM_STRMWND_WALL_03.BLP` are ordinary wall textures, not windows. Window metadata does not make an opaque WMO material transparent and does not apply glass tint, because many house facades use window-named atlas textures for walls and trim too. The separate glass shader variant and glass tint are only used when the WMO material is also `blendMode == 2`. In the Godot path, glass and other alpha-blend WMO surfaces avoid depth writes; depth-writing glass caused moving-camera flicker/z-fighting on facade window overlays. Glass WMO surfaces also use back-face culling instead of `cull_disabled`, because two-sided transparent facade panes can show the quad's internal triangle split as a moving diagonal seam. - Runtime WMO cache material refresh is versioned separately from WMO geometry cache. Shader-only WMO material fixes should bump `WMO_MATERIAL_REFRESH_VERSION` instead of forcing a full `wmo_render_v1` rebake. ## 2026-07-07 M2 Billboard Pass @@ -741,10 +782,40 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' - Billboard metadata is resolved per drawn vertex through the skin section bone palette and vertex bone indices. This avoids treating an entire mixed surface as billboard just because the palette contains a billboard bone. - `M2Builder` stores billboard pivot/mode in `Mesh.ARRAY_CUSTOM0` as RGBA float data. `CUSTOM0.xyz` is the Godot-space bone pivot; `CUSTOM0.w` is the billboard mode: spherical, lock X, lock Y, or lock Z. - `WowM2Material` applies camera-facing billboarding in the vertex shader for marked vertices. This works with grouped ADT M2 `MultiMesh` batches because the shader uses each instance transform. -- Runtime M2 cache refresh now has `M2_MATERIAL_REFRESH_VERSION`. Old cached `.tscn/.glb` meshes are replaced in memory from raw `.m2` when available, so the billboard/custom-attribute path does not require an immediate cache rebake. +- Runtime M2 cache refresh now has `M2_MATERIAL_REFRESH_VERSION`. Old cached `.tscn/.glb` meshes are replaced in memory from raw `.m2` only when the raw model actually needs custom billboard attributes or UV-rotation data. Do not rebuild every stale cached M2 during streaming: creating fresh materials for ordinary props can exhaust the D3D12 resource descriptor heap. - Verified with `ElwynnGrass1.m2`: native data exposes a billboard batch, the built mesh contains `CUSTOM0`, and the M2 shader compiles with the billboard uniform enabled. - Remaining parity work: many Elwynn tree canopy M2s do not carry billboard bone flags, so their flat-looking leaves are a separate material/geometry parity issue, not this bone-billboard path. +## 2026-07-07 M2 UV Transform Pass + +- Native `M2Loader` now decodes `M2TextureTransform.rotationTrack` as raw texture-space quaternions, separate from Godot-space bone quaternions. +- Texture transform dictionaries now expose static UV rotation matrices and approximate Z-rotation speeds, in addition to existing translation, translation speed and scale. +- `WowM2Material` applies UV rotation and scale around WoW's texture center `(0.5, 0.5)`, then applies translation/scroll. This is closer to the client path shown by `CM2Model::AnimateTextureTransformsMT`. +- All four M2 texture stages receive UV rotation and rotation-speed uniforms, matching the existing four-stage texture binding path. +- `M2_MATERIAL_REFRESH_VERSION` and `M2Builder.MATERIAL_FORMAT_VERSION` are bumped to `2`, so cached M2 meshes are rebuilt in memory when raw `.m2` data is available. +- Verified with `Spells/ArcaneForceShield_Blue.m2`: native data exposes a non-identity UV rotation, the built material receives the stage rotation uniform, and the shader compiles. + +## 2026-07-08 Renderer Material Regression Smoke + +- Added `src/tools/verify_render_materials.gd` as a cheap headless guard for shader/material regressions. +- The script checks the D3D12 descriptor heap project setting, opaque WMO window atlas handling, ordinary Stormwind wall detection, WMO glass depth/culling state, M2 alpha depth state, and M2 billboard/UV-rotation shader paths. +- This is not a visual screenshot regression suite; it catches the material-state mistakes that caused the waterfall/window fixes to regress. + +## 2026-07-08 ADT M2 UniqueId Probe + +- Native `ADTLoader` now exposes `MDDF.uniqueId` as `unique_id` on M2 placement dictionaries, matching the existing WMO `MODF.unique_id` field. +- Added `src/tools/verify_adt_m2_placements.gd` to find raw ADT M2 placements by `unique_id` and path substring. The default check targets the Elwynn waterfall placement `uid=11785`. +- Runtime M2 cross-tile dedupe is enabled through an active `MDDF.uniqueId` registry. It filters duplicate placements before the M2 grouping worker runs, then releases the reservation when the owning tile unloads or leaves the M2 detail radius. +- Candidate tiles that skipped a duplicate uid are re-queued when the active owner releases it. This avoids the old `floor(world_pos / TILE_SIZE)` ownership trap where boundary props could disappear. +- `BakedADTTile.FORMAT_VERSION` is now `5` and `StreamingADTTile.FORMAT_VERSION` is now `2`, so old ADT caches without M2 `unique_id` are ignored until rebaked. +- Added `src/tools/verify_m2_unique_dedupe.gd` as a headless smoke for reserve, skip and release behavior. + +## 2026-07-08 Renderer Checkpoint Capture + +- Added `src/tools/find_adt_placements.gd` as a diagnostic placement search helper for M2/WMO paths. +- Added `src/tools/capture_render_checkpoints.gd` to load the Eastern Kingdoms streaming scene, move a dedicated camera to fixed regression viewpoints and save PNGs under `user://render_checkpoints`. +- Current checkpoints cover the Elwynn waterfall from front/side and Goldshire Inn/Blacksmith windows. Headless runs are dry-run only because the headless display server cannot produce viewport PNGs. + ## Practical Rule For Future Work If something improves quality but creates visible hitch, it is not done. Move it to bake/cache/background work, split finalization over frames, or prewarm it before the player can see it. diff --git a/addons/mpq_extractor/loaders/m2_builder.gd b/addons/mpq_extractor/loaders/m2_builder.gd index 99166d9..b0a1009 100644 --- a/addons/mpq_extractor/loaders/m2_builder.gd +++ b/addons/mpq_extractor/loaders/m2_builder.gd @@ -6,7 +6,7 @@ class_name M2Builder const WOW_M2_MATERIAL := preload("res://addons/mpq_extractor/loaders/wow_m2_material.gd") -const MATERIAL_FORMAT_VERSION := 1 +const MATERIAL_FORMAT_VERSION := 2 static var _texture_cache: Dictionary = {} @@ -164,6 +164,14 @@ static func _build_material( combiner["stage1_uv_scroll"] = _resolve_texture_scroll(batch, 1, tex_transform_combos, tex_transforms) combiner["stage2_uv_scroll"] = _resolve_texture_scroll(batch, 2, tex_transform_combos, tex_transforms) combiner["stage3_uv_scroll"] = _resolve_texture_scroll(batch, 3, tex_transform_combos, tex_transforms) + combiner["stage0_uv_rotation"] = _resolve_texture_rotation(batch, 0, tex_transform_combos, tex_transforms) + combiner["stage1_uv_rotation"] = _resolve_texture_rotation(batch, 1, tex_transform_combos, tex_transforms) + combiner["stage2_uv_rotation"] = _resolve_texture_rotation(batch, 2, tex_transform_combos, tex_transforms) + combiner["stage3_uv_rotation"] = _resolve_texture_rotation(batch, 3, tex_transform_combos, tex_transforms) + combiner["stage0_uv_rotation_speed"] = _resolve_texture_rotation_speed(batch, 0, tex_transform_combos, tex_transforms) + combiner["stage1_uv_rotation_speed"] = _resolve_texture_rotation_speed(batch, 1, tex_transform_combos, tex_transforms) + combiner["stage2_uv_rotation_speed"] = _resolve_texture_rotation_speed(batch, 2, tex_transform_combos, tex_transforms) + combiner["stage3_uv_rotation_speed"] = _resolve_texture_rotation_speed(batch, 3, tex_transform_combos, tex_transforms) combiner["vertex_shader_id"] = _m2_vertex_shader_id(texture_count, int(batch.get("shader_id", 0))) combiner["pixel_shader_id"] = _m2_pixel_shader_id(texture_count, int(batch.get("shader_id", 0))) combiner["priority_plane"] = int(batch.get("priority_plane", 0)) @@ -460,6 +468,38 @@ static func _resolve_texture_scroll( return transform.get("translation_speed", Vector2.ZERO) +static func _resolve_texture_rotation( + batch: Dictionary, + stage: int, + tex_transform_combos: PackedInt32Array, + tex_transforms: Array) -> Vector4: + + var combo_index := int(batch.get("texture_transform_combo_index", -1)) + stage + if combo_index < 0 or combo_index >= tex_transform_combos.size(): + return Vector4(1.0, 0.0, 0.0, 1.0) + var transform_index := int(tex_transform_combos[combo_index]) + if transform_index < 0 or transform_index >= tex_transforms.size(): + return Vector4(1.0, 0.0, 0.0, 1.0) + var transform: Dictionary = tex_transforms[transform_index] if tex_transforms[transform_index] is Dictionary else {} + return transform.get("rotation", Vector4(1.0, 0.0, 0.0, 1.0)) + + +static func _resolve_texture_rotation_speed( + batch: Dictionary, + stage: int, + tex_transform_combos: PackedInt32Array, + tex_transforms: Array) -> float: + + var combo_index := int(batch.get("texture_transform_combo_index", -1)) + stage + if combo_index < 0 or combo_index >= tex_transform_combos.size(): + return 0.0 + var transform_index := int(tex_transform_combos[combo_index]) + if transform_index < 0 or transform_index >= tex_transforms.size(): + return 0.0 + var transform: Dictionary = tex_transforms[transform_index] if tex_transforms[transform_index] is Dictionary else {} + return float(transform.get("rotation_speed", 0.0)) + + static func _default_texture_path(model_path: String, texture_type: int) -> String: var normalized := model_path.replace("\\", "/") var rel_model := _relative_extracted_model_path(normalized) diff --git a/addons/mpq_extractor/loaders/wow_m2_material.gd b/addons/mpq_extractor/loaders/wow_m2_material.gd index 96ce9e9..cdaac6c 100644 --- a/addons/mpq_extractor/loaders/wow_m2_material.gd +++ b/addons/mpq_extractor/loaders/wow_m2_material.gd @@ -84,6 +84,14 @@ static func build( mat.set_shader_parameter("stage1_uv_scroll", combiner.get("stage1_uv_scroll", Vector2.ZERO)) mat.set_shader_parameter("stage2_uv_scroll", combiner.get("stage2_uv_scroll", Vector2.ZERO)) mat.set_shader_parameter("stage3_uv_scroll", combiner.get("stage3_uv_scroll", Vector2.ZERO)) + mat.set_shader_parameter("stage0_uv_rotation", combiner.get("stage0_uv_rotation", Vector4(1.0, 0.0, 0.0, 1.0))) + mat.set_shader_parameter("stage1_uv_rotation", combiner.get("stage1_uv_rotation", Vector4(1.0, 0.0, 0.0, 1.0))) + mat.set_shader_parameter("stage2_uv_rotation", combiner.get("stage2_uv_rotation", Vector4(1.0, 0.0, 0.0, 1.0))) + mat.set_shader_parameter("stage3_uv_rotation", combiner.get("stage3_uv_rotation", Vector4(1.0, 0.0, 0.0, 1.0))) + mat.set_shader_parameter("stage0_uv_rotation_speed", float(combiner.get("stage0_uv_rotation_speed", 0.0))) + mat.set_shader_parameter("stage1_uv_rotation_speed", float(combiner.get("stage1_uv_rotation_speed", 0.0))) + mat.set_shader_parameter("stage2_uv_rotation_speed", float(combiner.get("stage2_uv_rotation_speed", 0.0))) + mat.set_shader_parameter("stage3_uv_rotation_speed", float(combiner.get("stage3_uv_rotation_speed", 0.0))) mat.set_shader_parameter("effect_alpha_scale", combiner.get("effect_alpha_scale", 1.0)) mat.set_shader_parameter("effect_alpha_power", combiner.get("effect_alpha_power", 1.0)) mat.set_shader_parameter("uv_edge_fade_strength", combiner.get("uv_edge_fade_strength", 0.0)) @@ -227,6 +235,14 @@ uniform vec2 stage0_uv_scroll = vec2(0.0); uniform vec2 stage1_uv_scroll = vec2(0.0); uniform vec2 stage2_uv_scroll = vec2(0.0); uniform vec2 stage3_uv_scroll = vec2(0.0); +uniform vec4 stage0_uv_rotation = vec4(1.0, 0.0, 0.0, 1.0); +uniform vec4 stage1_uv_rotation = vec4(1.0, 0.0, 0.0, 1.0); +uniform vec4 stage2_uv_rotation = vec4(1.0, 0.0, 0.0, 1.0); +uniform vec4 stage3_uv_rotation = vec4(1.0, 0.0, 0.0, 1.0); +uniform float stage0_uv_rotation_speed = 0.0; +uniform float stage1_uv_rotation_speed = 0.0; +uniform float stage2_uv_rotation_speed = 0.0; +uniform float stage3_uv_rotation_speed = 0.0; uniform float effect_alpha_scale = 1.0; uniform float effect_alpha_power = 1.0; uniform float uv_edge_fade_strength = 0.0; @@ -315,8 +331,27 @@ vec3 apply_wow_fog(vec3 color) { return mix(color, wow_fog_color.rgb, fog_amount); } -vec2 transform_uv(vec2 uv, vec4 uv_transform, vec2 uv_scroll) { - return uv * uv_transform.xy + uv_transform.zw + uv_scroll * TIME; +vec2 rotate_uv(vec2 uv, vec4 rotation) { + return vec2( + rotation.x * uv.x + rotation.y * uv.y, + rotation.z * uv.x + rotation.w * uv.y + ); +} + +vec2 rotate_uv_angle(vec2 uv, float angle) { + float c = cos(angle); + float s = sin(angle); + return vec2(c * uv.x - s * uv.y, s * uv.x + c * uv.y); +} + +vec2 transform_uv(vec2 uv, vec4 uv_transform, vec2 uv_scroll, vec4 uv_rotation, float uv_rotation_speed) { + vec2 centered = uv - vec2(0.5); + centered = rotate_uv(centered, uv_rotation); + if (abs(uv_rotation_speed) > 0.000001) { + centered = rotate_uv_angle(centered, uv_rotation_speed * TIME); + } + centered *= uv_transform.xy; + return centered + vec2(0.5) + uv_transform.zw + uv_scroll * TIME; } vec2 env_uv(vec3 normal) { @@ -325,10 +360,10 @@ vec2 env_uv(vec3 normal) { } vec2 stage_uv(int stage, vec3 normal) { - vec2 t1 = transform_uv(uv_interp, stage0_uv_transform, stage0_uv_scroll); - vec2 t2 = transform_uv(uv2_interp, stage1_uv_transform, stage1_uv_scroll); - vec2 t3 = transform_uv(uv2_interp, stage2_uv_transform, stage2_uv_scroll); - vec2 t4 = transform_uv(uv2_interp, stage3_uv_transform, stage3_uv_scroll); + vec2 t1 = transform_uv(uv_interp, stage0_uv_transform, stage0_uv_scroll, stage0_uv_rotation, stage0_uv_rotation_speed); + vec2 t2 = transform_uv(uv2_interp, stage1_uv_transform, stage1_uv_scroll, stage1_uv_rotation, stage1_uv_rotation_speed); + vec2 t3 = transform_uv(uv2_interp, stage2_uv_transform, stage2_uv_scroll, stage2_uv_rotation, stage2_uv_rotation_speed); + vec2 t4 = transform_uv(uv2_interp, stage3_uv_transform, stage3_uv_scroll, stage3_uv_rotation, stage3_uv_rotation_speed); vec2 env = env_uv(normal); if (vertex_shader_id == 1) { @@ -383,12 +418,12 @@ vec2 stage_uv(int stage, vec3 normal) { if (stage0_env) { return env; } - return transform_uv(stage0_uv2 ? uv2_interp : uv_interp, stage0_uv_transform, stage0_uv_scroll); + return transform_uv(stage0_uv2 ? uv2_interp : uv_interp, stage0_uv_transform, stage0_uv_scroll, stage0_uv_rotation, stage0_uv_rotation_speed); } if (stage1_env) { return env; } - return transform_uv(stage1_uv2 ? uv2_interp : uv_interp, stage1_uv_transform, stage1_uv_scroll); + return transform_uv(stage1_uv2 ? uv2_interp : uv_interp, stage1_uv_transform, stage1_uv_scroll, stage1_uv_rotation, stage1_uv_rotation_speed); } void resolve_m2_pixel(vec4 tex1, vec4 tex2, vec4 tex3, vec4 tex4, out vec3 diffuse, out vec3 specular, out float discard_alpha, out float can_discard) { diff --git a/addons/mpq_extractor/loaders/wow_wmo_material.gd b/addons/mpq_extractor/loaders/wow_wmo_material.gd index aa60f61..f83a871 100644 --- a/addons/mpq_extractor/loaders/wow_wmo_material.gd +++ b/addons/mpq_extractor/loaders/wow_wmo_material.gd @@ -4,7 +4,7 @@ class_name WowWMOMaterial const WMOBLEND_OPAQUE := 0 const WMOBLEND_ALPHA_KEY := 1 const WMOBLEND_ALPHA := 2 -const SHADER_VERSION := 3 +const SHADER_VERSION := 10 static var _shader_cache: Dictionary = {} @@ -31,7 +31,9 @@ static func build( texture3: Texture2D = null, texture3_path: String = "") -> ShaderMaterial: - var mode := _blend_shader_mode(blend_mode) + var is_window_material := _is_window_texture(texture_path) + var is_glass_blend_material := is_window_material and blend_mode == WMOBLEND_ALPHA + var mode := _blend_shader_mode(blend_mode, is_glass_blend_material) var effective_diffuse := diffuse_color if maxf(maxf(effective_diffuse.r, effective_diffuse.g), effective_diffuse.b) <= 0.001: effective_diffuse = Color.WHITE @@ -40,7 +42,7 @@ static func build( var mat := ShaderMaterial.new() mat.resource_name = texture_path.get_file().get_basename() var pixel_shader := _wmo_pixel_shader_id(shader_id) - mat.shader = _get_shader(mode, texture2 != null, texture3 != null) + mat.shader = _get_shader(mode, texture2 != null, texture3 != null, is_window_material) mat.render_priority = 0 if blend_mode <= WMOBLEND_ALPHA_KEY else 1 mat.set_shader_parameter("albedo_tex", texture) mat.set_shader_parameter("detail_tex", texture2) @@ -53,11 +55,13 @@ static func build( mat.set_shader_parameter("alpha_output_strength", _alpha_output_strength(blend_mode, pixel_shader)) mat.set_shader_parameter("vertex_color_strength", 0.72) mat.set_shader_parameter("ambient_floor", 0.22) + mat.set_shader_parameter("unlit_material", 1.0 if (material_flags & 0x01) != 0 else 0.0) mat.set_shader_parameter("diffuse_color", effective_diffuse) mat.set_shader_parameter("emissive_color", emissive_color) mat.set_shader_parameter("material_emissive_strength", _material_emissive_strength(material_flags, emissive_color, texture_path)) mat.set_shader_parameter("secondary_color", _effective_secondary_color(secondary_color)) mat.set_shader_parameter("detail_strength", 1.0 if texture2 != null else 0.0) + mat.set_shader_parameter("window_material", 1.0 if is_glass_blend_material else 0.0) mat.set_meta("texture0_path", texture_path) mat.set_meta("texture1_path", texture2_path) mat.set_meta("texture2_path", texture3_path) @@ -65,6 +69,8 @@ static func build( mat.set_meta("wow_shader", shader_id) mat.set_meta("wow_wmo_pixel_shader", pixel_shader) mat.set_meta("wow_blend_mode", blend_mode) + mat.set_meta("wow_wmo_window_material", is_window_material) + mat.set_meta("wow_wmo_glass_blend_material", is_glass_blend_material) return mat @@ -127,7 +133,7 @@ static func _shader_alpha_is_opacity(pixel_shader: int) -> bool: static func _alpha_ref(blend_mode: int, pixel_shader: int) -> float: - if blend_mode > WMOBLEND_OPAQUE: + if blend_mode == WMOBLEND_ALPHA_KEY: return 0.501960814 return 0.0 @@ -151,13 +157,24 @@ static func _material_emissive_strength(material_flags: int, color: Color, textu var strength := 0.12 if (material_flags & 0x10) != 0: strength = 0.26 - var lower_path := texture_path.to_lower() - if lower_path.contains("window") or lower_path.contains("_wnd_") or lower_path.contains("wnd_"): + if _is_window_texture(texture_path): strength = minf(strength, 0.18) return strength -static func _blend_shader_mode(blend_mode: int) -> String: +static func _is_window_texture(texture_path: String) -> bool: + var lower_path := texture_path.to_lower().replace("\\", "/") + var file_name := lower_path.get_file() + return lower_path.contains("/windows/") \ + or lower_path.contains("/glass/") \ + or file_name.contains("window") \ + or file_name.contains("glass") \ + or file_name.contains("_wnd_") + + +static func _blend_shader_mode(blend_mode: int, is_glass_blend_material: bool) -> String: + if is_glass_blend_material: + return "glass" match blend_mode: WMOBLEND_ALPHA_KEY: return "cutout" @@ -167,29 +184,31 @@ static func _blend_shader_mode(blend_mode: int) -> String: return "opaque" -static func _get_shader(mode: String, has_detail: bool, has_extra: bool) -> Shader: - var key := "v=%d|%s|detail=%s|extra=%s" % [SHADER_VERSION, mode, str(has_detail), str(has_extra)] +static func _get_shader(mode: String, has_detail: bool, has_extra: bool, stable_albedo_sampler: bool = false) -> Shader: + var key := "v=%d|%s|detail=%s|extra=%s|stable_albedo=%s" % [SHADER_VERSION, mode, str(has_detail), str(has_extra), str(stable_albedo_sampler)] if _shader_cache.has(key): return _shader_cache[key] var shader := Shader.new() - shader.code = _shader_code(mode, has_detail, has_extra) + shader.code = _shader_code(mode, has_detail, has_extra, stable_albedo_sampler) _shader_cache[key] = shader return shader -static func _shader_code(mode: String, has_detail: bool, has_extra: bool) -> String: - var render_modes: Array[String] = ["blend_mix", "cull_disabled", "unshaded"] +static func _shader_code(mode: String, has_detail: bool, has_extra: bool, stable_albedo_sampler: bool = false) -> String: + var render_modes: Array[String] = ["blend_mix", "unshaded"] + render_modes.append("cull_back" if mode == "glass" or stable_albedo_sampler else "cull_disabled") if mode == "opaque" or mode == "cutout": render_modes.append("depth_draw_opaque") else: - render_modes.append("depth_prepass_alpha") + render_modes.append("depth_draw_never") + var albedo_sampler_hint := "filter_linear, repeat_disable" if stable_albedo_sampler else "filter_linear_mipmap_anisotropic, repeat_enable" return """ shader_type spatial; render_mode %s; -uniform sampler2D albedo_tex : source_color, filter_linear_mipmap_anisotropic, repeat_enable; +uniform sampler2D albedo_tex : source_color, %s; uniform sampler2D detail_tex : source_color, filter_linear_mipmap_anisotropic, repeat_enable; uniform sampler2D extra_tex : source_color, filter_linear_mipmap_anisotropic, repeat_enable; uniform bool use_texture = false; @@ -200,11 +219,13 @@ uniform float alpha_ref = 0.0; uniform float alpha_output_strength = 0.0; uniform float vertex_color_strength = 0.88; uniform float ambient_floor = 0.20; +uniform float unlit_material = 0.0; uniform vec4 diffuse_color : source_color = vec4(1.0); uniform vec4 emissive_color : source_color = vec4(0.0); uniform float material_emissive_strength = 0.0; uniform vec4 secondary_color : source_color = vec4(1.0); uniform float detail_strength = 0.0; +uniform float window_material = 0.0; global uniform vec4 wow_ambient_color; global uniform vec4 wow_light_color; @@ -217,11 +238,13 @@ global uniform float wow_sun_elevation; varying vec3 world_normal; varying vec3 world_pos; varying vec3 view_pos; +varying vec3 view_normal; void vertex() { world_normal = normalize(MODEL_NORMAL_MATRIX * NORMAL); world_pos = (MODEL_MATRIX * vec4(VERTEX, 1.0)).xyz; view_pos = (MODELVIEW_MATRIX * vec4(VERTEX, 1.0)).xyz; + view_normal = normalize((MODELVIEW_MATRIX * vec4(NORMAL, 0.0)).xyz); } vec3 apply_wow_fog(vec3 color) { @@ -318,6 +341,9 @@ void fragment() { vec3 zone_light = mix(vec3(1.0), wow_light_color.rgb, 0.10); float day_strength = mix(0.48, 0.92, clamp(wow_sun_elevation, 0.0, 1.0)); vec3 lit = baked_lit * (zone_ambient * 0.86 + zone_light * ndl * 0.24 * day_strength); + if (unlit_material > 0.5) { + lit = vec3(1.0); + } lit = clamp(lit, vec3(0.46), vec3(1.05)); vec3 material_color = untextured_material_color(diffuse_color.rgb); if (use_texture) { @@ -327,10 +353,30 @@ void fragment() { vec3 material_emissive = mat_diffuse * emissive_color.rgb * emissive_color.a * material_emissive_strength * emissive_mask; vec3 emissive = shader_emissive * 0.35 + material_emissive; vec3 color = clamp(material_color * lit + emissive, vec3(0.0), vec3(1.0)); + float window_facing = 1.0; + if (window_material > 0.5) { + vec3 vn = normalize(view_normal); + if (!FRONT_FACING) { + vn = -vn; + } + window_facing = abs(dot(vn, normalize(-view_pos))); + float edge_reflect = pow(1.0 - window_facing, 3.0); + vec3 glass_tint = mix(color, vec3(0.58, 0.70, 0.82), 0.24); + color = clamp(mix(color, glass_tint + vec3(edge_reflect) * 0.12, 0.36), vec3(0.0), vec3(1.0)); + } ALBEDO = apply_wow_fog(color); %s } """ % [ ", ".join(render_modes), - "" if mode == "opaque" or mode == "cutout" else "\tALPHA = mix(diffuse_color.a, tex1.a * diffuse_color.a, clamp(alpha_output_strength, 0.0, 1.0));", + albedo_sampler_hint, + _alpha_output_code(mode), ] + + +static func _alpha_output_code(mode: String) -> String: + if mode == "opaque" or mode == "cutout": + return "" + if mode == "glass": + return "\tALPHA = clamp(mix(0.40, 0.95, window_facing) * max(diffuse_color.a, 0.45), 0.35, 0.95);" + return "\tALPHA = mix(diffuse_color.a, tex1.a * diffuse_color.a, clamp(alpha_output_strength, 0.0, 1.0));" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..86a57b3 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,170 @@ +# Архитектура OpenWC + +## Цель + +OpenWC состоит из двух продуктов на общей платформе: + +- runtime-клиент WoW 3.3.5a для подключения к TrinityCore/AzerothCore; +- World and Gameplay Authoring Studio как набор dock-панелей, inspector-плагинов и viewport-инструментов внутри Godot Editor. + +Оба продукта используют общие канонические модели, импорт данных и renderer preview, но не разделяют UI, lifecycle и права на изменение данных. + +## Архитектурные принципы + +1. Сервер авторитетен для боя, ресурсов, инвентаря, квестового прогресса и общего мира. +2. Domain-модель не зависит от Godot Node, Control, Resource, сокетов или SQL. +3. Renderer и UI являются проекциями состояния, а не владельцами gameplay-данных. +4. Raw packet и server schema не выходят за границы adapter-слоя. +5. Исходником пользовательского контента является версионируемый Content Project, а не рабочая БД. +6. Любой тяжёлый импорт и bake отделяется от frame-critical runtime. +7. Расширение выполняется через стабильные контракты, capability discovery и версионируемые схемы, а не через глобальные singleton-зависимости. +8. Детерминированные преобразования отделяются от side effects и тестируются без сцены. + +## Контекст системы + +```text +Original 3.3.5a data ──► Import/Cache ───────────────┐ + ▼ +TrinityCore/AzerothCore ◄── Network Adapter ◄── Runtime Client + ▲ ▲ + │ SQL/change sets │ read models + │ │ + Build/Deploy ◄── Content Project ◄── Godot Editor Tools +``` + +## Слои runtime + +### Domain + +Содержит идентификаторы, value objects, команды и события: `EntityId`, `WowGuid`, `MapPosition`, `SpellId`, `MoveIntent`, `EntityMoved`. Не содержит side effects. + +### Gameplay + +Владеет `WorldState`, entity registry, локальным игроком, movement prediction, targeting, combat presentation state, spells, auras, inventory, quests и social state. Принимает domain events и формирует intents/commands. + +### Network + +Владеет transport, SRP/auth session, realm/world session, crypt, framing, opcode registry, packet codecs, update fields и replay traces. Публикует только domain events. Профиль протокола фиксируется как `wotlk_335a_build_12340`; различия ядер находятся в adapters/capabilities. + +### Render + +Владеет ADT/WDT/WDL, M2, WMO, liquids, sky, lighting, weather visuals, animation presentation, LOD, culling, caches и frame budgets. Текущее состояние и ограничения описывает [`../RENDER.md`](../RENDER.md). + +Renderer получает `StreamingFocus`, `WorldVisualSnapshot` и presentation-команды. Он НЕ ДОЛЖЕН читать packets, SQL или gameplay input. + +### UI + +Владеет login/realm/character screens, HUD и FrameXML/Lua-compatible presentation. UI читает immutable view models и отправляет intents. Lua API получает capability-based facade; прямой доступ к network, filesystem и editor API запрещён. + +### Audio + +Владеет music, ambience, positional SFX, UI sounds и voice playback. Получает zone/combat/weather events и DBC-backed descriptors. Gameplay не управляет AudioStreamPlayer напрямую. + +### Application + +Собирает зависимости, управляет lifecycle `Boot → Login → Realm → CharacterSelect → Loading → InWorld → Disconnect` и режимами `RenderSandbox`, `OfflineSandbox`, `ProtocolSandbox`, `GameClient`. + +## Слои authoring + +### Editor shell + +Godot `EditorPlugin` регистрирует workspace, docks, inspectors, gizmos, importers и команды. Shell не содержит правил конкретных таблиц или квестов. + +### Authoring domain + +Канонические редактируемые сущности: map, zone, terrain patch, placement, creature, gameobject, quest, dialogue, loot, encounter, dungeon package и localization bundle. + +### Commands and history + +Каждая мутация оформляется командой с `execute`, `undo`, validation impact и change description. Инструменты не меняют scene tree или файлы в обход общей истории операций. + +### Server data adapters + +Адаптеры TrinityCore/AzerothCore преобразуют канонические сущности в snapshot и change set. Неизвестная версия схемы разрешает read-only inspection, но блокирует запись. + +### Build and deployment + +Pipeline выполняет validate, allocate IDs, bake, diff, package, generate SQL, dry-run, apply-to-dev и verification. Production deployment является отдельной явно подтверждаемой операцией. + +## Направление зависимостей + +```text +app ─────► gameplay, network, render, ui, audio +editor ──► authoring, content, render-preview, server-adapters +network ─► domain +gameplay ► domain +render ──► domain presentation contracts +ui ──────► view models and intents +content ─► domain schemas +domain ──► standard language/runtime only +``` + +Запрещены зависимости `render → network`, `ui → packet codec`, `gameplay → material`, `domain → Node`, `server adapter → editor Control`. + +## Runtime data flow + +```text +Input/UI → Intent → Gameplay → Network command → Server +Server → Packet codec → Domain event → Gameplay state +Gameplay state → Presenters → Render/UI/Audio +``` + +Для локального движения допускается prediction. Reconciliation всегда использует server snapshot; correction policy должна быть измеримой и тестируемой. + +## Предлагаемая структура кода + +```text +src/ + app/ + domain/ + gameplay/ + network/ + protocol/wotlk_335a/ + adapters/trinitycore/ + adapters/azerothcore/ + render/ + ui/ + audio/ + content/ + editor/ + server_data/ + build_pipeline/ + shared/ + tests/ +addons/ + openwc_editor/ +``` + +Существующий `addons/mpq_extractor` остаётся content infrastructure. Его runtime API следует закрыть интерфейсами asset repository и import service. + +## Контракты расширения + +- `ProtocolProfile` — opcodes, update fields, codecs, capabilities. +- `ServerSchemaAdapter` — inspect, import, diff, generate, validate. +- `ContentTypeDescriptor` — schema, inspector, validator, compiler. +- `WorldRenderer` — streaming focus и entity presentation. +- `GameplaySystem` — commands/events без scene dependency. +- `EditorTool` — selection, command creation и gizmo, без прямой записи. +- `TestFixtureProvider` — обезличенные packets, DB snapshots и content fixtures. + +Каждый контракт версионируется. Несовместимое изменение требует migration и ADR. + +## Потоки и производительность + +- Network thread читает байты; main/game thread применяет domain events. +- Worker pool выполняет parsing, decompression, import и bake. +- Godot scene tree и GPU resource finalization изменяются только на допустимом потоке и ограничиваются budget scheduler. +- Очереди имеют bounded capacity, cancellation и telemetry. +- Editor-задачи показывают progress, позволяют cancel и не блокируют UI. + +## Безопасность и законность + +- Репозиторий не распространяет proprietary game data, ключи, пароли или production dumps. +- Credentials хранятся вне Content Project и логов. +- Packet traces и DB fixtures редактируются/обезличиваются. +- Lua/addons исполняются с документированными возможностями и лимитами. +- Direct DB apply доступен только для явно выбранной dev-среды с backup/checkpoint. + +## ADR + +Значимые решения записываются в `docs/adr/NNNN-title.md`: контекст, варианты, решение, последствия и migration. ADR требуется для изменения границы слоя, формата контента, координат, authoritative policy или поддерживаемого server profile. diff --git a/docs/CLIENT_FEATURES.md b/docs/CLIENT_FEATURES.md new file mode 100644 index 0000000..0e6f30e --- /dev/null +++ b/docs/CLIENT_FEATURES.md @@ -0,0 +1,117 @@ +# Карта функций клиента WoW 3.3.5a + +## Назначение + +Это coverage map, а не обещание реализовать всё одновременно. Для каждой строки в будущем ведутся статусы `Not started`, `Research`, `Prototype`, `Playable`, `Compatible`, `Verified` и ссылки на issue/spec/tests. + +## Platform и данные + +- MPQ archive chain, locale и patch precedence. +- BLP, DBC, DB2 при расширении профиля, WDT/WDL/ADT, M2/SKIN, WMO, animation, particles, ribbons, cameras. +- Cache/versioning, import diagnostics, missing/corrupt asset policy. +- Typed DBC access: maps, areas, races, classes, characters, items, creatures, spells, talents, factions, sounds, liquids, lights, maps and taxi. +- Settings, keybindings, locale, screenshots, logs, crash recovery и accessibility options. + +## World presentation + +- Terrain layers, alpha maps, holes, normals, LOD и distant terrain. +- WMO groups, doodads, portals/rooms, indoor/outdoor state и collision. +- M2 static/animated models, attachments, billboards, particles, ribbons и material combiners. +- MH2O/MCLQ water, ocean, lava, slime и shore/depth behavior. +- Sky, day/night, fog, zone lighting, weather и selective shadows. +- World streaming, preload/retention, occlusion, render budgets и deterministic checkpoints. + +## Character presentation + +- Race/gender/body customization и character creation preview. +- Skin/hair/face/facial hair/geosets. +- Equipment textures, component models, enchant visuals, sheathe/unsheathe и attachments. +- Animation selection, blending, emotes, combat, cast, death, swim, fly, mount и vehicle. +- Creature display/model data, NPC equipment, pets, guardians и shapeshift. + +## Connection и world synchronization + +- SRP authentication, realm list, world auth, reconnect и disconnect reasons. +- Encrypted framing, compressed packets, opcode dispatch и packet tracing. +- Character enum/create/delete/rename/login/logout. +- Packed GUIDs, update fields, create/update/out-of-range/destroy. +- Movement flags, heartbeat, jump/fall/swim/fly, transports, knockback и spline movement. +- Server time, tutorial flags, account data и feature capability detection. + +## Player и camera + +- Remappable actions вместо hardcoded keys. +- Walk/run/backpedal/strafe/jump/fall/swim/fly и autorun. +- Click-to-move как отдельная опциональная policy. +- Camera orbit, zoom, collision, first-person limit и follow policies. +- Client prediction, server correction, transport-relative movement и latency telemetry. +- Selection, interaction range, facing и line-of-sight feedback. + +## Combat, spells и progression + +- Auto attack, attack stop, combat state, damage/heal/miss/immune feedback. +- Spellbook, ranks, cast/channel, interruption, cooldown, global cooldown, range и resources. +- Auras, stacks, durations, dispels, proc presentation и diminishing returns display. +- Death, ghost, corpse, resurrection, durability и repair. +- XP, level, rested XP, reputation, honor, arena и achievements. +- Talents, glyphs, skills, professions, recipes и training. + +## Items и economy + +- Bags, bank, keyring, equipment, ammo, currencies и item cooldowns. +- Loot, roll, master loot, disenchant и bind confirmations. +- Vendors, buyback, repair, trainer и gossip services. +- Trade, mail, auction house, guild bank и item links/tooltips. +- Enchanting, sockets/gems, random properties и item sets. + +## Quests и world activities + +- Availability, accept, abandon, track, progress, complete и reward choice. +- Kill, collect, interact, explore, escort, timed, reputation и spell objectives. +- Chains, prerequisites, exclusivity, repeatable/daily/weekly и phased conditions. +- Gossip/dialogue, scripted events, gameobjects, transports и taxi. +- World map, minimap, exploration, POI, quest indicators и instance maps. + +## Groups, social и PvP + +- Party, raid, roles, ready check, raid targets и loot rules. +- Friends, ignore, who, guild, channels, whispers и chat moderation feedback. +- Duels, PvP flags, battlegrounds, arenas, scores, queues и resurrection flow. +- LFG/Dungeon Finder, proposal, teleport, vote kick и rewards. + +## Instances и cinematics + +- Dungeon/raid entry, difficulty, lockouts, encounter states и reset. +- Doors, triggers, boss frames, instance messages и saved state presentation. +- Vehicles, multi-passenger seats и possession UI. +- Cinematics, camera sequences, movies, loading screens и transitions. + +## UI, Lua и addons + +- Login, legal/realm/character creation/selection/loading screens. +- HUD: unit frames, target/focus, action bars, buffs, cast bars, bags, quest tracking, chat и combat text. +- FrameXML: templates, inheritance, anchors, strata, layers, scripts и events. +- Lua 5.1-compatible API surface, secure boundaries, SavedVariables и addon lifecycle. +- Widgets: Frame, Button, CheckButton, EditBox, Slider, StatusBar, ScrollFrame, Tooltip, Cooldown, Model, Texture, FontString, Minimap. +- Macro conditionals, keybindings, slash commands, localization и UI scaling. + +## Audio + +- Zone/day/night music и crossfade. +- Ambience, weather и indoor/outdoor transitions. +- Positional world sounds, spell/combat/footstep/foley/voice и UI sounds. +- Cache/streaming, device selection, category volumes и mute-on-focus policy. + +## Authoring coverage + +- Terrain sculpt/paint/holes/liquids и tile lifecycle. +- M2/WMO placement, transforms, unique IDs и doodad sets. +- Creature/gameobject templates, spawns, groups, paths и equipment. +- Quests, gossip, conditions, loot, vendors, trainers и localization. +- Zones, areas, graveyards, teleports, taxi, triggers и phase rules. +- Dungeon packages, encounters, doors, bosses, scripts и validation. +- Import, diff, package, SQL/change-set generation, dev deployment и playtest. + +## Требование к расширяемости + +Каждая функция должна быть модифицируема заменой data/config, policy или adapter до изменения core-кода. Если это невозможно, extension point и причина ограничения документируются. Пользовательские override-пакеты имеют явный приоритет, зависимости и совместимость; молчаливое изменение оригинальных данных запрещено. diff --git a/docs/CONTENT_AND_SERVER_DATA.md b/docs/CONTENT_AND_SERVER_DATA.md new file mode 100644 index 0000000..dfe674d --- /dev/null +++ b/docs/CONTENT_AND_SERVER_DATA.md @@ -0,0 +1,126 @@ +# Контент, клиентские данные и серверная БД + +## Источник истины + +Пользовательские изменения хранятся в Content Project. Original client data и server DB являются импортируемыми upstream-источниками; generated cache, SQL и packages являются build artifacts. + +```text +upstream snapshot + user content + build profile → validated artifacts +``` + +## Структура Content Project + +```text +content/projects// + project.json + manifests/ + maps/ + placements/ + creatures/ + gameobjects/ + quests/ + dialogues/ + loot/ + encounters/ + dungeons/ + localization/ + scripts/ + client_assets/ + tests/ +``` + +Файлы должны быть детерминированными, Git-friendly, с явными `schema_version`, stable symbolic ID и минимальным шумом форматирования. Generated IDs хранятся в allocation manifest, чтобы две сборки давали одинаковый результат. + +## Идентификаторы и ссылки + +- Authoring использует symbolic ID вида `quest.my_pack.bandit_problem`. +- Server numeric entry выделяется allocator-ом по профилю окружения. +- WoW GUID не используется как content ID. +- Ссылки типизированы и проверяются до сборки. +- Удаление сущности с входящими ссылками требует явного cascade/replacement решения. + +## Координаты + +Определяются отдельные типы `CanonicalWowPosition`, `GodotWorldPosition`, `AdtTileCoordinate`, `AdtChunkCoordinate`, `LocalTilePosition`, orientation и transport-relative position. + +Только `CoordinateMapper` выполняет преобразования. Axis swap, origin, handedness, rotation units и scale фиксируются тестовыми golden points. Ручные преобразования в renderer, SQL exporter или gizmo запрещены. + +## Server snapshot + +Read-only import создаёт versioned snapshot с provenance: + +- core family и commit/revision; +- world/auth/characters schema version; +- время получения; +- набор импортированных таблиц; +- checksum и applied filters. + +Snapshot нужен для поиска, ссылок, offline validation и diff. Credentials и персональные данные в snapshot не включаются. + +## Каноническая модель и adapters + +Редактор работает с `QuestDefinition`, `CreatureTemplate`, `CreatureSpawn`, `GameObjectDefinition`, `LootTable`, `Dialogue`, `Condition`, `EncounterDefinition`. Адаптеры `TrinityCore335` и `AzerothCore335` отвечают за конкретные таблицы, flags, SmartAI/script conventions и capabilities. + +Нельзя считать близкие схемы идентичными. Перед записью adapter проверяет fingerprint схемы. Unsupported property выдаёт ошибку или documented degradation, но не игнорируется молча. + +## Change set + +Build генерирует immutable change set: + +- metadata и target profile; +- preconditions; +- ordered operations; +- SQL preview; +- affected entities/tables; +- validation report; +- rollback или описание необратимости; +- checksum. + +Apply сначала выполняет dry-run на disposable/dev DB. Повторное применение должно быть idempotent либо безопасно отклоняться precondition-ом. + +## Клиентские данные + +Custom locations и mechanics могут требовать согласованных изменений: + +- ADT/WDT/WDL, M2/WMO/BLP; +- DBC overlays; +- minimap/world map assets; +- loading screens, audio и localization; +- server maps, collision, pathfinding/mmaps/vmaps; +- DB records и scripts. + +`ContentBundleManifest` связывает client и server artifacts общей версией. Клиент не входит в мир при несовместимой обязательной версии пакета без понятной диагностики. + +## Overlay и модификации + +Оригинальные извлечённые файлы не изменяются in-place. Пользовательские пакеты образуют ordered overlay: + +```text +base → core compatibility → project dependencies → user project → local dev override +``` + +Конфликт разрешается декларативным priority/dependency rule и показывается в build report. Для коллекций предпочтительны semantic merge operations, а не копирование целой upstream-таблицы. + +## Сборка и публикация + +Pipeline: `inspect → import → validate → compile → bake → diff → package → verify → publish`. + +Каждый шаг: + +- детерминирован при одинаковых входах; +- кешируется по content hash; +- пишет structured diagnostics; +- не публикует частичный результат; +- доступен из Godot Editor и headless CI. + +## Миграции форматов + +Каждая схема имеет monotonically increasing version. Loader либо мигрирует старую версию с backup, либо открывает read-only. Migration имеет fixture `before → after`, проверку idempotence и documented loss policy. + +## Локализация + +Текст хранится по locale, отдельно от логики. Validator проверяет placeholders, plural/gender conventions, длины UI, отсутствующие переводы и orphan keys. Fallback locale задаётся профилем и явно отображается в preview. + +## Пользовательские scripts + +Script assets имеют declared hooks, API version, permissions и deterministic validation. Генерация C++ skeleton для core допустима, но его сборка и deployment являются отдельным артефактом. Editor не обещает выразить любую серверную механику только через визуальный граф. diff --git a/docs/GAME_DESIGN.md b/docs/GAME_DESIGN.md new file mode 100644 index 0000000..093e78d --- /dev/null +++ b/docs/GAME_DESIGN.md @@ -0,0 +1,100 @@ +# Геймдизайн пользовательского контента + +## Цель + +Инструменты должны помогать создавать понятный, проверяемый и поддерживаемый контент, не навязывая автору один стиль игры. Blizzlike-параметры являются профилем по умолчанию, а не скрытым ограничением. + +## Карточка игровой функции + +Перед реализацией функции фиксируются: + +- целевой игрок и player goal; +- core loop и ожидаемая длительность; +- правила, ресурсы и серверная авторитетность; +- affordance, feedback и failure/recovery states; +- reward, progression и economy impact; +- solo/group/PvP context; +- accessibility и localization; +- telemetry/test questions; +- abuse/exploit risks. + +Настройка должна быть data-driven там, где это не нарушает безопасность и целостность сервера. + +## Квесты + +Квест описывается через intent и progression graph, а не только набор DB-полей. Проверяются: + +- понятная причина и цель; +- доступность giver/ender; +- достижимость каждого objective; +- prerequisites, chains и mutually exclusive branches; +- плотность целей, travel time и конкуренция игроков; +- drop chance и ожидаемое число действий; +- failure/retry для escort, timed и scripted objectives; +- reward относительно уровня, сложности и экономики; +- локализация и отсутствие противоречий текста фактическим правилам. + +Graph validator обнаруживает циклы без намеренного repeat policy, недостижимые узлы, отсутствующие targets и dead ends. + +## Локации + +Локация проектируется слоями: + +- macro navigation и landmarks; +- безопасные маршруты и опасные зоны; +- quest hubs и distribution целей; +- spawn ecology и respawn pressure; +- точки отдыха, vendors, graveyard и transport; +- визуальная читаемость, lighting, audio и weather; +- streaming/performance budgets; +- boundary behavior и переходы между зонами. + +Редактор должен показывать heatmaps плотности spawn, quest travel paths, level/difficulty bands, line-of-sight blockers и приблизительную стоимость рендера. + +## Подземелья и encounters + +`DungeonPackage` объединяет входы, difficulty, encounters, doors, triggers, trash groups, bosses, loot, quests, graveyards, scripts и localization. + +Для encounter фиксируются: + +- learning objective и fantasy; +- phases и transition conditions; +- ability cadence и telegraphs; +- positioning/movement requirements; +- tank/healer/damage responsibilities без исключения альтернативных составов, если это цель; +- fail states, wipe recovery и reset; +- tuning parameters отдельно от script logic; +- deterministic simulation cases и live playtest checklist. + +Сложность должна появляться из читаемых взаимодействий, а не из скрытой информации или случайной несовместимости механик. + +## Баланс + +- Формулы и tuning values версионируются отдельно от UI и scripts. +- У каждого параметра есть unit, диапазон и rationale. +- Нельзя балансировать только по одному идеальному сценарию; проверяются level, gear, party size и latency bands. +- Изменение reward/economy сопровождается impact report. +- Randomness документирует distribution, pity/guarantee policy при наличии и worst-case experience. + +## Feedback и UX + +Игрок должен понимать: что произошло, почему, что изменилось и что можно сделать дальше. Network delay не маскируется ложным успехом: pending, confirmed и rejected состояния различаются визуально. + +Критическая информация не должна зависеть только от цвета, звука или addon. Ошибки range, target, resource, cooldown, inventory space и prerequisites имеют специфичное сообщение. + +## Совместимость и модификации + +Автор может переопределять tuning, тексты, layouts, content graphs и presentation assets через overlay-пакеты. Изменение protocol/security/authoritative rules требует отдельного совместимого profile. Пакет объявляет зависимости, конфликты и ожидаемые core capabilities. + +## Playtesting + +Каждая feature проходит: + +1. content validation; +2. designer smoke в offline preview; +3. server-integrated functional test; +4. exploratory playtest с заранее заданными вопросами; +5. regression после правок; +6. performance и multiplayer soak, если применимо. + +Отчёт отделяет наблюдения от интерпретаций и решений. Исправление симптома не заменяет проверку исходной design hypothesis. diff --git a/docs/GODOT_EDITOR.md b/docs/GODOT_EDITOR.md new file mode 100644 index 0000000..b9b1bee --- /dev/null +++ b/docs/GODOT_EDITOR.md @@ -0,0 +1,107 @@ +# Инструменты OpenWC внутри Godot Editor + +## Принцип + +Все интерактивные authoring-инструменты реализуются как Godot `EditorPlugin`. Отдельный native GUI не является частью целевой архитектуры. Headless-команды допускаются для CI и batch build, но используют те же application services и validators. + +## Workspace + +Плагин `addons/openwc_editor` должен предоставлять: + +- главное рабочее пространство World; +- docks Content Browser, Outliner, Inspector, Validation, Build, Database и History; +- viewport overlays и gizmos; +- project settings для путей и dev environments; +- command palette и контекстные действия; +- playtest launcher. + +Editor UI вызывает services, а не SQL, MPQ parsers или файловую запись напрямую. + +## Инструменты мира + +- Camera/navigation и переход по map/tile/coordinates. +- Selection, multiselect, filtering, snapping и transform gizmo. +- Terrain sculpt: raise/lower, smooth, flatten, noise, stamp. +- Terrain paint: texture layers, alpha, holes и liquid masks. +- Placement: M2, WMO, creature, gameobject, trigger, sound emitter. +- Paths: waypoint, patrol, spline, taxi и transport preview. +- Volumes: area, subzone, encounter, graveyard, phase и weather. +- Visualization modes: collision, normals, chunk/tile bounds, nav/pathing, spawn density, quest dependencies и streaming budgets. + +Каждый stroke или placement создаёт одну логическую undoable command. Длительный stroke агрегируется, чтобы history не содержала команду на каждую вершину. + +## Data editors + +- Creature и gameobject templates. +- Spawn/group/path editor. +- Quest form и quest-chain graph. +- Gossip/dialogue и conditions. +- Loot/vendor/trainer/item editors. +- Dungeon/encounter graph. +- Localization table с completeness report. +- Raw server record inspector только как диагностический режим. + +Инспекторы генерируются из `ContentTypeDescriptor`, но сложные сущности могут иметь специализированный inspector. Валидация отображается у поля, сущности и проекта. + +## Selection и scene tree + +Authoring selection хранится отдельно от runtime scene tree. Preview-ноды считаются disposable projection: их можно пересоздать из `EditorWorldModel`. Сохранение `.tscn` не является способом записи server content. + +## Undo/redo и autosave + +- Все мутации проходят через command history. +- Команда хранит стабильные IDs, а не ссылки на Node. +- Autosave пишет recovery journal атомарно. +- После crash предлагается восстановление с preview diff. +- External file changes обнаруживаются; конфликт не перезаписывается автоматически. + +## Длительные задачи + +Import, bake, validation, packaging и DB snapshot выполняются как jobs: + +- progress и текущий этап; +- cancellation token; +- структурированные diagnostics; +- отсутствие блокировки editor UI; +- staging output и атомарная публикация результата; +- повторяемая команда для headless CI. + +## Database dock + +- Показывает тип ядра, revision, schema version и capabilities. +- По умолчанию read-only. +- Production target визуально и технически отделён от dev. +- Перед apply показывает SQL/change diff, preconditions и backup status. +- Secrets не сохраняются в проект и не попадают в output/log. + +## Playtest + +Редактор должен уметь: + +1. проверить Content Project; +2. собрать client assets и server change set; +3. применить change set к disposable/dev environment; +4. запустить или перезапустить core при необходимости; +5. запустить клиент на нужной карте и позиции; +6. собрать client/server logs и test observations; +7. откатить временный change set. + +Fast preview без сервера допускается для геометрии, освещения, spawn layout и quest graph, но НЕ считается проверкой серверной механики. + +## Расширения редактора + +Новый тип контента регистрирует schema, icon, inspector, validator, compiler и optional viewport presenter. Плагин обязан объявить версию API и зависимости. Неизвестное поле сохраняется round-trip, если формат это допускает. + +## UX-принципы + +- Безопасные значения по умолчанию и preview перед разрушительной операцией. +- Ошибка объясняет проблему, местоположение и действие для исправления. +- Stable IDs видны, но пользователь в основном работает с именами и ссылками. +- Bulk-edit показывает scope и итоговый diff. +- Горячие клавиши переназначаются. +- Цвет не является единственным носителем статуса. +- Основные сценарии доступны без ручного SQL. + +## MVP + +Первый editor slice: открыть карту, импортировать server snapshot, увидеть spawns, поставить NPC, создать простой kill quest, проверить ссылки, получить SQL diff, применить к dev-базе и запустить playtest. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..f8339ee --- /dev/null +++ b/docs/README.md @@ -0,0 +1,45 @@ +# OpenWC Documentation + +Этот каталог — единая точка входа в разработку OpenWC: клиента World of Warcraft 3.3.5a и набора инструментов авторинга внутри Godot Editor. + +Исполняемая последовательность текущих целей находится в [`../targets/README.md`](../targets/README.md). Все агенты обязаны следовать корневому [`../AGENTS.md`](../AGENTS.md). + +## Статус документов + +Документы описывают целевую архитектуру. Реализованное состояние рендера зафиксировано отдельно в [`../RENDER.md`](../RENDER.md). Если описание целевого решения расходится с кодом, это считается известным архитектурным долгом, а не разрешением обходить границы слоёв. + +Нормативные слова `ДОЛЖЕН`, `НЕ ДОЛЖЕН`, `СЛЕДУЕТ` и `МОЖЕТ` задают обязательность решения. Существенные отклонения оформляются Architecture Decision Record (ADR). + +## Карта документов + +| Документ | Назначение | +|---|---| +| [ARCHITECTURE.md](ARCHITECTURE.md) | Границы модулей, направления зависимостей, runtime и editor | +| [CLIENT_FEATURES.md](CLIENT_FEATURES.md) | Полная карта функций клиента WoW 3.3.5a | +| [GODOT_EDITOR.md](GODOT_EDITOR.md) | Архитектура инструментов внутри Godot Editor | +| [CONTENT_AND_SERVER_DATA.md](CONTENT_AND_SERVER_DATA.md) | Content Project, данные клиента, БД TrinityCore/AzerothCore, сборка и публикация | +| [GAME_DESIGN.md](GAME_DESIGN.md) | Принципы геймдизайна, квестов, локаций, подземелий и баланса | +| [TESTING.md](TESTING.md) | Стратегия тестирования runtime, протокола, рендера и authoring pipeline | +| [ROADMAP.md](ROADMAP.md) | Последовательность вертикальных срезов и критерии готовности | +| [REFERENCES.md](REFERENCES.md) | Использованные локальные reference-проекты и правила работы с ними | + +## Обязательный workflow изменения функции + +1. Найти функцию в матрице [CLIENT_FEATURES.md](CLIENT_FEATURES.md). +2. Определить владельца данных и границу модуля по [ARCHITECTURE.md](ARCHITECTURE.md). +3. Для контента описать канонические данные и server adapter по [CONTENT_AND_SERVER_DATA.md](CONTENT_AND_SERVER_DATA.md). +4. Для игровых изменений сформулировать player goal, feedback и failure states по [GAME_DESIGN.md](GAME_DESIGN.md). +5. До реализации определить тесты и observability по [TESTING.md](TESTING.md). +6. Выполнить работу вертикальным срезом и обновить статус feature matrix. + +## Definition of Done для документации + +Изменение считается документированным, если указаны: + +- владелец состояния; +- публичные команды, события или read models; +- формат сохранения и версия схемы, если есть данные; +- серверная авторитетность и поведение при рассинхронизации; +- пользовательский сценарий и обработка ошибок; +- автоматические и ручные проверки; +- migration/rollback для изменяемого формата или БД. diff --git a/docs/REFERENCES.md b/docs/REFERENCES.md new file mode 100644 index 0000000..4b70275 --- /dev/null +++ b/docs/REFERENCES.md @@ -0,0 +1,59 @@ +# Локальные reference-проекты + +Reference-код используется для исследования форматов, поведения и архитектурных вариантов. Он не определяет API OpenWC и не копируется без проверки лицензии, корректности и соответствия Godot. + +## Основные источники + +### OpenWC renderer + +- [`../RENDER.md`](../RENDER.md) — фактическое состояние streaming, terrain, M2/WMO, liquids, sky, caches и performance rules. + +### WowUnreal + +- `reference/WowUnreal/specs/overview.md` — системная декомпозиция полного клиента. +- `networking.md`, `movement.md`, `character.md`, `ui-framexml.md`, `lua-api.md`, `dbc-wrappers.md`, `audio.md`, `water.md` — полезные coverage checklists и acceptance criteria. + +Используем: полноту feature map и привычку задавать критерии проверки. Не переносим Unreal-specific классы и renderer assumptions. + +### WoWee + +- `reference/WoWee/docs/architecture.md` — разделение renderer/network/game/UI/pipeline. +- `reference/WoWee/tools/editor/FORMAT_SPEC.md` — open formats, coordinates, collision, packaging и SQL export. +- `reference/WoWee/TESTING.md` — единая точка запуска тестов, fixtures, sanitizers и CI discipline. +- `reference/WoWee/EXPANSION_GUIDE.md` — protocol/data profile separation. + +Используем: детерминированные authoring formats, headless parity, adapter profiles и validation-first pipeline. Не принимаем автоматически конкретные форматы или заявленную полноту реализации. + +### Noggit Red + +- `reference/noggit-red` — UX и domain concepts специализированного world editor: terrain editing, placements, UIDs и map workflows. + +Используем: понимание authoring interaction и рисков unique IDs. Целевой UI реализуется только через Godot EditorPlugin API. + +### open-realm и whoa + +- `reference/open-realm/games/world-of-warcraft` — format/render pipeline и архитектурные исследования. +- `reference/whoa` — поведение клиента 3.3.5a и test fixtures. + +Используем как независимую проверку binary semantics и runtime behavior. + +### wow.export и Blender WoW Studio + +Используем для сверки conversion pipeline, материалов, моделей и authoring/export edge cases. Runtime web/Blender architecture не переносится в OpenWC. + +## Правила исследования + +1. Для protocol semantics приоритет имеют wire fixtures и соответствующая версия server core. +2. Для бинарных форматов вывод подтверждается минимум двумя независимыми источниками либо fixture-тестом. +3. Reference implementation не считается спецификацией без проверки edge cases. +4. Любое заимствование проверяется по лицензии и документируется. +5. Proprietary data не переносится в fixtures или репозиторий. +6. Вывод, влияющий на публичный контракт, оформляется ADR или обновлением соответствующей спецификации. + +## Замеченные полезные уроки + +- Полный клиент требует coverage map; иначе хорошо видимые renderer-функции вытесняют gameplay, UI и recovery paths. +- Editor нуждается в CLI/headless parity для CI, даже если вся интерактивная работа находится в Godot. +- Coordinate conversion и ID allocation должны быть централизованы раньше world editing. +- DB export без canonical model быстро связывает UI с одной версией ядра. +- Screenshot smoke полезен, но должен дополняться deterministic state, performance и protocol tests. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md new file mode 100644 index 0000000..b0323cd --- /dev/null +++ b/docs/ROADMAP.md @@ -0,0 +1,112 @@ +# Roadmap OpenWC + +> Этот документ объясняет стратегию на высоком уровне. Исполняемый порядок, текущий статус, checklist и доказательства завершения ведутся только в [`../targets/README.md`](../targets/README.md). При расхождении `targets/` имеет приоритет. + +## Стратегия + +Работа идёт вертикальными срезами. Каждый срез включает data, domain, adapter, presentation, editor support, diagnostics и tests. Горизонтальная реализация всех opcodes или всех editor-панелей без пользовательского результата не считается milestone. + +## M0 — Архитектурный фундамент + +- Утвердить документы и ADR workflow. +- Ввести module boundaries и dependency checks. +- Выделить input, camera, movement, terrain query и character presentation из тестового контроллера. +- Заменить зависимость streamer от Camera3D на `StreamingFocus`. +- Зафиксировать coordinate mapper и golden points. + +Готово, когда render sandbox сохраняет текущее качество, а domain тестируется без scene tree. + +## M1 — Content Project и Editor shell + +- `addons/openwc_editor` с workspace и docks. +- Content Project schema, stable IDs, save/load/migration. +- Selection, command history, autosave/recovery. +- Validation dock и headless validation. +- Read-only asset/content browser. + +Готово, когда небольшой synthetic project можно создать, изменить, undo, перезапустить Editor и получить идентичное состояние. + +## M2 — Server inspector и adapters + +- TrinityCore/AzerothCore connection profiles. +- Schema detection и capabilities. +- Read-only snapshots creatures, gameobjects, spawns и quests. +- Канонические модели, semantic diff и ID allocation. +- SQL change-set dry-run на disposable DB. + +Готово, когда одна сущность round-trip проходит оба поддерживаемых adapter profile без молчаливой потери данных. + +## M3 — World Editor MVP + +- Map viewport и coordinate overlays. +- Server spawn visualization. +- Selection, transform gizmo, placement и paths. +- NPC inspector, validation и undo/redo. +- Change set и dev apply. + +Готово, когда NPC размещается в Editor и появляется на тестовом core в ожидаемой позиции. + +## M4 — Quest vertical slice + +- Quest form и chain graph. +- Kill/collect/explore objectives, giver/ender, rewards и localization. +- Dependency/reachability/economy validation. +- Dev deploy и integrated playtest launcher. + +Готово, когда созданный в Editor квест полностью проходится клиентом. + +## M5 — Playable network client + +- Auth, realm, character selection и world session. +- Entity/update fields и world spawn. +- Movement prediction/reconciliation. +- Basic HUD, targeting, chat и disconnect recovery. + +Готово, когда клиент стабильно входит в мир, перемещается и видит синхронизированные entities. + +## M6 — Core gameplay + +- Combat/spells/auras/death. +- Inventory/equipment/loot/vendors. +- Quest runtime и world map/minimap. +- Character visuals и animation coverage. +- Lua/FrameXML foundation. + +Готово, когда базовый leveling loop проходит без внешнего клиента. + +## M7 — Dungeon authoring + +- DungeonPackage, encounters, triggers, doors и spawn groups. +- SmartAI/script skeleton generation. +- Encounter graph validation и tuning profiles. +- Group/instance/loot/lockout runtime. + +Готово, когда custom dungeon собирается, разворачивается и проходится группой на test core. + +## M8 — Compatibility и completeness + +- Feature matrix WoW 3.3.5a. +- Addon compatibility tiers. +- Social, PvP, professions, economy services, vehicles и cinematics. +- Core revision matrix, migration и release packaging. +- Performance, accessibility, localization и soak hardening. + +## Приоритет backlog + +При равной ценности порядок такой: + +1. безопасность данных и воспроизводимость; +2. корректность протокола и authoritative state; +3. пользовательский vertical slice; +4. diagnostics и testability; +5. frame pacing; +6. визуальная точность и polish. + +## Не делать раньше времени + +- прямую запись в production DB; +- универсальный visual scripting для любой C++ механики; +- массовую реализацию Lua API без работающего UI slice; +- большой rewrite существующего renderer; +- multi-expansion abstraction до устойчивого профиля 3.3.5a; +- proprietary asset packaging в репозитории. diff --git a/docs/TESTING.md b/docs/TESTING.md new file mode 100644 index 0000000..78f2fdb --- /dev/null +++ b/docs/TESTING.md @@ -0,0 +1,152 @@ +# Стратегия тестирования OpenWC + +## Принцип + +Тесты являются частью функционала платформы: Godot Editor должен запускать проверки, показывать diagnostics, открывать проблемную сущность и формировать воспроизводимый playtest bundle. Один ручной запуск сцены не является достаточным доказательством. + +## Пирамида тестов + +### Unit + +- parsers, codecs, bit/byte boundaries и malformed input; +- coordinate transforms и math; +- domain reducers, state machines, formulas и validators; +- schema migrations, ID allocation и semantic diff; +- quest/encounter graph rules; +- material mapping helpers без GPU там, где возможно. + +### Contract + +- packet fixtures для build 12340; +- golden update fields и packed GUID; +- TrinityCore/AzerothCore schema fingerprints; +- adapter round-trip `canonical → SQL → snapshot → canonical`; +- Content Project compatibility и migration fixtures; +- renderer/editor public API contracts. + +### Integration + +- auth/realm/world session с disposable test core; +- character enum/login/update object/movement; +- asset import → cache → render attach; +- content build → SQL apply → server query; +- Godot Editor command → undo/redo → save/reload. + +### End-to-end + +- login → character → enter world; +- movement и server reconciliation; +- combat/cast/aura/death; +- quest accept/progress/complete/reward; +- loot/inventory/vendor; +- party/dungeon flow; +- editor NPC + quest → deploy → playtest. + +### Visual и performance + +- deterministic screenshot checkpoints по картам, времени и погоде; +- perceptual diff с approved baseline и tolerance; +- frame time, hitch percentiles, draw calls, memory, descriptor count и streaming queues; +- long traversal через ADT boundaries; +- dense WMO/M2, water, character equipment и UI scale matrices. + +### Exploratory и game design + +- quest clarity и travel time; +- encounter readability и recovery; +- economy/reward impact; +- accessibility, localization и atypical input; +- latency/loss/reconnect behavior; +- authoring usability и error recovery. + +## Test fixtures + +Fixtures должны быть минимальными, обезличенными и легально распространяемыми. Не коммитятся credentials, proprietary bulk assets, account data или production dumps. + +Типы fixtures: + +- synthetic binary chunks; +- короткие redacted packet captures; +- schema-only/disposable DB seed; +- tiny custom content packs; +- known coordinate points; +- corrupt/truncated input corpus; +- screenshot metadata без обязательного включения proprietary textures. + +## Test matrix + +Минимальные оси: + +- Godot/OS/rendering backend; +- TrinityCore и AzerothCore supported revisions; +- locale; +- quality preset; +- offline/online; +- clean cache/warm cache; +- latency, jitter, packet loss и reconnect; +- content schema current/previous version. + +Полная матрица запускается периодически; pull request использует risk-based subset. + +## Godot Editor Test Center + +Editor dock должен предоставлять: + +- Validate Selection, Map, Package и Project; +- список severity/code/location/fix suggestion; +- переход к объекту, полю или viewport position; +- фильтр и baseline известных diagnostics; +- запуск headless test suites; +- playtest checklist и запись результата; +- экспорт machine-readable report для CI. + +Quick Fix допускается только как undoable command с preview diff. + +## Network verification + +- Каждый codec проверяет exact bytes и round-trip. +- Parser никогда не читает за packet boundary. +- Unknown opcode логируется rate-limited и не разрушает session без policy. +- Replay отделён от transport и детерминирован. +- Fuzz/property tests покрывают lengths, masks, packed values, compression и fragmentation. +- Secrets/session keys не появляются в обычных логах. + +## Server data verification + +Перед apply обязательны schema capability check, foreign references, ID collisions, dry-run и backup/checkpoint. После apply выполняются targeted queries и optional core startup smoke. Rollback тестируется на disposable DB. + +## Content validation levels + +- Error: сборка или применение запрещены. +- Warning: допустимо после осознанного подтверждения. +- Info: рекомендация или стоимость. + +Примеры errors: broken reference, incompatible schema, duplicate allocation, invalid coordinate, unreachable mandatory quest step. Warnings: отсутствующий optional locale, высокая spawn density, превышение render budget. + +## Observability + +Логи структурированы по subsystem/category/correlation ID. Метрики включают network queues, entity counts, reconciliation errors, asset cache hit rate, editor job durations и validation counts. Пользовательские тексты, credentials и packet payload не логируются по умолчанию. + +## CI gates + +Изменение не сливается, если не проходят: + +- headless project load; +- unit/contract tests затронутых модулей; +- content schema validation; +- deterministic generated artifact check; +- renderer material smoke при изменении renderer; +- adapter dry-run при изменении server mapping; +- documentation link check. + +Nightly дополнительно запускает core integration, packet replay corpus, visual checkpoints, traversal performance и migration matrix. + +## Definition of Done функции + +- acceptance criteria наблюдаемы; +- happy path и минимум один failure/recovery path автоматизированы; +- boundary и malformed cases проверены для parser/network/data; +- diagnostics ведут к проблемному объекту; +- performance budget указан для frame-critical функции; +- manual playtest имеет сценарий и ожидаемый результат; +- regression test воспроизводит исправленный дефект. diff --git a/project.godot b/project.godot index 4b44231..c41a0b3 100644 --- a/project.godot +++ b/project.godot @@ -25,8 +25,8 @@ enabled=PackedStringArray("res://addons/mpq_extractor/plugin.cfg") [rendering] +rendering_device/d3d12/max_resource_descriptors=1048576 rendering_device/driver.windows="d3d12" -rendering/rendering_device/d3d12/max_resource_descriptors=1048576 [shader_globals] diff --git a/src/native/src/adt_loader.cpp b/src/native/src/adt_loader.cpp index 814c835..6b6d6f9 100644 --- a/src/native/src/adt_loader.cpp +++ b/src/native/src/adt_loader.cpp @@ -365,10 +365,11 @@ void ADTLoader::_parse_adt(const uint8_t *raw, size_t len, Dictionary &result) { adt_placement_rot_to_godot(dd[i].rot[0], dd[i].rot[1], dd[i].rot[2], rx, ry, rz); Dictionary p; - p["name_id"] = (int)dd[i].nameId; - p["pos"] = Vector3(gx, gy, gz); - p["rot"] = Vector3(rx, ry, rz); - p["scale"] = dd[i].scale / 1024.f; + p["name_id"] = (int)dd[i].nameId; + p["unique_id"] = (int)dd[i].uniqueId; + p["pos"] = Vector3(gx, gy, gz); + p["rot"] = Vector3(rx, ry, rz); + p["scale"] = dd[i].scale / 1024.f; m2_placements.push_back(p); } } diff --git a/src/native/src/adt_loader.h b/src/native/src/adt_loader.h index 6a0e5f5..9435777 100644 --- a/src/native/src/adt_loader.h +++ b/src/native/src/adt_loader.h @@ -16,7 +16,6 @@ namespace godot { -// ───────────────────────────────────────────────────────────────────────────── // ADTLoader // // Loads WoW 3.3.5a ADT terrain tiles. @@ -32,13 +31,13 @@ namespace godot { // "wmo_names": PackedStringArray, # WMO filenames // "m2_placements": Array[Dictionary], // "wmo_placements": Array[Dictionary], -// "chunks": Array[Dictionary], # 16×16 = 256 entries, row-major +// "chunks": Array[Dictionary], # 16x16 = 256 entries, row-major // } // // Placement Dictionary (both M2 and WMO): // { // "name_id": int, # index into m2_names / wmo_names -// "unique_id": int, # WMO only — MODF uniqueId (dedupe key across tiles) +// "unique_id": int, # MDDF/MODF uniqueId (dedupe key across tiles) // "pos": Vector3, # Godot world coords // "rot": Vector3, # Euler angles (radians) // "scale": float, # M2 only (WMO always 1.0) @@ -52,7 +51,7 @@ namespace godot { // "index_x": int, # 0-15 // "index_y": int, # 0-15 // "origin": Vector3, # Godot world position of chunk origin -// "heights": PackedFloat32Array, # 145 values (9×9 outer + 8×8 inner grid) +// "heights": PackedFloat32Array, # 145 values (9x9 outer + 8x8 inner grid) // "normals": PackedVector3Array, # 145 normals // "holes": int, # hole bit mask (low 16 bits) // "layers": Array[Dictionary], # texture layer definitions @@ -66,7 +65,6 @@ namespace godot { // "effect_id": int, // "alpha_offset": int, // } -// ───────────────────────────────────────────────────────────────────────────── class ADTLoader : public RefCounted { GDCLASS(ADTLoader, RefCounted) diff --git a/src/native/src/m2_loader.cpp b/src/native/src/m2_loader.cpp index e6ef446..8a3ccfa 100644 --- a/src/native/src/m2_loader.cpp +++ b/src/native/src/m2_loader.cpp @@ -389,6 +389,34 @@ static Vector4 decode_comp_quat(const uint16_t q[4]) { return wow_quat_to_godot(x, y, z, w); } +static Vector4 decode_comp_quat_raw(const uint16_t q[4]) { + float x = ((float)q[0] - 32768.0f) / 32767.0f; + float y = ((float)q[1] - 32768.0f) / 32767.0f; + float z = ((float)q[2] - 32768.0f) / 32767.0f; + float w = ((float)q[3] - 32768.0f) / 32767.0f; + return normalize_quat(x, y, z, w); +} + +static Vector4 texture_quat_to_uv_rotation(const Vector4 &q) { + float x = q.x; + float y = q.y; + float z = q.z; + float w = q.w; + return Vector4( + 1.0f - 2.0f * (y * y + z * z), + 2.0f * (x * y - z * w), + 2.0f * (x * y + z * w), + 1.0f - 2.0f * (x * x + z * z)); +} + +static float texture_quat_z_angle(const Vector4 &q) { + float x = q.x; + float y = q.y; + float z = q.z; + float w = q.w; + return std::atan2(2.0f * (w * z + x * y), 1.0f - 2.0f * (y * y + z * z)); +} + template static const T *track_sequence_array(const std::vector &buf, const M2ArrayRef &outer, uint32_t sequence_index, uint32_t &count_out) { count_out = 0; @@ -448,6 +476,37 @@ static Vector2 first_vec3_track_xy_speed(const std::vector &buf, const return Vector2(0.0f, 0.0f); } +static float angle_delta(float from, float to) { + float delta = to - from; + const float pi = 3.14159265358979323846f; + while (delta > pi) delta -= pi * 2.0f; + while (delta < -pi) delta += pi * 2.0f; + return delta; +} + +static float first_quat_track_z_rotation_speed(const std::vector &buf, const M2Track &track) { + for (uint32_t i = 0; i < track.values.count && i < track.timestamps.count; ++i) { + uint32_t time_count = 0; + uint32_t value_count = 0; + const uint32_t *times = track_sequence_array(buf, track.timestamps, i, time_count); + const M2CompQuat *values = track_sequence_array(buf, track.values, i, value_count); + uint32_t key_count = std::min(time_count, value_count); + if (!times || !values || key_count < 2) { + continue; + } + uint32_t first = 0; + uint32_t last = key_count - 1; + float duration = (float)(times[last] - times[first]) / 1000.0f; + if (duration <= 0.0001f) { + continue; + } + float first_angle = texture_quat_z_angle(decode_comp_quat_raw(values[first].v)); + float last_angle = texture_quat_z_angle(decode_comp_quat_raw(values[last].v)); + return angle_delta(first_angle, last_angle) / duration; + } + return 0.0f; +} + static uint32_t sequence_activity_score(const std::vector &buf, const M2CompBone *bones, uint32_t bone_count, uint32_t sequence_index) { if (!bones) return 0; uint32_t score = 0; @@ -704,18 +763,25 @@ Dictionary M2Loader::parse_m2(const std::vector &buf, const std::string for (uint32_t i = 0; i < hdr.nTexTransforms; ++i) { Vector2 translation(0.0f, 0.0f); Vector2 scale(1.0f, 1.0f); + Vector4 rotation(1.0f, 0.0f, 0.0f, 1.0f); const M2Float3 *trans = first_track_value(buf, transform_arr[i].translationTrack); if (trans) { translation = Vector2(trans->v[0], trans->v[1]); } + const M2CompQuat *rot = first_track_value(buf, transform_arr[i].rotationTrack); + if (rot) { + rotation = texture_quat_to_uv_rotation(decode_comp_quat_raw(rot->v)); + } const M2Float3 *scl = first_track_value(buf, transform_arr[i].scaleTrack); if (scl) { scale = Vector2(scl->v[0], scl->v[1]); } Dictionary t; t["translation"] = translation; + t["rotation"] = rotation; t["scale"] = scale; t["translation_speed"] = first_vec3_track_xy_speed(buf, transform_arr[i].translationTrack); + t["rotation_speed"] = first_quat_track_z_rotation_speed(buf, transform_arr[i].rotationTrack); texture_transforms.push_back(t); } } diff --git a/src/scenes/streaming/kalimdor_streaming.tscn b/src/scenes/streaming/kalimdor_streaming.tscn new file mode 100644 index 0000000..e37635e --- /dev/null +++ b/src/scenes/streaming/kalimdor_streaming.tscn @@ -0,0 +1,132 @@ +[gd_scene format=3 uid="uid://drf5o036q4dn8"] + +[ext_resource type="Script" uid="uid://yi6lawwjgocg" path="res://src/scenes/streaming/streaming_world_loader.gd" id="1_sisqv"] +[ext_resource type="Script" uid="uid://ltn2ko5kxvo4" path="res://src/scenes/player/third_person_wow_controller.gd" id="2_puy8r"] +[ext_resource type="Script" uid="uid://didjth34ut0v1" path="res://src/scenes/sky/wow_sky_controller.gd" id="3_43ha7"] + +[sub_resource type="CapsuleShape3D" id="PlayerCapsuleShape_1"] +radius = 0.45 +height = 2.1 + +[sub_resource type="StandardMaterial3D" id="PlayerMaterial_1"] +albedo_color = Color(0.25, 0.42, 0.85, 1) +roughness = 0.65 + +[sub_resource type="CapsuleMesh" id="PlayerCapsuleMesh_1"] +material = SubResource("PlayerMaterial_1") +radius = 0.45 +height = 2.1 + +[sub_resource type="ProceduralSkyMaterial" id="ProceduralSkyMaterial_1"] +sky_top_color = Color(0.13, 0.32, 0.62, 1) +sky_horizon_color = Color(0.62, 0.76, 0.9, 1) +sky_curve = 0.18 +sky_energy_multiplier = 1.15 +ground_bottom_color = Color(0.12, 0.17, 0.14, 1) +ground_horizon_color = Color(0.42, 0.5, 0.38, 1) +ground_curve = 0.12 +ground_energy_multiplier = 0.55 +sun_angle_max = 12.0 +sun_curve = 0.08 + +[sub_resource type="Sky" id="Sky_1"] +sky_material = SubResource("ProceduralSkyMaterial_1") + +[sub_resource type="Environment" id="Environment_1"] +background_mode = 2 +background_energy_multiplier = 0.9 +sky = SubResource("Sky_1") +ambient_light_source = 3 +ambient_light_color = Color(0.72, 0.8, 0.88, 1) +ambient_light_sky_contribution = 0.65 +ambient_light_energy = 0.75 +reflected_light_source = 2 +fog_enabled = true +fog_mode = 1 +fog_light_color = Color(0.55, 0.66, 0.72, 1) +fog_light_energy = 0.55 +fog_sun_scatter = 0.08 +fog_density = 0.00018 +fog_sky_affect = 0.35 +fog_depth_begin = 1200.0 +fog_depth_end = 5200.0 + +[node name="StreamingWorld" type="Node3D" unique_id=1063159974] +script = ExtResource("1_sisqv") +map_name = "Kalimdor" +camera_path = NodePath("ThirdPersonPlayer/CameraPivot/Camera3D") +quality_preset = "High" +update_interval = 0.1 +tiles_per_tick = 1 +max_concurrent_tile_tasks = 1 +tile_lod_remove_ops_per_tick = 1 +m2_build_groups_per_tick = 1 +m2_multimesh_batch_size = 64 +m2_animated_denylist_patterns = PackedStringArray("gryphonroost") +m2_animated_allowlist_patterns = PackedStringArray("creature/fish/", "creature/eagle/", "world/critter/") +wmo_render_group_ops_per_tick = 16 +cached_tile_mesh_limit = 48 +terrain_quality_mesh_cache_limit = 48 +lod0_radius_chunks = 10 +lod1_radius_chunks = 20 +lod2_radius_chunks = 40 +lod2_tile_radius = 5 +prewarm_tile_margin = 3 +retain_tile_margin = 4 +full_lod_prewarm_tiles = 1.5 +boundary_prefetch_threshold = 0.42 +auto_position_camera = false +enable_occlusion_culling = false +terrain_full_quality_radius_tiles = 5 +terrain_control_splat_hide_fallback = true +terrain_control_splat_radius_tiles = 5 +max_active_terrain_control_splat_tiles = 121 +terrain_control_splat_cache_finalize_ops_per_tick = 8 +terrain_splat_quality_enabled = false +enable_water = true +m2_tile_radius = 3 +wmo_tile_radius = 5 +m2_visibility_range = 1600.0 +wmo_visibility_range = 3600.0 +debug_streaming = true +hitch_profiler_enabled = true + +[node name="ThirdPersonPlayer" type="CharacterBody3D" parent="." unique_id=502573687] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 16800, 80, 26400) +script = ExtResource("2_puy8r") +spawn_tile_x = 31 +spawn_tile_y = 49 + +[node name="CollisionShape3D" type="CollisionShape3D" parent="ThirdPersonPlayer" unique_id=1297880621] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.05, 0) +shape = SubResource("PlayerCapsuleShape_1") + +[node name="Visual" type="MeshInstance3D" parent="ThirdPersonPlayer" unique_id=1028210492] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.05, 0) +mesh = SubResource("PlayerCapsuleMesh_1") + +[node name="CameraPivot" type="Node3D" parent="ThirdPersonPlayer" unique_id=499263249] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1.7, 0) + +[node name="Camera3D" type="Camera3D" parent="ThirdPersonPlayer/CameraPivot" unique_id=2142337971] +transform = Transform3D(1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 8) +current = true +fov = 70.0 +far = 50000.0 + +[node name="Sun" type="DirectionalLight3D" parent="." unique_id=1436804627] +transform = Transform3D(0.866025, -0.353553, 0.353553, 0, 0.707107, 0.707107, -0.5, -0.612372, 0.612372, 0, 0, 0) +light_color = Color(1, 0.91, 0.78, 1) +light_energy = 1.25 +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") + +[node name="WowSkyController" type="Node" parent="." unique_id=1473026848] +script = ExtResource("3_43ha7") +target_path = NodePath("../ThirdPersonPlayer") +world_environment_path = NodePath("../WorldEnvironment") +sun_path = NodePath("../Sun") diff --git a/src/scenes/streaming/streaming_world_loader.gd b/src/scenes/streaming/streaming_world_loader.gd index a465065..ffa779b 100644 --- a/src/scenes/streaming/streaming_world_loader.gd +++ b/src/scenes/streaming/streaming_world_loader.gd @@ -12,11 +12,11 @@ const WMO_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/wmo_buil const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd") const M2_NATIVE_ANIMATED_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_native_animated_builder.gd") const M2_NATIVE_ANIMATOR_SCRIPT := preload("res://src/scenes/streaming/m2_native_animator.gd") -const REQUIRED_BAKED_TILE_FORMAT_VERSION := 4 +const REQUIRED_BAKED_TILE_FORMAT_VERSION := 5 const REQUIRED_SPLAT_TILE_FORMAT_VERSION := 1 const REQUIRED_CONTROL_SPLAT_TILE_FORMAT_VERSION := 3 -const M2_MATERIAL_REFRESH_VERSION := 1 -const WMO_MATERIAL_REFRESH_VERSION := 3 +const M2_MATERIAL_REFRESH_VERSION := 2 +const WMO_MATERIAL_REFRESH_VERSION := 10 const TILE_SIZE := 533.33333 const CHUNK_SIZE := TILE_SIZE / 16.0 @@ -183,9 +183,11 @@ var _m2_group_result_mutex := Mutex.new() var _m2_group_result_queue: Array = [] var _m2_build_jobs: Dictionary = {} var _m2_build_queue: Array = [] +var _m2_unique_registry: Dictionary = {} var _m2_mesh_cache: Dictionary = {} var _m2_mesh_load_requests: Dictionary = {} var _m2_mesh_finalize_queue: Array = [] +var _m2_runtime_rebuild_required_cache: Dictionary = {} var _m2_animation_load_requests: Dictionary = {} var _m2_animation_finalize_queue: Array = [] var _wmo_build_jobs: Dictionary = {} @@ -2066,6 +2068,7 @@ func _wait_for_tile_tasks() -> void: ResourceLoader.load_threaded_get(path) _m2_mesh_load_requests.clear() _m2_mesh_finalize_queue.clear() + _m2_runtime_rebuild_required_cache.clear() for pending in _m2_animation_load_requests.values(): var path: String = String(pending.get("path", "")) @@ -2219,6 +2222,8 @@ func _finalize_loaded_tile(request: Dictionary, data: Dictionary) -> void: "m2_names": data.get("m2_names", PackedStringArray()), "m2_placements": data.get("m2_placements", []), "m2_built": false, + "m2_unique_keys": [], + "m2_skipped_unique_keys": [], "wmo_names": data.get("wmo_names", PackedStringArray()), "wmo_placements": data.get("wmo_placements", []), "wmo_refs": [], @@ -2295,6 +2300,8 @@ func _finalize_loaded_baked_tile(request: Dictionary, baked_tile: Resource) -> v "m2_names": baked_m2_names, "m2_placements": baked_m2_placements, "m2_built": false, + "m2_unique_keys": [], + "m2_skipped_unique_keys": [], "wmo_names": baked_wmo_names, "wmo_placements": baked_wmo_placements, "wmo_refs": [], @@ -2370,6 +2377,8 @@ func _finalize_loaded_streaming_tile(request: Dictionary, stream_tile: Resource) "m2_names": stream_m2_names, "m2_placements": stream_m2_placements, "m2_built": false, + "m2_unique_keys": [], + "m2_skipped_unique_keys": [], "wmo_names": stream_wmo_names, "wmo_placements": stream_wmo_placements, "wmo_refs": [], @@ -2658,6 +2667,7 @@ func _release_tile(key: String) -> void: var state: Dictionary = _tile_states[key] _unregister_tile_wmos(state) + _remove_tile_m2_assets(state) _terrain_upgrade_tasks.erase(key) _terrain_control_splat_cache_tasks.erase(key) _terrain_splat_cache_tasks.erase(key) @@ -2708,8 +2718,10 @@ func _clear_streamed_world() -> void: for key in _m2_build_jobs.keys(): _cancel_m2_build_job(String(key)) _m2_build_queue.clear() + _m2_unique_registry.clear() _m2_mesh_load_requests.clear() _m2_mesh_finalize_queue.clear() + _m2_runtime_rebuild_required_cache.clear() _m2_animation_load_requests.clear() _m2_animation_finalize_queue.clear() _wmo_build_jobs.clear() @@ -3192,12 +3204,17 @@ func _process_detail_asset_key(key: String) -> void: if enable_m2_assets and _tile_within_detail_radius(state, _last_focus_pos, m2_tile_radius): if not bool(state.get("m2_built", false)): - state["m2_built"] = _request_tile_m2_assets( + var m2_request := _request_tile_m2_assets( tile_root as Node3D, String(state.get("key", "")), state.get("origin", Vector3.ZERO), state.get("m2_names", PackedStringArray()), state.get("m2_placements", [])) + state["m2_built"] = bool(m2_request.get("built", false)) + if m2_request.has("unique_keys"): + state["m2_unique_keys"] = m2_request.get("unique_keys", []) + if m2_request.has("skipped_unique_keys"): + state["m2_skipped_unique_keys"] = m2_request.get("skipped_unique_keys", []) else: _remove_tile_m2_assets(state) state["m2_built"] = false @@ -3854,19 +3871,121 @@ func _request_tile_m2_assets( key: String, tile_origin: Vector3, m2_names: PackedStringArray, - m2_placements: Array) -> bool: + m2_placements: Array) -> Dictionary: + var result := { + "built": false, + } if key.is_empty() or m2_names.is_empty() or m2_placements.is_empty(): - return false + return result 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() + result["built"] = not existing_m2_root.is_queued_for_deletion() + return result if _m2_group_tasks.has(key) or _m2_build_jobs.has(key): - return false + return result + + var filtered := _reserve_tile_m2_placements(key, m2_placements) + var filtered_placements: Array = filtered.get("placements", []) + result["unique_keys"] = filtered.get("unique_keys", []) + result["skipped_unique_keys"] = filtered.get("skipped_unique_keys", []) + if filtered_placements.is_empty(): + result["built"] = true + return result var task_id: int = WorkerThreadPool.add_task( - _group_tile_m2_task.bind(key, tile_origin, m2_names, m2_placements)) + _group_tile_m2_task.bind(key, tile_origin, m2_names, filtered_placements)) _m2_group_tasks[key] = task_id - return false + return result + + +func _reserve_tile_m2_placements(tile_key: String, m2_placements: Array) -> Dictionary: + var filtered_placements: Array = [] + var unique_keys: Array = [] + var skipped_unique_keys: Array = [] + for index in m2_placements.size(): + var placement_variant = m2_placements[index] + if not (placement_variant is Dictionary): + continue + var placement: Dictionary = placement_variant + var unique_key := _m2_unique_key(placement) + if unique_key.is_empty(): + filtered_placements.append(placement) + continue + if _m2_unique_registry.has(unique_key): + var entry: Dictionary = _m2_unique_registry[unique_key] + var owner_tile := String(entry.get("tile", "")) + if owner_tile == tile_key and unique_keys.has(unique_key): + continue + if owner_tile != tile_key: + if not skipped_unique_keys.has(unique_key): + skipped_unique_keys.append(unique_key) + continue + _m2_unique_registry[unique_key] = {"tile": tile_key} + if not unique_keys.has(unique_key): + unique_keys.append(unique_key) + filtered_placements.append(placement) + return { + "placements": filtered_placements, + "unique_keys": unique_keys, + "skipped_unique_keys": skipped_unique_keys, + } + + +func _m2_unique_key(placement: Dictionary) -> String: + var uid: int = int(placement.get("unique_id", -1)) + if uid <= 0: + return "" + return "uid:%d" % uid + + +func _release_tile_m2_unique_keys(state: Dictionary, notify_candidates: bool = true) -> void: + var tile_key := String(state.get("key", "")) + var unique_keys: Array = state.get("m2_unique_keys", []) + if tile_key.is_empty() or unique_keys.is_empty(): + state["m2_unique_keys"] = [] + state["m2_skipped_unique_keys"] = [] + return + + var released_keys: Array = [] + for unique_key_variant in unique_keys: + var unique_key := String(unique_key_variant) + if unique_key.is_empty() or not _m2_unique_registry.has(unique_key): + continue + var entry: Dictionary = _m2_unique_registry[unique_key] + if String(entry.get("tile", "")) != tile_key: + continue + _m2_unique_registry.erase(unique_key) + released_keys.append(unique_key) + + state["m2_unique_keys"] = [] + state["m2_skipped_unique_keys"] = [] + if not notify_candidates: + return + for unique_key in released_keys: + _queue_m2_tiles_skipping_unique_key(String(unique_key), tile_key) + + +func _queue_m2_tiles_skipping_unique_key(unique_key: String, released_tile_key: String) -> void: + if unique_key.is_empty(): + return + for tile_key_variant in _tile_states.keys(): + var tile_key := String(tile_key_variant) + if tile_key == released_tile_key: + continue + var state: Dictionary = _tile_states[tile_key] + var skipped_keys: Array = state.get("m2_skipped_unique_keys", []) + if not skipped_keys.has(unique_key): + continue + if not bool(state.get("wanted", false)): + continue + if not _tile_within_detail_radius(state, _last_focus_pos, m2_tile_radius): + continue + _cancel_m2_build_job(tile_key) + _remove_tile_m2_root(state) + state["m2_built"] = false + state["m2_skipped_unique_keys"] = [] + _tile_states[tile_key] = state + _enqueue_detail_asset_sync(tile_key) func _group_tile_m2_task( @@ -4059,6 +4178,9 @@ func _process_m2_build_jobs() -> void: var state: Dictionary = _tile_states[key] if not bool(state.get("wanted", false)) or not _tile_within_detail_radius(state, _last_focus_pos, m2_tile_radius): _cancel_m2_build_job(key) + _release_tile_m2_unique_keys(state) + state["m2_built"] = false + _tile_states[key] = state _m2_build_queue.pop_front() continue @@ -4314,6 +4436,11 @@ func _remove_tile_m2_assets(state: Dictionary) -> void: var key := String(state.get("key", "")) if not key.is_empty(): _cancel_m2_build_job(key) + _release_tile_m2_unique_keys(state) + _remove_tile_m2_root(state) + + +func _remove_tile_m2_root(state: Dictionary) -> void: var tile_root: Node = state.get("root", null) if tile_root == null or not is_instance_valid(tile_root): return @@ -4403,7 +4530,11 @@ func _prepare_m2_mesh_for_runtime(normalized_rel: String, mesh: Mesh) -> Mesh: return null if int(mesh.get_meta("wow_m2_material_refresh_version", 0)) >= M2_MATERIAL_REFRESH_VERSION: return mesh - var rebuilt := _rebuild_m2_mesh_from_raw(normalized_rel) + var data := _load_m2_raw_data_for_refresh(normalized_rel) + if data.is_empty() or not _m2_raw_data_needs_runtime_mesh_rebuild(normalized_rel, data): + mesh.set_meta("wow_m2_material_refresh_version", M2_MATERIAL_REFRESH_VERSION) + return mesh + var rebuilt := _rebuild_m2_mesh_from_data(data) if rebuilt != null: return rebuilt mesh.set_meta("wow_m2_material_refresh_version", M2_MATERIAL_REFRESH_VERSION) @@ -4421,16 +4552,67 @@ func _prepare_m2_node_for_runtime(normalized_rel: String, root: Node3D) -> void: mesh_instance.mesh = prepared -func _rebuild_m2_mesh_from_raw(normalized_rel: String) -> Mesh: +func _load_m2_raw_data_for_refresh(normalized_rel: String) -> Dictionary: if normalized_rel.is_empty() or not ClassDB.class_exists("M2Loader"): - return null + return {} var abs_path := ProjectSettings.globalize_path(extracted_dir.path_join(normalized_rel)) if not FileAccess.file_exists(abs_path): - return null + return {} var loader = ClassDB.instantiate("M2Loader") if loader == null: - return null - var data: Dictionary = loader.call("load_m2", abs_path) + return {} + var data: Variant = loader.call("load_m2", abs_path) + return data if data is Dictionary else {} + + +func _m2_raw_data_needs_runtime_mesh_rebuild(normalized_rel: String, data: Dictionary) -> bool: + if _m2_runtime_rebuild_required_cache.has(normalized_rel): + return bool(_m2_runtime_rebuild_required_cache[normalized_rel]) + var needs_rebuild := _m2_raw_data_has_billboards(data) or _m2_raw_data_has_uv_rotation(data) + _m2_runtime_rebuild_required_cache[normalized_rel] = needs_rebuild + return needs_rebuild + + +func _m2_raw_data_has_billboards(data: Dictionary) -> bool: + for batch_variant in data.get("batches", []): + if not (batch_variant is Dictionary): + continue + var batch: Dictionary = batch_variant + if bool(batch.get("has_billboard", false)) or int(batch.get("billboard_vertex_count", 0)) > 0: + return true + return false + + +func _m2_raw_data_has_uv_rotation(data: Dictionary) -> bool: + var transforms: Array = data.get("texture_transforms", []) + if transforms.is_empty(): + return false + var combos: PackedInt32Array = data.get("texture_transform_combos", PackedInt32Array()) + if combos.is_empty(): + return false + for batch_variant in data.get("batches", []): + if not (batch_variant is Dictionary): + continue + var batch: Dictionary = batch_variant + var texture_count := maxi(1, int(batch.get("texture_count", 1))) + var base_combo := int(batch.get("texture_transform_combo_index", -1)) + for stage in mini(texture_count, 4): + var combo_index := base_combo + stage + if combo_index < 0 or combo_index >= combos.size(): + continue + var transform_index := int(combos[combo_index]) + if transform_index < 0 or transform_index >= transforms.size(): + continue + var transform: Dictionary = transforms[transform_index] if transforms[transform_index] is Dictionary else {} + var rotation: Vector4 = transform.get("rotation", Vector4(1.0, 0.0, 0.0, 1.0)) + if not rotation.is_equal_approx(Vector4(1.0, 0.0, 0.0, 1.0)): + return true + if absf(float(transform.get("rotation_speed", 0.0))) > 0.000001: + return true + return false + + +func _rebuild_m2_mesh_from_data(data: Dictionary) -> Mesh: if data.is_empty(): return null var prototype: Node3D = M2_BUILDER_SCRIPT.build(data, extracted_dir) diff --git a/src/tools/capture_render_checkpoints.gd b/src/tools/capture_render_checkpoints.gd new file mode 100644 index 0000000..36ab468 --- /dev/null +++ b/src/tools/capture_render_checkpoints.gd @@ -0,0 +1,136 @@ +extends SceneTree + +const STREAMING_SCENE := "res://src/scenes/streaming/eastern_kingdoms_streaming.tscn" + +const CHECKPOINTS := [ + { + "name": "elwynn_waterfall_front", + "camera": Vector3(16445.0, 125.0, 26295.0), + "target": Vector3(16518.84, 68.0, 26427.27), + "player": Vector3(16518.84, 55.0, 26427.27), + }, + { + "name": "elwynn_waterfall_side", + "camera": Vector3(16670.0, 115.0, 26320.0), + "target": Vector3(16518.84, 72.0, 26427.27), + "player": Vector3(16518.84, 55.0, 26427.27), + }, + { + "name": "goldshire_inn_windows", + "camera": Vector3(16942.0, 82.0, 26503.0), + "target": Vector3(17042.27, 66.0, 26530.91), + "player": Vector3(17042.27, 58.0, 26530.91), + }, + { + "name": "goldshire_blacksmith_windows", + "camera": Vector3(16892.0, 79.0, 26562.0), + "target": Vector3(16972.46, 64.0, 26526.7), + "player": Vector3(16972.46, 58.0, 26526.7), + }, +] + + +func _initialize() -> void: + _capture_async.call_deferred() + + +func _capture_async() -> void: + var args := OS.get_cmdline_user_args() + var output_dir := _arg(args, "--output", "user://render_checkpoints") + var only := _arg(args, "--only", "").to_lower() + var wait_seconds := float(_arg(args, "--wait", "8.0")) + var width := int(_arg(args, "--width", "1280")) + var height := int(_arg(args, "--height", "900")) + var headless := DisplayServer.get_name().to_lower() == "headless" + var dry_run := args.has("--dry-run") or headless + + get_root().size = Vector2i(maxi(16, width), maxi(16, height)) + var abs_output_dir := ProjectSettings.globalize_path(output_dir) + if not dry_run: + DirAccess.make_dir_recursive_absolute(abs_output_dir) + elif headless and not args.has("--dry-run"): + print("RENDER_CHECKPOINT dry-run: headless display cannot capture viewport PNGs.") + + var packed: PackedScene = load(STREAMING_SCENE) + if packed == null: + push_error("Cannot load streaming scene: %s" % STREAMING_SCENE) + quit(1) + return + + var world = packed.instantiate() + if not (world is Node3D): + push_error("Streaming scene root is not Node3D: %s" % STREAMING_SCENE) + quit(1) + return + + var camera := Camera3D.new() + camera.name = "CheckpointCamera" + camera.current = true + camera.fov = 62.0 + camera.far = 50000.0 + camera.position = CHECKPOINTS[0].get("camera", Vector3.ZERO) + (world as Node3D).add_child(camera) + world.set("camera_path", NodePath("CheckpointCamera")) + world.set("debug_streaming", true) + get_root().add_child(world) + await process_frame + await process_frame + + var player := world.get_node_or_null("ThirdPersonPlayer") as Node3D + if player != null: + player.set_process(false) + player.set_physics_process(false) + var visual := player.get_node_or_null("Visual") as Node3D + if visual != null: + visual.visible = false + + var captured := 0 + for checkpoint_variant in CHECKPOINTS: + var checkpoint: Dictionary = checkpoint_variant + var checkpoint_name := String(checkpoint.get("name", "checkpoint")) + if not only.is_empty() and not checkpoint_name.to_lower().contains(only): + continue + + camera.global_position = checkpoint.get("camera", Vector3.ZERO) + camera.look_at(checkpoint.get("target", Vector3.ZERO), Vector3.UP) + if player != null: + player.global_position = checkpoint.get("player", checkpoint.get("target", Vector3.ZERO)) + if world.has_method("_refresh_streaming_targets_at"): + world.call("_refresh_streaming_targets_at", camera.global_position, true) + + await create_timer(maxf(wait_seconds, 0.0)).timeout + if dry_run: + print("RENDER_CHECKPOINT dry_run name=%s camera=%s target=%s" % [ + checkpoint_name, + camera.global_position, + checkpoint.get("target", Vector3.ZERO), + ]) + captured += 1 + continue + + await RenderingServer.frame_post_draw + + var image := get_root().get_texture().get_image() + var file_name := "%s.png" % checkpoint_name + var abs_path := abs_output_dir.path_join(file_name) + var err := image.save_png(abs_path) + if err != OK: + push_error("Failed to save checkpoint %s to %s (err=%d)" % [checkpoint_name, abs_path, err]) + quit(1) + return + print("RENDER_CHECKPOINT saved %s" % abs_path) + captured += 1 + + world.queue_free() + if captured <= 0: + push_error("No checkpoints captured. --only filter was: %s" % only) + quit(1) + return + quit(0) + + +func _arg(args: PackedStringArray, name: String, default_value: String) -> String: + var idx := args.find(name) + if idx >= 0 and idx + 1 < args.size(): + return args[idx + 1] + return default_value diff --git a/src/tools/capture_render_checkpoints.gd.uid b/src/tools/capture_render_checkpoints.gd.uid new file mode 100644 index 0000000..57b4d7d --- /dev/null +++ b/src/tools/capture_render_checkpoints.gd.uid @@ -0,0 +1 @@ +uid://ct7wd7gtf8h6o diff --git a/src/tools/find_adt_placements.gd b/src/tools/find_adt_placements.gd new file mode 100644 index 0000000..06d5936 --- /dev/null +++ b/src/tools/find_adt_placements.gd @@ -0,0 +1,80 @@ +extends SceneTree + +const DEFAULT_EXTRACTED_DIR := "res://data/extracted" +const DEFAULT_MAP := "Azeroth" + + +func _initialize() -> void: + var args := OS.get_cmdline_user_args() + var extracted_dir := _arg(args, "--extracted", DEFAULT_EXTRACTED_DIR) + var map_name := _arg(args, "--map", DEFAULT_MAP) + var kind := _arg(args, "--kind", "wmo").to_lower() + var path_contains := _arg(args, "--path-contains", "").to_lower() + var limit := int(_arg(args, "--limit", "25")) + + if not ClassDB.class_exists("ADTLoader"): + push_error("ADTLoader is not registered. Rebuild the native GDExtension first.") + quit(1) + return + + var map_dir := ProjectSettings.globalize_path( + extracted_dir.path_join("World/Maps").path_join(map_name) + ) + var dir := DirAccess.open(map_dir) + if dir == null: + push_error("Cannot open map directory: %s" % map_dir) + quit(1) + return + + var loader = ClassDB.instantiate("ADTLoader") + var files := dir.get_files() + files.sort() + var printed := 0 + for file_name in files: + if printed >= limit: + break + if not file_name.to_lower().ends_with(".adt"): + continue + if not file_name.begins_with("%s_" % map_name): + continue + var data: Dictionary = loader.call("load_adt", map_dir.path_join(file_name)) + var names: PackedStringArray = data.get("%s_names" % kind, PackedStringArray()) + var placements: Array = data.get("%s_placements" % kind, []) + for placement_variant in placements: + if printed >= limit: + break + if not (placement_variant is Dictionary): + continue + var placement: Dictionary = placement_variant + var name_id := int(placement.get("name_id", -1)) + if name_id < 0 or name_id >= names.size(): + continue + var rel_path := String(names[name_id]).replace("\\", "/") + if not path_contains.is_empty() and not rel_path.to_lower().contains(path_contains): + continue + var rot: Vector3 = placement.get("rot", Vector3.ZERO) + print("ADT_%s tile=%s uid=%d path=%s pos=%s rot_deg=%s scale=%.3f" % [ + kind.to_upper(), + _tile_from_adt_filename(file_name), + int(placement.get("unique_id", -1)), + rel_path, + placement.get("pos", Vector3.ZERO), + Vector3(rad_to_deg(rot.x), rad_to_deg(rot.y), rad_to_deg(rot.z)), + float(placement.get("scale", 1.0)), + ]) + printed += 1 + quit(0) + + +func _tile_from_adt_filename(file_name: String) -> Vector2i: + var parts := file_name.get_basename().split("_") + if parts.size() < 3: + return Vector2i(-1, -1) + return Vector2i(int(parts[parts.size() - 2]), int(parts[parts.size() - 1])) + + +func _arg(args: PackedStringArray, name: String, default_value: String) -> String: + var idx := args.find(name) + if idx >= 0 and idx + 1 < args.size(): + return args[idx + 1] + return default_value diff --git a/src/tools/find_adt_placements.gd.uid b/src/tools/find_adt_placements.gd.uid new file mode 100644 index 0000000..755386b --- /dev/null +++ b/src/tools/find_adt_placements.gd.uid @@ -0,0 +1 @@ +uid://bad7oihytwnq0 diff --git a/src/tools/verify_adt_m2_placements.gd b/src/tools/verify_adt_m2_placements.gd new file mode 100644 index 0000000..13e2b44 --- /dev/null +++ b/src/tools/verify_adt_m2_placements.gd @@ -0,0 +1,145 @@ +extends SceneTree + +const DEFAULT_EXTRACTED_DIR := "res://data/extracted" +const DEFAULT_MAP := "Azeroth" +const DEFAULT_TARGET_UID := 11785 + +var _failures := 0 + + +func _initialize() -> void: + var args := OS.get_cmdline_user_args() + var extracted_dir := _arg(args, "--extracted", DEFAULT_EXTRACTED_DIR) + var map_name := _arg(args, "--map", DEFAULT_MAP) + var target_uid := int(_arg(args, "--uid", str(DEFAULT_TARGET_UID))) + var path_contains := _arg(args, "--path-contains", "waterfall").to_lower() + print("ADT M2 placement verification started. map=%s uid=%d path_contains=%s" % [ + map_name, + target_uid, + path_contains, + ]) + + var matches := _find_m2_placements(extracted_dir, map_name, target_uid, path_contains) + if matches.is_empty(): + _fail("No matching ADT M2 placement found. map=%s uid=%d path_contains=%s" % [ + map_name, + target_uid, + path_contains, + ]) + else: + print("ADT M2 placement verification matched %d placement(s)." % matches.size()) + for match in matches: + print(_format_match(match)) + + if _failures > 0: + quit(1) + return + quit(0) + + +func _find_m2_placements( + extracted_dir: String, + map_name: String, + target_uid: int, + path_contains: String) -> Array: + if not ClassDB.class_exists("ADTLoader"): + _fail("ADTLoader is not registered. Rebuild the native GDExtension first.") + return [] + + var map_dir := ProjectSettings.globalize_path( + extracted_dir.path_join("World/Maps").path_join(map_name) + ) + var dir := DirAccess.open(map_dir) + if dir == null: + _fail("Cannot open map directory: %s" % map_dir) + return [] + + var files := _collect_adt_files(dir, map_name) + if files.is_empty(): + _fail("No ADT files found in %s" % map_dir) + return [] + + var loader = ClassDB.instantiate("ADTLoader") + if loader == null: + _fail("Cannot instantiate ADTLoader.") + return [] + + var matches := [] + for file_name in files: + var tile := _tile_from_adt_filename(file_name) + var data: Dictionary = loader.call("load_adt", map_dir.path_join(file_name)) + var names: PackedStringArray = data.get("m2_names", PackedStringArray()) + var placements: Array = data.get("m2_placements", []) + for placement_variant in placements: + if not (placement_variant is Dictionary): + continue + var placement: Dictionary = placement_variant + var uid := int(placement.get("unique_id", -1)) + if target_uid >= 0 and uid != target_uid: + continue + var name_id := int(placement.get("name_id", -1)) + if name_id < 0 or name_id >= names.size(): + continue + var rel_path := String(names[name_id]).replace("\\", "/") + if not path_contains.is_empty() and not rel_path.to_lower().contains(path_contains): + continue + matches.append({ + "tile": tile, + "uid": uid, + "path": rel_path, + "pos": placement.get("pos", Vector3.ZERO), + "rot": placement.get("rot", Vector3.ZERO), + "scale": float(placement.get("scale", 1.0)), + }) + return matches + + +func _collect_adt_files(dir: DirAccess, map_name: String) -> Array: + var result := [] + dir.list_dir_begin() + while true: + var file_name := dir.get_next() + if file_name.is_empty(): + break + if dir.current_is_dir(): + continue + if not file_name.to_lower().ends_with(".adt"): + continue + if not file_name.begins_with("%s_" % map_name): + continue + result.append(file_name) + dir.list_dir_end() + result.sort() + return result + + +func _tile_from_adt_filename(file_name: String) -> Vector2i: + var parts := file_name.get_basename().split("_") + if parts.size() < 3: + return Vector2i(-1, -1) + return Vector2i(int(parts[parts.size() - 2]), int(parts[parts.size() - 1])) + + +func _format_match(match: Dictionary) -> String: + var rot: Vector3 = match.get("rot", Vector3.ZERO) + var rot_deg := Vector3(rad_to_deg(rot.x), rad_to_deg(rot.y), rad_to_deg(rot.z)) + return "ADT_M2_PLACEMENT tile=%s uid=%d path=%s pos=%s rot_deg=%s scale=%.3f" % [ + match.get("tile", Vector2i(-1, -1)), + int(match.get("uid", -1)), + String(match.get("path", "")), + match.get("pos", Vector3.ZERO), + rot_deg, + float(match.get("scale", 1.0)), + ] + + +func _arg(args: PackedStringArray, name: String, default_value: String) -> String: + var idx := args.find(name) + if idx >= 0 and idx + 1 < args.size(): + return args[idx + 1] + return default_value + + +func _fail(message: String) -> void: + _failures += 1 + push_error(message) diff --git a/src/tools/verify_adt_m2_placements.gd.uid b/src/tools/verify_adt_m2_placements.gd.uid new file mode 100644 index 0000000..dbb7b73 --- /dev/null +++ b/src/tools/verify_adt_m2_placements.gd.uid @@ -0,0 +1 @@ +uid://bbg18pmk3svu2 diff --git a/src/tools/verify_m2_unique_dedupe.gd b/src/tools/verify_m2_unique_dedupe.gd new file mode 100644 index 0000000..4aeb07f --- /dev/null +++ b/src/tools/verify_m2_unique_dedupe.gd @@ -0,0 +1,54 @@ +extends SceneTree + +const STREAMING_WORLD_LOADER := preload("res://src/scenes/streaming/streaming_world_loader.gd") + +var _failures := 0 + + +func _initialize() -> void: + var loader = STREAMING_WORLD_LOADER.new() + var placement := { + "name_id": 0, + "unique_id": 11785, + "pos": Vector3.ZERO, + "rot": Vector3.ZERO, + "scale": 1.0, + } + + var first: Dictionary = loader.call("_reserve_tile_m2_placements", "30_49", [placement]) + _expect((first.get("placements", []) as Array).size() == 1, "first tile should reserve unique M2 placement") + _expect((first.get("unique_keys", []) as Array).has("uid:11785"), "first tile should own uid:11785") + _expect((first.get("skipped_unique_keys", []) as Array).is_empty(), "first tile should not skip uid:11785") + + var duplicate: Dictionary = loader.call("_reserve_tile_m2_placements", "31_49", [placement]) + _expect((duplicate.get("placements", []) as Array).is_empty(), "second tile should skip duplicate uid:11785") + _expect((duplicate.get("skipped_unique_keys", []) as Array).has("uid:11785"), "second tile should remember skipped uid:11785") + + loader.call("_release_tile_m2_unique_keys", { + "key": "30_49", + "m2_unique_keys": ["uid:11785"], + "m2_skipped_unique_keys": [], + }, false) + var after_release: Dictionary = loader.call("_reserve_tile_m2_placements", "31_49", [placement]) + _expect((after_release.get("placements", []) as Array).size() == 1, "second tile should reserve uid:11785 after owner release") + _expect((after_release.get("unique_keys", []) as Array).has("uid:11785"), "second tile should own uid:11785 after release") + + var same_tile_loader = STREAMING_WORLD_LOADER.new() + var same_tile_duplicate: Dictionary = same_tile_loader.call("_reserve_tile_m2_placements", "31_49", [placement, placement]) + _expect((same_tile_duplicate.get("placements", []) as Array).size() == 1, "same tile should keep only one copy of duplicate uid:11785") + + loader.free() + same_tile_loader.free() + if _failures > 0: + push_error("M2 unique dedupe verification failed: %d issue(s)" % _failures) + quit(1) + return + print("M2 unique dedupe verification passed.") + quit(0) + + +func _expect(condition: bool, message: String) -> void: + if condition: + return + _failures += 1 + push_error(message) diff --git a/src/tools/verify_m2_unique_dedupe.gd.uid b/src/tools/verify_m2_unique_dedupe.gd.uid new file mode 100644 index 0000000..9e1a2de --- /dev/null +++ b/src/tools/verify_m2_unique_dedupe.gd.uid @@ -0,0 +1 @@ +uid://b2xmxm1dxmx10 diff --git a/src/tools/verify_render_materials.gd b/src/tools/verify_render_materials.gd new file mode 100644 index 0000000..e026da8 --- /dev/null +++ b/src/tools/verify_render_materials.gd @@ -0,0 +1,142 @@ +extends SceneTree + +const WOW_M2_MATERIAL := preload("res://addons/mpq_extractor/loaders/wow_m2_material.gd") +const WOW_WMO_MATERIAL := preload("res://addons/mpq_extractor/loaders/wow_wmo_material.gd") + +var _failures := 0 + + +func _initialize() -> void: + _check_project_descriptor_setting() + _check_wmo_window_material() + _check_wmo_wall_material() + _check_wmo_glass_material() + _check_m2_alpha_material() + _check_m2_billboard_and_uv_rotation_shader() + + if _failures > 0: + push_error("Renderer material verification failed: %d issue(s)" % _failures) + quit(1) + return + + print("Renderer material verification passed.") + quit(0) + + +func _check_project_descriptor_setting() -> void: + var descriptors := int(ProjectSettings.get_setting( + "rendering/rendering_device/d3d12/max_resource_descriptors", + 0 + )) + _expect( + descriptors >= 1048576, + "D3D12 resource descriptor heap setting should be at least 1048576, got %d" % descriptors + ) + + +func _check_wmo_window_material() -> void: + var mat := WOW_WMO_MATERIAL.build( + null, + null, + 0x11, + 0, + 0, + "DUNGEONS/TEXTURES/WINDOWS/MM_ELWYNN_WND_EXT__01.BLP" + ) + var code := mat.shader.code + _expect(bool(mat.get_meta("wow_wmo_window_material", false)), "Elwynn exterior window should be tagged as a WMO window material") + _expect(not bool(mat.get_meta("wow_wmo_glass_blend_material", false)), "Opaque Elwynn exterior window should not be tagged as glass blend") + _expect(float(mat.get_shader_parameter("unlit_material")) > 0.5, "WMO flag 0x01 should make Elwynn exterior window unlit") + _expect(code.contains("cull_back"), "Opaque WMO window atlas shader should use cull_back") + _expect(not code.contains("cull_disabled"), "Opaque WMO window atlas shader should not use cull_disabled") + _expect(code.contains("depth_draw_opaque"), "Opaque WMO window atlas shader should use opaque depth draw") + _expect(code.contains("filter_linear, repeat_disable"), "WMO window atlas shader should use stable non-mipmapped albedo sampler") + _expect(not code.contains("ALPHA ="), "Opaque WMO window atlas shader should not write ALPHA") + + +func _check_wmo_wall_material() -> void: + var mat := WOW_WMO_MATERIAL.build( + null, + null, + 0, + 0, + 0, + "DUNGEONS/TEXTURES/WALLS/MM_STRMWND_WALL_03.BLP" + ) + var code := mat.shader.code + _expect(not bool(mat.get_meta("wow_wmo_window_material", false)), "Stormwind wall texture name must not be matched as a window") + _expect(float(mat.get_shader_parameter("unlit_material")) < 0.5, "Ordinary WMO wall should not be unlit") + _expect(code.contains("cull_disabled"), "Ordinary WMO wall should keep cull_disabled") + _expect(code.contains("filter_linear_mipmap_anisotropic, repeat_enable"), "Ordinary WMO wall should keep mipmapped repeating albedo sampler") + + +func _check_wmo_glass_material() -> void: + var mat := WOW_WMO_MATERIAL.build( + null, + null, + 0, + 0, + 2, + "DUNGEONS/TEXTURES/WINDOWS/MM_ELWYNN_WND_EXT__01.BLP" + ) + var code := mat.shader.code + _expect(bool(mat.get_meta("wow_wmo_glass_blend_material", false)), "BlendMode 2 WMO window should be tagged as glass") + _expect(code.contains("cull_back"), "WMO glass shader should use cull_back") + _expect(code.contains("depth_draw_never"), "WMO glass shader should avoid depth writes") + _expect(code.contains("ALPHA = clamp"), "WMO glass shader should write alpha") + + +func _check_m2_alpha_material() -> void: + var mat := WOW_M2_MATERIAL.build( + null, + null, + null, + null, + 0, + 2, + 0, + 0, + 0, + 0, + "NewWaterfall", + {} + ) + var code := mat.shader.code + _expect(code.contains("blend_mix"), "M2 alpha shader should use blend_mix") + _expect(code.contains("depth_draw_never"), "M2 alpha shader should avoid depth writes") + _expect(code.contains("ALPHA = clamp"), "M2 alpha shader should write alpha") + + +func _check_m2_billboard_and_uv_rotation_shader() -> void: + var mat := WOW_M2_MATERIAL.build( + null, + null, + null, + null, + 0x04, + 0, + 0, + 0, + 0, + 0, + "BillboardProbe", + { + "billboard_enabled": true, + "stage0_uv_rotation": Vector4(0.0, -1.0, 1.0, 0.0), + "stage0_uv_rotation_speed": 0.25, + } + ) + var code := mat.shader.code + _expect(bool(mat.get_meta("wow_m2_billboard_enabled", false)), "M2 material should preserve billboard metadata") + _expect(bool(mat.get_shader_parameter("billboard_enabled")), "M2 material should enable billboard shader parameter") + _expect(code.contains("apply_m2_billboard"), "M2 shader should keep billboard vertex path") + _expect(code.contains("CUSTOM0"), "M2 shader should consume CUSTOM0 billboard data") + _expect(code.contains("stage0_uv_rotation"), "M2 shader should keep UV rotation uniforms") + _expect(code.contains("uv_rotation_speed * TIME"), "M2 shader should keep animated UV rotation path") + + +func _expect(condition: bool, message: String) -> void: + if condition: + return + _failures += 1 + push_error(message) diff --git a/src/tools/verify_render_materials.gd.uid b/src/tools/verify_render_materials.gd.uid new file mode 100644 index 0000000..a27f403 --- /dev/null +++ b/src/tools/verify_render_materials.gd.uid @@ -0,0 +1 @@ +uid://c58bfxr64yglq diff --git a/targets/00-render-baseline.md b/targets/00-render-baseline.md new file mode 100644 index 0000000..849491e --- /dev/null +++ b/targets/00-render-baseline.md @@ -0,0 +1,47 @@ +# M00 — Renderer Baseline + + + +## Outcome + +Получить воспроизводимую точку отсчёта текущего renderer до архитектурной декомпозиции. + +## Steps + +- [ ] Зафиксировать версию Godot, backend, hardware/profile и cache state. +- [ ] Выбрать контрольные позиции: terrain, ADT boundary, dense M2, large WMO, liquid, animated M2, sky transition. +- [ ] Объединить существующие renderer smoke scripts в документированный запуск без изменения visual behavior. +- [ ] Снять deterministic screenshots и metadata для каждой позиции. +- [ ] Записать cold/warm показатели frame time p50/p95/p99, max hitch, memory, queues и load time. +- [ ] Зафиксировать cache format versions и правила invalidation. +- [ ] Добавить regression manifest, не содержащий proprietary assets. +- [ ] Обновить `RENDER.md` ссылкой на baseline и известные расхождения с клиентом 3.3.5a. + +## Fidelity evidence + +- Сравнить контрольные сцены с оригинальным клиентом 3.3.5a при максимально совпадающих map position, time и weather. +- Каждое видимое расхождение зарегистрировать: terrain/material, placement, animation, lighting, liquid или culling. +- Baseline не объявляет parity; он создаёт измеримый список gap. + +## Verification + +```powershell +godot --headless --path . --quit +godot --headless --path . --script res://src/tools/verify_render_materials.gd +godot --headless --path . --script res://src/tools/verify_m2_unique_dedupe.gd +godot --headless --path . --script res://src/tools/capture_render_checkpoints.gd -- --dry-run --wait 0.1 +``` + +## Exit criteria + +Один документированный запуск создаёт отчёт baseline; контрольные точки воспроизводимы; gaps и budgets записаны; последующий refactor можно объективно сравнить. + +## Evidence + +- Date: +- Revision/worktree: +- Commands: +- Results: +- Fidelity comparison: +- Changed files: +- Remaining risks: diff --git a/targets/01-coordinates-and-seams.md b/targets/01-coordinates-and-seams.md new file mode 100644 index 0000000..b8531c7 --- /dev/null +++ b/targets/01-coordinates-and-seams.md @@ -0,0 +1,37 @@ +# M01 — Coordinates and Architecture Seams + + + +## Outcome + +Ввести независимые от scene tree типы координат, identity и streaming focus. + +## Steps + +- [ ] Создать domain/value-object слой без `Node`/`Resource` dependencies. +- [ ] Определить canonical WoW, Godot, ADT tile/chunk и local coordinate types. +- [ ] Реализовать единственный `CoordinateMapper` для position и orientation. +- [ ] Собрать golden points из ADT, server spawn и оригинального клиента. +- [ ] Ввести `StreamingFocus` вместо неявной зависимости от active Camera3D. +- [ ] Ввести stable `ContentId`, `EntityId` и отличить их от WoW GUID/server entry. +- [ ] Запретить ручные axis conversion через code review rule/test search. + +## Verification + +- Unit round-trip и boundary tests для всех coordinate spaces. +- Golden server spawn отображается в ожидаемой точке renderer. +- Existing render checkpoints не ухудшились. + +## Exit criteria + +Renderer/test scene получают focus через контракт; координаты конвертируются централизованно; domain tests работают headless без сцены. + +## Evidence + +- Date: +- Revision/worktree: +- Commands: +- Results: +- Fidelity comparison: +- Changed files: +- Remaining risks: diff --git a/targets/02-player-decomposition.md b/targets/02-player-decomposition.md new file mode 100644 index 0000000..6ab733c --- /dev/null +++ b/targets/02-player-decomposition.md @@ -0,0 +1,36 @@ +# M02 — Player Decomposition + + + +## Outcome + +Разделить тестового player controller на input, movement, camera, terrain query и presentation без изменения наблюдаемого поведения. + +## Steps + +- [ ] Заменить hardcoded keys на remappable Input Map actions с профилем 3.3.5a. +- [ ] Выделить `PlayerInputSource → MoveIntent`. +- [ ] Выделить local movement state/controller. +- [ ] Выделить `TerrainQuery` и убрать прямой ADT parsing из player. +- [ ] Выделить third-person camera rig и camera collision policy. +- [ ] Выделить character appearance и animation presenters. +- [ ] Оставить sprint/flight debug-функции только в sandbox profile. +- [ ] Добавить movement/camera state tests и regression scene. + +## Fidelity evidence + +Скорости, повороты, jump/fall/swim/fly, camera orbit/zoom и input semantics сверяются с клиентом 3.3.5a. Debug sprint не считается gameplay parity. + +## Exit criteria + +Каждый компонент заменяем независимо; sandbox управляется не хуже baseline; gameplay logic не загружает визуальные assets. + +## Evidence + +- Date: +- Revision/worktree: +- Commands: +- Results: +- Fidelity comparison: +- Changed files: +- Remaining risks: diff --git a/targets/03-renderer-facade.md b/targets/03-renderer-facade.md new file mode 100644 index 0000000..604509f --- /dev/null +++ b/targets/03-renderer-facade.md @@ -0,0 +1,35 @@ +# M03 — Renderer Facade and Safe Extraction + + + +## Outcome + +Закрыть монолитный streamer стабильным API и постепенно извлечь тестируемые подсистемы без rewrite. + +## Steps + +- [ ] Ввести `WorldRenderFacade` для focus, environment, entity visuals, ground query и metrics. +- [ ] Запретить gameplay/editor доступ к внутренним очередям streamer. +- [ ] Извлечь pure `StreamingTargetPlanner`. +- [ ] Извлечь `RenderBudgetScheduler` с bounded queues/cancellation. +- [ ] Затем извлекать terrain, M2, WMO и liquid services по одному. +- [ ] Сохранить cache versioning и main-thread GPU finalization rules. +- [ ] Добавить dependency и performance regression checks. + +## Fidelity evidence + +Все baseline checkpoints сравниваются до/после; placement, visibility, animation и materials не меняются без отдельной fidelity-задачи. + +## Exit criteria + +Runtime и Editor используют facade; planner/scheduler тестируются без полного мира; p95/p99 и hitch budgets не хуже согласованного baseline. + +## Evidence + +- Date: +- Revision/worktree: +- Commands: +- Results: +- Fidelity comparison: +- Changed files: +- Remaining risks: diff --git a/targets/04-editor-shell.md b/targets/04-editor-shell.md new file mode 100644 index 0000000..cc411a8 --- /dev/null +++ b/targets/04-editor-shell.md @@ -0,0 +1,35 @@ +# M04 — Godot Editor Shell + + + +## Outcome + +Создать основу Authoring Studio как `addons/openwc_editor` исключительно внутри Godot Editor. + +## Steps + +- [ ] Зарегистрировать EditorPlugin и отдельный workspace/docks. +- [ ] Добавить Content Browser, Outliner, Inspector, Validation, History и Build Output. +- [ ] Отделить authoring selection от preview scene tree. +- [ ] Реализовать command history и Godot-integrated undo/redo. +- [ ] Добавить cancellable editor jobs с progress и diagnostics. +- [ ] Добавить recovery journal и обработку external file changes. +- [ ] Подключить world preview только через renderer facade. + +## Verification + +Plugin enable/disable/reload не оставляет ноды и сигналы; command undo/redo детерминирован; Editor не блокируется долгим synthetic job. + +## Exit criteria + +Synthetic marker можно создать, переместить, undo/redo, сохранить и восстановить после перезапуска Editor. + +## Evidence + +- Date: +- Revision/worktree: +- Commands: +- Results: +- Fidelity comparison: +- Changed files: +- Remaining risks: diff --git a/targets/05-content-project.md b/targets/05-content-project.md new file mode 100644 index 0000000..0b8423a --- /dev/null +++ b/targets/05-content-project.md @@ -0,0 +1,32 @@ +# M05 — Content Project + + + +## Outcome + +Создать версионируемый источник истины для пользовательских модификаций. + +## Steps + +- [ ] Реализовать manifest, schema version, dependencies и profiles. +- [ ] Реализовать stable symbolic IDs и deterministic numeric allocation manifest. +- [ ] Добавить канонические map, placement, creature, gameobject, quest и localization entities. +- [ ] Реализовать typed references и graph validation. +- [ ] Реализовать deterministic save/load и semantic diff. +- [ ] Добавить schema migration `previous → current`, backup и fixtures. +- [ ] Реализовать ordered overlays без изменения extracted upstream assets. +- [ ] Обеспечить headless validate/build parity с Editor. + +## Exit criteria + +Synthetic project проходит create/edit/undo/save/reload/migrate/build; повторная сборка создаёт byte-stable manifest/artifacts там, где формат это допускает. + +## Evidence + +- Date: +- Revision/worktree: +- Commands: +- Results: +- Fidelity comparison: +- Changed files: +- Remaining risks: diff --git a/targets/06-server-adapters.md b/targets/06-server-adapters.md new file mode 100644 index 0000000..df2f62e --- /dev/null +++ b/targets/06-server-adapters.md @@ -0,0 +1,36 @@ +# M06 — TrinityCore/AzerothCore Server Adapters + + + +## Outcome + +Безопасно читать и генерировать изменения БД через канонические модели. + +## Steps + +- [ ] Создать connection profiles без хранения secrets в проекте. +- [ ] Реализовать core family/revision/schema fingerprint и capabilities. +- [ ] Добавить read-only snapshots creatures, gameobjects, spawns, waypoints, quests и locales. +- [ ] Реализовать отдельные adapters TrinityCore335 и AzerothCore335. +- [ ] Реализовать canonical round-trip и documented degradation. +- [ ] Реализовать semantic diff, preconditions, ordered SQL и checksum. +- [ ] Реализовать dry-run, disposable DB tests, backup/checkpoint и rollback policy. +- [ ] Блокировать запись для неизвестной схемы и production target по умолчанию. + +## Fidelity evidence + +Flags, coordinates, orientation, quest relations и SmartAI/script fields сверяются с соответствующей revision ядра, а не предполагаются одинаковыми. + +## Exit criteria + +Минимум creature spawn и quest round-trip проходят оба adapter profile без молчаливой потери поддерживаемых полей. + +## Evidence + +- Date: +- Revision/worktree: +- Commands: +- Results: +- Fidelity comparison: +- Changed files: +- Remaining risks: diff --git a/targets/07-world-editor-slice.md b/targets/07-world-editor-slice.md new file mode 100644 index 0000000..a55a3a7 --- /dev/null +++ b/targets/07-world-editor-slice.md @@ -0,0 +1,32 @@ +# M07 — World Editor Vertical Slice + + + +## Outcome + +Разместить NPC в Godot Editor и безопасно развернуть его на dev-core без ручного SQL. + +## Steps + +- [ ] Отобразить server snapshot spawns в world viewport. +- [ ] Реализовать selection/filter/outliner и inspector. +- [ ] Реализовать transform gizmo, snapping и coordinate display. +- [ ] Создать/переместить/удалить creature spawn через undoable commands. +- [ ] Добавить waypoint/patrol path minimum. +- [ ] Показать validation и semantic/SQL diff. +- [ ] Применить change set к dev DB и выполнить post-apply verification. +- [ ] Повторно импортировать snapshot и подтвердить отсутствие неожиданного diff. + +## Exit criteria + +NPC, поставленный на известную точку карты, появляется на dev-core с правильными position/orientation/template и переживает повторный import. + +## Evidence + +- Date: +- Revision/worktree: +- Commands: +- Results: +- Fidelity comparison: +- Changed files: +- Remaining risks: diff --git a/targets/08-network-foundation.md b/targets/08-network-foundation.md new file mode 100644 index 0000000..c9840e6 --- /dev/null +++ b/targets/08-network-foundation.md @@ -0,0 +1,37 @@ +# M08 — WoW 3.3.5a Network Foundation + + + +## Outcome + +Headless protocol sandbox подключается к build 12340 server и строит authoritative entity stream. + +## Steps + +- [ ] TCP transport, bounded queues, cancellation и timeouts. +- [ ] SRP6 auth, realm list и world authentication. +- [ ] Header crypt, packet framing, fragmentation и compression. +- [ ] Typed opcode registry для build 12340. +- [ ] Character enum/create/delete/login minimum. +- [ ] Packed GUID, update masks/fields и object lifecycle. +- [ ] Movement packet family и server time. +- [ ] Redacted packet capture/replay и malformed corpus. +- [ ] Disconnect/reconnect reasons и structured diagnostics. + +## Fidelity evidence + +Exact-byte fixtures сверяются с оригинальным клиентом или соответствующей server-core реализацией. Network handler не публикует raw packets за пределы adapter. + +## Exit criteria + +Replay детерминирован; live sandbox проходит auth → realm → character → world и создаёт entity registry без renderer/UI. + +## Evidence + +- Date: +- Revision/worktree: +- Commands: +- Results: +- Fidelity comparison: +- Changed files: +- Remaining risks: diff --git a/targets/09-gameplay-domain.md b/targets/09-gameplay-domain.md new file mode 100644 index 0000000..c358788 --- /dev/null +++ b/targets/09-gameplay-domain.md @@ -0,0 +1,36 @@ +# M09 — Gameplay Domain + + + +## Outcome + +Создать независимое от Godot и packets клиентское состояние WoW 3.3.5a. + +## Steps + +- [ ] `WorldState`, typed entity registry и local player lifecycle. +- [ ] Domain events/reducers для create/update/destroy. +- [ ] Movement state, prediction и server reconciliation policy. +- [ ] Targeting, unit health/power/faction и interaction state. +- [ ] Spell/cooldown/aura presentation state minimum. +- [ ] Inventory и quest log snapshots minimum. +- [ ] Immutable read models для UI/render/audio. +- [ ] Deterministic replay, state snapshots и reducer tests. + +## Fidelity evidence + +State transitions, server authority, movement flags, errors и timing сверяются с 3.3.5a. Client prediction не подтверждает combat/items/quests самостоятельно. + +## Exit criteria + +Один packet/domain replay всегда создаёт одинаковый gameplay snapshot без Node, UI или live socket. + +## Evidence + +- Date: +- Revision/worktree: +- Commands: +- Results: +- Fidelity comparison: +- Changed files: +- Remaining risks: diff --git a/targets/10-playable-client.md b/targets/10-playable-client.md new file mode 100644 index 0000000..6f12bb6 --- /dev/null +++ b/targets/10-playable-client.md @@ -0,0 +1,37 @@ +# M10 — First Playable Client Slice + + + +## Outcome + +Собственный клиент проходит login и позволяет перемещаться в синхронизированном мире. + +## Steps + +- [ ] Application lifecycle Boot/Login/Realm/Character/Loading/InWorld/Disconnect. +- [ ] Минимальные login, realm и character screens. +- [ ] Связать gameplay state с renderer facade через presenters. +- [ ] Создавать/обновлять/удалять nearby entity visuals. +- [ ] Подключить input/movement packets и reconciliation feedback. +- [ ] Реализовать target selection и базовый unit frame. +- [ ] Добавить chat receive/send minimum. +- [ ] Добавить loading, timeout, server reject и reconnect UX. +- [ ] Провести traversal/latency/performance tests. + +## Fidelity evidence + +Сценарии login, movement, selection, camera и disconnect сравниваются с клиентом 3.3.5a; улучшенный UX включается отдельно. + +## Exit criteria + +Клиент стабильно входит на TrinityCore/AzerothCore, видит мир/entities, перемещается с server correction и корректно выходит/восстанавливается после ошибки. + +## Evidence + +- Date: +- Revision/worktree: +- Commands: +- Results: +- Fidelity comparison: +- Changed files: +- Remaining risks: diff --git a/targets/11-quest-end-to-end.md b/targets/11-quest-end-to-end.md new file mode 100644 index 0000000..87c9445 --- /dev/null +++ b/targets/11-quest-end-to-end.md @@ -0,0 +1,37 @@ +# M11 — Quest End-to-End + + + +## Outcome + +Создать квест в Godot Editor, развернуть на dev-core и полностью пройти собственным клиентом. + +## Steps + +- [ ] Quest form и chain graph. +- [ ] Giver/ender, kill/collect/explore objectives, prerequisites и rewards. +- [ ] Localization и text/behavior consistency checks. +- [ ] Reachability, ID/reference, economy и server capability validation. +- [ ] SQL/change-set build и dev deployment. +- [ ] Runtime gossip, accept, quest log, progress, completion и reward choice. +- [ ] Quest UI через view models; server reject/pending states. +- [ ] Editor playtest launcher в нужной позиции. +- [ ] Automated integrated happy path и failure/recovery path. + +## Fidelity evidence + +Opcode/state/UI event sequence и quest rules сравниваются с 3.3.5a. Custom extensions маркируются и не меняют Blizzlike335. + +## Exit criteria + +Новый квест создаётся и проходится без ручного SQL и оригинального клиента; повторная сборка/установка воспроизводима. + +## Evidence + +- Date: +- Revision/worktree: +- Commands: +- Results: +- Fidelity comparison: +- Changed files: +- Remaining risks: diff --git a/targets/12-client-completeness.md b/targets/12-client-completeness.md new file mode 100644 index 0000000..03f9512 --- /dev/null +++ b/targets/12-client-completeness.md @@ -0,0 +1,37 @@ +# M12 — WoW 3.3.5a Client Completeness + + + +## Outcome + +Последовательно закрыть всю матрицу `docs/CLIENT_FEATURES.md` до подтверждённой совместимости. + +## Required order + +- [ ] Combat, spells, casting, cooldowns, auras, death/resurrection. +- [ ] Inventory, equipment, loot, vendors, trainers, trade и bank. +- [ ] World map, minimap, exploration, taxi и transports. +- [ ] Party, raid, social, guild и chat completeness. +- [ ] Talents, glyphs, skills, professions и achievements. +- [ ] Mail, auction, economy services и guild bank. +- [ ] PvP, duel, battlegrounds, arenas и LFG. +- [ ] Vehicles, cinematics, weather, audio и loading transitions. +- [ ] Character/M2/WMO/liquid/material/animation fidelity gaps. +- [ ] Lua 5.1, FrameXML, default UI и addon compatibility tiers. +- [ ] Localization, settings, accessibility и recovery. + +Каждая строка превращается в отдельный vertical-slice spec с acceptance criteria, fixtures и fidelity evidence. Массовая реализация opcodes или Lua stubs без работающего сценария запрещена. + +## Exit criteria + +Каждая строка feature matrix имеет статус `Compatible` или `Verified`; известные расхождения перечислены, воспроизводимы и приняты явно; regression suite покрывает основные игровые циклы. + +## Evidence + +- Date: +- Revision/worktree: +- Commands: +- Results: +- Fidelity comparison: +- Changed files: +- Remaining risks: diff --git a/targets/13-dungeon-authoring.md b/targets/13-dungeon-authoring.md new file mode 100644 index 0000000..4107f73 --- /dev/null +++ b/targets/13-dungeon-authoring.md @@ -0,0 +1,36 @@ +# M13 — Dungeon and Encounter Authoring + + + +## Outcome + +Создать custom dungeon в Godot Editor, развернуть и пройти группой собственным клиентом. + +## Steps + +- [ ] `DungeonPackage`: map, entrance/exit, difficulty, graveyard и lockout. +- [ ] Spawn groups, patrols, triggers, doors и encounter ownership. +- [ ] Encounter graph с phases, abilities, transitions, reset и tuning profiles. +- [ ] Loot, quests, localization и instance messages. +- [ ] SmartAI generation и/или versioned core script skeleton. +- [ ] Validation: reachability, references, door-state graph, wipe recovery и budgets. +- [ ] Offline preview, server deploy и group playtest launcher. +- [ ] Deterministic encounter simulation cases и multiplayer soak. + +## Fidelity policy + +Базовые instance/group/loot/lockout semantics соответствуют 3.3.5a. Новые encounters могут иметь оригинальный дизайн, но используют явные content capabilities и не ломают Blizzlike335 runtime. + +## Exit criteria + +Минимальный dungeon с маршрутом, trash groups, дверью и boss encounter собирается воспроизводимо, разворачивается и проходится группой; reset/wipe/loot проверены. + +## Evidence + +- Date: +- Revision/worktree: +- Commands: +- Results: +- Fidelity comparison: +- Changed files: +- Remaining risks: diff --git a/targets/README.md b/targets/README.md new file mode 100644 index 0000000..c175ae5 --- /dev/null +++ b/targets/README.md @@ -0,0 +1,71 @@ +# OpenWC Targets + + + +Этот каталог задаёт обязательную последовательность реализации. Архитектура описана в `docs/`, фактическое состояние renderer — в `RENDER.md`, а здесь находятся исполняемые milestones. + +## Current target + +`M00` — [00-render-baseline.md](00-render-baseline.md) + +Одновременно `ACTIVE` может быть только одна цель. Следующая цель становится `ACTIVE` после появления валидной `OPENWC_TARGET_DONE` метки у предыдущей. + +## Definition of fidelity 1:1 + +`1:1` означает совпадение наблюдаемого поведения с оригинальным клиентом WoW 3.3.5a build 12340 для выбранного сценария: + +- wire protocol и update fields; +- игровые состояния и переходы; +- координаты, timing и server authority; +- UI/Lua/FrameXML semantics; +- выбор assets, animation, audio и visual rules; +- edge cases, ошибки и recovery. + +Современные улучшения разрешены только за feature flag/profile и не могут менять `Blizzlike335` по умолчанию. Визуальная пиксельная идентичность ограничена возможностями Godot, но каждое осознанное расхождение документируется и измеряется. + +## Последовательность + +| ID | Цель | Зависит от | Статус | +|---|---|---|---| +| M00 | [Renderer baseline](00-render-baseline.md) | — | ACTIVE | +| M01 | [Coordinates and architecture seams](01-coordinates-and-seams.md) | M00 | OPEN | +| M02 | [Player decomposition](02-player-decomposition.md) | M01 | OPEN | +| M03 | [Renderer facade and extraction](03-renderer-facade.md) | M02 | OPEN | +| M04 | [Godot Editor shell](04-editor-shell.md) | M03 | OPEN | +| M05 | [Content Project](05-content-project.md) | M04 | OPEN | +| M06 | [Server database adapters](06-server-adapters.md) | M05 | OPEN | +| M07 | [World Editor vertical slice](07-world-editor-slice.md) | M06 | OPEN | +| M08 | [Network foundation](08-network-foundation.md) | M03 | OPEN | +| M09 | [Gameplay domain](09-gameplay-domain.md) | M08 | OPEN | +| M10 | [Playable client slice](10-playable-client.md) | M09, M03 | OPEN | +| M11 | [Quest end-to-end](11-quest-end-to-end.md) | M07, M10 | OPEN | +| M12 | [WoW 3.3.5a completeness](12-client-completeness.md) | M11 | OPEN | +| M13 | [Dungeon authoring](13-dungeon-authoring.md) | M12 foundation | OPEN | + +M07 и M08 могут выполняться параллельными командами только после M06/M03 соответственно и только если пользователь явно разрешил параллельную работу. Их результаты сходятся в M11. + +## Общий quality gate + +Каждая цель должна сохранить: + +- headless загрузку Godot project; +- затронутые unit/contract/integration tests; +- renderer checkpoints при изменении рендера; +- отсутствие новых нарушений dependency rules; +- обновлённые документы и diagnostics; +- воспроизводимость без proprietary данных в Git. + +## Evidence format + +В конце target-файла агент заполняет: + +```text +## Evidence +- Date: +- Revision/worktree: +- Commands: +- Results: +- Fidelity comparison: +- Changed files: +- Remaining risks: +```