diff --git a/RENDER.md b/RENDER.md index 2a571ac..217cf8f 100644 --- a/RENDER.md +++ b/RENDER.md @@ -37,6 +37,7 @@ Paired run 2026-07-11 подтвердил крупный coordinate/placement g - `src/render/liquid/adt_water_scene_finalizer.gd` - stateless main-thread ADT water build/attach and optional persisted Editor ownership through the existing ADTBuilder. - `src/render/m2/m2_runtime_mesh_rebuild_classifier.gd` - memoized billboard/UV-rotation decision for stale cached M2 runtime mesh refresh. - `src/render/m2/m2_mesh_load_pipeline_state.gd` - static M2 threaded-load request records, terminal statuses and completion-order finalize FIFO without I/O or Mesh ownership. +- `src/render/m2/m2_mesh_resource_cache_state.gd` - normalized-path prepared static M2 Mesh references with final-shutdown lifetime. - `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. @@ -1073,6 +1074,19 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' - Cache formats, material refresh, profiles and visible output are unchanged. Synthetic state timing is not asset-backed I/O, leak or p95/p99 evidence. +## 2026-07-17 M2 Mesh Resource Cache State Extraction + +- `M2MeshResourceCacheState` now owns normalized-path references to prepared + static M2 Mesh resources. Lookup returns the exact retained reference and a + later store for one path keeps the historical replacement behavior. +- The cache retains map-refresh lifetime and clears only at the existing final + runtime-cache shutdown site after asynchronous work is drained. +- `StreamingWorldLoader` still owns normalization, `.tscn`/`.glb` selection, + ResourceLoader I/O, Mesh extraction, material refresh/rebuild, shared missing + and prototype/animated caches, permits and MultiMesh materialization. +- Cache formats, refresh metadata, profiles and visible output are unchanged. + Synthetic cache timing is not asset-backed memory/leak or p95/p99 evidence. + ## 2026-07-17 WMO Placement Resolver Extraction - `WmoPlacementResolver` now owns lowercase/slash cache-key normalization, diff --git a/coordination/claims/M03-RND-M2-MESH-RESOURCE-CACHE-001.md b/coordination/claims/M03-RND-M2-MESH-RESOURCE-CACHE-001.md index 032b0b4..4e705e6 100644 --- a/coordination/claims/M03-RND-M2-MESH-RESOURCE-CACHE-001.md +++ b/coordination/claims/M03-RND-M2-MESH-RESOURCE-CACHE-001.md @@ -30,6 +30,8 @@ all existing cache consumers. `src/tools/verify_m2_mesh_resource_cache_state.gd`, `docs/modules/m2-mesh-resource-cache-state.md`, this claim - Shared/hotspots: `src/scenes/streaming/streaming_world_loader.gd`, + `src/tools/verify_m2_mesh_load_pipeline_state.gd`, + `src/tools/verify_render_runtime_cache_shutdown.gd`, `docs/modules/world-renderer.md`, `docs/modules/README.md`, `RENDER.md`, `targets/03-renderer-facade.md` - Generated/ignored: `.godot`, native DLL, generated M2 resources, caches and @@ -76,14 +78,22 @@ all existing cache consumers. ## Status -- State: claimed -- Done: all Mesh cache read/write/clear sites identified -- Next: implement cache state, migrate adapters, verify and document +- State: ready +- Done: cache state, loader/test adapters, contract verifier and required documentation +- Next: integrator merge and post-merge smoke - Blocked by: ## Handoff -- Commit: -- Results: -- Remaining risks: -- Documentation updated: +- Commit: `e990a65` +- Results: dedicated cache verifier passes 100-by-256 store/lookups in + 38.974 ms; 35 headless renderer/coordinate regressions pass; checkpoint + dry-run passes 7/7; documentation and coordination gates pass. +- Remaining risks: no private M2 traversal, Mesh memory/leak/descriptor-pressure + run or original-client visual comparison; extraction/preparation, shared + missing state and materialization remain loader-owned by design. +- Documentation updated: inline API; `m2-mesh-resource-cache-state.md` with + data-flow, lifecycle, sequence and dependency diagrams; pipeline spec, module + registry, `world-renderer.md` and `RENDER.md`. + + diff --git a/docs/modules/README.md b/docs/modules/README.md index 80e54a9..96811b9 100644 --- a/docs/modules/README.md +++ b/docs/modules/README.md @@ -23,6 +23,7 @@ | M2 build batch planner | Implemented extraction | [`m2-build-batch-planner.md`](m2-build-batch-planner.md) | | M2 runtime mesh rebuild classifier | Implemented extraction | [`m2-runtime-mesh-rebuild-classifier.md`](m2-runtime-mesh-rebuild-classifier.md) | | M2 mesh load pipeline state | Implemented extraction | [`m2-mesh-load-pipeline-state.md`](m2-mesh-load-pipeline-state.md) | +| M2 mesh resource cache state | Implemented extraction | [`m2-mesh-resource-cache-state.md`](m2-mesh-resource-cache-state.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) | | WMO render build step planner | Implemented extraction | [`wmo-render-build-step-planner.md`](wmo-render-build-step-planner.md) | diff --git a/docs/modules/m2-mesh-load-pipeline-state.md b/docs/modules/m2-mesh-load-pipeline-state.md index 68f9e10..496fbef 100644 --- a/docs/modules/m2-mesh-load-pipeline-state.md +++ b/docs/modules/m2-mesh-load-pipeline-state.md @@ -19,7 +19,7 @@ loader retains every I/O and engine-resource operation. ## Non-goals - Call ResourceLoader or select cache paths/formats. -- Own M2 Mesh, scene, missing or material-refresh caches. +- Own M2 scene, missing or material-refresh caches, or prepared Mesh references. - Extract Meshes from PackedScene/GLB Resources. - Consume finalize permits or rebuild/adopt runtime Meshes. - Merge static and animated M2 pipelines. @@ -38,7 +38,7 @@ flowchart LR State --> FIFO[Finalize FIFO] FIFO --> Loader Loader --> Budget[M2_MESH_FINALIZE permit] - Loader --> Cache[Mesh or missing cache] + Loader --> Cache[M2 Mesh resource cache or shared missing cache] ``` Allowed dependencies are value containers, Strings and opaque integer statuses. @@ -135,7 +135,7 @@ flowchart TB Loader[StreamingWorldLoader] --> State[M2MeshLoadPipelineState] Loader --> Resource[ResourceLoader] Loader --> Budget[RenderBudgetScheduler] - Loader --> MeshCache[M2 Mesh/missing caches] + Loader --> MeshCache[M2 Mesh resource cache and shared missing cache] Loader --> Classifier[M2RuntimeMeshRebuildClassifier] State -. no dependency .-> Resource State -. no dependency .-> Budget @@ -147,7 +147,8 @@ flowchart TB - Main thread serializes all state mutation and snapshots. - State owns only request/finalize Dictionaries containing Strings and status integers. - Loader owns ResourceLoader request lifetime and drains active paths before shutdown clear. -- Loader owns Mesh/missing caches, material refresh, M2Builder and engine resources. +- `M2MeshResourceCacheState` owns prepared static Mesh references; the loader + owns shared missing state, material refresh, M2Builder and other engine resources. - Snapshot and diagnostic records are detached; caller mutation cannot affect state. ## Errors, cancellation and recovery @@ -202,7 +203,8 @@ rebuild policy are unchanged; no migration or rebake is required. |---|---|---|---| | Static M2 request/finalize state | Implemented extraction | Contract/source/timing verifier | Asset-backed long traversal pending | | ResourceLoader I/O | Existing loader-owned | Shutdown/material regressions | I/O adapter extraction optional | -| Mesh cache/materialization | Existing loader-owned | Renderer/material tests | Separate state/finalizer extraction pending | +| Mesh cache | Implemented extraction | Mesh resource cache state verifier | Asset-backed memory/leak run pending | +| Mesh materialization | Existing loader-owned | Renderer/material tests | Separate finalizer extraction pending | ## Known gaps and risks @@ -216,6 +218,7 @@ rebuild policy are unchanged; no migration or rebake is required. | Path | Responsibility | |---|---| | `src/render/m2/m2_mesh_load_pipeline_state.gd` | Pending records, terminal FIFO and metrics | +| `src/render/m2/m2_mesh_resource_cache_state.gd` | Prepared static Mesh references and final clear | | `src/scenes/streaming/streaming_world_loader.gd` | Cache path choice, I/O polling, permits and Mesh/missing adoption | | `src/render/m2/m2_runtime_mesh_rebuild_classifier.gd` | Downstream stale Mesh rebuild decision | | `src/tools/verify_m2_mesh_load_pipeline_state.gd` | Lifecycle/boundary/timing regression | diff --git a/docs/modules/m2-mesh-resource-cache-state.md b/docs/modules/m2-mesh-resource-cache-state.md new file mode 100644 index 0000000..cef5f9c --- /dev/null +++ b/docs/modules/m2-mesh-resource-cache-state.md @@ -0,0 +1,209 @@ +# M2 Mesh Resource Cache State + +## Metadata + +| Field | Value | +|---|---| +| Status | Implemented extraction | +| Target/work package | M03 / `M03-RND-M2-MESH-RESOURCE-CACHE-001` | +| Owners | Prepared static M2 Mesh resource references by normalized path | +| Last verified | Worktree `work/sindo-main-codex/m03-m2-mesh-resource-cache`, 2026-07-17 | +| Profiles/capabilities | Existing static M2 cached and raw fallback paths | + +## Purpose + +Own the runtime cache of prepared static M2 `Mesh` references outside +`StreamingWorldLoader`. The loader remains responsible for finding, loading, +extracting, refreshing and materializing each Mesh. + +## Non-goals + +- Own the shared M2 missing-path, prototype or animated-scene caches. +- Call ResourceLoader/FileAccess or select `.tscn`/`.glb` paths. +- Extract a Mesh from a Resource or Node. +- Refresh materials, rebuild Meshes or create MultiMesh instances. +- Add eviction, persistence, cache versions or quality-profile behavior. + +## Context and boundaries + +```mermaid +flowchart LR + Need[Static M2 mesh lookup] --> Loader[StreamingWorldLoader] + Loader --> Cache[M2MeshResourceCacheState] + Cache -->|cached Mesh or null| Loader + Loader --> Pipeline[M2MeshLoadPipelineState] + Pipeline --> IO[ResourceLoader] + IO --> Prepare[Loader extraction and runtime preparation] + Prepare --> Cache + Loader --> Build[M2 MultiMesh materialization] +``` + +The cache may retain `Mesh` resources and copied normalized-path Strings. It has +no Node, ResourceLoader, filesystem, worker, scheduler, missing-state, builder, +gameplay, network or editor dependency. + +## Public API + +| Symbol | Kind | Purpose | Thread/lifetime | Errors | +|---|---|---|---|---| +| `store_mesh(path, mesh)` | Command/query | Retain or replace one prepared Mesh | Renderer main thread; final shutdown | Empty path/null Mesh rejected | +| `has_mesh(path)` | Query | Test whether a Mesh is retained | Renderer main thread | Empty/unknown false | +| `find_mesh(path)` | Query | Borrow exact retained Mesh reference | Renderer main thread | Empty/unknown null | +| `mesh_count()` | Query | Return retained entry count | Diagnostics/tests | None | +| `normalized_paths_snapshot()` | Query | Copy insertion-order path keys | Diagnostics/tests | Never exposes Mesh references | +| `clear()` | Command | Release every retained Mesh reference | Final shutdown after async drain | Idempotent | + +## Inputs and outputs + +| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime | +|---|---|---|---|---|---| +| Input | Normalized relative M2 path | Loader normalization adapter | Cache key | Copied String | Cache lifetime | +| Input | Prepared static Mesh | Loader finalizer or synchronous fallback | Cache value | Strong reference retained | Until replacement/clear | +| Output | Borrowed exact Mesh | Cache lookup | Static M2 batch builder | Cache retains reference | One build/materialization pass | +| Output | Detached normalized paths | Cache diagnostics | Verifier/future metrics | Caller-owned Strings | Snapshot lifetime | + +Store and clear mutate only the cache Dictionary and Resource reference counts. + +## Data flow + +```mermaid +flowchart TD + Lookup[Lookup normalized M2 path] --> Cached{Mesh cached?} + Cached -->|yes| Return[Return retained Mesh] + Cached -->|no| Missing{Shared missing state?} + Missing -->|yes| Null[Return null] + Missing -->|no| Load[Loader request or synchronous fallback] + Load --> Prepare[Loader extracts and prepares Mesh] + Prepare --> Valid{Prepared Mesh valid?} + Valid -->|yes| Store[Store or replace cache entry] + Valid -->|no| MarkMissing[Loader updates shared missing state] + Store --> Return +``` + +## Lifecycle/state + +```mermaid +stateDiagram-v2 + [*] --> Absent + Absent --> Cached: store valid Mesh + Cached --> Cached: replace Mesh reference + Cached --> Absent: final shutdown clear +``` + +Map/world refresh retains cached Meshes. Only the existing final runtime-cache +release clears them after asynchronous shutdown drain. + +## Main sequence + +```mermaid +sequenceDiagram + participant Loader as StreamingWorldLoader + participant Cache as M2MeshResourceCacheState + participant Pipeline as M2MeshLoadPipelineState + Loader->>Cache: find/has normalized path + alt cache miss + Loader->>Pipeline: request/poll/finalize record + Loader->>Loader: ResourceLoader get + extract + prepare + Loader->>Cache: store_mesh(path, prepared Mesh) + end + Cache-->>Loader: exact retained Mesh + Loader->>Loader: materialize static M2 batch +``` + +## Dependency diagram + +```mermaid +flowchart TB + Loader[StreamingWorldLoader] --> Cache[M2MeshResourceCacheState] + Loader --> Pipeline[M2MeshLoadPipelineState] + Loader --> Classifier[M2RuntimeMeshRebuildClassifier] + Loader --> Builder[M2Builder and MultiMesh] + Cache --> Mesh[Godot Mesh Resource] + Cache -. no dependency .-> Pipeline + Cache -. no dependency .-> Classifier + Cache -. no dependency .-> Builder +``` + +## Ownership, threading and resources + +- Renderer main-thread adapters serialize every cache operation. +- The cache owns normalized-path Strings and strong Mesh references. +- Borrowed Mesh lookups do not transfer ownership or duplicate resources. +- The loader owns missing/prototype/animated state and all Nodes/MultiMeshes. +- The loader drains asynchronous work before the final cache clear. + +## Errors, cancellation and recovery + +| Failure/state | Detection | Behavior | Diagnostic | Recovery | +|---|---|---|---|---| +| Empty path or null Mesh | Cache guard | Reject unchanged | Contract verifier | Correct caller input | +| Unknown lookup | Membership query | Return null | Existing loader path | Request/load or missing decision | +| Replacement | Existing key | Retain new exact reference | Contract verifier | Normal subsequent lookup | +| Load/preparation failure | Loader boundary | Do not store; loader marks missing | Existing renderer behavior | Cache/source correction and reload | +| Final shutdown | Loader lifecycle | Clear releases references | Shutdown/source verifier | New loader starts empty | + +## Configuration and capabilities + +The module adds no setting, budget or profile branch. Cache path order, +`M2_MATERIAL_REFRESH_VERSION`, finalize permits and batching remain loader-owned. + +## Persistence, cache and migration + +The state is runtime-only and serializes nothing. Existing `.tscn`/`.glb` +formats, import caches and material refresh metadata are unchanged; no migration +or rebake is required. + +## Diagnostics and observability + +`mesh_count` and `normalized_paths_snapshot` expose scalar ownership diagnostics +without Mesh references. Existing renderer metrics/logging are unchanged because +the historical Mesh cache had no queue contribution or log site. + +## Verification + +- `verify_m2_mesh_resource_cache_state.gd`: invalid input, exact reference, + replacement, lookup, detached path snapshot, idempotent clear, loader source + ownership and 100-by-256 bounded timing. +- Mesh pipeline/classifier/material/shutdown/M2 build/facade regressions cover + adjacent behavior. +- Fidelity evidence is exact cache/lifetime extraction; no original-client + visual-parity or asset-backed performance claim is made. + +## Extension points + +- A separate M2 Mesh resource finalizer may later return a prepared Mesh for + `store_mesh` without changing cache ownership. +- Eviction requires measured memory pressure and explicit lifetime semantics. + +## Capability status + +| Capability | Status | Evidence | Gap/next step | +|---|---|---|---| +| Prepared static M2 Mesh cache | Implemented extraction | Contract/source/timing verifier | Asset-backed memory/leak run pending | +| M2 Mesh request lifecycle | Implemented extraction | Pipeline state verifier | ResourceLoader I/O remains loader-owned | +| M2 Mesh extraction/preparation | Existing loader-owned | Material/classifier regressions | Separate finalizer extraction next | + +## Known gaps and risks + +- Strong Mesh references intentionally remain until final shutdown. +- Shared missing state cannot move here without also redesigning prototype and + animated lookup behavior. +- No proprietary M2 traversal, leak/descriptor-pressure, p95/p99 or paired + original-client capture is included. + +## Source map + +| Path | Responsibility | +|---|---| +| `src/render/m2/m2_mesh_resource_cache_state.gd` | Prepared Mesh references and final clear | +| `src/scenes/streaming/streaming_world_loader.gd` | Normalization, I/O, preparation, missing state and materialization | +| `src/render/m2/m2_mesh_load_pipeline_state.gd` | Pending request and terminal finalize records | +| `src/tools/verify_m2_mesh_resource_cache_state.gd` | Cache ownership/lifetime/boundary/timing regression | + +## Related decisions and references + +- [`m2-mesh-load-pipeline-state.md`](m2-mesh-load-pipeline-state.md) +- [`m2-runtime-mesh-rebuild-classifier.md`](m2-runtime-mesh-rebuild-classifier.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/world-renderer.md b/docs/modules/world-renderer.md index b9c4457..aebca9e 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-m2-mesh-load-pipeline`, 2026-07-17 | +| Last verified | Worktree `work/sindo-main-codex/m03-m2-mesh-resource-cache`, 2026-07-17 | | Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete | ## Purpose @@ -134,6 +134,7 @@ from externally reading/writing loader-private queue, task, cache and tile-state | `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 | | `M2RuntimeMeshRebuildClassifier` | Internal memoized M2 service | Detects billboard/UV-rotation metadata requiring stale cached-mesh rebuild | Renderer main thread; cached until reset | Invalid variants/indices skipped; first path decision wins | | `M2MeshLoadPipelineState` | Internal M2 async-state service | Owns static Mesh pending request records and terminal finalize FIFO | Renderer main thread; map/session | Empty/duplicate/unknown transitions rejected | +| `M2MeshResourceCacheState` | Internal M2 Resource cache | Owns prepared static Mesh references by normalized path | Renderer main thread; final shutdown | Empty path/null Mesh rejected; same path replaces | | `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 | | `WmoRenderBuildStepPlanner.plan_step` | Internal pure WMO service | Selects one mesh-first lightweight render-group operation and next cursors | Main/any thread; stateless | Raw integer comparisons are preserved without clamping | @@ -372,7 +373,10 @@ sequenceDiagram refresh-version checks, Mesh rebuild/adoption and both historical clear sites. - `M2MeshLoadPipelineState` owns static M2 pending Resource paths, opaque terminal statuses and completion-order finalize FIFO. The loader retains cache - path selection, ResourceLoader calls, permits, Mesh/missing caches and adoption. + path selection, ResourceLoader calls, permits, shared missing state and adoption. +- `M2MeshResourceCacheState` owns prepared static Mesh references and releases + them at the existing final-shutdown site. The loader retains extraction, + material refresh/rebuild, missing/prototype state and materialization. - 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 @@ -575,6 +579,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m | `src/render/liquid/adt_water_scene_finalizer.gd` | Main-thread ADT water build, tile attachment and optional editor ownership | | `src/render/m2/m2_runtime_mesh_rebuild_classifier.gd` | Billboard/UV-rotation stale cached-mesh rebuild decision and memoization | | `src/render/m2/m2_mesh_load_pipeline_state.gd` | Static M2 pending Resource paths, terminal statuses and finalize FIFO | +| `src/render/m2/m2_mesh_resource_cache_state.gd` | Prepared static M2 Mesh references and final-shutdown release | | `src/render/streaming/streaming_target_planner.gd` | Scene-free wanted/retained ADT target calculation | | `src/render/streaming/streaming_target_policy.gd` | Immutable renderer radius/prefetch policy | | `src/render/streaming/streaming_target_plan.gd` | Immutable planner result with read-only tile-key sets | @@ -591,6 +596,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m | `src/tools/verify_adt_water_scene_finalizer.gd` | Synthetic ADT water scene/ownership/boundary/timing regression | | `src/tools/verify_m2_runtime_mesh_rebuild_classifier.gd` | M2 rebuild predicate/cache/boundary/timing regression | | `src/tools/verify_m2_mesh_load_pipeline_state.gd` | Static M2 request/terminal/FIFO/boundary/timing regression | +| `src/tools/verify_m2_mesh_resource_cache_state.gd` | Static M2 Mesh cache ownership/lifetime/boundary/timing regression | | `src/tools/verify_streaming_target_planner.gd` | Planner behavior, dependency and bounded timing regression | | `src/tools/verify_render_budget_scheduler.gd` | Scheduler bounds, shared-lane priority, cancellation and timing regression | | `src/tools/verify_renderer_internal_access.gd` | Gameplay/EditorPlugin/registered renderer-tool boundary gate derived from private streamer fields | diff --git a/src/render/m2/m2_mesh_resource_cache_state.gd b/src/render/m2/m2_mesh_resource_cache_state.gd new file mode 100644 index 0000000..8c2cb85 --- /dev/null +++ b/src/render/m2/m2_mesh_resource_cache_state.gd @@ -0,0 +1,50 @@ +class_name M2MeshResourceCacheState +extends RefCounted + +## Owns normalized-path references to prepared static M2 Mesh resources. +## The caller owns loading, preparation, missing-state and materialization. + +var _mesh_by_normalized_path: Dictionary = {} + + +## Stores one prepared Mesh for a non-empty normalized M2 path. A later store +## for the same path replaces the retained reference. +func store_mesh(normalized_relative_path: String, mesh: Mesh) -> bool: + if normalized_relative_path.is_empty() or mesh == null: + return false + _mesh_by_normalized_path[normalized_relative_path] = mesh + return true + + +## Returns whether one normalized path currently has a retained Mesh. +func has_mesh(normalized_relative_path: String) -> bool: + return ( + not normalized_relative_path.is_empty() + and _mesh_by_normalized_path.has(normalized_relative_path) + ) + + +## Returns the exact retained Mesh reference, or null for empty/unknown paths. +func find_mesh(normalized_relative_path: String) -> Mesh: + if not has_mesh(normalized_relative_path): + return null + return _mesh_by_normalized_path[normalized_relative_path] as Mesh + + +## Returns the number of retained static M2 Mesh resources. +func mesh_count() -> int: + return _mesh_by_normalized_path.size() + + +## Returns a detached insertion-order list of cached normalized paths. +func normalized_paths_snapshot() -> Array[String]: + var normalized_paths: Array[String] = [] + for normalized_path_variant in _mesh_by_normalized_path.keys(): + normalized_paths.append(String(normalized_path_variant)) + return normalized_paths + + +## Releases all retained Mesh references. The loader calls this only after +## asynchronous work has completed during final scene shutdown. +func clear() -> void: + _mesh_by_normalized_path.clear() diff --git a/src/render/m2/m2_mesh_resource_cache_state.gd.uid b/src/render/m2/m2_mesh_resource_cache_state.gd.uid new file mode 100644 index 0000000..2788660 --- /dev/null +++ b/src/render/m2/m2_mesh_resource_cache_state.gd.uid @@ -0,0 +1 @@ +uid://dhkyjcrdblgkd diff --git a/src/scenes/streaming/streaming_world_loader.gd b/src/scenes/streaming/streaming_world_loader.gd index 61339bc..6d120f7 100644 --- a/src/scenes/streaming/streaming_world_loader.gd +++ b/src/scenes/streaming/streaming_world_loader.gd @@ -66,6 +66,9 @@ const M2_RUNTIME_MESH_REBUILD_CLASSIFIER_SCRIPT := preload( const M2_MESH_LOAD_PIPELINE_STATE_SCRIPT := preload( "res://src/render/m2/m2_mesh_load_pipeline_state.gd" ) +const M2_MESH_RESOURCE_CACHE_STATE_SCRIPT := preload( + "res://src/render/m2/m2_mesh_resource_cache_state.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") @@ -256,7 +259,7 @@ var _m2_build_batch_planner := M2_BUILD_BATCH_PLANNER_SCRIPT.new() var _m2_runtime_mesh_rebuild_classifier := ( M2_RUNTIME_MESH_REBUILD_CLASSIFIER_SCRIPT.new() ) -var _m2_mesh_cache: Dictionary = {} +var _m2_mesh_resource_cache_state := M2_MESH_RESOURCE_CACHE_STATE_SCRIPT.new() var _m2_mesh_load_pipeline_state := M2_MESH_LOAD_PIPELINE_STATE_SCRIPT.new() var _m2_animation_load_requests: Dictionary = {} var _m2_animation_finalize_queue: Array = [] @@ -476,7 +479,7 @@ func _release_runtime_caches_for_shutdown() -> void: _free_detached_node_cache(_m2_scene_cache) _free_detached_node_cache(_m2_animated_scene_cache) _free_detached_node_cache(_wmo_prototype_cache) - _m2_mesh_cache.clear() + _m2_mesh_resource_cache_state.clear() _m2_static_animation_cache.clear() _m2_missing_cache.clear() _wmo_render_resource_cache_state.clear_all() @@ -4263,7 +4266,10 @@ func _drain_m2_mesh_loads() -> void: RENDER_BUDGET_SCHEDULER_SCRIPT.M2_MESH_FINALIZE): var pending: Dictionary = _m2_mesh_load_pipeline_state.pop_finalize_record() var normalized_rel := String(pending.get("normalized", "")) - if normalized_rel.is_empty() or _m2_mesh_cache.has(normalized_rel): + if ( + normalized_rel.is_empty() + or _m2_mesh_resource_cache_state.has_mesh(normalized_rel) + ): continue if int(pending.get("status", ResourceLoader.THREAD_LOAD_FAILED)) != ResourceLoader.THREAD_LOAD_LOADED: _m2_missing_cache[normalized_rel] = true @@ -4273,7 +4279,10 @@ func _drain_m2_mesh_loads() -> void: var resource: Resource = ResourceLoader.load_threaded_get(path) var mesh := _extract_first_mesh_from_m2_resource(resource) if mesh != null: - _m2_mesh_cache[normalized_rel] = _prepare_m2_mesh_for_runtime(normalized_rel, mesh) + _m2_mesh_resource_cache_state.store_mesh( + normalized_rel, + _prepare_m2_mesh_for_runtime(normalized_rel, mesh) + ) else: _m2_missing_cache[normalized_rel] = true @@ -4608,8 +4617,8 @@ func _get_m2_mesh_or_request(rel_path: String) -> Mesh: var normalized_rel := _normalize_m2_rel_path(rel_path) if normalized_rel.is_empty(): return null - if _m2_mesh_cache.has(normalized_rel): - return _m2_mesh_cache[normalized_rel] + if _m2_mesh_resource_cache_state.has_mesh(normalized_rel): + return _m2_mesh_resource_cache_state.find_mesh(normalized_rel) if _m2_missing_cache.has(normalized_rel): return null _request_m2_mesh_load(normalized_rel) @@ -4716,15 +4725,15 @@ func _find_first_mesh_recursive(node: Node) -> Mesh: func _get_or_load_m2_mesh(rel_path: String) -> Mesh: var normalized_rel := _normalize_m2_rel_path(rel_path) - if _m2_mesh_cache.has(normalized_rel): - return _m2_mesh_cache[normalized_rel] + if _m2_mesh_resource_cache_state.has_mesh(normalized_rel): + return _m2_mesh_resource_cache_state.find_mesh(normalized_rel) var prototype: Node3D = _get_or_load_m2_prototype(rel_path) if prototype == null: return null var mesh := _find_first_mesh_recursive(prototype) if mesh != null and not normalized_rel.is_empty(): mesh = _prepare_m2_mesh_for_runtime(normalized_rel, mesh) - _m2_mesh_cache[normalized_rel] = mesh + _m2_mesh_resource_cache_state.store_mesh(normalized_rel, mesh) return mesh diff --git a/src/tools/verify_m2_mesh_load_pipeline_state.gd b/src/tools/verify_m2_mesh_load_pipeline_state.gd index d49f2c4..e119b4f 100644 --- a/src/tools/verify_m2_mesh_load_pipeline_state.gd +++ b/src/tools/verify_m2_mesh_load_pipeline_state.gd @@ -110,7 +110,7 @@ func _verify_ownership_boundaries(failures: Array[String]) -> void: "ResourceLoader.load_threaded_get_status(path)", "ResourceLoader.load_threaded_get(path)", "RENDER_BUDGET_SCHEDULER_SCRIPT.M2_MESH_FINALIZE", - "_m2_mesh_cache[normalized_rel]", + "_m2_mesh_resource_cache_state.store_mesh(", "_m2_missing_cache[normalized_rel]", ]: _expect_true(loader_source.contains(retained_loader_rule), "loader retains %s" % retained_loader_rule, failures) @@ -121,6 +121,7 @@ func _verify_ownership_boundaries(failures: Array[String]) -> void: "Node3D", "RID(", "_m2_mesh_cache", + "_m2_mesh_resource_cache_state", "_m2_missing_cache", ]: _expect_false( diff --git a/src/tools/verify_m2_mesh_resource_cache_state.gd b/src/tools/verify_m2_mesh_resource_cache_state.gd new file mode 100644 index 0000000..d61fbd6 --- /dev/null +++ b/src/tools/verify_m2_mesh_resource_cache_state.gd @@ -0,0 +1,158 @@ +extends SceneTree + +## Synthetic ownership/lifetime/boundary/timing regression for the prepared +## static M2 Mesh resource cache. + +const CACHE_SCRIPT := preload("res://src/render/m2/m2_mesh_resource_cache_state.gd") +const CACHE_PATH := "res://src/render/m2/m2_mesh_resource_cache_state.gd" +const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd" + + +func _initialize() -> void: + var failures: Array[String] = [] + _verify_validation_store_lookup_and_replace(failures) + _verify_clear_and_diagnostics(failures) + _verify_ownership_boundaries(failures) + var elapsed_milliseconds := _verify_bounded_timing(failures) + if not failures.is_empty(): + for failure in failures: + push_error("M2_MESH_RESOURCE_CACHE_STATE: %s" % failure) + quit(1) + return + print( + "M2_MESH_RESOURCE_CACHE_STATE PASS cases=9 iterations=100 elapsed_ms=%.3f" + % elapsed_milliseconds + ) + quit(0) + + +func _verify_validation_store_lookup_and_replace(failures: Array[String]) -> void: + var cache: RefCounted = CACHE_SCRIPT.new() + var first_mesh := ArrayMesh.new() + var replacement_mesh := ArrayMesh.new() + _expect_false(cache.call("store_mesh", "", first_mesh), "empty path rejected", failures) + _expect_false(cache.call("store_mesh", "world/a.m2", null), "null Mesh rejected", failures) + _expect_true(cache.call("store_mesh", "world/a.m2", first_mesh), "first Mesh stored", failures) + _expect_true(cache.call("has_mesh", "world/a.m2"), "stored Mesh observable", failures) + _expect_same(cache.call("find_mesh", "world/a.m2"), first_mesh, "exact Mesh retained", failures) + _expect_true( + cache.call("store_mesh", "world/a.m2", replacement_mesh), + "same-path Mesh replaced", + failures + ) + _expect_same( + cache.call("find_mesh", "world/a.m2"), + replacement_mesh, + "replacement Mesh retained", + failures + ) + _expect_equal(int(cache.call("mesh_count")), 1, "replacement keeps one entry", failures) + _expect_same(cache.call("find_mesh", "world/missing.m2"), null, "unknown path returns null", failures) + + +func _verify_clear_and_diagnostics(failures: Array[String]) -> void: + var cache: RefCounted = CACHE_SCRIPT.new() + cache.call("store_mesh", "world/a.m2", ArrayMesh.new()) + cache.call("store_mesh", "world/b.m2", ArrayMesh.new()) + var normalized_paths: Array[String] = cache.call("normalized_paths_snapshot") + _expect_equal(normalized_paths.size(), 2, "diagnostic path count", failures) + _expect_string_equal(normalized_paths[0], "world/a.m2", "diagnostic insertion order first", failures) + _expect_string_equal(normalized_paths[1], "world/b.m2", "diagnostic insertion order second", failures) + normalized_paths.clear() + _expect_equal(int(cache.call("mesh_count")), 2, "diagnostics detached", failures) + cache.call("clear") + cache.call("clear") + _expect_equal(int(cache.call("mesh_count")), 0, "clear releases all entries", failures) + + +func _verify_ownership_boundaries(failures: Array[String]) -> void: + var cache_source := FileAccess.get_file_as_string(CACHE_PATH) + var loader_source := FileAccess.get_file_as_string(LOADER_PATH) + _expect_true( + loader_source.contains("M2_MESH_RESOURCE_CACHE_STATE_SCRIPT.new()"), + "loader composes Mesh cache state", + failures + ) + _expect_false(loader_source.contains("var _m2_mesh_cache:"), "legacy Mesh cache removed", failures) + _expect_equal( + loader_source.count("_m2_mesh_resource_cache_state.store_mesh("), + 2, + "two existing stores delegate", + failures + ) + _expect_equal( + loader_source.count("_m2_mesh_resource_cache_state.find_mesh("), + 2, + "two existing lookups delegate", + failures + ) + _expect_equal( + loader_source.count("_m2_mesh_resource_cache_state.clear()"), + 1, + "final shutdown clear delegates", + failures + ) + for retained_loader_rule in [ + "ResourceLoader.load_threaded_get(path)", + "_extract_first_mesh_from_m2_resource(resource)", + "_prepare_m2_mesh_for_runtime(normalized_rel, mesh)", + "_m2_missing_cache[normalized_rel]", + "_m2_scene_cache[normalized_rel]", + ]: + _expect_true(loader_source.contains(retained_loader_rule), "loader retains %s" % retained_loader_rule, failures) + for forbidden_dependency in [ + "ResourceLoader.", + "FileAccess.", + "WorkerThreadPool.", + "PackedScene", + "Node3D", + "M2Builder", + "_m2_missing_cache", + "_m2_scene_cache", + ]: + _expect_false( + cache_source.contains(forbidden_dependency), + "cache omits %s ownership" % forbidden_dependency, + failures + ) + + +func _verify_bounded_timing(failures: Array[String]) -> float: + var cache: RefCounted = CACHE_SCRIPT.new() + var meshes: Array[Mesh] = [] + for _path_index in range(256): + meshes.append(ArrayMesh.new()) + var started_microseconds := Time.get_ticks_usec() + for _iteration in range(100): + for path_index in range(256): + cache.call("store_mesh", "world/model_%d.m2" % path_index, meshes[path_index]) + for path_index in range(256): + cache.call("find_mesh", "world/model_%d.m2" % path_index) + cache.call("clear") + var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0 + _expect_true(elapsed_milliseconds < 1000.0, "100 by 256 store/lookups under 1 second", failures) + return elapsed_milliseconds + + +func _expect_true(condition: bool, label: String, failures: Array[String]) -> void: + if not condition: + failures.append(label) + + +func _expect_false(condition: bool, label: String, failures: Array[String]) -> void: + _expect_true(not condition, label, failures) + + +func _expect_equal(actual: int, expected: int, label: String, failures: Array[String]) -> void: + if actual != expected: + failures.append("%s expected=%d actual=%d" % [label, expected, actual]) + + +func _expect_string_equal(actual: String, expected: String, label: String, failures: Array[String]) -> void: + if actual != expected: + failures.append("%s expected=%s actual=%s" % [label, expected, actual]) + + +func _expect_same(actual: Variant, expected: Variant, label: String, failures: Array[String]) -> void: + if not is_same(actual, expected): + failures.append(label) diff --git a/src/tools/verify_m2_mesh_resource_cache_state.gd.uid b/src/tools/verify_m2_mesh_resource_cache_state.gd.uid new file mode 100644 index 0000000..5069d1a --- /dev/null +++ b/src/tools/verify_m2_mesh_resource_cache_state.gd.uid @@ -0,0 +1 @@ +uid://ld4k7pnaueno diff --git a/src/tools/verify_render_runtime_cache_shutdown.gd b/src/tools/verify_render_runtime_cache_shutdown.gd index bb21967..6f7ea28 100644 --- a/src/tools/verify_render_runtime_cache_shutdown.gd +++ b/src/tools/verify_render_runtime_cache_shutdown.gd @@ -35,7 +35,8 @@ func _verify_runtime_cache_ownership() -> void: loader.set("_m2_scene_cache", {"static": static_m2_prototype}) loader.set("_m2_animated_scene_cache", {"animated": animated_m2_prototype}) loader.set("_wmo_prototype_cache", {"wmo": wmo_prototype}) - loader.set("_m2_mesh_cache", {"mesh": ArrayMesh.new()}) + var m2_mesh_resource_cache_state: RefCounted = loader.get("_m2_mesh_resource_cache_state") + m2_mesh_resource_cache_state.call("store_mesh", "mesh", ArrayMesh.new()) loader.set("_m2_static_animation_cache", {"static": true}) loader.set("_m2_missing_cache", {"missing": true}) var wmo_render_cache_state: RefCounted = loader.get("_wmo_render_resource_cache_state") @@ -59,7 +60,6 @@ func _verify_runtime_cache_ownership() -> void: "_m2_scene_cache", "_m2_animated_scene_cache", "_wmo_prototype_cache", - "_m2_mesh_cache", "_m2_static_animation_cache", "_m2_missing_cache", "_wmo_missing_cache", @@ -67,6 +67,10 @@ func _verify_runtime_cache_ownership() -> void: ]: var cache: Dictionary = loader.get(cache_name) _expect(cache.is_empty(), "%s should be empty after shutdown" % cache_name) + _expect( + int(m2_mesh_resource_cache_state.call("mesh_count")) == 0, + "M2 Mesh Resources should be empty after shutdown" + ) var wmo_render_snapshot: Dictionary = wmo_render_cache_state.call("diagnostic_snapshot") _expect( (wmo_render_snapshot["resource_paths"] as Array).is_empty(),