diff --git a/RENDER.md b/RENDER.md index 72d812e..a806078 100644 --- a/RENDER.md +++ b/RENDER.md @@ -27,6 +27,8 @@ Paired run 2026-07-11 подтвердил крупный coordinate/placement g - `src/render/world_render_facade.gd` - стабильная граница runtime/tools для focus и metrics. - `src/scenes/streaming/streaming_world_loader.gd` - внутренний runtime streamer мира. +- `src/render/wmo/wmo_placement_resolver.gd` - value-only WMO path, identity and transform rules. +- `src/render/wmo/wmo_placement_registry.gd` - WMO placement-key to tile/global reference sets; Nodes and render jobs remain in the streamer. - `src/scenes/streaming/eastern_kingdoms_streaming.tscn` - текущая сцена для проверки Azeroth/Eastern Kingdoms. - `addons/mpq_extractor/loaders/adt_builder.gd` - сборка ADT terrain, control splat материалов и MH2O liquids. - `addons/mpq_extractor/loaders/m2_builder.gd` - сборка статического M2 mesh/material. @@ -394,6 +396,8 @@ Known limits: - `WMOBuilder` строит group meshes; - runtime registry дедуплицирует WMO по `unique_id`; - WMO на границе ADT не дублируются; +- `WmoPlacementRegistry` owns only placement reference sets; the streamer keeps + the parallel key-to-Node map and performs final-release cancellation/free; - большие WMO не должны инстанцироваться как тяжелые `.tscn` во время движения; - добавлен lightweight render-cache path для WMO groups; - `wmo_render_group_ops_per_tick` ограничивает подключение WMO groups по кадрам; diff --git a/coordination/claims/M03-RND-WMO-PLACEMENT-REGISTRY-001.md b/coordination/claims/M03-RND-WMO-PLACEMENT-REGISTRY-001.md index 8bbbed2..0cf0ec3 100644 --- a/coordination/claims/M03-RND-WMO-PLACEMENT-REGISTRY-001.md +++ b/coordination/claims/M03-RND-WMO-PLACEMENT-REGISTRY-001.md @@ -79,14 +79,24 @@ leaving every Node and renderer-resource lifecycle in the loader. ## Status -- State: claimed -- Done: lifecycle, return semantics and ownership split published -- Next: implement registry, migrate global/tile adapters, tests and docs +- State: ready for integration +- Done: registry extracted; global/tile adapters migrated; Node/resource/job + ownership retained in loader; contract, regressions, dry-run and docs passed +- Next: integrator merge and post-merge verification - Blocked by: ## Handoff -- Commit: -- Results: -- Remaining risks: -- Documentation updated: +- Commit: `c590036` +- Results: `verify_wmo_placement_registry.gd` PASS cases=10, + iterations=100, elapsed_ms=53.941; 21 adjacent WMO/M2/terrain/facade/ + scheduler/streaming/coordinate regressions PASS; documentation and + coordination gates PASS; render dry-run PASS 7/7. +- Remaining risks: no proprietary asset corpus or original-client paired WMO + capture; dry-run reports expected missing ADT/DBC/character assets; WMO + build/resource state remains loader-owned. +- Documentation updated: `docs/modules/wmo-placement-registry.md`, + `docs/modules/wmo-placement-resolver.md`, `docs/modules/world-renderer.md`, + `docs/modules/README.md`, `RENDER.md`; inline public API comments added. + + diff --git a/docs/modules/README.md b/docs/modules/README.md index 2fbedca..7d19f99 100644 --- a/docs/modules/README.md +++ b/docs/modules/README.md @@ -20,6 +20,7 @@ | 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) | +| WMO placement registry | Implemented extraction | [`wmo-placement-registry.md`](wmo-placement-registry.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-registry.md b/docs/modules/wmo-placement-registry.md new file mode 100644 index 0000000..449bb81 --- /dev/null +++ b/docs/modules/wmo-placement-registry.md @@ -0,0 +1,207 @@ +# WMO Placement Registry + +## Metadata + +| Field | Value | +|---|---| +| Status | Implemented extraction | +| Target/work package | M03 / `M03-RND-WMO-PLACEMENT-REGISTRY-001` | +| Owners | WMO placement-key to tile/global reference-set ownership | +| Last verified | Worktree `work/sindo-main-codex/m03-wmo-placement-registry`, 2026-07-17 | +| Profiles/capabilities | Existing ADT/WDT WMO placement paths; profile-independent | + +## Purpose + +Own the reference sets that keep one resolved WMO placement active across one +or more ADT tiles, or across the global WDT placement lifetime. This separates +value-only placement ownership from scene and render-resource ownership. + +## Non-goals + +- Resolve WMO paths, placement identity or world transforms. +- Own Nodes, Resources, RIDs, roots, jobs, queues, tasks or caches. +- Load, instantiate, build or free WMO content. +- Change duplicate order, budgets, retries or global-WMO behavior. +- Claim original-client WMO visual, portal, room or material parity. + +## Context and boundaries + +```mermaid +flowchart LR + Parsed[ADT/WDT placement] --> Resolver[WmoPlacementResolver] + Resolver --> Key[Placement key] + Tile[Tile/global reference key] --> Loader[StreamingWorldLoader] + Key --> Loader + Loader --> Registry[WmoPlacementRegistry] + Registry --> Decision[Existing or final-release decision] + Decision --> Loader + Loader --> NodeMap[Loader key-to-Node map] + Loader --> Jobs[Loader jobs/queues/resources] +``` + +The registry depends only on `RefCounted`, `Dictionary`, `Array` and `String`. +SceneTree, Node, Resource, RID, files, loaders, threads, locks, network, +gameplay and editor UI are forbidden dependencies. + +## Public API + +| Symbol | Kind | Purpose | Thread/lifetime | Errors | +|---|---|---|---|---| +| `add_reference(placement_key, reference_key)` | Command/query | Idempotently add a reference; return true only when creating the placement key | Renderer main thread; map session | Empty key returns false and changes nothing | +| `release_reference(placement_key, reference_key)` | Command/query | Remove an owned reference; return true only on final removal | Renderer main thread; map session | Empty, unknown or non-owner input returns false | +| `contains(placement_key)` | Query | Test whether any reference keeps a placement active | Renderer main thread; map session | Empty key returns false | +| `active_count()` | Query | Count active placement keys, not references | Renderer main thread; map session | None | +| `diagnostic_snapshot()` | Query | Return detached, placement/reference-sorted records | Renderer main thread; caller-owned result | None | +| `clear()` | Command | Remove all ownership records idempotently | Renderer main thread; reset/shutdown | None | + +## Inputs and outputs + +| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime | +|---|---|---|---|---|---| +| Input | Resolved `uid:*`, `tile:*:*` or `global` placement key | `WmoPlacementResolver` / loader global adapter | Registry | Copied String dictionary key | Until final release/clear | +| Input | ADT tile key or `__global__` reference key | Loader | Registry | Copied String set key | Until release/clear | +| Output | First-placement Boolean | Registry | Loader creation path | Value | One call | +| Output | Final-release Boolean | Registry | Loader cancellation/free path | Value | One call | +| Output | Active count and sorted diagnostic records | Registry | Renderer diagnostics/verifier | Detached values | Caller lifetime | + +## Data flow + +```mermaid +flowchart TD + Add[add placement + reference] --> Valid{Both non-empty?} + Valid -->|no| Reject[false; unchanged] + Valid -->|yes| Existing{Placement exists?} + Existing -->|no| Create[Create reference set; true] + Existing -->|yes| Retain[Idempotently add reference; false] + Release[release placement + reference] --> Owner{Known owner reference?} + Owner -->|no| Reject + Owner -->|yes| Erase[Erase reference] + Erase --> Empty{Set empty?} + Empty -->|no| Retained[false; placement remains] + Empty -->|yes| Final[Erase placement; true] +``` + +## Lifecycle and state + +The states for one placement are `Absent`, `Referenced` and back to `Absent`. +The first valid add enters `Referenced`; further tile/global adds remain there. +Only release of the final owned reference returns to `Absent`. Map reset and +shutdown call `clear`, which is safe from either state. + +```mermaid +stateDiagram-v2 + [*] --> Absent + Absent --> Referenced: first valid add / true + Referenced --> Referenced: idempotent/shared add / false + Referenced --> Referenced: non-owner/non-final release / false + Referenced --> Absent: final owned release / true + Referenced --> Absent: clear +``` + +## Main sequence + +```mermaid +sequenceDiagram + participant Loader as StreamingWorldLoader + participant Registry as WmoPlacementRegistry + participant Nodes as Loader Node map + Loader->>Registry: contains(placement key) + alt existing placement + Loader->>Registry: add_reference(key, tile) + else new placement built successfully + Loader->>Nodes: store key-to-Node + Loader->>Registry: add_reference(key, tile/global) + end + Loader->>Registry: release_reference(key, tile) + alt final reference + Registry-->>Loader: true + Loader->>Loader: cancel render build + Loader->>Nodes: queue-free and erase Node + else shared/unknown reference + Registry-->>Loader: false + end +``` + +## Ownership, threading and resources + +- The registry owns only placement-key Dictionaries and reference-key sets. +- The loader owns the parallel placement-key-to-Node map and every Node action. +- The loader also owns WMO roots, tile state, caches, requests, jobs, queues, + retries, cancellation and render-resource lifetime. +- Current calls are serialized on the renderer main thread. The registry has no + lock and must not be mutated concurrently. + +## Errors, cancellation and recovery + +| Failure | Detection | Behavior | Diagnostic | Recovery | +|---|---|---|---|---| +| Empty placement/reference key | API guard | Reject without mutation | Contract verifier | Repair caller input | +| Duplicate add | Existing set member | Idempotent retain | Sorted snapshot | No recovery required | +| Unknown/non-owner release | Missing placement/reference | Ignore without mutation | Contract verifier | Release from actual owner or reset | +| Tile build cancellation | Loader-owned condition | Loader cancels job, then releases recorded refs | Existing queue metrics | Retry when tile becomes wanted | +| Final release | Empty set after erase | Return true and erase registry record | Active count | Loader cancels/free Node exactly once | +| Map reset/shutdown | Loader lifecycle | Loader frees Nodes/jobs and clears registry | Shutdown regression | Next map begins empty | + +## Configuration and capabilities + +There is no setting, profile branch or capacity knob. WMO tile radius, cache +limits, render budgets and visibility settings remain loader-owned. + +## Persistence, cache and migration + +No data is serialized and no cache/schema/version changes. Placement and +reference keys exist only for the current renderer map session. + +## Diagnostics and observability + +`active_count` backs existing WMO instance metrics. `diagnostic_snapshot` +returns deterministic detached records for tests and future read-only +diagnostics; mutating the returned arrays cannot alter registry state. + +## Verification + +- `verify_wmo_placement_registry.gd`: invalid/first/idempotent/shared/non-owner/ + final/global/clear behavior, deterministic detached diagnostics, source + ownership boundaries and bounded 100-by-256 shared-reference cycles. +- WMO resolver, material, runtime-cache shutdown and the renderer regression + suite verify the unchanged loader adapters and surrounding lifecycle. +- Fidelity evidence is exact preservation of the repository's prior shared-ref + and final-release semantics. Asset-backed 3.3.5a comparison is still pending. + +## Extension points + +- WMO build/resource state can be extracted separately without moving Nodes + into this registry. +- A future diagnostic adapter may expose the detached snapshot through the + facade without exposing mutable loader internals. + +## Capability status + +| Capability | Status | Evidence | Gap/next step | +|---|---|---|---| +| WMO placement reference ownership | Implemented extraction | Contract/source/timing verifier | Asset-backed cross-tile corpus pending | +| WMO build/resource scheduling | Remains in loader | Existing runtime regressions | Separate state/cancellation package | +| WMO visual/portal/material fidelity | Partial | Renderer baseline/material checks | Original-client paired evidence pending | + +## Known gaps and risks + +- Correctness assumes the loader records each successful placement reference in + the tile `wmo_refs` array before unregister. +- Old caches without positive UID retain resolver-defined per-tile identity. +- No proprietary WMO corpus or original-client paired capture is in this package. + +## Source map + +| Path | Responsibility | +|---|---| +| `src/render/wmo/wmo_placement_registry.gd` | Placement-key/reference-set ownership | +| `src/render/wmo/wmo_placement_resolver.gd` | Stable path, identity and transform rules | +| `src/scenes/streaming/streaming_world_loader.gd` | Node map, instances, resources, jobs, queues and lifecycle reactions | +| `src/tools/verify_wmo_placement_registry.gd` | Contract, boundary and timing regression | + +## Related decisions and references + +- [`wmo-placement-resolver.md`](wmo-placement-resolver.md) +- [`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/wmo-placement-resolver.md b/docs/modules/wmo-placement-resolver.md index 336b688..9d2c341 100644 --- a/docs/modules/wmo-placement-resolver.md +++ b/docs/modules/wmo-placement-resolver.md @@ -19,7 +19,8 @@ live-prototype instance paths. ## Non-goals - Parse ADT/WDT or convert canonical WoW coordinates. -- Own WMO registry references, tile state, jobs, queues or retries. +- Own WMO registry references, tile state, jobs, queues or retries; reference + ownership belongs to `WmoPlacementRegistry`. - Load/validate cached scenes or lightweight render resources. - Create Nodes, Mesh/MultiMesh, materials or RIDs. - Implement portals, rooms, visibility, materials, doodads or WMO parity. @@ -34,7 +35,7 @@ flowchart LR Resolver --> Identity[Registry unique key] Resolver --> Transform[World Transform3D] CacheKey --> Cache[Loader WMO caches/requests] - Identity --> Registry[Loader WMO registry] + Identity --> Registry[WmoPlacementRegistry] Transform --> RenderRoot[Lightweight render root] Transform --> Scene[Cached scene instance] Transform --> Live[Live prototype instance] @@ -60,7 +61,7 @@ WorkerThreadPool, mutexes, files, gameplay, network and editor UI are forbidden. | 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 | `uid:*` or `tile:*:*` key | Resolver | `WmoPlacementRegistry` and loader 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. @@ -91,7 +92,7 @@ and shutdown require no resolver operation. sequenceDiagram participant Loader as StreamingWorldLoader participant Resolver as WmoPlacementResolver - participant Registry as Loader WMO registry/cache + participant Registry as WmoPlacementRegistry participant Instance as Render/cached/live instance Loader->>Resolver: normalize_relative_path(path) Resolver-->>Loader: cache key @@ -105,8 +106,9 @@ sequenceDiagram ## 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. +- `WmoPlacementRegistry` owns placement-key reference sets. The loader owns its + key-to-Node map, cache/load-request state, 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. @@ -152,8 +154,8 @@ in the facade/loader diagnostic snapshot. ## Extension points -- A later package may extract WMO registry ownership/ref release using these - stable keys. +- `WmoPlacementRegistry` consumes these stable keys; later packages may extract + WMO build/resource state without changing the resolver. - Render-group build cursor and resource-selection state require separate lifecycle/cancellation contracts. @@ -162,7 +164,8 @@ in the facade/loader diagnostic snapshot. | 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 | +| WMO placement registry | Implemented extraction | Ownership/source/timing verifier | Asset-backed cross-tile corpus pending | +| WMO 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 @@ -177,11 +180,13 @@ in the facade/loader diagnostic snapshot. | 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/render/wmo/wmo_placement_registry.gd` | Placement reference-set ownership | +| `src/scenes/streaming/streaming_world_loader.gd` | Node map/cache/build/resource ownership | | `src/tools/verify_wmo_placement_resolver.gd` | Contract, source and timing regression | ## Related decisions and references - [`world-renderer.md`](world-renderer.md) +- [`wmo-placement-registry.md`](wmo-placement-registry.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 7616f49..03e1a53 100644 --- a/docs/modules/world-renderer.md +++ b/docs/modules/world-renderer.md @@ -7,7 +7,7 @@ | 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 packages; M03 WMO placement package | | Owners | Renderer workstream / milestone integrator | -| Last verified | Worktree `work/sindo-main-codex/m03-wmo-placement-resolver`, 2026-07-17 | +| Last verified | Worktree `work/sindo-main-codex/m03-wmo-placement-registry`, 2026-07-17 | | Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete | ## Purpose @@ -56,6 +56,8 @@ flowchart LR M2Batch --> Loader Loader --> WmoPlacement[WmoPlacementResolver] WmoPlacement --> Loader + Loader --> WmoRegistry[WmoPlacementRegistry] + WmoRegistry --> Loader Native --> Parsed[Parsed tile/model data] Parsed --> Loader Loader --> Scene[SceneTree nodes] @@ -127,6 +129,7 @@ from externally reading/writing loader-private queue, task, cache and tile-state | `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 | +| `WmoPlacementRegistry.add_reference/release_reference/contains/active_count/diagnostic_snapshot/clear` | Internal WMO service | Owns placement-key to tile/global reference sets | Renderer main thread; map session | Empty/unknown/non-owner input is rejected without mutation | Публичным contract не считаются `StreamingWorldLoader` methods/properties, внутренние dictionaries, queues, job records и generated node names. Scene-owned @@ -156,6 +159,7 @@ loader configuration remains transitional composition data, not a caller API. | 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 | +| Internal WMO ownership | Resolved placement key and tile/global reference key | Loader / `WmoPlacementRegistry` | Loader create/retain/final-free decisions | Registry-owned String sets; detached diagnostics | Map session or final release | | 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 | @@ -205,7 +209,8 @@ flowchart TD M2Grouper --> M2Batch[M2BuildBatchPlanner] M2Batch --> M2 R --> WmoPlacement[WmoPlacementResolver] - WmoPlacement --> WMO + WmoPlacement --> WmoRegistry[WmoPlacementRegistry] + WmoRegistry --> WMO R --> B[RenderBudgetScheduler permits] B --> Terrain[Terrain attach/upgrade] B --> M2[M2 group/MultiMesh attach] @@ -328,7 +333,9 @@ sequenceDiagram - `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. + identity and transform values. `WmoPlacementRegistry` owns only placement-key + reference sets. The loader retains the key-to-Node map, caches, jobs, queues, + resources, cancellation and every Node lifecycle action. - 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 @@ -422,6 +429,8 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m 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. +- WMO placement registry contract: first/idempotent/shared/non-owner/final/global + lifecycle, detached sorted diagnostics, source ownership 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. @@ -455,7 +464,8 @@ 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 placement resolver | Implemented extraction | Scene-free path/identity/transform/source/timing contract | Asset-backed comparison pending | +| WMO placement registry | Implemented extraction | Scene-free ownership/lifecycle/source/timing contract | Build/resource state and asset-backed cross-tile corpus 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_registry.gd b/src/render/wmo/wmo_placement_registry.gd new file mode 100644 index 0000000..d6bbd88 --- /dev/null +++ b/src/render/wmo/wmo_placement_registry.gd @@ -0,0 +1,69 @@ +class_name WmoPlacementRegistry +extends RefCounted + +## Owns WMO placement-key to tile/global reference-key sets without Nodes. + +var _reference_keys_by_placement_key: Dictionary = {} + + +## Adds one idempotent reference and returns true only for a newly registered +## placement key. Empty placement/reference keys are rejected. +func add_reference(placement_key: String, reference_key: String) -> bool: + if placement_key.is_empty() or reference_key.is_empty(): + return false + var is_new_placement := not _reference_keys_by_placement_key.has(placement_key) + if is_new_placement: + _reference_keys_by_placement_key[placement_key] = {} + var reference_keys: Dictionary = _reference_keys_by_placement_key[placement_key] + reference_keys[reference_key] = true + return is_new_placement + + +## Releases one reference and returns true only when the final reference removes +## the placement key. Unknown and non-owner references leave state unchanged. +func release_reference(placement_key: String, reference_key: String) -> bool: + if placement_key.is_empty() or reference_key.is_empty(): + return false + if not _reference_keys_by_placement_key.has(placement_key): + return false + var reference_keys: Dictionary = _reference_keys_by_placement_key[placement_key] + if not reference_keys.has(reference_key): + return false + reference_keys.erase(reference_key) + if not reference_keys.is_empty(): + return false + _reference_keys_by_placement_key.erase(placement_key) + return true + + +## Returns whether the placement key currently has at least one reference. +func contains(placement_key: String) -> bool: + return not placement_key.is_empty() and _reference_keys_by_placement_key.has(placement_key) + + +## Removes every placement/reference record. Safe to call repeatedly. +func clear() -> void: + _reference_keys_by_placement_key.clear() + + +## Returns the number of currently referenced placement keys. +func active_count() -> int: + return _reference_keys_by_placement_key.size() + + +## Returns detached, placement/reference-key-sorted diagnostic records. +func diagnostic_snapshot() -> Array[Dictionary]: + var placement_keys: Array = _reference_keys_by_placement_key.keys() + placement_keys.sort() + var snapshot: Array[Dictionary] = [] + for placement_key_variant in placement_keys: + var placement_key := String(placement_key_variant) + var reference_keys: Array = ( + _reference_keys_by_placement_key[placement_key] as Dictionary + ).keys() + reference_keys.sort() + snapshot.append({ + "placement_key": placement_key, + "reference_keys": reference_keys.duplicate(), + }) + return snapshot diff --git a/src/render/wmo/wmo_placement_registry.gd.uid b/src/render/wmo/wmo_placement_registry.gd.uid new file mode 100644 index 0000000..240775d --- /dev/null +++ b/src/render/wmo/wmo_placement_registry.gd.uid @@ -0,0 +1 @@ +uid://cfbo5fw33vw4f diff --git a/src/scenes/streaming/streaming_world_loader.gd b/src/scenes/streaming/streaming_world_loader.gd index 95d4b19..10c842d 100644 --- a/src/scenes/streaming/streaming_world_loader.gd +++ b/src/scenes/streaming/streaming_world_loader.gd @@ -12,6 +12,9 @@ const WMO_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/wmo_buil const WMO_PLACEMENT_RESOLVER_SCRIPT := preload( "res://src/render/wmo/wmo_placement_resolver.gd" ) +const WMO_PLACEMENT_REGISTRY_SCRIPT := preload( + "res://src/render/wmo/wmo_placement_registry.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") @@ -286,7 +289,8 @@ var _m2_animated_scene_cache: Dictionary = {} var _m2_static_animation_cache: Dictionary = {} var _m2_missing_cache: Dictionary = {} var _world_wmo_root: Node3D -var _wmo_registry: Dictionary = {} +var _wmo_placement_registry := WMO_PLACEMENT_REGISTRY_SCRIPT.new() +var _wmo_instance_nodes_by_unique_key: Dictionary = {} func _ready() -> void: @@ -1031,7 +1035,7 @@ func render_baseline_snapshot() -> Dictionary: }, "cache_entries": _tile_mesh_cache.size(), "m2_active_unique_ids": _m2_unique_placement_registry.active_count(), - "wmo_instances": _wmo_registry.size(), + "wmo_instances": _wmo_placement_registry.active_count(), } @@ -1313,7 +1317,7 @@ func _tick_runtime_stats(delta: float) -> void: m2_tiles, m2_multimeshes, m2_instances, - _wmo_registry.size(), + _wmo_placement_registry.active_count(), ]) @@ -3363,7 +3367,7 @@ func _wdt_has_global_wmo() -> bool: func _place_global_wmo() -> void: if not _wdt_has_global_wmo(): return - if _wmo_registry.has("global"): + if _wmo_placement_registry.contains("global"): return _ensure_world_wmo_root() @@ -3387,10 +3391,7 @@ func _place_global_wmo() -> void: _world_wmo_root.add_child(instance) _set_editor_owner_recursive(instance) - _wmo_registry["global"] = { - "node": instance, - "refs": {"__global__": true}, - } + _register_wmo_instance_reference("global", "__global__", instance) func _sync_detail_assets(state: Dictionary, focus_pos: Vector3) -> Dictionary: @@ -3566,10 +3567,8 @@ func _process_wmo_build_jobs() -> void: tile_key, index ) - if _wmo_registry.has(unique_key): - var entry: Dictionary = _wmo_registry[unique_key] - var entry_refs: Dictionary = entry["refs"] - entry_refs[tile_key] = true + if _wmo_placement_registry.contains(unique_key): + _wmo_placement_registry.add_reference(unique_key, tile_key) refs.append(unique_key) else: var rel_path: String = str(wmo_names[name_id]).replace("\\", "/") @@ -3579,10 +3578,7 @@ func _process_wmo_build_jobs() -> void: if instance != null: _world_wmo_root.add_child(instance) _set_editor_owner_recursive(instance) - _wmo_registry[unique_key] = { - "node": instance, - "refs": {tile_key: true}, - } + _register_wmo_instance_reference(unique_key, tile_key, instance) refs.append(unique_key) _start_wmo_render_build(unique_key, instance, render_resource) else: @@ -3602,10 +3598,11 @@ func _process_wmo_build_jobs() -> void: _prepare_runtime_wmo_instance(live_instance) _world_wmo_root.add_child(live_instance) _set_editor_owner_recursive(live_instance) - _wmo_registry[unique_key] = { - "node": live_instance, - "refs": {tile_key: true}, - } + _register_wmo_instance_reference( + unique_key, + tile_key, + live_instance + ) refs.append(unique_key) else: var scene_instance := _instantiate_wmo_scene(rel_path, scene, placement) @@ -3613,10 +3610,11 @@ func _process_wmo_build_jobs() -> void: _prepare_runtime_wmo_instance(scene_instance) _world_wmo_root.add_child(scene_instance) _set_editor_owner_recursive(scene_instance) - _wmo_registry[unique_key] = { - "node": scene_instance, - "refs": {tile_key: true}, - } + _register_wmo_instance_reference( + unique_key, + tile_key, + scene_instance + ) refs.append(unique_key) if advance_job: job["refs"] = refs @@ -3994,6 +3992,17 @@ func _cancel_wmo_build_job(tile_key: String) -> void: _wmo_build_queue.erase(tile_key) +func _register_wmo_instance_reference( + placement_key: String, + reference_key: String, + instance: Node3D +) -> void: + if placement_key.is_empty() or reference_key.is_empty() or instance == null: + return + _wmo_instance_nodes_by_unique_key[placement_key] = instance + _wmo_placement_registry.add_reference(placement_key, reference_key) + + func _unregister_tile_wmos(state: Dictionary) -> void: var refs: Array = state.get("wmo_refs", []) var tile_key: String = String(state.get("key", "")) @@ -4003,18 +4012,16 @@ func _unregister_tile_wmos(state: Dictionary) -> void: state["wmo_building"] = false return - for unique_key in refs: - if not _wmo_registry.has(unique_key): + for unique_key_variant in refs: + var unique_key := String(unique_key_variant) + if not _wmo_placement_registry.contains(unique_key): continue - var entry: Dictionary = _wmo_registry[unique_key] - var entry_refs: Dictionary = entry["refs"] - entry_refs.erase(tile_key) - if entry_refs.is_empty(): + if _wmo_placement_registry.release_reference(unique_key, tile_key): _cancel_wmo_render_build_job(String(unique_key)) - var node: Node = entry.get("node", null) - if node: + var node: Node = _wmo_instance_nodes_by_unique_key.get(unique_key, null) + if node != null and is_instance_valid(node): node.queue_free() - _wmo_registry.erase(unique_key) + _wmo_instance_nodes_by_unique_key.erase(unique_key) state["wmo_refs"] = [] state["wmo_building"] = false @@ -4023,14 +4030,14 @@ func _unregister_tile_wmos(state: Dictionary) -> void: func _clear_wmo_registry() -> void: _wmo_render_build_jobs.clear() _wmo_render_build_queue.clear() - for entry_variant in _wmo_registry.values(): - if not (entry_variant is Dictionary): + for node_variant in _wmo_instance_nodes_by_unique_key.values(): + if not (node_variant is Node): continue - var entry: Dictionary = entry_variant - var node: Node = entry.get("node", null) - if node and is_instance_valid(node): + var node := node_variant as Node + if is_instance_valid(node): _queue_free_streamed_node(node) - _wmo_registry.clear() + _wmo_instance_nodes_by_unique_key.clear() + _wmo_placement_registry.clear() if _world_wmo_root and is_instance_valid(_world_wmo_root): _queue_free_streamed_node(_world_wmo_root) _world_wmo_root = null diff --git a/src/tools/verify_wmo_placement_registry.gd b/src/tools/verify_wmo_placement_registry.gd new file mode 100644 index 0000000..8b5fc46 --- /dev/null +++ b/src/tools/verify_wmo_placement_registry.gd @@ -0,0 +1,203 @@ +extends SceneTree + +## Asset-free ownership, lifecycle, dependency and timing regression for WMOs. + +const REGISTRY_SCRIPT := preload("res://src/render/wmo/wmo_placement_registry.gd") +const REGISTRY_PATH := "res://src/render/wmo/wmo_placement_registry.gd" +const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd" + + +func _initialize() -> void: + var failures: Array[String] = [] + _verify_invalid_and_first_reference(failures) + _verify_idempotent_and_shared_references(failures) + _verify_owner_checked_final_release(failures) + _verify_global_reference_and_clear(failures) + _verify_detached_sorted_diagnostics(failures) + _verify_ownership_boundaries(failures) + var elapsed_milliseconds := _verify_bounded_timing(failures) + if not failures.is_empty(): + for failure in failures: + push_error("WMO_PLACEMENT_REGISTRY: %s" % failure) + quit(1) + return + print( + "WMO_PLACEMENT_REGISTRY PASS cases=10 iterations=100 elapsed_ms=%.3f" + % elapsed_milliseconds + ) + quit(0) + + +func _verify_invalid_and_first_reference(failures: Array[String]) -> void: + var registry: RefCounted = REGISTRY_SCRIPT.new() + _expect_false(registry.call("add_reference", "", "tile-a"), "empty placement rejected", failures) + _expect_false(registry.call("add_reference", "uid:7", ""), "empty reference rejected", failures) + _expect_true(registry.call("add_reference", "uid:7", "tile-a"), "first reference registers", failures) + _expect_true(registry.call("contains", "uid:7"), "registered placement contained", failures) + _expect_equal(int(registry.call("active_count")), 1, "first active count", failures) + + +func _verify_idempotent_and_shared_references(failures: Array[String]) -> void: + var registry: RefCounted = REGISTRY_SCRIPT.new() + registry.call("add_reference", "uid:11", "tile-a") + _expect_false( + registry.call("add_reference", "uid:11", "tile-a"), + "same reference idempotent", + failures + ) + _expect_false( + registry.call("add_reference", "uid:11", "tile-b"), + "shared reference retains placement", + failures + ) + _expect_equal(int(registry.call("active_count")), 1, "shared active count", failures) + _expect_false( + registry.call("release_reference", "uid:11", "tile-a"), + "non-final release retains placement", + failures + ) + _expect_true(registry.call("contains", "uid:11"), "shared placement remains", failures) + + +func _verify_owner_checked_final_release(failures: Array[String]) -> void: + var registry: RefCounted = REGISTRY_SCRIPT.new() + registry.call("add_reference", "uid:19", "tile-owner") + _expect_false( + registry.call("release_reference", "uid:19", "tile-other"), + "non-owner release ignored", + failures + ) + _expect_false( + registry.call("release_reference", "uid:404", "tile-owner"), + "unknown placement release ignored", + failures + ) + _expect_true( + registry.call("release_reference", "uid:19", "tile-owner"), + "final owner release removes placement", + failures + ) + _expect_false(registry.call("contains", "uid:19"), "released placement absent", failures) + + +func _verify_global_reference_and_clear(failures: Array[String]) -> void: + var registry: RefCounted = REGISTRY_SCRIPT.new() + _expect_true( + registry.call("add_reference", "global", "__global__"), + "global reference registers", + failures + ) + _expect_true( + registry.call("release_reference", "global", "__global__"), + "global final release removes", + failures + ) + registry.call("add_reference", "uid:3", "tile-a") + registry.call("clear") + registry.call("clear") + _expect_equal(int(registry.call("active_count")), 0, "clear idempotent", failures) + + +func _verify_detached_sorted_diagnostics(failures: Array[String]) -> void: + var registry: RefCounted = REGISTRY_SCRIPT.new() + registry.call("add_reference", "uid:20", "tile-b") + registry.call("add_reference", "uid:10", "tile-c") + registry.call("add_reference", "uid:10", "tile-a") + var snapshot: Array = registry.call("diagnostic_snapshot") + _expect_equal(snapshot.size(), 2, "diagnostic record count", failures) + _expect_string_equal(String(snapshot[0]["placement_key"]), "uid:10", "placement sort", failures) + _expect_string_array(snapshot[0]["reference_keys"], ["tile-a", "tile-c"], "reference sort", failures) + (snapshot[0]["reference_keys"] as Array).append("mutated") + var fresh_snapshot: Array = registry.call("diagnostic_snapshot") + _expect_string_array( + fresh_snapshot[0]["reference_keys"], + ["tile-a", "tile-c"], + "diagnostics detached", + failures + ) + + +func _verify_ownership_boundaries(failures: Array[String]) -> void: + var loader_source := _read_text(LOADER_PATH, failures) + var registry_source := _read_text(REGISTRY_PATH, failures) + _expect_true( + loader_source.contains("WMO_PLACEMENT_REGISTRY_SCRIPT.new()"), + "loader composes registry", + failures + ) + _expect_true(loader_source.contains("_wmo_placement_registry.add_reference("), "loader delegates add", failures) + _expect_true(loader_source.contains("_wmo_placement_registry.release_reference("), "loader delegates release", failures) + _expect_true(loader_source.contains("_wmo_placement_registry.clear()"), "loader delegates clear", failures) + _expect_true( + loader_source.contains("_wmo_instance_nodes_by_unique_key"), + "loader retains node map", + failures + ) + _expect_false(loader_source.contains("var _wmo_registry"), "legacy mixed registry removed", failures) + for forbidden_token in ["extends Node", "Node3D", "Resource", "RID", "Thread", "Mutex", "Semaphore"]: + _expect_false( + registry_source.contains(forbidden_token), + "registry omits %s ownership" % forbidden_token, + failures + ) + + +func _verify_bounded_timing(failures: Array[String]) -> float: + var registry: RefCounted = REGISTRY_SCRIPT.new() + var started_microseconds := Time.get_ticks_usec() + for iteration in range(100): + for placement_index in range(256): + var placement_key := "uid:%d" % placement_index + registry.call("add_reference", placement_key, "tile-a") + registry.call("add_reference", placement_key, "tile-b") + for placement_index in range(256): + var placement_key := "uid:%d" % placement_index + registry.call("release_reference", placement_key, "tile-a") + registry.call("release_reference", placement_key, "tile-b") + _expect_equal(int(registry.call("active_count")), 0, "timing cycle %d clears" % iteration, failures) + var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0 + _expect_true(elapsed_milliseconds < 1000.0, "100 shared-reference cycles remain 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_equal(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_string_equal( + 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_string_array( + actual_values: Array, + expected_values: Array, + label: String, + failures: Array[String] +) -> void: + if actual_values != expected_values: + failures.append("%s expected %s, got %s" % [label, expected_values, actual_values]) + + +func _expect_true(actual_value: bool, label: String, failures: Array[String]) -> void: + if not actual_value: + failures.append("%s expected true" % label) + + +func _expect_false(actual_value: bool, label: String, failures: Array[String]) -> void: + if actual_value: + failures.append("%s expected false" % label) diff --git a/src/tools/verify_wmo_placement_registry.gd.uid b/src/tools/verify_wmo_placement_registry.gd.uid new file mode 100644 index 0000000..1a4e13a --- /dev/null +++ b/src/tools/verify_wmo_placement_registry.gd.uid @@ -0,0 +1 @@ +uid://dprfwayji363v