diff --git a/RENDER.md b/RENDER.md index 722ac41..a3ff8c6 100644 --- a/RENDER.md +++ b/RENDER.md @@ -977,6 +977,19 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' - Cache formats, quality profiles and visual rules are unchanged. Asset-backed p95/p99 and visual acceptance remain required. +## 2026-07-16 M2 Unique Placement Registry Extraction + +- `M2UniquePlacementRegistry` now owns positive ADT `MDDF.uniqueId` reservations + across streamed tiles using the existing `uid:` keys. +- Missing/zero/negative IDs remain untracked; duplicates inside one reservation + call are suppressed and another tile records one skipped key until owner release. +- Owner release still lets the loader find eligible waiting tiles and enqueue the + existing detail retry path. First ownership remains tile-processing-order based. +- `StreamingWorldLoader` still owns tile state, retry queues, grouping/build tasks, + M2 caches, MultiMesh/Node/Mesh/material/RID creation and all operation budgets. +- M2/ADT cache formats, transforms, profiles and visible rules are unchanged. + Asset-backed cross-boundary p95/p99 and visual acceptance remain required. + ## 2026-07-16 World Environment Snapshot Facade - `WorldEnvironmentSnapshot` carries one immutable finite time-of-day value, diff --git a/coordination/claims/M03-RND-M2-UNIQUE-REGISTRY-001.md b/coordination/claims/M03-RND-M2-UNIQUE-REGISTRY-001.md index f4b4637..94a8ad0 100644 --- a/coordination/claims/M03-RND-M2-UNIQUE-REGISTRY-001.md +++ b/coordination/claims/M03-RND-M2-UNIQUE-REGISTRY-001.md @@ -30,6 +30,7 @@ streamer into a scene-free `M2UniquePlacementRegistry`. `src/tools/verify_m2_unique_placement_registry.gd`, `docs/modules/m2-unique-placement-registry.md`, this claim - Shared/hotspots: `src/scenes/streaming/streaming_world_loader.gd`, + `src/tools/verify_m2_unique_dedupe.gd`, `docs/modules/world-renderer.md`, `docs/modules/README.md`, `RENDER.md`, `targets/03-renderer-facade.md` - Generated/ignored: `.godot`, native DLL, generated ADT resources, caches and @@ -77,14 +78,23 @@ streamer into a scene-free `M2UniquePlacementRegistry`. ## Status -- State: claimed -- Done: scope, key semantics and ownership published -- Next: implement registry, migrate loader adapters, tests and docs +- State: handoff +- Done: stateful registry, loader adapters, migrated smoke, regressions and docs +- Next: milestone integrator merge and asset-backed boundary traversal later in M03 - Blocked by: ## Handoff -- Commit: -- Results: -- Remaining risks: -- Documentation updated: +- Commit: `acb3a80` (`refactor(M03): extract M2 unique placement registry`) +- Results: dedicated contract PASS (`cases=10`, `iterations=100`, `35.688 ms`); + historical `uid:11785` smoke PASS; terrain/facade/internal-access/manifest/ + shutdown/scheduler/streaming/focus/terrain-query/coordinate regressions PASS; + internal inventory remains `42`; editor parse and documentation/coordination/ + diff gates PASS; checkpoint dry-run planned `7/7` +- Remaining risks: private Azeroth corpus was absent, so the asset-backed ADT + placement probe could not open the map and is not counted as passed evidence; + first ownership remains processing-order based; M2 grouping/build/tasks/caches/ + finalization and asset-backed p95/p99 remain pending +- Documentation updated: `m2-unique-placement-registry.md`, module registry, + `world-renderer.md`, `RENDER.md`, inline API documentation and data-flow, + lifecycle and sequence diagrams diff --git a/docs/modules/README.md b/docs/modules/README.md index 8ca2f83..a6e5903 100644 --- a/docs/modules/README.md +++ b/docs/modules/README.md @@ -15,6 +15,7 @@ | Terrain quality Mesh cache | Implemented extraction | [`terrain-quality-mesh-cache.md`](terrain-quality-mesh-cache.md) | | Terrain chunk LOD planner | Implemented extraction | [`terrain-chunk-lod-planner.md`](terrain-chunk-lod-planner.md) | | Terrain chunk geometry queue planner | Implemented extraction | [`terrain-chunk-geometry-queue-planner.md`](terrain-chunk-geometry-queue-planner.md) | +| M2 unique placement registry | Implemented extraction | [`m2-unique-placement-registry.md`](m2-unique-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/m2-unique-placement-registry.md b/docs/modules/m2-unique-placement-registry.md new file mode 100644 index 0000000..f6fd9d4 --- /dev/null +++ b/docs/modules/m2-unique-placement-registry.md @@ -0,0 +1,220 @@ +# M2 Unique Placement Registry + +## Metadata + +| Field | Value | +|---|---| +| Status | Implemented extraction | +| Target/work package | M03 / `M03-RND-M2-UNIQUE-REGISTRY-001` | +| Owners | Cross-tile positive ADT MDDF unique-ID reservations | +| Last verified | Worktree `work/sindo-main-codex/m03-m2-unique-registry`, 2026-07-16 | +| Profiles/capabilities | Existing static M2 placement path; session-memory only | + +## Purpose + +Ensure a positive ADT `MDDF.uniqueId` has at most one owning streamed tile while +allowing unkeyed placements through and returning skipped keys for loader-driven +candidate retry after the owner unloads. + +## Non-goals + +- Own tile state, retry queues, worker/group/build jobs or detail radius rules. +- Group placements or create MultiMesh, Mesh, Node, material or RID resources. +- Validate M2 paths, transforms, animation, materials or build-12340 fidelity. +- Persist reservations or define a generic identity/ownership framework. +- Change cache formats, quality profiles or placement visibility. + +## Context and boundaries + +```mermaid +flowchart LR + ADT[ADT M2 placement dictionaries] --> Loader[StreamingWorldLoader adapter] + Loader --> Registry[M2UniquePlacementRegistry] + Registry --> Accepted[Filtered placements + owned keys] + Registry --> Skipped[Skipped keys] + Accepted --> Group[Existing worker grouping/build path] + Skipped --> TileState[Loader-owned tile retry state] + Unload[Owner tile unload] --> Loader + Loader -->|release keys| Registry + Loader -->|notify released candidates| Retry[Existing detail sync queue] +``` + +Allowed dependencies are scalar/container values from the ADT adapter. Node, +SceneTree, RenderingServer, ResourceLoader, WorkerThreadPool, gameplay, network, +editor UI, files and persistent caches are forbidden. + +## Public API + +| Symbol | Kind | Purpose | Thread/lifetime | Errors | +|---|---|---|---|---| +| `reserve(tile_key, m2_placements)` | Command/query | Reserve available positive IDs and return fresh filtered/owned/skipped arrays | Renderer owner thread/session | Invalid non-Dictionary entries are ignored; unkeyed entries pass through | +| `release(tile_key, unique_keys)` | Command | Release only keys owned by the supplied tile | Renderer owner thread/session | Unknown/non-owner/empty keys are ignored | +| `clear()` | Lifecycle command | Drop every reservation on world reset/shutdown | Renderer owner thread | Idempotent | +| `active_count()` | Query | Return current reservation count | Renderer owner thread | No failure state | +| `diagnostic_snapshot()` | Read model | Return sorted detached key/tile ownership records | Caller-owned result | Exposes no mutable registry reference | + +## Inputs and outputs + +| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime | +|---|---|---|---|---|---| +| Input | Tile key | Streamer tile state | Registry reservation owner | Copied string | Reservation lifetime | +| Input | Raw M2 placement dictionaries | ADT parser result | Registry | Loader-owned; read only | One reserve call | +| Input | Claimed key array | Tile state | Registry release | Loader-owned; read only | One release call | +| Output | Filtered placement array | Registry | M2 grouping task adapter | Fresh shallow array; placement dictionaries remain source values | One request | +| Output | Owned `uid:` keys | Registry | Loader tile state | Fresh strings/array | Until release | +| Output | Skipped keys | Registry | Loader candidate retry state | Fresh strings/array | Until owner release/retry | +| Output | Released keys | Registry | Loader candidate notifier | Fresh array in input order | One release | +| Output | Detached diagnostics/count | Registry | Metrics/tests | Caller-owned scalars/dictionaries | One query | + +Only `unique_id > 0` enters the registry. Missing, zero and negative values have +an empty key and remain untracked, matching the prior loader behavior. + +## Data flow + +```mermaid +flowchart TD + Reserve[reserve tile + placements] --> Placement[Iterate placement order] + Placement --> Dict{Dictionary?} + Dict -->|no| Placement + Dict -->|yes| Positive{unique_id > 0?} + Positive -->|no| Pass[Append untracked placement] + Positive -->|yes| Owned{Key already owned?} + Owned -->|no| Claim[Set owner and append placement/key] + Owned -->|same tile + already in this call| Duplicate[Suppress duplicate] + Owned -->|same tile + first in this call| PassOwner[Append placement/key] + Owned -->|other tile| Skip[Append skipped key once] + Pass --> Placement + Claim --> Placement + Duplicate --> Placement + PassOwner --> Placement + Skip --> Placement + Release[release tile + keys] --> Owner{Tile is current owner?} + Owner -->|yes| Erase[Erase and return key] + Owner -->|no| Ignore[Leave reservation unchanged] +``` + +## Lifecycle/state + +```mermaid +stateDiagram-v2 + [*] --> Empty + Empty --> Reserved: reserve positive unowned ID + Reserved --> Reserved: same-owner reserve / competing skip / non-owner release + Reserved --> Empty: owner releases final ID + Reserved --> Empty: clear world/reset + Empty --> Empty: unkeyed reserve / unknown release / clear + Empty --> [*] + Reserved --> [*]: owner releases registry after clear +``` + +## Main sequence + +```mermaid +sequenceDiagram + participant TileA as First streamed tile + participant Loader as StreamingWorldLoader + participant Registry as M2UniquePlacementRegistry + participant TileB as Cross-border candidate tile + TileA->>Loader: placements include positive unique ID + Loader->>Registry: reserve(tile A, placements) + Registry-->>Loader: accepted placement + owned key + TileB->>Loader: same placement ID + Loader->>Registry: reserve(tile B, placements) + Registry-->>Loader: skipped key + Loader->>Loader: retain skipped key in tile B state + TileA->>Loader: unload/remove details + Loader->>Registry: release(tile A, owned keys) + Registry-->>Loader: released key + Loader->>Loader: enqueue eligible tile B detail retry +``` + +## Ownership, threading and resources + +- The registry owns one private key-to-owner-string dictionary. +- The loader owns tile-held owned/skipped arrays, retry discovery, task/build + lifecycle and every MultiMesh/Node/Mesh/material/RID side effect. +- Calls currently occur on the renderer main thread. The registry has no lock and + must not be mutated concurrently. +- Returned arrays and diagnostic dictionaries are fresh; source placement + dictionaries are passed through shallowly and must remain read-only. + +## Errors, cancellation and recovery + +| Failure | Detection | Behavior | Diagnostic | Recovery | +|---|---|---|---|---| +| Invalid placement variant | Type guard | Ignore entry | Contract test | Repair parser/adapter input | +| Missing/non-positive ID | Key rule | Pass placement without ownership | Smoke/contract test | Expected legacy behavior | +| Cross-tile duplicate | Owner lookup | Skip placement and return key once | Tile skipped-key state | Owner release triggers eligible candidate retry | +| Non-owner release | Owner comparison | Ignore; preserve owner | Contract test | Release from recorded tile | +| Reset/shutdown | Loader lifecycle | Clear all reservations | Shutdown regression/count | New world reserves afresh | + +There is no asynchronous work or cancellation inside the registry. Loader-owned +tasks are cancelled independently before/reset around registry clearing. + +## Configuration and capabilities + +The registry introduces no settings. It applies to all positive IDs in the +existing static M2 placement path. Detail radius, M2 enablement, build budgets +and animation allowlists remain loader/profile configuration. + +## Persistence, cache and migration + +Reservations are runtime-only and are not serialized. M2 `.tscn/.glb`, animation, +mesh/material refresh and ADT cache versions remain unchanged. No rebuild or +migration is required. + +## Diagnostics and observability + +- `active_count()` continues to feed `m2_active_unique_ids` baseline metrics. +- Detached diagnostics sort by unique key for deterministic inspection and expose + only strings/counts. +- Contract timing covers repeated 256-placement reserve/release cycles. Asset- + backed streaming p95/p99 remains milestone acceptance work. + +## Verification + +- `verify_m2_unique_placement_registry.gd`: invalid/unkeyed handling, within-call + duplicate suppression, same-owner repeat, cross-tile skip, owner-checked release, + retry, idempotent clear, detached/sorted diagnostics, source boundary and timing. +- `verify_m2_unique_dedupe.gd`: existing `uid:11785` cross-tile smoke now targets + the extracted public contract rather than loader-private methods. +- Facade, terrain, internal-access, manifest, shutdown, scheduler, streaming and + coordinate regressions remain required. +- Fidelity evidence is dedupe behavior preservation only. No new build-12340 + visual parity claim is made. + +## Extension points + +- Expose detached ownership diagnostics through facade metrics only if needed. +- Extract grouping/build orchestration as separate services with explicit budgets. + +## Capability status + +| Capability | Status | Evidence | Gap/next step | +|---|---|---|---| +| Cross-tile positive-ID ownership | Implemented extraction | Dedicated contract plus legacy smoke | Asset-backed boundary traversal timing pending | +| Candidate retry and M2 build | Remains in loader | Existing release adapter/source boundary | Broader M2 service extraction required | + +## Known gaps and risks + +- Placement dictionaries are shallow pass-through values at the ADT boundary. +- Same-owner reservations across separate calls intentionally pass again; the + caller's existing root/task guards prevent duplicate build requests. +- First owner follows tile processing order, matching prior behavior rather than + a canonical geometric owner rule. +- Group/build/load/animation caches and render finalization remain monolithic. + +## Source map + +| Path | Responsibility | +|---|---| +| `src/render/m2/m2_unique_placement_registry.gd` | Positive-ID keying, ownership, release and diagnostics | +| `src/scenes/streaming/streaming_world_loader.gd` | Reservation adapters, tile state, candidate retry and M2 rendering | +| `src/tools/verify_m2_unique_placement_registry.gd` | Contract, lifecycle, source and timing regression | +| `src/tools/verify_m2_unique_dedupe.gd` | Historical cross-tile smoke fixture | + +## 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 975c27d..8f37cf5 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 cache/LOD/geometry-queue 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-RND-M2-UNIQUE-REGISTRY-001` | | Owners | Renderer workstream / milestone integrator | -| Last verified | Worktree `work/sindo-main-codex/m03-terrain-chunk-queue`, 2026-07-16 | +| Last verified | Worktree `work/sindo-main-codex/m03-m2-unique-registry`, 2026-07-16 | | Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete | ## Purpose @@ -46,6 +46,8 @@ flowchart LR ChunkLod --> Loader Loader --> ChunkQueue[TerrainChunkGeometryQueuePlanner] ChunkQueue --> Loader + Loader --> M2Registry[M2UniquePlacementRegistry] + M2Registry --> Loader Native --> Parsed[Parsed tile/model data] Parsed --> Loader Loader --> Scene[SceneTree nodes] @@ -112,6 +114,7 @@ from externally reading/writing loader-private queue, task, cache and tile-state | `TerrainQualityMeshCache.store/restore/clear` | Internal terrain service | Owns admitted full-quality revisit Mesh references and LRU | Renderer thread/map session | Rejected/missed entry leaves loader fallback unchanged | | `TerrainChunkLodPlanner.plan` | Internal pure terrain query | Maps populated parsed chunk indices to desired LOD 0/1/2 | Synchronous per target refresh | Invalid/disabled input returns empty for existing tile-LOD fallback | | `TerrainChunkGeometryQueuePlanner.plan` | Internal pure terrain query | Produces chunk create/remove requests from current and desired state | Synchronous per queue rebuild | Invalid focus returns empty arrays | +| `M2UniquePlacementRegistry.reserve/release/clear` | Internal M2 service | Owns positive cross-tile ADT placement-ID reservations | Renderer thread/map session | Invalid/unkeyed/non-owner inputs preserve documented fallback | Публичным contract не считаются `StreamingWorldLoader` methods/properties, внутренние dictionaries, queues, job records и generated node names. Scene-owned @@ -136,6 +139,7 @@ loader configuration remains transitional composition data, not a caller API. | Internal cache | Full-quality terrain Mesh/source | Loader terrain upgrade / `TerrainQualityMeshCache` | Revisited tile state | Cache-retained Mesh reference | Map session until eviction/reset | | Internal plan | Parsed chunks, typed focus and immutable chunk-LOD policy | Loader / `TerrainChunkLodPlanner` | Loader desired state | Fresh dictionary; no retained resources | One target refresh | | Internal plan | Current/desired chunk state and typed focus | Loader / `TerrainChunkGeometryQueuePlanner` | Loader chunk queues | Fresh request arrays; no retained resources | One queue rebuild | +| Internal registry | Tile key and M2 placement unique IDs | Loader / `M2UniquePlacementRegistry` | Filtered grouping input and tile retry state | Registry-owned strings; fresh result arrays | Map session | | 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 | @@ -179,6 +183,8 @@ flowchart TD ChunkLod --> DesiredChunkLod[Desired chunk LOD state] DesiredChunkLod --> ChunkQueue[TerrainChunkGeometryQueuePlanner] ChunkQueue --> B + R --> M2Registry[M2UniquePlacementRegistry reserve] + M2Registry --> M2 R --> B[RenderBudgetScheduler permits] B --> Terrain[Terrain attach/upgrade] B --> M2[M2 group/MultiMesh attach] @@ -290,6 +296,9 @@ sequenceDiagram - `TerrainChunkGeometryQueuePlanner` is stateless and owns only call-local create/remove request calculation and sorting. The loader adopts the fresh arrays and retains removal-first drains, budgets and render side effects. +- `M2UniquePlacementRegistry` owns only positive unique-key to tile-key strings. + The loader retains owned/skipped tile arrays, candidate retry, grouping/build + tasks, caches and all MultiMesh/Node/Mesh/material/RID side effects. - 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 @@ -372,6 +381,9 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m - Terrain chunk geometry queue contract: current/desired comparison, tile-LOD takeover, sparse origin fallback, stable removals, nearest creates, loader drain boundary and bounded multi-tile timing without a world scene. +- M2 unique placement contract: unkeyed pass-through, within-call/cross-tile + dedupe, owner release/retry, clear, detached diagnostics, loader boundary and + bounded reserve/release timing without a world scene. - 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. @@ -401,6 +413,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m |---|---|---|---| | ADT streaming/terrain | Partial | M00 checkpoints and current scenes | Fidelity/performance gaps remain | | Static M2 placement | Partial | MultiMesh/dedupe probes | Full materials/animation/effects | +| M2 unique placement registry | Implemented extraction | Scene-free ownership/lifecycle/timing contract and historical `uid:11785` smoke | Group/build/tasks/finalization and asset-backed p95/p99 remain 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/m2/m2_unique_placement_registry.gd b/src/render/m2/m2_unique_placement_registry.gd new file mode 100644 index 0000000..9b81d47 --- /dev/null +++ b/src/render/m2/m2_unique_placement_registry.gd @@ -0,0 +1,88 @@ +class_name M2UniquePlacementRegistry +extends RefCounted + +## Session-memory ownership registry for positive ADT MDDF unique IDs. + +var _owner_tile_by_unique_key: Dictionary = {} + + +## Reserves unowned positive unique IDs for one tile and returns fresh arrays. +## Unkeyed placements pass through without entering registry ownership. +func reserve(tile_key: String, m2_placements: Array) -> Dictionary: + var filtered_placements: Array = [] + var unique_keys: Array = [] + var skipped_unique_keys: Array = [] + for placement_variant in m2_placements: + if not (placement_variant is Dictionary): + continue + var placement: Dictionary = placement_variant + var unique_key := _unique_key_for_placement(placement) + if unique_key.is_empty(): + filtered_placements.append(placement) + continue + if _owner_tile_by_unique_key.has(unique_key): + var owner_tile := String(_owner_tile_by_unique_key[unique_key]) + 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 + _owner_tile_by_unique_key[unique_key] = 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, + } + + +## Releases only keys currently owned by the supplied tile and returns them in +## input order so the loader can notify waiting candidate tiles. +func release(tile_key: String, unique_keys: Array) -> Array: + var released_unique_keys: Array = [] + for unique_key_variant in unique_keys: + var unique_key := String(unique_key_variant) + if unique_key.is_empty() or not _owner_tile_by_unique_key.has(unique_key): + continue + if String(_owner_tile_by_unique_key[unique_key]) != tile_key: + continue + _owner_tile_by_unique_key.erase(unique_key) + released_unique_keys.append(unique_key) + return released_unique_keys + + +## Removes all reservations during world reset or renderer shutdown. +func clear() -> void: + _owner_tile_by_unique_key.clear() + + +## Returns the number of currently owned positive unique IDs. +func active_count() -> int: + return _owner_tile_by_unique_key.size() + + +## Returns detached, key-sorted scalar diagnostics without registry references. +func diagnostic_snapshot() -> Dictionary: + var sorted_unique_keys: Array = _owner_tile_by_unique_key.keys() + sorted_unique_keys.sort() + var owners: Array = [] + for unique_key_variant in sorted_unique_keys: + var unique_key := String(unique_key_variant) + owners.append({ + "unique_key": unique_key, + "tile_key": String(_owner_tile_by_unique_key[unique_key]), + }) + return { + "active_count": owners.size(), + "owners": owners, + } + + +func _unique_key_for_placement(placement: Dictionary) -> String: + var unique_id := int(placement.get("unique_id", -1)) + if unique_id <= 0: + return "" + return "uid:%d" % unique_id diff --git a/src/render/m2/m2_unique_placement_registry.gd.uid b/src/render/m2/m2_unique_placement_registry.gd.uid new file mode 100644 index 0000000..b05280e --- /dev/null +++ b/src/render/m2/m2_unique_placement_registry.gd.uid @@ -0,0 +1 @@ +uid://cqdvx0qnpcte1 diff --git a/src/scenes/streaming/streaming_world_loader.gd b/src/scenes/streaming/streaming_world_loader.gd index 856d2aa..7e4478f 100644 --- a/src/scenes/streaming/streaming_world_loader.gd +++ b/src/scenes/streaming/streaming_world_loader.gd @@ -24,6 +24,9 @@ const TERRAIN_CHUNK_LOD_PLANNER_SCRIPT := preload("res://src/render/terrain/terr const TERRAIN_CHUNK_GEOMETRY_QUEUE_PLANNER_SCRIPT := preload( "res://src/render/terrain/terrain_chunk_geometry_queue_planner.gd" ) +const M2_UNIQUE_PLACEMENT_REGISTRY_SCRIPT := preload( + "res://src/render/m2/m2_unique_placement_registry.gd" +) const STREAMING_TARGET_PLANNER_SCRIPT := preload("res://src/render/streaming/streaming_target_planner.gd") const STREAMING_TARGET_POLICY_SCRIPT := preload("res://src/render/streaming/streaming_target_policy.gd") const RENDER_BUDGET_SCHEDULER_SCRIPT := preload("res://src/render/streaming/render_budget_scheduler.gd") @@ -207,7 +210,9 @@ 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_unique_placement_registry := ( + M2_UNIQUE_PLACEMENT_REGISTRY_SCRIPT.new() +) var _m2_mesh_cache: Dictionary = {} var _m2_mesh_load_requests: Dictionary = {} var _m2_mesh_finalize_queue: Array = [] @@ -1011,7 +1016,7 @@ func render_baseline_snapshot() -> Dictionary: "chunk_remove": _chunk_remove_queue.size(), }, "cache_entries": _tile_mesh_cache.size(), - "m2_active_unique_ids": _m2_unique_registry.size(), + "m2_active_unique_ids": _m2_unique_placement_registry.active_count(), "wmo_instances": _wmo_registry.size(), } @@ -2965,7 +2970,7 @@ 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_unique_placement_registry.clear() _m2_mesh_load_requests.clear() _m2_mesh_finalize_queue.clear() _m2_runtime_rebuild_required_cache.clear() @@ -4129,7 +4134,10 @@ func _request_tile_m2_assets( if _m2_group_tasks.has(key) or _m2_build_jobs.has(key): return result - var filtered := _reserve_tile_m2_placements(key, m2_placements) + var filtered: Dictionary = _m2_unique_placement_registry.reserve( + 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", []) @@ -4143,46 +4151,6 @@ func _request_tile_m2_assets( 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", []) @@ -4191,16 +4159,7 @@ func _release_tile_m2_unique_keys(state: Dictionary, notify_candidates: bool = t 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) + var released_keys: Array = _m2_unique_placement_registry.release(tile_key, unique_keys) state["m2_unique_keys"] = [] state["m2_skipped_unique_keys"] = [] diff --git a/src/tools/verify_m2_unique_dedupe.gd b/src/tools/verify_m2_unique_dedupe.gd index 4aeb07f..39aa28d 100644 --- a/src/tools/verify_m2_unique_dedupe.gd +++ b/src/tools/verify_m2_unique_dedupe.gd @@ -1,12 +1,14 @@ extends SceneTree -const STREAMING_WORLD_LOADER := preload("res://src/scenes/streaming/streaming_world_loader.gd") +const M2_UNIQUE_PLACEMENT_REGISTRY := preload( + "res://src/render/m2/m2_unique_placement_registry.gd" +) var _failures := 0 func _initialize() -> void: - var loader = STREAMING_WORLD_LOADER.new() + var registry: RefCounted = M2_UNIQUE_PLACEMENT_REGISTRY.new() var placement := { "name_id": 0, "unique_id": 11785, @@ -15,30 +17,24 @@ func _initialize() -> void: "scale": 1.0, } - var first: Dictionary = loader.call("_reserve_tile_m2_placements", "30_49", [placement]) + var first: Dictionary = registry.call("reserve", "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]) + var duplicate: Dictionary = registry.call("reserve", "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]) + registry.call("release", "30_49", ["uid:11785"]) + var after_release: Dictionary = registry.call("reserve", "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]) + var same_tile_registry: RefCounted = M2_UNIQUE_PLACEMENT_REGISTRY.new() + var same_tile_duplicate: Dictionary = same_tile_registry.call("reserve", "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) diff --git a/src/tools/verify_m2_unique_placement_registry.gd b/src/tools/verify_m2_unique_placement_registry.gd new file mode 100644 index 0000000..738f66f --- /dev/null +++ b/src/tools/verify_m2_unique_placement_registry.gd @@ -0,0 +1,156 @@ +extends SceneTree + +## Asset-free ownership, lifecycle, dependency and timing regression for M2 IDs. + +const REGISTRY_SCRIPT := preload("res://src/render/m2/m2_unique_placement_registry.gd") +const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd" + + +func _initialize() -> void: + var failures: Array[String] = [] + _verify_unkeyed_and_invalid_placements(failures) + _verify_same_tile_and_cross_tile_deduplication(failures) + _verify_owner_checked_release_and_retry(failures) + _verify_clear_and_detached_diagnostics(failures) + _verify_loader_boundary(failures) + var elapsed_milliseconds := _verify_bounded_timing(failures) + if not failures.is_empty(): + for failure in failures: + push_error("M2_UNIQUE_PLACEMENT_REGISTRY: %s" % failure) + quit(1) + return + print( + "M2_UNIQUE_PLACEMENT_REGISTRY PASS cases=10 iterations=100 elapsed_ms=%.3f" + % elapsed_milliseconds + ) + quit(0) + + +func _verify_unkeyed_and_invalid_placements(failures: Array[String]) -> void: + var registry: RefCounted = REGISTRY_SCRIPT.new() + var result: Dictionary = registry.call("reserve", "tile-a", [ + {"unique_id": -1}, + {"unique_id": 0}, + {"name_id": 7}, + "invalid", + ]) + _expect_equal((result["placements"] as Array).size(), 3, "unkeyed placements pass", failures) + _expect_true((result["unique_keys"] as Array).is_empty(), "unkeyed placements not owned", failures) + _expect_equal(int(registry.call("active_count")), 0, "unkeyed active count", failures) + + +func _verify_same_tile_and_cross_tile_deduplication(failures: Array[String]) -> void: + var registry: RefCounted = REGISTRY_SCRIPT.new() + var placement := {"unique_id": 11785, "name_id": 0} + var first: Dictionary = registry.call("reserve", "30_49", [placement, placement]) + _expect_equal((first["placements"] as Array).size(), 1, "same call duplicate filtered", failures) + _expect_string_array((first["unique_keys"] as Array), ["uid:11785"], "first owner key", failures) + var same_owner_retry: Dictionary = registry.call("reserve", "30_49", [placement]) + _expect_equal((same_owner_retry["placements"] as Array).size(), 1, "same owner later call passes", failures) + var competing: Dictionary = registry.call("reserve", "31_49", [placement, placement]) + _expect_true((competing["placements"] as Array).is_empty(), "competing tile filtered", failures) + _expect_string_array( + competing["skipped_unique_keys"] as Array, + ["uid:11785"], + "competing skip recorded once", + failures + ) + + +func _verify_owner_checked_release_and_retry(failures: Array[String]) -> void: + var registry: RefCounted = REGISTRY_SCRIPT.new() + var placement := {"unique_id": 44} + registry.call("reserve", "owner", [placement]) + _expect_true( + (registry.call("release", "other", ["uid:44"]) as Array).is_empty(), + "non-owner release ignored", + failures + ) + _expect_equal(int(registry.call("active_count")), 1, "non-owner keeps reservation", failures) + var released: Array = registry.call("release", "owner", ["", "uid:44", "uid:44"]) + _expect_string_array(released, ["uid:44"], "owner release order and dedupe", failures) + var retried: Dictionary = registry.call("reserve", "other", [placement]) + _expect_equal((retried["placements"] as Array).size(), 1, "waiting tile reserves after release", failures) + + +func _verify_clear_and_detached_diagnostics(failures: Array[String]) -> void: + var registry: RefCounted = REGISTRY_SCRIPT.new() + registry.call("reserve", "tile-b", [{"unique_id": 20}]) + registry.call("reserve", "tile-a", [{"unique_id": 10}]) + var snapshot: Dictionary = registry.call("diagnostic_snapshot") + var owners: Array = snapshot["owners"] + _expect_equal(int(snapshot["active_count"]), 2, "diagnostic count", failures) + _expect_string_equal(String(owners[0]["unique_key"]), "uid:10", "diagnostic sorted", failures) + owners[0]["tile_key"] = "mutated" + var fresh_snapshot: Dictionary = registry.call("diagnostic_snapshot") + _expect_string_equal( + String((fresh_snapshot["owners"] as Array)[0]["tile_key"]), + "tile-a", + "diagnostics detached", + failures + ) + registry.call("clear") + registry.call("clear") + _expect_equal(int(registry.call("active_count")), 0, "clear idempotent", failures) + + +func _verify_loader_boundary(failures: Array[String]) -> void: + var source := _read_text(LOADER_PATH, failures) + _expect_true(source.contains("M2_UNIQUE_PLACEMENT_REGISTRY_SCRIPT.new()"), "loader composes registry", failures) + _expect_true(source.contains("_m2_unique_placement_registry.reserve("), "loader delegates reserve", failures) + _expect_true(source.contains("_m2_unique_placement_registry.release("), "loader delegates release", failures) + _expect_true(source.contains("_m2_unique_placement_registry.clear()"), "loader delegates clear", failures) + _expect_true(not source.contains("var _m2_unique_registry"), "loader omits registry dictionary", failures) + _expect_true(not source.contains("func _m2_unique_key("), "loader omits key formula", failures) + + +func _verify_bounded_timing(failures: Array[String]) -> float: + var placements: Array = [] + for placement_index in range(256): + placements.append({"unique_id": placement_index + 1}) + var registry: RefCounted = REGISTRY_SCRIPT.new() + var started_microseconds := Time.get_ticks_usec() + for _iteration in range(100): + var reservation: Dictionary = registry.call("reserve", "owner", placements) + registry.call("release", "owner", reservation["unique_keys"]) + var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0 + _expect_true(elapsed_milliseconds < 1000.0, "100 reserve/release 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) diff --git a/src/tools/verify_m2_unique_placement_registry.gd.uid b/src/tools/verify_m2_unique_placement_registry.gd.uid new file mode 100644 index 0000000..79abead --- /dev/null +++ b/src/tools/verify_m2_unique_placement_registry.gd.uid @@ -0,0 +1 @@ +uid://inw4fft20be0