From 05e216a953746a03c6b18ca1d8fe18a0f1556f22 Mon Sep 17 00:00:00 2001 From: sindoring Date: Fri, 17 Jul 2026 00:22:53 +0400 Subject: [PATCH 1/2] refactor(M03): extract WMO placement resolver --- RENDER.md | 12 + docs/modules/README.md | 1 + docs/modules/wmo-placement-resolver.md | 187 ++++++++++++ docs/modules/world-renderer.md | 15 +- src/render/wmo/wmo_placement_resolver.gd | 32 +++ src/render/wmo/wmo_placement_resolver.gd.uid | 1 + .../streaming/streaming_world_loader.gd | 47 ++-- src/tools/verify_wmo_placement_resolver.gd | 266 ++++++++++++++++++ .../verify_wmo_placement_resolver.gd.uid | 1 + 9 files changed, 530 insertions(+), 32 deletions(-) create mode 100644 docs/modules/wmo-placement-resolver.md create mode 100644 src/render/wmo/wmo_placement_resolver.gd create mode 100644 src/render/wmo/wmo_placement_resolver.gd.uid create mode 100644 src/tools/verify_wmo_placement_resolver.gd create mode 100644 src/tools/verify_wmo_placement_resolver.gd.uid diff --git a/RENDER.md b/RENDER.md index 655cd9c..72d812e 100644 --- a/RENDER.md +++ b/RENDER.md @@ -1025,6 +1025,18 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' - Cache formats, quality profiles, batching output and visible rules are unchanged. Asset-backed p95/p99 and spatial-cell batching evidence remain pending. +## 2026-07-17 WMO Placement Resolver Extraction + +- `WmoPlacementResolver` now owns lowercase/slash cache-key normalization, + positive MODF `uid:` identity with the legacy tile/index fallback and + world `Transform3D` composition. +- Lightweight render roots, cached scenes and live prototypes now share the same + position/Euler/unclamped-scale formula. +- The loader retains registry refs, jobs/queues, cache/load state, resource + fallback, cancellation and all Node/Mesh/MultiMesh/material/RID ownership. +- Cache formats, placement values, profiles and visible rules are unchanged. + Asset-backed placement/p95/p99 and general WMO parity remain pending. + ## 2026-07-16 World Environment Snapshot Facade - `WorldEnvironmentSnapshot` carries one immutable finite time-of-day value, diff --git a/docs/modules/README.md b/docs/modules/README.md index a4696dd..2fbedca 100644 --- a/docs/modules/README.md +++ b/docs/modules/README.md @@ -19,6 +19,7 @@ | M2 placement transform resolver | Implemented | [`m2-placement-transform-resolver.md`](m2-placement-transform-resolver.md) | | M2 placement grouper | Implemented extraction | [`m2-placement-grouper.md`](m2-placement-grouper.md) | | M2 build batch planner | Implemented extraction | [`m2-build-batch-planner.md`](m2-build-batch-planner.md) | +| WMO placement resolver | Implemented extraction | [`wmo-placement-resolver.md`](wmo-placement-resolver.md) | | Third-person camera | Implemented | [`third-person-camera.md`](third-person-camera.md) | | Character presentation | Implemented boundary / Partial fidelity | [`character-presentation.md`](character-presentation.md) | | Renderer | Partial | [`world-renderer.md`](world-renderer.md), [`../../RENDER.md`](../../RENDER.md) | diff --git a/docs/modules/wmo-placement-resolver.md b/docs/modules/wmo-placement-resolver.md new file mode 100644 index 0000000..336b688 --- /dev/null +++ b/docs/modules/wmo-placement-resolver.md @@ -0,0 +1,187 @@ +# WMO Placement Resolver + +## Metadata + +| Field | Value | +|---|---| +| Status | Implemented | +| Target/work package | M03 / `M03-RND-WMO-PLACEMENT-RESOLVER-001` | +| Owners | Pure WMO cache-key, placement-identity and world-transform rules | +| Last verified | Worktree `work/sindo-main-codex/m03-wmo-placement-resolver`, 2026-07-17 | +| Profiles/capabilities | Existing ADT/WDT WMO placement paths | + +## Purpose + +Provide one scene-free boundary for normalized WMO cache keys, MODF placement +identity and world-space Godot transforms across render-cache, cached-scene and +live-prototype instance paths. + +## Non-goals + +- Parse ADT/WDT or convert canonical WoW coordinates. +- Own WMO registry references, tile state, jobs, queues or retries. +- Load/validate cached scenes or lightweight render resources. +- Create Nodes, Mesh/MultiMesh, materials or RIDs. +- Implement portals, rooms, visibility, materials, doodads or WMO parity. + +## Context and boundaries + +```mermaid +flowchart LR + Parsed[ADT/WDT WMO placement] --> Loader[StreamingWorldLoader adapter] + Loader --> Resolver[WmoPlacementResolver] + Resolver --> CacheKey[Normalized cache key] + Resolver --> Identity[Registry unique key] + Resolver --> Transform[World Transform3D] + CacheKey --> Cache[Loader WMO caches/requests] + Identity --> Registry[Loader WMO registry] + Transform --> RenderRoot[Lightweight render root] + Transform --> Scene[Cached scene instance] + Transform --> Live[Live prototype instance] +``` + +Allowed dependencies are Dictionary/String values and Godot `Vector3`, `Basis` +and `Transform3D` math. Node, SceneTree, RenderingServer, ResourceLoader, +WorkerThreadPool, mutexes, files, gameplay, network and editor UI are forbidden. + +## Public API + +| Symbol | Kind | Purpose | Thread/lifetime | Errors | +|---|---|---|---|---| +| `normalize_relative_path(relative_wmo_path)` | Pure query | Return lowercase slash-normalized cache/lookup key | Any thread; value result | Empty input returns empty | +| `resolve_unique_key(placement, tile_key, placement_index)` | Pure query | Return positive UID identity or tile/index fallback | Any thread; value result | Missing/non-positive UID uses fallback | +| `resolve_world_transform(placement)` | Pure query | Compose world-space position, Godot Euler rotation and uniform scale | Any thread; value result | Missing fields use zero/zero/one defaults; scale is not clamped | + +## Inputs and outputs + +| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime | +|---|---|---|---|---|---| +| Input | Relative WMO path | WDT/ADT name table | Path normalizer | Caller String | One call | +| Input | Raw placement Dictionary | WDT global or ADT MODF parser result | Identity/transform queries | Read-only shallow value | One call | +| Input | Tile key and placement index | Loader build job | Synthetic identity fallback | Copied scalar/String | Registry entry lifetime | +| Output | Normalized relative path | Resolver | Render/scene cache and load-request maps | New String value | Request/cache lookup | +| Output | `uid:*` or `tile:*:*` key | Resolver | Loader WMO registry/ref arrays | New String value | Until unregister/reset | +| Output | World `Transform3D` | Resolver | Three WMO instance adapters | Value copy | Instance lifetime after assignment | + +The resolver retains no source Dictionary, output or engine resource. + +## Data flow + +```mermaid +flowchart TD + Path[relative path] --> Slash[replace backslash with slash] + Slash --> Lower[lowercase entire key] + Placement[placement Dictionary] --> UID{unique_id > 0?} + UID -->|yes| UidKey[uid decimal] + UID -->|no| TileKey[tile key + placement index] + Placement --> Defaults[position/rotation/scale defaults] + Defaults --> Basis[Godot Basis.from_euler] + Basis --> Scale[unclamped uniform scale] + Scale --> World[world Transform3D] +``` + +## Lifecycle/state + +The resolver is stateless. Construction, world reset, map switch, cancellation +and shutdown require no resolver operation. + +## Main sequence + +```mermaid +sequenceDiagram + participant Loader as StreamingWorldLoader + participant Resolver as WmoPlacementResolver + participant Registry as Loader WMO registry/cache + participant Instance as Render/cached/live instance + Loader->>Resolver: normalize_relative_path(path) + Resolver-->>Loader: cache key + Loader->>Resolver: resolve_unique_key(placement, tile, index) + Resolver-->>Registry: identity adopted by loader + Loader->>Resolver: resolve_world_transform(placement) + Resolver-->>Loader: value Transform3D + Loader->>Instance: assign transform and attach/build +``` + +## Ownership, threading and resources + +- The resolver owns only call-local scalar/value calculations. +- The loader owns WMO registry entries/ref sets, cache/load-request state, build + jobs/queues, resource fallback and cancellation. +- The loader and builders own every Node/Mesh/MultiMesh/material/RID lifecycle. +- Pure calls are thread-safe; current consumers execute on the main thread. + +## Errors, cancellation and recovery + +| Failure | Detection | Behavior | Diagnostic | Recovery | +|---|---|---|---|---| +| Empty path | Empty String | Empty normalized key | Contract fixture | Loader cache path rejects/degrades | +| Missing/non-positive UID | Integer rule | Tile/index key prevents same-tile collisions | Contract fixture | Rebake old ADT cache to restore cross-tile UID dedupe | +| Missing transform fields | Dictionary defaults | Zero position/rotation; scale one | Contract fixture | Repair parser/cache input | +| Zero/negative scale | No historical clamp | Preserve zero/mirrored basis | Contract fixture | Preserve source behavior | +| Resource missing/pending | Outside resolver | Loader retry/fallback path unchanged | Existing loader diagnostics | Restore cache/raw asset | +| Tile unregister/reset | Outside resolver | Loader releases refs/nodes/jobs | Shutdown/registry regressions | New placements resolve afresh | + +## Configuration and capabilities + +The resolver introduces no setting or profile branch. WMO radii, visibility, +shadow/occlusion flags, cache size limits and build budgets remain loader-owned. + +## Persistence, cache and migration + +No format is written. Existing WMO `.tscn`/lightweight render cache versions and +ADT/WDT formats are unchanged. Older placement caches without positive UID retain +the existing per-tile fallback and require no migration for this extraction. + +## Diagnostics and observability + +The resolver emits no logs. Its verifier records path/identity/transform/source +contracts and bounded 20,000-triple timing. WMO queue/cache/instance metrics remain +in the facade/loader diagnostic snapshot. + +## Verification + +- `verify_wmo_placement_resolver.gd`: slash/case/empty path, positive UID, + missing/zero/negative UID fallback, default and rotated/scaled transform, + unclamped scale, historical Node3D property equivalence, stateless results, + all loader adapters, dependency boundary + and bounded timing. +- Existing WMO material/cache/shutdown and renderer baseline regressions remain + required alongside M2/terrain/facade/internal-access/coordinate gates. +- Fidelity evidence is exact extraction of current placement rules. No new + original-client WMO visual/portal/material parity claim is made. + +## Extension points + +- A later package may extract WMO registry ownership/ref release using these + stable keys. +- Render-group build cursor and resource-selection state require separate + lifecycle/cancellation contracts. + +## Capability status + +| Capability | Status | Evidence | Gap/next step | +|---|---|---|---| +| Consistent WMO placement identity/transform | Implemented extraction | Contract/source/timing verifier | Asset-backed placement comparison pending | +| WMO registry/build services | Remains in loader | Existing runtime regressions | Stateful extraction pending | +| Portals/rooms/material fidelity | Partial | M00 checkpoint/material evidence | Broader WMO implementation pending | + +## Known gaps and risks + +- Raw placement Dictionaries remain the parser/cache boundary. +- Old caches without UID cannot dedupe one WMO across ADT tile boundaries. +- WMO scale remains intentionally unclamped, including zero/negative input. +- Asset-backed visual/p95/p99 evidence is unavailable in this package. + +## Source map + +| Path | Responsibility | +|---|---| +| `src/render/wmo/wmo_placement_resolver.gd` | Path, identity and transform rules | +| `src/scenes/streaming/streaming_world_loader.gd` | Registry/cache/build/resource/Node ownership | +| `src/tools/verify_wmo_placement_resolver.gd` | Contract, source and timing regression | + +## Related decisions and references + +- [`world-renderer.md`](world-renderer.md) +- [`../../RENDER.md`](../../RENDER.md) +- [`../../targets/roadmap/02-rendering-and-graphics.md`](../../targets/roadmap/02-rendering-and-graphics.md) diff --git a/docs/modules/world-renderer.md b/docs/modules/world-renderer.md index 5d4e529..7616f49 100644 --- a/docs/modules/world-renderer.md +++ b/docs/modules/world-renderer.md @@ -5,9 +5,9 @@ | Field | Value | |---|---| | Status | Partial | -| Target/work package | M00 baseline; `M01-RND-STREAMING-FOCUS-001`; `M01-QAR-SERVER-SPAWN-RENDERER-001`; M03 facade/planner/scheduler/internal-access/ground/environment/entity packages; M03 terrain packages; M03 M2 unique/transform/grouping/batch packages | +| Target/work package | M00 baseline; `M01-RND-STREAMING-FOCUS-001`; `M01-QAR-SERVER-SPAWN-RENDERER-001`; M03 facade/planner/scheduler/internal-access/ground/environment/entity packages; M03 terrain packages; M03 M2 packages; M03 WMO placement package | | Owners | Renderer workstream / milestone integrator | -| Last verified | Worktree `work/sindo-main-codex/m03-m2-build-batch-planner`, 2026-07-17 | +| Last verified | Worktree `work/sindo-main-codex/m03-wmo-placement-resolver`, 2026-07-17 | | Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete | ## Purpose @@ -54,6 +54,8 @@ flowchart LR M2Transform --> Loader Loader --> M2Batch[M2BuildBatchPlanner] M2Batch --> Loader + Loader --> WmoPlacement[WmoPlacementResolver] + WmoPlacement --> Loader Native --> Parsed[Parsed tile/model data] Parsed --> Loader Loader --> Scene[SceneTree nodes] @@ -124,6 +126,7 @@ from externally reading/writing loader-private queue, task, cache and tile-state | `M2PlacementTransformResolver.resolve_basis/resolve_origin_offset` | Internal pure M2 service | Resolves regular and calibrated model-specific ADT placement transforms | Worker/main thread; stateless | Unknown paths use regular basis and zero offset | | `M2PlacementGrouper.group_placements` | Internal pure M2 service | Validates and groups ordered tile-local placement transforms by normalized path | Worker/main thread; stateless | Invalid variants/name IDs/empty paths are skipped | | `M2BuildBatchPlanner.plan_batch` | Internal pure M2 service | Selects static/animated batch count and next group cursor | Main/any thread; stateless | Non-positive selected limit clamps to one; empty range completes | +| `WmoPlacementResolver.normalize_relative_path/resolve_unique_key/resolve_world_transform` | Internal pure WMO service | Resolves cache key, registry identity and world transform | Main/any thread; stateless | Missing UID uses tile/index fallback; transform fields use historical defaults | Публичным contract не считаются `StreamingWorldLoader` methods/properties, внутренние dictionaries, queues, job records и generated node names. Scene-owned @@ -152,6 +155,7 @@ loader configuration remains transitional composition data, not a caller API. | Internal transform | Rotation/path/scale | Loader or grouper / `M2PlacementTransformResolver` | Group/placeholder/instance transforms | Value-only Basis/Vector3 | One placement | | Internal grouping | Tile origin, M2 names and placements | Loader / `M2PlacementGrouper` | Loader worker result/build job | Fresh Dictionary/Transform3D arrays | One grouping task | | Internal batch plan | Transform count/offset, path kind and limits | Loader / `M2BuildBatchPlanner` | Loader materialization/cursor adapter | Fresh scalar Dictionary | One build operation | +| Internal WMO placement | Path, MODF placement, tile/index | Loader / `WmoPlacementResolver` | WMO caches, registry and three instance adapters | Value-only String/Transform3D | Lookup/placement lifetime | | Output | Desired tile/detail operations | Streamer plan application | Finalize queues | Loader-owned | Cross-frame | | Output | Terrain/M2/WMO/liquid instances | Loader/builders | Godot world/renderer | Loader/world owner | Main-thread attach | | Output | Detached renderer metrics | `WorldRenderFacade` | Capture/baseline tools | Caller-owned deep copy | On demand | @@ -200,6 +204,8 @@ flowchart TD M2Transform[M2PlacementTransformResolver] --> M2Grouper M2Grouper --> M2Batch[M2BuildBatchPlanner] M2Batch --> M2 + R --> WmoPlacement[WmoPlacementResolver] + WmoPlacement --> WMO R --> B[RenderBudgetScheduler permits] B --> Terrain[Terrain attach/upgrade] B --> M2[M2 group/MultiMesh attach] @@ -321,6 +327,8 @@ sequenceDiagram The loader retains tasks, mutex/result queues, stale checks and build state. - `M2BuildBatchPlanner` is stateless and owns only call-local scalar plans. The loader retains queue/resource transitions, cursor adoption and materialization. +- `WmoPlacementResolver` is stateless and owns only call-local cache-key, + identity and transform values. The loader retains WMO refs/caches/jobs/resources. - Rendered-ground query results and diagnostic snapshots are caller-owned values; the facade never returns Mesh, Node, tile-state or queue references. - Loaded-mesh ground sampling is renderer diagnostics, not authoritative gameplay @@ -412,6 +420,8 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m group order, local transforms, fresh output, source boundary and bounded timing. - M2 build batch contract: static/animated limit selection, clamp, remaining count, completion/cursor rules, source boundary and bounded timing. +- WMO placement contract: cache-path normalization, positive/fallback identity, + default/rotated/unclamped transforms, three consumers and bounded timing. - Budget scheduler contract: exact lane exhaustion, shared chunk removal/create priority, independent lanes, frame reset, invalid limits, terminal cancellation, dependency boundaries and bounded permit timing without loading a world scene. @@ -445,6 +455,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m | M2 placement transform resolver | Implemented extraction | Scene-free formula/source/timing contract across three consumers | Asset-backed visual recheck and general placement parity pending | | M2 placement grouper | Implemented extraction | Scene-free validation/order/transform/source/timing contract | Worker/build state, spatial cells and asset-backed p95/p99 remain pending | | M2 build batch planner | Implemented extraction | Scene-free limit/count/cursor/source/timing contract | Queue/resource state, spatial cells and asset-backed p95/p99 remain pending | +| WMO placement resolver | Implemented extraction | Scene-free path/identity/transform/source/timing contract | Registry/build extraction and asset-backed comparison pending | | WMO rendering | Partial | Cached group rendering | Portals/rooms/material parity | | Liquids | Partial | MH2O/MLIQ paths | LiquidType/depth/shore fidelity | | Sky/light | Partial | DBC controller/logs | Zone transition paired validation | diff --git a/src/render/wmo/wmo_placement_resolver.gd b/src/render/wmo/wmo_placement_resolver.gd new file mode 100644 index 0000000..d5cd952 --- /dev/null +++ b/src/render/wmo/wmo_placement_resolver.gd @@ -0,0 +1,32 @@ +class_name WmoPlacementResolver +extends RefCounted + +## Stateless WMO cache-key, placement-identity and world-transform rules. + + +## Returns the historical lowercase slash-normalized relative WMO cache key. +func normalize_relative_path(relative_wmo_path: String) -> String: + return relative_wmo_path.replace("\\", "/").to_lower() + + +## Returns the positive MODF unique-ID key or the historical tile/index fallback. +func resolve_unique_key( + placement: Dictionary, + tile_key: String, + placement_index: int +) -> String: + var unique_id := int(placement.get("unique_id", -1)) + if unique_id > 0: + return "uid:%d" % unique_id + return "tile:%s:%d" % [tile_key, placement_index] + + +## Returns the world-space Godot transform used by every WMO instance path. +func resolve_world_transform(placement: Dictionary) -> Transform3D: + var world_position: Vector3 = placement.get("pos", Vector3.ZERO) + var rotation_radians: Vector3 = placement.get("rot", Vector3.ZERO) + var scale_value := float(placement.get("scale", 1.0)) + return Transform3D( + Basis.from_euler(rotation_radians).scaled(Vector3.ONE * scale_value), + world_position + ) diff --git a/src/render/wmo/wmo_placement_resolver.gd.uid b/src/render/wmo/wmo_placement_resolver.gd.uid new file mode 100644 index 0000000..cb70f50 --- /dev/null +++ b/src/render/wmo/wmo_placement_resolver.gd.uid @@ -0,0 +1 @@ +uid://02omydj0sxmo diff --git a/src/scenes/streaming/streaming_world_loader.gd b/src/scenes/streaming/streaming_world_loader.gd index 3593a86..95d4b19 100644 --- a/src/scenes/streaming/streaming_world_loader.gd +++ b/src/scenes/streaming/streaming_world_loader.gd @@ -9,6 +9,9 @@ const SPLAT_TILE_SCRIPT := preload("res://src/resources/splat_adt_tile.gd") const CONTROL_SPLAT_TILE_SCRIPT := preload("res://src/resources/control_splat_adt_tile.gd") const WMO_STREAMING_SCRIPT := preload("res://src/resources/wmo_streaming_resource.gd") const WMO_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/wmo_builder.gd") +const WMO_PLACEMENT_RESOLVER_SCRIPT := preload( + "res://src/render/wmo/wmo_placement_resolver.gd" +) 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") @@ -277,6 +280,7 @@ var _wmo_scene_resource_cache: Dictionary = {} var _wmo_scene_cache_missing: Dictionary = {} var _wmo_scene_load_requests: Dictionary = {} var _wmo_missing_cache: Dictionary = {} +var _wmo_placement_resolver := WMO_PLACEMENT_RESOLVER_SCRIPT.new() var _m2_scene_cache: Dictionary = {} var _m2_animated_scene_cache: Dictionary = {} var _m2_static_animation_cache: Dictionary = {} @@ -3557,7 +3561,11 @@ func _process_wmo_build_jobs() -> void: var placement: Dictionary = placement_variant var name_id: int = int(placement.get("name_id", -1)) if name_id >= 0 and name_id < wmo_names.size(): - var unique_key := _wmo_unique_key(placement, tile_key, index) + var unique_key := _wmo_placement_resolver.resolve_unique_key( + placement, + tile_key, + index + ) if _wmo_registry.has(unique_key): var entry: Dictionary = _wmo_registry[unique_key] var entry_refs: Dictionary = entry["refs"] @@ -3578,7 +3586,9 @@ func _process_wmo_build_jobs() -> void: refs.append(unique_key) _start_wmo_render_build(unique_key, instance, render_resource) else: - var normalized_rel := _normalize_wmo_rel_path(rel_path) + var normalized_rel := _wmo_placement_resolver.normalize_relative_path( + rel_path + ) if _wmo_render_load_requests.has(normalized_rel): advance_job = false else: @@ -3676,12 +3686,8 @@ func _drain_wmo_render_loads() -> void: _wmo_render_missing_cache[normalized_rel] = true -func _normalize_wmo_rel_path(rel_path: String) -> String: - return rel_path.replace("\\", "/").to_lower() - - func _get_wmo_render_or_request(rel_path: String) -> Resource: - var normalized_rel := _normalize_wmo_rel_path(rel_path) + var normalized_rel := _wmo_placement_resolver.normalize_relative_path(rel_path) if normalized_rel.is_empty(): return null if _wmo_render_cache.has(normalized_rel): @@ -3706,7 +3712,7 @@ func _get_wmo_render_or_request(rel_path: String) -> Resource: func _get_wmo_scene_or_request(rel_path: String) -> PackedScene: - var normalized_rel := _normalize_wmo_rel_path(rel_path) + var normalized_rel := _wmo_placement_resolver.normalize_relative_path(rel_path) if normalized_rel.is_empty(): return null if _wmo_scene_resource_cache.has(normalized_rel): @@ -3748,10 +3754,7 @@ func _get_wmo_scene_or_request(rel_path: String) -> PackedScene: func _instantiate_wmo_render_root(rel_path: String, placement: Dictionary) -> Node3D: var root := Node3D.new() root.name = rel_path.get_file().get_basename() - root.position = placement.get("pos", Vector3.ZERO) - root.rotation = placement.get("rot", Vector3.ZERO) - var scale_value: float = float(placement.get("scale", 1.0)) - root.scale = Vector3.ONE * scale_value + root.transform = _wmo_placement_resolver.resolve_world_transform(placement) return root @@ -3893,10 +3896,7 @@ func _instantiate_wmo_scene(rel_path: String, scene: PackedScene, placement: Dic instance.free() return null instance.name = rel_path.get_file().get_basename() - instance.position = placement.get("pos", Vector3.ZERO) - instance.rotation = placement.get("rot", Vector3.ZERO) - var scale_value: float = float(placement.get("scale", 1.0)) - instance.scale = Vector3.ONE * scale_value + instance.transform = _wmo_placement_resolver.resolve_world_transform(placement) return instance @@ -4020,16 +4020,6 @@ func _unregister_tile_wmos(state: Dictionary) -> void: state["wmo_building"] = false -func _wmo_unique_key(placement: Dictionary, tile_key: String, idx: int) -> String: - var uid: int = int(placement.get("unique_id", -1)) - # uniqueId is always set in vanilla/3.3.5a MODF data, but older baked caches - # predating the ADTLoader unique_id field will miss it. Fall back to a - # per-tile synthetic key — no cross-tile dedup, but also no collisions. - if uid > 0: - return "uid:%d" % uid - return "tile:%s:%d" % [tile_key, idx] - - func _clear_wmo_registry() -> void: _wmo_render_build_jobs.clear() _wmo_render_build_queue.clear() @@ -4898,10 +4888,7 @@ func _instantiate_wmo_world(rel_path: String, placement: Dictionary) -> Node3D: instance.name = rel_path.get_file().get_basename() # WMOs are parented to _terrain_root, whose position absorbs the editor # offset — use world-space placement.pos directly (no tile_origin subtraction). - instance.position = placement.get("pos", Vector3.ZERO) - instance.rotation = placement.get("rot", Vector3.ZERO) - var scale_value: float = float(placement.get("scale", 1.0)) - instance.scale = Vector3.ONE * scale_value + instance.transform = _wmo_placement_resolver.resolve_world_transform(placement) if not enable_occlusion_culling: var occluders := instance.get_node_or_null("Occluders") if occluders != null: diff --git a/src/tools/verify_wmo_placement_resolver.gd b/src/tools/verify_wmo_placement_resolver.gd new file mode 100644 index 0000000..699efeb --- /dev/null +++ b/src/tools/verify_wmo_placement_resolver.gd @@ -0,0 +1,266 @@ +extends SceneTree + +## Asset-free contract, dependency and timing regression for WMO placements. + +const RESOLVER_SCRIPT := preload("res://src/render/wmo/wmo_placement_resolver.gd") +const RESOLVER_PATH := "res://src/render/wmo/wmo_placement_resolver.gd" +const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd" + + +func _initialize() -> void: + var failures: Array[String] = [] + _verify_path_normalization(failures) + _verify_positive_unique_id(failures) + _verify_synthetic_unique_keys(failures) + _verify_default_transform(failures) + _verify_position_rotation_and_scale(failures) + _verify_unclamped_scale(failures) + _verify_node_property_equivalence(failures) + _verify_source_boundaries(failures) + _verify_fresh_stateless_results(failures) + var elapsed_milliseconds := _verify_bounded_timing(failures) + if not failures.is_empty(): + for failure in failures: + push_error("WMO_PLACEMENT_RESOLVER: %s" % failure) + quit(1) + return + print( + "WMO_PLACEMENT_RESOLVER PASS cases=9 iterations=20000 elapsed_ms=%.3f" + % elapsed_milliseconds + ) + quit(0) + + +func _verify_path_normalization(failures: Array[String]) -> void: + var resolver := RESOLVER_SCRIPT.new() + _expect_equal_string( + resolver.normalize_relative_path("World\\WMO\\Stormwind\\Keep.WMO"), + "world/wmo/stormwind/keep.wmo", + "slash and case normalization", + failures + ) + _expect_equal_string(resolver.normalize_relative_path(""), "", "empty path", failures) + + +func _verify_positive_unique_id(failures: Array[String]) -> void: + var resolver := RESOLVER_SCRIPT.new() + _expect_equal_string( + resolver.resolve_unique_key({"unique_id": 11785}, "31:49", 6), + "uid:11785", + "positive unique ID", + failures + ) + + +func _verify_synthetic_unique_keys(failures: Array[String]) -> void: + var resolver := RESOLVER_SCRIPT.new() + for placement in [{}, {"unique_id": 0}, {"unique_id": -4}]: + _expect_equal_string( + resolver.resolve_unique_key(placement, "31:49", 6), + "tile:31:49:6", + "synthetic unique key", + failures + ) + + +func _verify_default_transform(failures: Array[String]) -> void: + var resolver := RESOLVER_SCRIPT.new() + var transform: Transform3D = resolver.resolve_world_transform({}) + _expect_basis_near(transform.basis, Basis.IDENTITY, "default basis", failures) + _expect_vector_near(transform.origin, Vector3.ZERO, "default origin", failures) + + +func _verify_position_rotation_and_scale(failures: Array[String]) -> void: + var resolver := RESOLVER_SCRIPT.new() + var position := Vector3(10.0, 20.0, 30.0) + var rotation := Vector3(0.2, -0.4, 0.6) + var transform: Transform3D = resolver.resolve_world_transform({ + "pos": position, + "rot": rotation, + "scale": 2.5, + }) + _expect_basis_near( + transform.basis, + Basis.from_euler(rotation).scaled(Vector3.ONE * 2.5), + "rotated scaled basis", + failures + ) + _expect_vector_near(transform.origin, position, "world origin", failures) + + +func _verify_unclamped_scale(failures: Array[String]) -> void: + var resolver := RESOLVER_SCRIPT.new() + var zero_transform: Transform3D = resolver.resolve_world_transform({"scale": 0.0}) + _expect_basis_near( + zero_transform.basis, + Basis.IDENTITY.scaled(Vector3.ZERO), + "zero scale", + failures + ) + var negative_transform: Transform3D = resolver.resolve_world_transform({"scale": -2.0}) + _expect_basis_near( + negative_transform.basis, + Basis.IDENTITY.scaled(Vector3.ONE * -2.0), + "negative scale", + failures + ) + + +func _verify_node_property_equivalence(failures: Array[String]) -> void: + var resolver := RESOLVER_SCRIPT.new() + for scale_value in [2.5, 0.0, -2.0]: + var placement := { + "pos": Vector3(10.0, 20.0, 30.0), + "rot": Vector3(0.2, -0.4, 0.6), + "scale": scale_value, + } + var historical_node := Node3D.new() + historical_node.position = placement["pos"] + historical_node.rotation = placement["rot"] + historical_node.scale = Vector3.ONE * scale_value + var historical_transform := historical_node.transform + historical_node.free() + var resolved_transform: Transform3D = resolver.resolve_world_transform(placement) + _expect_basis_near( + resolved_transform.basis, + historical_transform.basis, + "Node3D property equivalence scale %s" % scale_value, + failures + ) + _expect_vector_near( + resolved_transform.origin, + historical_transform.origin, + "Node3D origin equivalence scale %s" % scale_value, + failures + ) + + +func _verify_source_boundaries(failures: Array[String]) -> void: + var loader_source := _read_text(LOADER_PATH, failures) + var resolver_source := _read_text(RESOLVER_PATH, failures) + _expect_true( + loader_source.contains("WMO_PLACEMENT_RESOLVER_SCRIPT.new()"), + "loader composes resolver", + failures + ) + _expect_equal_int( + loader_source.count("_wmo_placement_resolver.normalize_relative_path("), + 3, + "three normalization adapters", + failures + ) + _expect_equal_int( + loader_source.count("_wmo_placement_resolver.resolve_unique_key("), + 1, + "one identity adapter", + failures + ) + _expect_equal_int( + loader_source.count("_wmo_placement_resolver.resolve_world_transform("), + 3, + "three transform adapters", + failures + ) + _expect_true( + not loader_source.contains("func _normalize_wmo_rel_path("), + "loader omits normalization formula", + failures + ) + _expect_true( + not loader_source.contains("func _wmo_unique_key("), + "loader omits identity formula", + failures + ) + for forbidden_dependency in [ + "Node", + "ResourceLoader", + "RenderingServer", + "WorkerThreadPool", + "Mutex", + "FileAccess", + ]: + _expect_true( + not resolver_source.contains(forbidden_dependency), + "resolver omits %s" % forbidden_dependency, + failures + ) + + +func _verify_fresh_stateless_results(failures: Array[String]) -> void: + var resolver := RESOLVER_SCRIPT.new() + var placement := {"pos": Vector3(1.0, 2.0, 3.0), "scale": 4.0} + var first: Transform3D = resolver.resolve_world_transform(placement) + first.origin = Vector3.ZERO + var second: Transform3D = resolver.resolve_world_transform(placement) + _expect_vector_near(second.origin, Vector3(1.0, 2.0, 3.0), "stateless result", failures) + + +func _verify_bounded_timing(failures: Array[String]) -> float: + var resolver := RESOLVER_SCRIPT.new() + var placement := { + "unique_id": 11785, + "pos": Vector3(10.0, 20.0, 30.0), + "rot": Vector3(0.1, 0.2, 0.3), + "scale": 1.5, + } + var started_microseconds := Time.get_ticks_usec() + for iteration in range(20000): + resolver.normalize_relative_path("World\\WMO\\Stormwind\\Keep.WMO") + resolver.resolve_unique_key(placement, "31:49", iteration) + resolver.resolve_world_transform(placement) + var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0 + _expect_true(elapsed_milliseconds < 1000.0, "20000 placement triples bounded", failures) + return elapsed_milliseconds + + +func _read_text(path: String, failures: Array[String]) -> String: + var file := FileAccess.open(path, FileAccess.READ) + if file == null: + failures.append("cannot open %s" % path) + return "" + return file.get_as_text() + + +func _expect_basis_near( + actual_basis: Basis, + expected_basis: Basis, + label: String, + failures: Array[String] +) -> void: + if not actual_basis.is_equal_approx(expected_basis): + failures.append("%s basis mismatch" % label) + + +func _expect_vector_near( + actual_value: Vector3, + expected_value: Vector3, + label: String, + failures: Array[String] +) -> void: + if not actual_value.is_equal_approx(expected_value): + failures.append("%s expected %s, got %s" % [label, expected_value, actual_value]) + + +func _expect_equal_string( + actual_value: String, + expected_value: String, + label: String, + failures: Array[String] +) -> void: + if actual_value != expected_value: + failures.append("%s expected %s, got %s" % [label, expected_value, actual_value]) + + +func _expect_equal_int( + actual_value: int, + expected_value: int, + label: String, + failures: Array[String] +) -> void: + if actual_value != expected_value: + failures.append("%s expected %d, got %d" % [label, expected_value, actual_value]) + + +func _expect_true(actual_value: bool, label: String, failures: Array[String]) -> void: + if not actual_value: + failures.append("%s expected true" % label) diff --git a/src/tools/verify_wmo_placement_resolver.gd.uid b/src/tools/verify_wmo_placement_resolver.gd.uid new file mode 100644 index 0000000..8fa7242 --- /dev/null +++ b/src/tools/verify_wmo_placement_resolver.gd.uid @@ -0,0 +1 @@ +uid://c5xnyoqrtceex From c2e0696fe02b29e388b0aabff01ee7b9ae27ecca Mon Sep 17 00:00:00 2001 From: sindoring Date: Fri, 17 Jul 2026 00:23:18 +0400 Subject: [PATCH 2/2] docs(M03): hand off WMO placement resolver --- .../M03-RND-WMO-PLACEMENT-RESOLVER-001.md | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/coordination/claims/M03-RND-WMO-PLACEMENT-RESOLVER-001.md b/coordination/claims/M03-RND-WMO-PLACEMENT-RESOLVER-001.md index 323de98..ba689b7 100644 --- a/coordination/claims/M03-RND-WMO-PLACEMENT-RESOLVER-001.md +++ b/coordination/claims/M03-RND-WMO-PLACEMENT-RESOLVER-001.md @@ -79,14 +79,27 @@ resolution from the loader into a scene-free service. ## Status -- State: claimed -- Done: exact rules, consumers and ownership boundary published -- Next: implement resolver, migrate adapters, tests and docs +- State: handoff +- Done: resolver, seven loader adapters, regressions and documentation verified +- Next: M03 integrator acceptance and merge to master - Blocked by: + + ## Handoff -- Commit: -- Results: -- Remaining risks: -- Documentation updated: +- Commit: `05e216a` (`refactor(M03): extract WMO placement resolver`) +- Results: dedicated resolver PASS (`cases=9`, `iterations=20000`, + `elapsed_ms=28.332`), including historical Node3D property equivalence; + renderer materials, M2 planner/grouper/transform/registry/dedupe, terrain + cache/LOD/queue, facade, internal-access, manifest, shutdown, scheduler, + streaming planner/focus, terrain-query and coordinate-boundary suites PASS; + editor import parsed with only expected missing private corpus diagnostics; + documentation PASS (`module_specs=19`, `required_files=7`); coordination PASS + with 23 pre-existing expired M00/M01 warnings; checkpoint dry-run planned `7/7`. +- Remaining risks: no private asset-backed WMO placement/visual/p95/p99 evidence; + old caches without positive UID retain per-tile fallback; raw Dictionaries and + WMO registry/build/resource state remain loader-owned; scale stays unclamped. +- Documentation updated: inline public API; new + `docs/modules/wmo-placement-resolver.md`; renderer module registry, + `docs/modules/world-renderer.md` and `RENDER.md`.