diff --git a/RENDER.md b/RENDER.md index 5ab2d43..0a69c2e 100644 --- a/RENDER.md +++ b/RENDER.md @@ -40,6 +40,7 @@ Paired run 2026-07-11 подтвердил крупный coordinate/placement g - `src/render/m2/m2_mesh_resource_cache_state.gd` - normalized-path prepared static M2 Mesh references with final-shutdown lifetime. - `src/render/m2/m2_mesh_resource_extractor.gd` - first-Mesh selection from direct/PackedScene/Node inputs with temporary instance cleanup. - `src/render/m2/m2_runtime_mesh_finalizer.gd` - refresh version, classifier lifetime, M2Builder rebuild and original-Mesh fallback. +- `src/render/m2/m2_prototype_cache_state.gd` - shutdown-lifetime static/animated prototype Nodes and missing/static-only path outcomes. - `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. @@ -1090,8 +1091,9 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' - 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. + ResourceLoader I/O, Mesh extraction, material refresh/rebuild, cache adoption + decisions, permits and MultiMesh materialization. Prototype/negative state is + now a sibling cache service. - Cache formats, refresh metadata, profiles and visible output are unchanged. Synthetic cache timing is not asset-backed memory/leak or p95/p99 evidence. @@ -1115,8 +1117,8 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' FileAccess/ClassDB M2Loader I/O and supplies raw data through the loader only when the finalizer reports a stale Mesh; both historical clear sites persist. - Billboard/UV-rotation predicates, rebuild extraction, metadata key and failure - fallback are unchanged. Cache adoption, missing state, permits and MultiMesh - materialization remain loader-owned. + fallback are unchanged. Cache adoption decisions, permits and MultiMesh + materialization remain loader-owned; negative outcomes belong to prototype state. - Synthetic triangle rebuild/fallback timing is not asset-backed material, descriptor-pressure/leak or p95/p99 evidence. @@ -1127,13 +1129,29 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' `load_m2_animated` raw Dictionaries. - `StreamingWorldLoader` delegates the stale-Mesh refresh, static prototype and native animated prototype reads. It retains normalization, `.tscn/.glb` - fallback order, missing/prototype caches, builders, permits and Node/Mesh use. + fallback order, builders, permits and Node/Mesh use; prototype/negative state + is now isolated in `M2PrototypeCacheState`. - The repository retains no path, native object or parsed data. Empty paths, absent files/classes/methods and invalid results produce the same empty-value fallback contract; path join/globalization and native method names are exact. - No cache format, parser, material, geometry, animation or visible rule changed. Synthetic missing-input timing is not asset-backed I/O/p95/p99 or fidelity evidence. +## 2026-07-17 M2 Prototype Cache State + +- `M2PrototypeCacheState` now owns the exact static and animated prototype Node + references plus missing-model and static-animation negative path outcomes. +- Sync static, native animated, threaded GLB animation, Mesh finalize and + material-prototype paths delegate lookup/adoption/negative transitions to the + service. First valid prototype remains canonical for each normalized path. +- Map refresh still retains all four states. Final shutdown still occurs after + asynchronous drain and now calls one idempotent release operation that frees + detached prototypes, queues defensive in-tree Nodes and clears path outcomes. +- `StreamingWorldLoader` retains normalization, ResourceLoader/native I/O, + builders, animation policy, fallback order, permits and materialization. +- No cache format, visible rule or profile changed. Synthetic ownership/timing + evidence is not an asset-backed traversal/leak/p95/p99 or fidelity claim. + ## 2026-07-17 WMO Placement Resolver Extraction - `WmoPlacementResolver` now owns lowercase/slash cache-key normalization, diff --git a/coordination/claims/M03-RND-M2-PROTOTYPE-CACHE-001.md b/coordination/claims/M03-RND-M2-PROTOTYPE-CACHE-001.md index 8ac4ae2..7e686cb 100644 --- a/coordination/claims/M03-RND-M2-PROTOTYPE-CACHE-001.md +++ b/coordination/claims/M03-RND-M2-PROTOTYPE-CACHE-001.md @@ -78,14 +78,24 @@ shutdown-lifetime cache-state service. ## Status -- State: claimed -- Done: four cache tables and all caller/lifecycle sites identified -- Next: implement state service, migrate adapters, verify and document +- State: ready +- Done: cache state, loader/test adapters, lifecycle verifier and required documentation +- Next: integrate and run post-merge acceptance - Blocked by: ## Handoff -- Commit: -- Results: -- Remaining risks: -- Documentation updated: +- Commit: `cd664eb` +- Results: prototype state passes 16 admission/identity/negative/lifecycle/source + cases and 100-by-256 cycles in 50.266 ms; 46 autonomous headless verifiers + pass, while the proprietary ADT placement probe is unavailable without + `data/extracted`; checkpoint dry-run passes 7/7; documentation and + coordination gates pass; loader-private inventory decreases from 34 to 31. +- Remaining risks: strong prototypes remain unbounded until shutdown; successful + asset traversal, descriptor pressure, leaks and p95/p99 require proprietary + data; no original-client visual comparison is claimed. +- Documentation updated: inline API; `m2-prototype-cache-state.md` with data-flow, + lifecycle, sequence and dependency diagrams; pipeline/Mesh-cache/extractor/raw + repository/world-renderer specs, module registry and `RENDER.md`. + + diff --git a/docs/modules/README.md b/docs/modules/README.md index 2bf662a..02441e7 100644 --- a/docs/modules/README.md +++ b/docs/modules/README.md @@ -27,6 +27,7 @@ | M2 mesh resource extractor | Implemented extraction | [`m2-mesh-resource-extractor.md`](m2-mesh-resource-extractor.md) | | M2 runtime mesh finalizer | Implemented extraction | [`m2-runtime-mesh-finalizer.md`](m2-runtime-mesh-finalizer.md) | | M2 raw model repository | Implemented extraction | [`m2-raw-model-repository.md`](m2-raw-model-repository.md) | +| M2 prototype cache state | Implemented extraction | [`m2-prototype-cache-state.md`](m2-prototype-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 729441f..956b56d 100644 --- a/docs/modules/m2-mesh-load-pipeline-state.md +++ b/docs/modules/m2-mesh-load-pipeline-state.md @@ -38,7 +38,7 @@ flowchart LR State --> FIFO[Finalize FIFO] FIFO --> Loader Loader --> Budget[M2_MESH_FINALIZE permit] - Loader --> Cache[M2 Mesh resource cache or shared missing cache] + Loader --> Cache[M2 Mesh resource cache or M2 prototype outcome state] ``` Allowed dependencies are value containers, Strings and opaque integer statuses. @@ -88,7 +88,7 @@ flowchart TD Permit -->|yes| Pop[Pop oldest terminal record] Pop --> Finalize[Loader gets Resource and delegates first-Mesh extraction] Finalize --> Prepare[M2RuntimeMeshFinalizer prepares Mesh] - Prepare --> Adopt[Loader adopts Mesh/missing state] + Prepare --> Adopt[Loader adopts Mesh or marks prototype outcome state] ``` ## Lifecycle/state @@ -137,7 +137,7 @@ flowchart TB Loader[StreamingWorldLoader] --> State[M2MeshLoadPipelineState] Loader --> Resource[ResourceLoader] Loader --> Budget[RenderBudgetScheduler] - Loader --> MeshCache[M2 Mesh resource cache and shared missing cache] + Loader --> MeshCache[M2 Mesh resource and prototype outcome cache states] Loader --> Finalizer[M2RuntimeMeshFinalizer] State -. no dependency .-> Resource State -. no dependency .-> Budget @@ -151,7 +151,8 @@ flowchart TB - Loader owns ResourceLoader request lifetime and drains active paths before shutdown clear. - `M2MeshResourceCacheState` owns prepared static Mesh references and `M2RuntimeMeshFinalizer` owns refresh/rebuild/fallback. The loader owns shared - missing state, raw I/O and remaining engine resources. + adoption decisions, raw I/O and remaining engine resources; the prototype + cache service owns missing outcomes. - Snapshot and diagnostic records are detached; caller mutation cannot affect state. ## Errors, cancellation and recovery @@ -226,7 +227,8 @@ rebuild policy are unchanged; no migration or rebake is required. | `src/render/m2/m2_mesh_resource_cache_state.gd` | Prepared static Mesh references and final clear | | `src/render/m2/m2_mesh_resource_extractor.gd` | First-Mesh selection and temporary PackedScene lifetime | | `src/render/m2/m2_runtime_mesh_finalizer.gd` | Refresh/rebuild/fallback preparation | -| `src/scenes/streaming/streaming_world_loader.gd` | Cache path choice, I/O polling, permits and Mesh/missing adoption | +| `src/render/m2/m2_prototype_cache_state.gd` | Missing-model outcome retention | +| `src/scenes/streaming/streaming_world_loader.gd` | Cache path choice, I/O polling, permits and adoption decisions | | `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 index 48bb41c..19ad994 100644 --- a/docs/modules/m2-mesh-resource-cache-state.md +++ b/docs/modules/m2-mesh-resource-cache-state.md @@ -71,13 +71,13 @@ Store and clear mutate only the cache Dictionary and Resource reference counts. flowchart TD Lookup[Lookup normalized M2 path] --> Cached{Mesh cached?} Cached -->|yes| Return[Return retained Mesh] - Cached -->|no| Missing{Shared missing state?} + Cached -->|no| Missing{Prototype outcome says missing?} Missing -->|yes| Null[Return null] Missing -->|no| Load[Loader request or synchronous fallback] Load --> Prepare[Extractor and runtime finalizer prepare Mesh] Prepare --> Valid{Prepared Mesh valid?} Valid -->|yes| Store[Store or replace cache entry] - Valid -->|no| MarkMissing[Loader updates shared missing state] + Valid -->|no| MarkMissing[Loader updates prototype outcome state] Store --> Return ``` @@ -131,7 +131,8 @@ flowchart TB - The cache owns normalized-path Strings and strong Mesh references. - Borrowed Mesh lookups do not transfer ownership or duplicate resources. - `M2MeshResourceExtractor` owns first-Mesh selection and temporary PackedScene - instances. The loader owns missing/prototype/animated state and MultiMeshes. + instances. `M2PrototypeCacheState` owns missing/prototype/animated state; the + loader owns MultiMeshes and adoption decisions. - The loader drains asynchronous work before the final cache clear. ## Errors, cancellation and recovery @@ -190,8 +191,7 @@ the historical Mesh cache had no queue contribution or log site. ## 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. +- Missing/prototype/animated lookup state is now a separate sibling service. - No proprietary M2 traversal, leak/descriptor-pressure, p95/p99 or paired original-client capture is included. @@ -201,7 +201,8 @@ the historical Mesh cache had no queue contribution or log site. |---|---| | `src/render/m2/m2_mesh_resource_cache_state.gd` | Prepared Mesh references and final clear | | `src/render/m2/m2_mesh_resource_extractor.gd` | First-Mesh selection and temporary PackedScene lifetime | -| `src/scenes/streaming/streaming_world_loader.gd` | Normalization, raw/resource I/O, missing state and materialization | +| `src/render/m2/m2_prototype_cache_state.gd` | Prototype references and negative lookup outcomes | +| `src/scenes/streaming/streaming_world_loader.gd` | Normalization, raw/resource I/O and materialization | | `src/render/m2/m2_runtime_mesh_finalizer.gd` | Refresh/rebuild/fallback preparation | | `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 | diff --git a/docs/modules/m2-mesh-resource-extractor.md b/docs/modules/m2-mesh-resource-extractor.md index 99e2ba9..84d5768 100644 --- a/docs/modules/m2-mesh-resource-extractor.md +++ b/docs/modules/m2-mesh-resource-extractor.md @@ -194,7 +194,8 @@ normalized M2 path. Its verifier measures traversal time and temporary Node coun | Path | Responsibility | |---|---| | `src/render/m2/m2_mesh_resource_extractor.gd` | First-Mesh traversal and temporary PackedScene lifetime | -| `src/scenes/streaming/streaming_world_loader.gd` | ResourceLoader/raw I/O, caching, missing state and materialization | +| `src/render/m2/m2_prototype_cache_state.gd` | Prototype and missing/static-only lookup state | +| `src/scenes/streaming/streaming_world_loader.gd` | ResourceLoader/raw I/O, cache decisions and materialization | | `src/render/m2/m2_runtime_mesh_finalizer.gd` | Refresh/rebuild/fallback preparation | | `src/render/m2/m2_mesh_resource_cache_state.gd` | Prepared Mesh retention | | `src/tools/verify_m2_mesh_resource_extractor.gd` | Resource/order/lifetime/boundary/timing regression | diff --git a/docs/modules/m2-prototype-cache-state.md b/docs/modules/m2-prototype-cache-state.md new file mode 100644 index 0000000..52e92f2 --- /dev/null +++ b/docs/modules/m2-prototype-cache-state.md @@ -0,0 +1,247 @@ +# M2 Prototype Cache State + +## Metadata + +| Field | Value | +|---|---| +| Status | Implemented extraction | +| Target/work package | M03 / `M03-RND-M2-PROTOTYPE-CACHE-001` | +| Owners | Detached static/animated prototypes and missing/static-animation outcomes | +| Last verified | Worktree `work/sindo-main-codex/m03-m2-prototype-cache`, 2026-07-17 | +| Profiles/capabilities | Static, GLB animated and native animated M2 prototype reuse | + +## Purpose + +Own the four shutdown-lifetime M2 prototype lookup states previously stored in +`StreamingWorldLoader`: static and animated `Node3D` references, missing model +paths and paths whose animation fallback is static-only. + +## Non-goals + +- Normalize paths, choose cache files, call ResourceLoader or parse raw M2 data. +- Build, repair or instantiate prototypes, Meshes, materials or MultiMeshes. +- Own animation/Mesh request queues, permits or tile/build state. +- Add eviction, persistence, retries, profile rules or format changes. + +## Context and boundaries + +```mermaid +flowchart LR + Loader[StreamingWorldLoader] --> State[M2PrototypeCacheState] + Resource[ResourceLoader / raw builders] --> Loader + State --> Static[Static prototype Node3D] + State --> Animated[Animated prototype Node3D] + State --> Missing[Missing-model path set] + State --> StaticOnly[Static-animation path set] + Loader --> Instances[Duplicate/animated instance materialization] +``` + +The state may depend on `Node3D` lifetime APIs and value-only path containers. +Filesystem/native/ResourceLoader APIs, builders, worker pools, render scheduling, +Mesh traversal and other application layers are forbidden. + +## Public API + +| Symbol | Kind | Purpose | Thread/lifetime | Errors | +|---|---|---|---|---| +| `has_static_prototype(path)` | Query | Test for one valid retained static prototype | Renderer main thread; shutdown lifetime | Empty/invalid returns false | +| `find_static_prototype(path)` | Query | Return exact retained static Node reference | Renderer main thread; borrowed | Empty/unknown/invalid returns null | +| `adopt_static_prototype(path, prototype)` | Command/query | Retain first valid static prototype and return canonical reference | Renderer main thread; until shutdown | Invalid input returns null; later candidate released | +| `has_animated_prototype(path)` | Query | Test for one valid retained animated prototype | Renderer main thread; shutdown lifetime | Empty/invalid returns false | +| `find_animated_prototype(path)` | Query | Return exact retained animated Node reference | Renderer main thread; borrowed | Empty/unknown/invalid returns null | +| `adopt_animated_prototype(path, prototype)` | Command/query | Retain first valid animated prototype and return canonical reference | Renderer main thread; until shutdown | Invalid input returns null; later candidate released | +| `mark_model_missing(path)` / `is_model_missing(path)` | Command/query | Store/query a missing-model outcome | Renderer main thread; until shutdown | Empty mark rejected | +| `mark_animation_static(path)` / `is_animation_static(path)` | Command/query | Store/query static-only animation outcome | Renderer main thread; until shutdown | Empty mark rejected | +| `diagnostic_snapshot()` | Diagnostic query | Return detached sorted path arrays for all four states | Caller-owned result | Never exposes Nodes | +| `clear_and_release()` | Lifecycle command | Release prototypes and clear all state | Final shutdown after async drain | Idempotent | + +## Inputs and outputs + +| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime | +|---|---|---|---|---|---| +| Input | Normalized non-empty M2 path | Loader normalization | Cache state | Copied String key | Shutdown lifetime | +| Input | Detached static Node3D | Cached scene/raw builder adapter | Cache state | Adopted on success | Until final shutdown | +| Input | Detached animated Node3D | GLB/native animation adapter | Cache state | Adopted on success | Until final shutdown | +| Input | Missing/static-only outcome | Loader failure/fallback adapter | Cache state | Boolean set entry | Until final shutdown | +| Output | Canonical prototype Node3D | Cache state | Loader instance/material adapter | Borrowed exact reference | One lookup/use | +| Output | Detached path-only snapshot | Cache state | Tests/diagnostics | Fresh caller-owned arrays | One query | + +Side effects are strong Node retention, release of rejected duplicate candidates, +final Node destruction/queueing and mutation of runtime-only path state. + +## Data flow + +```mermaid +flowchart TD + Request[Normalized prototype request] --> Positive{Valid prototype cached?} + Positive -->|yes| Return[Return exact prototype] + Positive -->|no| Negative{Missing or static-only outcome?} + Negative -->|yes| Fallback[Return caller fallback] + Negative -->|no| Load[Loader performs existing I/O/build] + Load --> Success{Valid prototype?} + Success -->|yes| Adopt[Adopt first candidate] + Adopt --> Return + Success -->|no| Mark[Mark relevant negative outcome] + Mark --> Fallback +``` + +## Lifecycle/state + +```mermaid +stateDiagram-v2 + [*] --> Empty + Empty --> Positive: adopt static/animated prototype + Empty --> Negative: mark missing/static-only path + Positive --> Positive: lookup or same-reference adoption + Positive --> Positive: duplicate candidate released; first wins + Negative --> Negative: repeated mark/query + Positive --> Released: final shutdown + Negative --> Released: final shutdown + Released --> Released: repeated clear + Released --> [*] +``` + +Map refresh does not transition or clear this service. Final scene shutdown calls +`clear_and_release` only after worker and ResourceLoader work is drained. + +## Main sequence + +```mermaid +sequenceDiagram + participant Loader as StreamingWorldLoader + participant State as M2PrototypeCacheState + participant Source as ResourceLoader/raw builder + Loader->>State: find static/animated(path) + alt positive hit + State-->>Loader: exact retained Node3D + else negative hit + Loader->>State: is missing/static-only(path) + State-->>Loader: true; use historical fallback + else uncached + Loader->>Source: existing load/build operation + alt valid prototype + Loader->>State: adopt(path, prototype) + State-->>Loader: canonical Node3D + else failure + Loader->>State: mark missing/static-only(path) + end + end + Loader->>State: shutdown clear_and_release() + State-->>Loader: all paths empty; Nodes released +``` + +## Dependency diagram + +```mermaid +flowchart TB + Loader[StreamingWorldLoader] --> State[M2PrototypeCacheState] + State --> Node3D + Loader --> Raw[M2RawModelRepository] + Loader --> ResourceLoader + Loader --> StaticBuilder[M2Builder] + Loader --> AnimatedBuilder[M2NativeAnimatedBuilder] + State -. no dependency .-> Raw + State -. no dependency .-> ResourceLoader + State -. no dependency .-> StaticBuilder + State -. no dependency .-> MeshCache[M2MeshResourceCacheState] +``` + +## Ownership, threading and resources + +- Renderer main-thread adapters serialize all operations. +- The state owns strong references to adopted static and animated prototype Nodes. +- A duplicate detached candidate is released immediately; the first valid Node + stays canonical for that path. +- Lookups borrow exact Node references without transferring ownership. +- Detached Nodes are synchronously `free()`d at shutdown. Defensive in-tree + Nodes use `queue_free()` to preserve SceneTree rules. +- Negative entries own only copied normalized-path Strings. + +## Errors, cancellation and recovery + +| Failure/state | Detection | Behavior | Diagnostic | Recovery | +|---|---|---|---|---| +| Empty path/null/invalid Node | Admission guard | Reject/null/false | Dedicated verifier | Correct caller input | +| Unknown positive lookup | Map miss | Return null | Path snapshot | Loader continues existing load/fallback | +| Duplicate candidate | Occupied valid path | Release candidate; return first | Identity fixture | None required | +| Model source/load failure | Loader result | Mark missing outcome | Existing fallback behavior | New loader session/source repair | +| Animation unavailable/unsafe | Loader policy/result | Mark static-only outcome | Existing fallback behavior | New loader session/cache repair | +| Shutdown | Loader lifecycle | Release positive and clear all state | Shutdown verifier | New loader begins empty | +| Cancellation | Not owned | No state transition inside this service | N/A | Loader drains/cancels before shutdown clear | + +## Configuration and capabilities + +| Setting/capability | Default | Profile | Runtime mutable | Effect | +|---|---|---|---|---| +| Positive cache lifetime | Final loader shutdown | All | No | Reuses detached prototypes across map refresh | +| Negative cache lifetime | Final loader shutdown | All | No | Preserves historical fallback suppression | +| Eviction capacity | Unbounded historical behavior | All | No | No mid-session Node destruction | + +Animation enablement, candidate/allow/deny rules, cache paths and per-frame +permits remain loader configuration. + +## Persistence, cache and migration + +This is runtime-only state and serializes nothing. `.tscn/.glb`, material refresh +and native M2 formats are unchanged; no migration or rebake is introduced. + +## Diagnostics and observability + +- `diagnostic_snapshot` exposes four sorted path arrays without Node references. +- Existing `M2_ANIM_CACHE` and native animation logs remain loader-owned. +- Existing renderer queue metrics remain unchanged because these tables never + contributed work counts. +- Normalized relative path remains the correlation key. + +## Verification + +- `verify_m2_prototype_cache_state.gd`: invalid admission, exact static/animated + identity, independent positive/negative states, first-wins duplicate release, + detached/in-tree shutdown ownership, idempotent clear, detached diagnostics, + loader source boundaries and 100-by-256 bounded path-state timing. +- Runtime-cache shutdown regression verifies final release through the real loader. +- Raw repository/finalizer/extractor/Mesh cache/pipeline/material/animation/M2 + build regressions protect adjacent behavior. +- Fidelity evidence is exact cache transition and lifetime extraction. No new + proprietary asset or original-client visual comparison is claimed. + +## Extension points + +Eviction or byte/count budgets require measured memory evidence and explicit +prototype-user lifetime rules. Animation request-state extraction can consume +this service without moving ResourceLoader or builder ownership into it. + +## Capability status + +| Capability | Status | Evidence | Gap/next step | +|---|---|---|---| +| Static prototype retention | Implemented extraction | Identity/source/shutdown verifier | Asset-backed traversal/leak run pending | +| Animated prototype retention | Implemented extraction | Independent identity/shutdown verifier | Legal animated success fixture pending | +| Missing/static-only outcomes | Implemented extraction | Independent negative-state verifier | Recovery still requires new loader session | +| Bounded eviction | Historical unbounded behavior | Documented lifetime | Add only after memory profiling | + +## Known gaps and risks + +- Strong Node references intentionally live until final shutdown. +- Negative outcomes do not retain reason codes and cannot be individually retried, + preserving historical behavior. +- No proprietary long traversal, descriptor pressure, leak, p95/p99 or paired + original-client evidence is included. + +## Source map + +| Path | Responsibility | +|---|---| +| `src/render/m2/m2_prototype_cache_state.gd` | Positive Node ownership, negative path state and shutdown release | +| `src/scenes/streaming/streaming_world_loader.gd` | Normalization, I/O/build/fallback decisions and cache adapters | +| `src/tools/verify_m2_prototype_cache_state.gd` | Admission/identity/lifecycle/source/timing regression | +| `src/tools/verify_render_runtime_cache_shutdown.gd` | Integrated final-shutdown release regression | + +## Related decisions and references + +- [`m2-raw-model-repository.md`](m2-raw-model-repository.md) +- [`m2-mesh-resource-cache-state.md`](m2-mesh-resource-cache-state.md) +- [`m2-mesh-load-pipeline-state.md`](m2-mesh-load-pipeline-state.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/m2-raw-model-repository.md b/docs/modules/m2-raw-model-repository.md index 9da0df0..250f1b9 100644 --- a/docs/modules/m2-raw-model-repository.md +++ b/docs/modules/m2-raw-model-repository.md @@ -55,7 +55,7 @@ renderer policy and other application layers are forbidden. | Input | Extracted `.m2` bytes | Local legal extraction | Native M2Loader | File-owned | Native call | | Output | Static raw M2 Dictionary | Native `load_m2` | Loader/finalizer/M2Builder | Fresh native result | One caller operation | | Output | Animated raw M2 Dictionary | Native `load_m2_animated` | Loader/animated builder | Fresh native result | One caller operation | -| Output | Empty Dictionary | Repository guards | Loader fallback/missing state | Fresh value | One failed call | +| Output | Empty Dictionary | Repository guards | Loader fallback and prototype outcome adapter | Fresh value | One failed call | Side effects are limited to file-existence inspection, synchronous native file parsing and creation of one temporary native loader instance per accepted call. @@ -133,7 +133,8 @@ flowchart TB ## Ownership, threading and resources - The repository owns only call-local path, native instance and result values. -- The loader owns path normalization, cache/fallback selection and adoption. +- The loader owns path normalization and fallback selection; + `M2PrototypeCacheState` owns prototype/negative adoption. - Native `M2Loader` owns parsing behavior and returns a new Dictionary value. - Calls are synchronous on the caller's thread; current renderer callers use the main thread. The module starts no tasks and touches no SceneTree objects. @@ -145,7 +146,7 @@ flowchart TB |---|---|---|---|---| | Empty normalized path | String guard | Return `{}` | Dedicated verifier | Correct caller normalization | | Native extension absent | `ClassDB.class_exists` | Return `{}` | Existing renderer fallback | Install/load extension and retry | -| Extracted file absent | `FileAccess.file_exists` | Return `{}` | Loader adopts historical missing state | Restore extraction and clear caller state | +| Extracted file absent | `FileAccess.file_exists` | Return `{}` | Prototype cache records historical missing state through loader | Restore extraction and start a new loader session | | Instance/method absent | Null/`has_method` guard | Return `{}` | Source contract verifier | Repair extension registration | | Parse/non-Dictionary result | Result type check | Return `{}` | Existing loader fallback | Repair asset/parser and retry | | Cancellation requested | Not supported inside synchronous native call | Call completes | None | Caller controls whether to start call | @@ -210,7 +211,8 @@ measured work packages rather than expansion of this repository. | Path | Responsibility | |---|---| | `src/render/m2/m2_raw_model_repository.gd` | Stateless native class/file/method boundary | -| `src/scenes/streaming/streaming_world_loader.gd` | Path normalization, fallback/cache state and result consumers | +| `src/render/m2/m2_prototype_cache_state.gd` | Prototype and negative-result retention | +| `src/scenes/streaming/streaming_world_loader.gd` | Path normalization, fallback decisions and result consumers | | `src/native/src/m2_loader.cpp` | Native static/animated parsing implementation | | `src/tools/verify_m2_raw_model_repository.gd` | Rejected-input/source/dependency/timing regression | diff --git a/docs/modules/world-renderer.md b/docs/modules/world-renderer.md index c943828..49f7166 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-raw-model-repository`, 2026-07-17 | +| Last verified | Worktree `work/sindo-main-codex/m03-m2-prototype-cache`, 2026-07-17 | | Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete | ## Purpose @@ -138,6 +138,7 @@ from externally reading/writing loader-private queue, task, cache and tile-state | `M2MeshResourceExtractor` | Internal M2 scene/resource service | Selects first Mesh from direct Resource, PackedScene or Node subtree | Renderer main thread; stateless except temporary instance | Invalid/no Mesh returns null; temporary PackedScene root freed | | `M2RuntimeMeshFinalizer` | Internal M2 preparation service | Owns refresh version, rebuild classification, M2Builder rebuild and fallback | Renderer main thread; decisions cached until reset | Null returns null; missing/failed rebuild marks and reuses original Mesh | | `M2RawModelRepository` | Internal M2 native repository | Reads static/animated raw Dictionaries through exact M2Loader methods | Synchronous; stateless | Invalid/unavailable/non-Dictionary result returns empty Dictionary | +| `M2PrototypeCacheState` | Internal M2 prototype cache | Owns detached static/animated Nodes and missing/static-only outcomes | Renderer main thread; final shutdown | Invalid admission rejected; first valid prototype wins | | `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 | @@ -182,6 +183,7 @@ loader configuration remains transitional composition data, not a caller API. | Internal WMO scene cache | Normalized path, `.tscn` path and validated PackedScene | Loader / `WmoSceneResourceCacheState` | Loader lookup, request poll and scene instantiation | State-owned PackedScene/path references; detached request snapshots | Until transient/full clear | | Internal ADT water load | Tile key, ADT path, task ID and parsed Dictionary | Loader/worker / `AdtWaterLoadPipelineState` | Loader task start, budgeted drain and finalization | State-owned records; mutex result mailbox | Request through result completion/reset | | Internal raw M2 read | Extracted directory and normalized relative path | Loader / `M2RawModelRepository` | Finalizer, static or animated builder adapters | Fresh Dictionary; repository retains nothing | One synchronous native call | +| Internal M2 prototype state | Normalized path, adopted static/animated Node or negative outcome | Loader / `M2PrototypeCacheState` | Loader reuse/fallback adapters | State-owned strong Node refs and Strings; detached diagnostics | Until final shutdown | | 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 | @@ -377,10 +379,11 @@ sequenceDiagram finalizer. The raw repository loads value data; loader retains Mesh adoption. - `M2MeshLoadPipelineState` owns static M2 pending Resource paths, opaque terminal statuses and completion-order finalize FIFO. The loader retains cache - path selection, ResourceLoader calls, permits, shared missing state and adoption. + path selection, ResourceLoader calls, permits and adoption decisions; prototype + cache state owns shared missing outcomes. - `M2MeshResourceCacheState` owns prepared static Mesh references and releases - them at the existing final-shutdown site. The loader retains missing/prototype - state and materialization. + them at the existing final-shutdown site. Prototype state and materialization + belong to the sibling cache service and loader respectively. - `M2MeshResourceExtractor` owns depth-first first-Mesh selection and temporary PackedScene instance destruction. The loader retains ResourceLoader I/O, cache/missing adoption and materialization. @@ -388,8 +391,12 @@ sequenceDiagram M2Builder rebuild and original-Mesh fallback. The loader loads raw data only after the finalizer reports that a cached Mesh is stale. - `M2RawModelRepository` owns FileAccess/ClassDB availability and the exact - `load_m2`/`load_m2_animated` calls. The loader retains normalization, - fallback order, missing/prototype state and every result consumer. + `load_m2`/`load_m2_animated` calls. The loader retains normalization, fallback + order and every result consumer; `M2PrototypeCacheState` retains outcomes. +- `M2PrototypeCacheState` owns exact static/animated prototype references, + missing-model and static-animation path sets, duplicate-candidate release and + final-shutdown Node cleanup. ResourceLoader, builders and fallback decisions + remain loader-owned. - 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 @@ -531,6 +538,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m | M2 placement transform resolver | Implemented extraction | Scene-free formula/source/timing contract across three consumers | Asset-backed visual recheck and general placement parity pending | | M2 placement grouper | Implemented extraction | Scene-free validation/order/transform/source/timing contract | Worker/build state, spatial cells and asset-backed p95/p99 remain pending | | M2 build batch planner | Implemented extraction | Scene-free limit/count/cursor/source/timing contract | Queue/resource state, spatial cells and asset-backed p95/p99 remain pending | +| M2 prototype cache state | Implemented extraction | Synthetic identity/negative/lifecycle/source/timing plus shutdown contract | Asset-backed traversal/leak/p95/p99 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 render build step planner | Implemented extraction | Scene-free order/cursor/source/timing contract | Asset-backed traversal p95/p99 pending | @@ -596,6 +604,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m | `src/render/m2/m2_mesh_resource_extractor.gd` | First-Mesh selection and temporary PackedScene instance lifetime | | `src/render/m2/m2_runtime_mesh_finalizer.gd` | Runtime refresh version, rebuild classification/build and fallback | | `src/render/m2/m2_raw_model_repository.gd` | Stateless static/animated native M2 file boundary | +| `src/render/m2/m2_prototype_cache_state.gd` | Detached prototype ownership and negative lookup outcomes | | `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 | @@ -616,6 +625,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m | `src/tools/verify_m2_mesh_resource_extractor.gd` | M2 direct/PackedScene/subtree order/lifetime/boundary/timing regression | | `src/tools/verify_m2_runtime_mesh_finalizer.gd` | M2 current/stale/rebuild/fallback/boundary/timing regression | | `src/tools/verify_m2_raw_model_repository.gd` | M2 invalid/missing/native-boundary/dependency/timing regression | +| `src/tools/verify_m2_prototype_cache_state.gd` | M2 prototype identity/negative/lifecycle/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_prototype_cache_state.gd b/src/render/m2/m2_prototype_cache_state.gd new file mode 100644 index 0000000..714b26c --- /dev/null +++ b/src/render/m2/m2_prototype_cache_state.gd @@ -0,0 +1,157 @@ +class_name M2PrototypeCacheState +extends RefCounted + +## Owns detached static/animated M2 prototypes and their negative lookup +## outcomes until final renderer shutdown. + +var _static_prototypes_by_path: Dictionary = {} +var _animated_prototypes_by_path: Dictionary = {} +var _missing_model_paths: Dictionary = {} +var _static_animation_paths: Dictionary = {} + + +## Returns whether a valid static prototype is retained for the normalized path. +func has_static_prototype(normalized_relative_path: String) -> bool: + return find_static_prototype(normalized_relative_path) != null + + +## Returns the exact retained static prototype or null. +func find_static_prototype(normalized_relative_path: String) -> Node3D: + return _find_valid_prototype(_static_prototypes_by_path, normalized_relative_path) + + +## Adopts a static prototype and returns the canonical retained reference. The +## first valid prototype wins; a later detached candidate is released. +func adopt_static_prototype( + normalized_relative_path: String, + prototype: Node3D) -> Node3D: + return _adopt_prototype( + _static_prototypes_by_path, + normalized_relative_path, + prototype + ) + + +## Returns whether a valid animated prototype is retained for the normalized path. +func has_animated_prototype(normalized_relative_path: String) -> bool: + return find_animated_prototype(normalized_relative_path) != null + + +## Returns the exact retained animated prototype or null. +func find_animated_prototype(normalized_relative_path: String) -> Node3D: + return _find_valid_prototype(_animated_prototypes_by_path, normalized_relative_path) + + +## Adopts an animated prototype and returns the canonical retained reference. +func adopt_animated_prototype( + normalized_relative_path: String, + prototype: Node3D) -> Node3D: + return _adopt_prototype( + _animated_prototypes_by_path, + normalized_relative_path, + prototype + ) + + +## Records that no model prototype can currently be loaded for this path. +func mark_model_missing(normalized_relative_path: String) -> bool: + return _mark_path(_missing_model_paths, normalized_relative_path) + + +## Returns whether the model path has a retained missing outcome. +func is_model_missing(normalized_relative_path: String) -> bool: + return not normalized_relative_path.is_empty() and _missing_model_paths.has( + normalized_relative_path + ) + + +## Records that the path must use static presentation instead of animation. +func mark_animation_static(normalized_relative_path: String) -> bool: + return _mark_path(_static_animation_paths, normalized_relative_path) + + +## Returns whether the path has a retained static-animation outcome. +func is_animation_static(normalized_relative_path: String) -> bool: + return not normalized_relative_path.is_empty() and _static_animation_paths.has( + normalized_relative_path + ) + + +## Returns detached path-only cache state for diagnostics and verification. +func diagnostic_snapshot() -> Dictionary: + return { + "static_prototype_paths": _sorted_paths(_static_prototypes_by_path), + "animated_prototype_paths": _sorted_paths(_animated_prototypes_by_path), + "missing_model_paths": _sorted_paths(_missing_model_paths), + "static_animation_paths": _sorted_paths(_static_animation_paths), + } + + +## Releases every retained prototype and clears positive and negative state. +## Detached Nodes are freed synchronously; in-tree Nodes are queued for deletion. +func clear_and_release() -> void: + _release_prototypes(_static_prototypes_by_path) + _release_prototypes(_animated_prototypes_by_path) + _missing_model_paths.clear() + _static_animation_paths.clear() + + +func _find_valid_prototype( + prototypes_by_path: Dictionary, + normalized_relative_path: String) -> Node3D: + if normalized_relative_path.is_empty(): + return null + var prototype_variant: Variant = prototypes_by_path.get(normalized_relative_path) + if not (prototype_variant is Node3D): + return null + var prototype := prototype_variant as Node3D + return prototype if is_instance_valid(prototype) else null + + +func _adopt_prototype( + prototypes_by_path: Dictionary, + normalized_relative_path: String, + prototype: Node3D) -> Node3D: + if normalized_relative_path.is_empty() or prototype == null or not is_instance_valid(prototype): + return null + var retained_prototype := _find_valid_prototype( + prototypes_by_path, + normalized_relative_path + ) + if retained_prototype != null: + if not is_same(retained_prototype, prototype): + _release_prototype(prototype) + return retained_prototype + prototypes_by_path[normalized_relative_path] = prototype + return prototype + + +func _mark_path(paths: Dictionary, normalized_relative_path: String) -> bool: + if normalized_relative_path.is_empty(): + return false + paths[normalized_relative_path] = true + return true + + +func _release_prototypes(prototypes_by_path: Dictionary) -> void: + for prototype_variant in prototypes_by_path.values(): + if prototype_variant is Node3D: + _release_prototype(prototype_variant as Node3D) + prototypes_by_path.clear() + + +func _release_prototype(prototype: Node3D) -> void: + if prototype == null or not is_instance_valid(prototype): + return + if prototype.is_inside_tree(): + prototype.queue_free() + else: + prototype.free() + + +func _sorted_paths(paths: Dictionary) -> Array[String]: + var result: Array[String] = [] + for path_variant in paths.keys(): + result.append(String(path_variant)) + result.sort() + return result diff --git a/src/render/m2/m2_prototype_cache_state.gd.uid b/src/render/m2/m2_prototype_cache_state.gd.uid new file mode 100644 index 0000000..8547d0d --- /dev/null +++ b/src/render/m2/m2_prototype_cache_state.gd.uid @@ -0,0 +1 @@ +uid://srnqsus8pddp diff --git a/src/scenes/streaming/streaming_world_loader.gd b/src/scenes/streaming/streaming_world_loader.gd index 5796c9d..a4f589d 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_FINALIZER_SCRIPT := preload( const M2_RAW_MODEL_REPOSITORY_SCRIPT := preload( "res://src/render/m2/m2_raw_model_repository.gd" ) +const M2_PROTOTYPE_CACHE_STATE_SCRIPT := preload( + "res://src/render/m2/m2_prototype_cache_state.gd" +) const M2_MESH_LOAD_PIPELINE_STATE_SCRIPT := preload( "res://src/render/m2/m2_mesh_load_pipeline_state.gd" ) @@ -263,6 +266,7 @@ var _m2_placement_grouper := M2_PLACEMENT_GROUPER_SCRIPT.new() var _m2_build_batch_planner := M2_BUILD_BATCH_PLANNER_SCRIPT.new() var _m2_runtime_mesh_finalizer := M2_RUNTIME_MESH_FINALIZER_SCRIPT.new() var _m2_raw_model_repository := M2_RAW_MODEL_REPOSITORY_SCRIPT.new() +var _m2_prototype_cache_state := M2_PROTOTYPE_CACHE_STATE_SCRIPT.new() var _m2_mesh_resource_cache_state := M2_MESH_RESOURCE_CACHE_STATE_SCRIPT.new() var _m2_mesh_resource_extractor := M2_MESH_RESOURCE_EXTRACTOR_SCRIPT.new() var _m2_mesh_load_pipeline_state := M2_MESH_LOAD_PIPELINE_STATE_SCRIPT.new() @@ -309,10 +313,6 @@ var _wmo_render_build_queue := WMO_RENDER_BUILD_QUEUE_SCRIPT.new() var _wmo_scene_resource_cache_state := WMO_SCENE_RESOURCE_CACHE_STATE_SCRIPT.new() var _wmo_missing_cache: Dictionary = {} var _wmo_placement_resolver := WMO_PLACEMENT_RESOLVER_SCRIPT.new() -var _m2_scene_cache: Dictionary = {} -var _m2_animated_scene_cache: Dictionary = {} -var _m2_static_animation_cache: Dictionary = {} -var _m2_missing_cache: Dictionary = {} var _world_wmo_root: Node3D var _wmo_placement_registry := WMO_PLACEMENT_REGISTRY_SCRIPT.new() var _wmo_render_build_step_planner := WMO_RENDER_BUILD_STEP_PLANNER_SCRIPT.new() @@ -481,12 +481,9 @@ func _render_operation_limits_for_frame() -> Dictionary: ## loader after all asynchronous work has completed. Map refreshes retain these ## caches; only scene shutdown destroys them. func _release_runtime_caches_for_shutdown() -> void: - _free_detached_node_cache(_m2_scene_cache) - _free_detached_node_cache(_m2_animated_scene_cache) + _m2_prototype_cache_state.clear_and_release() _free_detached_node_cache(_wmo_prototype_cache) _m2_mesh_resource_cache_state.clear() - _m2_static_animation_cache.clear() - _m2_missing_cache.clear() _wmo_render_resource_cache_state.clear_all() _wmo_scene_resource_cache_state.clear_all() _wmo_missing_cache.clear() @@ -4219,7 +4216,7 @@ func _drain_m2_animation_loads() -> void: var path := String(pending.get("path", "")) if path.is_empty(): _m2_animation_load_requests.erase(normalized_rel) - _m2_static_animation_cache[normalized_rel] = true + _m2_prototype_cache_state.mark_animation_static(normalized_rel) continue var status := ResourceLoader.load_threaded_get_status(path) if status != ResourceLoader.THREAD_LOAD_LOADED and status != ResourceLoader.THREAD_LOAD_FAILED: @@ -4232,10 +4229,14 @@ func _drain_m2_animation_loads() -> void: RENDER_BUDGET_SCHEDULER_SCRIPT.M2_ANIMATION_FINALIZE): var pending: Dictionary = _m2_animation_finalize_queue.pop_front() var normalized_rel := String(pending.get("normalized", "")) - if normalized_rel.is_empty() or _m2_animated_scene_cache.has(normalized_rel) or _m2_static_animation_cache.has(normalized_rel): + if ( + normalized_rel.is_empty() + or _m2_prototype_cache_state.has_animated_prototype(normalized_rel) + or _m2_prototype_cache_state.is_animation_static(normalized_rel) + ): continue if int(pending.get("status", ResourceLoader.THREAD_LOAD_FAILED)) != ResourceLoader.THREAD_LOAD_LOADED: - _m2_static_animation_cache[normalized_rel] = true + _m2_prototype_cache_state.mark_animation_static(normalized_rel) continue var path := String(pending.get("path", "")) @@ -4246,12 +4247,15 @@ func _drain_m2_animation_loads() -> void: _repair_m2_animated_materials(normalized_rel, node as Node3D) var players := _find_animation_players_recursive(node) if not players.is_empty(): - _m2_animated_scene_cache[normalized_rel] = node as Node3D + _m2_prototype_cache_state.adopt_animated_prototype( + normalized_rel, + node as Node3D + ) if debug_streaming: print("M2_ANIM_CACHE path=%s cache=%s players=%d" % [normalized_rel, path, players.size()]) continue node.free() - _m2_static_animation_cache[normalized_rel] = true + _m2_prototype_cache_state.mark_animation_static(normalized_rel) func _drain_m2_mesh_loads() -> void: @@ -4260,7 +4264,7 @@ func _drain_m2_mesh_loads() -> void: var path: String = String(pending.get("path", "")) if path.is_empty(): _m2_mesh_load_pipeline_state.discard_request(normalized_rel) - _m2_missing_cache[normalized_rel] = true + _m2_prototype_cache_state.mark_model_missing(normalized_rel) continue var status := ResourceLoader.load_threaded_get_status(path) if status != ResourceLoader.THREAD_LOAD_LOADED and status != ResourceLoader.THREAD_LOAD_FAILED: @@ -4277,7 +4281,7 @@ func _drain_m2_mesh_loads() -> void: ): continue if int(pending.get("status", ResourceLoader.THREAD_LOAD_FAILED)) != ResourceLoader.THREAD_LOAD_LOADED: - _m2_missing_cache[normalized_rel] = true + _m2_prototype_cache_state.mark_model_missing(normalized_rel) continue var path := String(pending.get("path", "")) @@ -4289,7 +4293,7 @@ func _drain_m2_mesh_loads() -> void: _prepare_m2_mesh_for_runtime(normalized_rel, mesh) ) else: - _m2_missing_cache[normalized_rel] = true + _m2_prototype_cache_state.mark_model_missing(normalized_rel) func _process_m2_build_jobs() -> void: @@ -4357,7 +4361,7 @@ func _process_m2_build_jobs() -> void: else: var mesh := _get_m2_mesh_or_request(rel_path) if mesh == null: - if not _m2_missing_cache.has(normalized_rel): + if not _m2_prototype_cache_state.is_model_missing(normalized_rel): _m2_build_queue.pop_front() _m2_build_queue.append(key) _render_budget_scheduler.try_consume_permit(RENDER_BUDGET_SCHEDULER_SCRIPT.M2_BUILD) @@ -4624,7 +4628,7 @@ func _get_m2_mesh_or_request(rel_path: String) -> Mesh: return null 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): + if _m2_prototype_cache_state.is_model_missing(normalized_rel): return null _request_m2_mesh_load(normalized_rel) return null @@ -4649,7 +4653,7 @@ func _request_m2_mesh_load(normalized_rel: String) -> void: return break - _m2_missing_cache[normalized_rel] = true + _m2_prototype_cache_state.mark_model_missing(normalized_rel) func _prepare_m2_mesh_for_runtime(normalized_rel: String, mesh: Mesh) -> Mesh: @@ -4840,11 +4844,11 @@ func _get_or_load_m2_prototype(rel_path: String) -> Node3D: var normalized_rel := _normalize_m2_rel_path(rel_path) if normalized_rel.is_empty(): return null - if _m2_scene_cache.has(normalized_rel): - var cached: Node3D = _m2_scene_cache[normalized_rel] + var cached := _m2_prototype_cache_state.find_static_prototype(normalized_rel) + if cached != null: _prepare_m2_node_for_runtime(normalized_rel, cached) return cached - if _m2_missing_cache.has(normalized_rel): + if _m2_prototype_cache_state.is_model_missing(normalized_rel): return null # Try pre-baked cache (.tscn first, then legacy .glb) @@ -4858,8 +4862,10 @@ func _get_or_load_m2_prototype(rel_path: String) -> Node3D: var node = (resource as PackedScene).instantiate() if node is Node3D: _prepare_m2_node_for_runtime(normalized_rel, node as Node3D) - _m2_scene_cache[normalized_rel] = node as Node3D - return node as Node3D + return _m2_prototype_cache_state.adopt_static_prototype( + normalized_rel, + node as Node3D + ) break # Fall back to the raw native M2 repository. @@ -4868,25 +4874,30 @@ func _get_or_load_m2_prototype(rel_path: String) -> Node3D: normalized_rel ) if data.is_empty(): - _m2_missing_cache[normalized_rel] = true + _m2_prototype_cache_state.mark_model_missing(normalized_rel) return null var prototype: Node3D = M2_BUILDER_SCRIPT.build(data, extracted_dir) if prototype == null: - _m2_missing_cache[normalized_rel] = true + _m2_prototype_cache_state.mark_model_missing(normalized_rel) return null - _m2_scene_cache[normalized_rel] = prototype - return prototype + return _m2_prototype_cache_state.adopt_static_prototype( + normalized_rel, + prototype + ) func _get_or_load_m2_native_animated_prototype(rel_path: String) -> Node3D: var normalized_rel := _normalize_m2_rel_path(rel_path) if normalized_rel.is_empty() or not _is_m2_native_animation_candidate(normalized_rel): return null - if _m2_animated_scene_cache.has(normalized_rel): - return _m2_animated_scene_cache[normalized_rel] - if _m2_static_animation_cache.has(normalized_rel): + var cached_animated := _m2_prototype_cache_state.find_animated_prototype( + normalized_rel + ) + if cached_animated != null: + return cached_animated + if _m2_prototype_cache_state.is_animation_static(normalized_rel): return null var data := _m2_raw_model_repository.load_animated_model_data( extracted_dir, @@ -4894,15 +4905,18 @@ func _get_or_load_m2_native_animated_prototype(rel_path: String) -> Node3D: ) var animated_surfaces: Array = data.get("animated_surfaces", []) if data.is_empty() or animated_surfaces.is_empty(): - _m2_static_animation_cache[normalized_rel] = true + _m2_prototype_cache_state.mark_animation_static(normalized_rel) return null var prototype: Node3D = M2_NATIVE_ANIMATED_BUILDER_SCRIPT.build(data, extracted_dir) if prototype == null or prototype.get_child_count() <= 0: - _m2_static_animation_cache[normalized_rel] = true + _m2_prototype_cache_state.mark_animation_static(normalized_rel) return null - _m2_animated_scene_cache[normalized_rel] = prototype + prototype = _m2_prototype_cache_state.adopt_animated_prototype( + normalized_rel, + prototype + ) if debug_streaming: print("M2_NATIVE_ANIM_CACHE path=%s surfaces=%d bones=%d anim_id=%d seq=%d score=%d length=%.2f" % [ normalized_rel, @@ -4924,9 +4938,12 @@ func _get_or_load_m2_animated_prototype(rel_path: String) -> Node3D: var normalized_rel := _normalize_m2_rel_path(rel_path) if normalized_rel.is_empty(): return null - if _m2_animated_scene_cache.has(normalized_rel): - return _m2_animated_scene_cache[normalized_rel] - if _m2_static_animation_cache.has(normalized_rel): + var cached_animated := _m2_prototype_cache_state.find_animated_prototype( + normalized_rel + ) + if cached_animated != null: + return cached_animated + if _m2_prototype_cache_state.is_animation_static(normalized_rel): return null if _m2_animation_load_requests.has(normalized_rel): return null @@ -4940,7 +4957,7 @@ func _request_m2_animation_load(normalized_rel: String) -> void: return var cache_res_path := _find_m2_animated_glb_cache_path(normalized_rel) if cache_res_path.is_empty(): - _m2_static_animation_cache[normalized_rel] = true + _m2_prototype_cache_state.mark_animation_static(normalized_rel) return var err := ResourceLoader.load_threaded_request( cache_res_path, @@ -4953,7 +4970,7 @@ func _request_m2_animation_load(normalized_rel: String) -> void: "path": cache_res_path, } else: - _m2_static_animation_cache[normalized_rel] = true + _m2_prototype_cache_state.mark_animation_static(normalized_rel) func _find_m2_animated_glb_cache_path(normalized_rel: String) -> String: @@ -5018,8 +5035,8 @@ func _repair_m2_animated_materials(normalized_rel: String, animated_root: Node3D func _get_or_load_m2_material_prototype(normalized_rel: String) -> Node3D: if normalized_rel.is_empty(): return null - if _m2_scene_cache.has(normalized_rel): - var cached: Node3D = _m2_scene_cache[normalized_rel] + var cached := _m2_prototype_cache_state.find_static_prototype(normalized_rel) + if cached != null: _prepare_m2_node_for_runtime(normalized_rel, cached) return cached @@ -5031,8 +5048,10 @@ func _get_or_load_m2_material_prototype(normalized_rel: String) -> Node3D: var node = (resource as PackedScene).instantiate() if node is Node3D: _prepare_m2_node_for_runtime(normalized_rel, node as Node3D) - _m2_scene_cache[normalized_rel] = node as Node3D - return node as Node3D + return _m2_prototype_cache_state.adopt_static_prototype( + normalized_rel, + node as Node3D + ) return null diff --git a/src/tools/verify_m2_mesh_load_pipeline_state.gd b/src/tools/verify_m2_mesh_load_pipeline_state.gd index e119b4f..82ba54c 100644 --- a/src/tools/verify_m2_mesh_load_pipeline_state.gd +++ b/src/tools/verify_m2_mesh_load_pipeline_state.gd @@ -111,7 +111,7 @@ func _verify_ownership_boundaries(failures: Array[String]) -> void: "ResourceLoader.load_threaded_get(path)", "RENDER_BUDGET_SCHEDULER_SCRIPT.M2_MESH_FINALIZE", "_m2_mesh_resource_cache_state.store_mesh(", - "_m2_missing_cache[normalized_rel]", + "_m2_prototype_cache_state.mark_model_missing(normalized_rel)", ]: _expect_true(loader_source.contains(retained_loader_rule), "loader retains %s" % retained_loader_rule, failures) for forbidden_dependency in [ @@ -122,7 +122,7 @@ func _verify_ownership_boundaries(failures: Array[String]) -> void: "RID(", "_m2_mesh_cache", "_m2_mesh_resource_cache_state", - "_m2_missing_cache", + "_m2_prototype_cache_state", ]: _expect_false( pipeline_source.contains(forbidden_dependency), diff --git a/src/tools/verify_m2_mesh_resource_cache_state.gd b/src/tools/verify_m2_mesh_resource_cache_state.gd index 13667ac..ecb8d94 100644 --- a/src/tools/verify_m2_mesh_resource_cache_state.gd +++ b/src/tools/verify_m2_mesh_resource_cache_state.gd @@ -96,8 +96,8 @@ func _verify_ownership_boundaries(failures: Array[String]) -> void: "ResourceLoader.load_threaded_get(path)", "_m2_mesh_resource_extractor.extract_first_mesh(resource)", "_prepare_m2_mesh_for_runtime(normalized_rel, mesh)", - "_m2_missing_cache[normalized_rel]", - "_m2_scene_cache[normalized_rel]", + "_m2_prototype_cache_state.mark_model_missing(normalized_rel)", + "_m2_prototype_cache_state.adopt_static_prototype(", ]: _expect_true(loader_source.contains(retained_loader_rule), "loader retains %s" % retained_loader_rule, failures) for forbidden_dependency in [ @@ -107,8 +107,7 @@ func _verify_ownership_boundaries(failures: Array[String]) -> void: "PackedScene", "Node3D", "M2Builder", - "_m2_missing_cache", - "_m2_scene_cache", + "_m2_prototype_cache_state", ]: _expect_false( cache_source.contains(forbidden_dependency), diff --git a/src/tools/verify_m2_mesh_resource_extractor.gd b/src/tools/verify_m2_mesh_resource_extractor.gd index 61d6b32..2cf7863 100644 --- a/src/tools/verify_m2_mesh_resource_extractor.gd +++ b/src/tools/verify_m2_mesh_resource_extractor.gd @@ -126,7 +126,7 @@ func _verify_ownership_boundaries(failures: Array[String]) -> void: "_prepare_m2_mesh_for_runtime(normalized_rel, mesh)", "_m2_mesh_resource_cache_state.store_mesh(", "M2_BUILDER_SCRIPT.build(data, extracted_dir)", - "_m2_missing_cache[normalized_rel]", + "_m2_prototype_cache_state.mark_model_missing(normalized_rel)", ]: _expect_true(loader_source.contains(retained_loader_rule), "loader retains %s" % retained_loader_rule, failures) for forbidden_dependency in [ @@ -135,7 +135,7 @@ func _verify_ownership_boundaries(failures: Array[String]) -> void: "WorkerThreadPool.", "M2Builder", "_m2_mesh_resource_cache_state", - "_m2_missing_cache", + "_m2_prototype_cache_state", "MultiMesh", ]: _expect_false( diff --git a/src/tools/verify_m2_prototype_cache_state.gd b/src/tools/verify_m2_prototype_cache_state.gd new file mode 100644 index 0000000..b78a448 --- /dev/null +++ b/src/tools/verify_m2_prototype_cache_state.gd @@ -0,0 +1,162 @@ +extends SceneTree + +## Synthetic admission/identity/negative-state/release/source/timing contract. + +const CACHE_STATE_SCRIPT := preload("res://src/render/m2/m2_prototype_cache_state.gd") +const CACHE_STATE_PATH := "res://src/render/m2/m2_prototype_cache_state.gd" +const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd" + + +func _initialize() -> void: + var failures: Array[String] = [] + _verify_admission_identity_and_negative_state(failures) + _verify_release_ownership(failures) + _verify_ownership_boundaries(failures) + var elapsed_milliseconds := _verify_bounded_timing(failures) + if not failures.is_empty(): + for failure in failures: + push_error("M2_PROTOTYPE_CACHE_STATE: %s" % failure) + quit(1) + return + print( + "M2_PROTOTYPE_CACHE_STATE PASS cases=16 iterations=100 elapsed_ms=%.3f" + % elapsed_milliseconds + ) + quit(0) + + +func _verify_admission_identity_and_negative_state(failures: Array[String]) -> void: + var state: RefCounted = CACHE_STATE_SCRIPT.new() + _expect_same(state.call("find_static_prototype", "world/missing.m2"), null, "unknown static miss", failures) + _expect_false(state.call("mark_model_missing", ""), "empty missing path rejected", failures) + _expect_false(state.call("mark_animation_static", ""), "empty static-animation path rejected", failures) + var static_prototype := Node3D.new() + var animated_prototype := Node3D.new() + _expect_same( + state.call("adopt_static_prototype", "world/tree.m2", static_prototype), + static_prototype, + "static prototype adopted", + failures + ) + _expect_same( + state.call("find_static_prototype", "world/tree.m2"), + static_prototype, + "static identity retained", + failures + ) + _expect_same( + state.call("adopt_animated_prototype", "world/tree.m2", animated_prototype), + animated_prototype, + "animated prototype independent", + failures + ) + var duplicate_static := Node3D.new() + _expect_same( + state.call("adopt_static_prototype", "world/tree.m2", duplicate_static), + static_prototype, + "first static prototype wins", + failures + ) + _expect_false(is_instance_valid(duplicate_static), "duplicate detached prototype released", failures) + _expect_true(state.call("mark_model_missing", "world/missing.m2"), "missing path marked", failures) + _expect_true(state.call("is_model_missing", "world/missing.m2"), "missing path retained", failures) + _expect_true(state.call("mark_animation_static", "world/rock.m2"), "static animation marked", failures) + _expect_true(state.call("is_animation_static", "world/rock.m2"), "static animation retained", failures) + var snapshot: Dictionary = state.call("diagnostic_snapshot") + _expect_equal((snapshot["static_prototype_paths"] as Array).size(), 1, "one static path", failures) + _expect_equal((snapshot["animated_prototype_paths"] as Array).size(), 1, "one animated path", failures) + state.call("clear_and_release") + + +func _verify_release_ownership(failures: Array[String]) -> void: + var state: RefCounted = CACHE_STATE_SCRIPT.new() + var detached_static := Node3D.new() + var in_tree_animated := Node3D.new() + get_root().add_child(in_tree_animated) + state.call("adopt_static_prototype", "world/static.m2", detached_static) + state.call("adopt_animated_prototype", "world/animated.m2", in_tree_animated) + state.call("mark_model_missing", "world/missing.m2") + state.call("mark_animation_static", "world/static.m2") + state.call("clear_and_release") + state.call("clear_and_release") + _expect_false(is_instance_valid(detached_static), "detached prototype freed", failures) + _expect_true( + not is_instance_valid(in_tree_animated) or in_tree_animated.is_queued_for_deletion(), + "in-tree prototype queued or released by headless teardown", + failures + ) + var snapshot: Dictionary = state.call("diagnostic_snapshot") + for state_key in snapshot.keys(): + _expect_true((snapshot[state_key] as Array).is_empty(), "%s cleared" % state_key, failures) + + +func _verify_ownership_boundaries(failures: Array[String]) -> void: + var state_source := FileAccess.get_file_as_string(CACHE_STATE_PATH) + var loader_source := FileAccess.get_file_as_string(LOADER_PATH) + _expect_true( + loader_source.contains("M2_PROTOTYPE_CACHE_STATE_SCRIPT.new()"), + "loader composes prototype cache state", + failures + ) + for removed_loader_field in [ + "var _m2_scene_cache:", + "var _m2_animated_scene_cache:", + "var _m2_static_animation_cache:", + "var _m2_missing_cache:", + ]: + _expect_false(loader_source.contains(removed_loader_field), "loader omits %s" % removed_loader_field, failures) + for required_loader_rule in [ + "_m2_prototype_cache_state.find_static_prototype(", + "_m2_prototype_cache_state.adopt_static_prototype(", + "_m2_prototype_cache_state.find_animated_prototype(", + "_m2_prototype_cache_state.adopt_animated_prototype(", + "_m2_prototype_cache_state.mark_model_missing(", + "_m2_prototype_cache_state.mark_animation_static(", + "_m2_prototype_cache_state.clear_and_release()", + ]: + _expect_true(loader_source.contains(required_loader_rule), "loader delegates %s" % required_loader_rule, failures) + for forbidden_dependency in [ + "ResourceLoader.", + "FileAccess.", + "ClassDB.", + "M2Builder", + "WorkerThreadPool", + "MultiMesh", + "MeshInstance3D", + ]: + _expect_false(state_source.contains(forbidden_dependency), "state omits %s" % forbidden_dependency, failures) + + +func _verify_bounded_timing(failures: Array[String]) -> float: + var started_microseconds := Time.get_ticks_usec() + for _iteration in range(100): + var state: RefCounted = CACHE_STATE_SCRIPT.new() + for path_index in range(256): + var normalized_path := "world/model_%d.m2" % path_index + state.call("mark_model_missing", normalized_path) + state.call("mark_animation_static", normalized_path) + state.call("is_model_missing", normalized_path) + state.call("is_animation_static", normalized_path) + state.call("clear_and_release") + var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0 + _expect_true(elapsed_milliseconds < 1000.0, "100 by 256 path-state cycles 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_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_prototype_cache_state.gd.uid b/src/tools/verify_m2_prototype_cache_state.gd.uid new file mode 100644 index 0000000..0df3f1c --- /dev/null +++ b/src/tools/verify_m2_prototype_cache_state.gd.uid @@ -0,0 +1 @@ +uid://dkjneibuiqjss diff --git a/src/tools/verify_m2_raw_model_repository.gd b/src/tools/verify_m2_raw_model_repository.gd index ae0c7d4..a353014 100644 --- a/src/tools/verify_m2_raw_model_repository.gd +++ b/src/tools/verify_m2_raw_model_repository.gd @@ -96,7 +96,7 @@ func _verify_ownership_boundaries(failures: Array[String]) -> void: for forbidden_dependency in [ "ResourceLoader.", "M2_BUILDER", - "_m2_missing_cache", + "_m2_prototype_cache_state", "Mesh", "Node", "WorkerThreadPool", diff --git a/src/tools/verify_m2_runtime_mesh_finalizer.gd b/src/tools/verify_m2_runtime_mesh_finalizer.gd index a23cd2d..3322ab9 100644 --- a/src/tools/verify_m2_runtime_mesh_finalizer.gd +++ b/src/tools/verify_m2_runtime_mesh_finalizer.gd @@ -129,7 +129,7 @@ func _verify_ownership_boundaries(failures: Array[String]) -> void: "M2_RAW_MODEL_REPOSITORY_SCRIPT.new()", "_m2_raw_model_repository.load_static_model_data(", "_m2_mesh_resource_cache_state.store_mesh(", - "_m2_missing_cache[normalized_rel]", + "_m2_prototype_cache_state.mark_model_missing(normalized_rel)", ]: _expect_true(loader_source.contains(retained_loader_rule), "loader retains %s" % retained_loader_rule, failures) for raw_repository_rule in [ @@ -152,7 +152,7 @@ func _verify_ownership_boundaries(failures: Array[String]) -> void: "ClassDB.", "load_m2", "_m2_mesh_resource_cache_state", - "_m2_missing_cache", + "_m2_prototype_cache_state", "MultiMesh", ]: _expect_false( diff --git a/src/tools/verify_render_runtime_cache_shutdown.gd b/src/tools/verify_render_runtime_cache_shutdown.gd index 6f7ea28..126338e 100644 --- a/src/tools/verify_render_runtime_cache_shutdown.gd +++ b/src/tools/verify_render_runtime_cache_shutdown.gd @@ -32,13 +32,14 @@ func _verify_runtime_cache_ownership() -> void: var animated_m2_prototype := Node3D.new() var wmo_prototype := Node3D.new() - loader.set("_m2_scene_cache", {"static": static_m2_prototype}) - loader.set("_m2_animated_scene_cache", {"animated": animated_m2_prototype}) + var m2_prototype_cache_state: RefCounted = loader.get("_m2_prototype_cache_state") + m2_prototype_cache_state.call("adopt_static_prototype", "static", static_m2_prototype) + m2_prototype_cache_state.call("adopt_animated_prototype", "animated", animated_m2_prototype) loader.set("_wmo_prototype_cache", {"wmo": wmo_prototype}) 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}) + m2_prototype_cache_state.call("mark_animation_static", "static") + m2_prototype_cache_state.call("mark_model_missing", "missing") var wmo_render_cache_state: RefCounted = loader.get("_wmo_render_resource_cache_state") wmo_render_cache_state.call("remember_request", "render", "res://render.res") wmo_render_cache_state.call("complete_request_with_resource", "render", Resource.new()) @@ -56,12 +57,16 @@ func _verify_runtime_cache_ownership() -> void: _expect(not is_instance_valid(static_m2_prototype), "static M2 prototype should be freed") _expect(not is_instance_valid(animated_m2_prototype), "animated M2 prototype should be freed") _expect(not is_instance_valid(wmo_prototype), "WMO prototype should be freed") + var m2_prototype_snapshot: Dictionary = m2_prototype_cache_state.call( + "diagnostic_snapshot" + ) + for state_key in m2_prototype_snapshot.keys(): + _expect( + (m2_prototype_snapshot[state_key] as Array).is_empty(), + "M2 prototype %s should be empty after shutdown" % state_key + ) for cache_name in [ - "_m2_scene_cache", - "_m2_animated_scene_cache", "_wmo_prototype_cache", - "_m2_static_animation_cache", - "_m2_missing_cache", "_wmo_missing_cache", "_shared_tex_cache", ]: