refactor(M03): extract M2 prototype cache state

This commit is contained in:
2026-07-17 16:29:11 +04:00
parent 87bf067493
commit cd664eb4cb
19 changed files with 715 additions and 89 deletions
+23 -5
View File
@@ -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,
+1
View File
@@ -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) |
+7 -5
View File
@@ -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 |
+7 -6
View File
@@ -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 |
+2 -1
View File
@@ -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 |
+247
View File
@@ -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)
+6 -4
View File
@@ -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 |
+16 -6
View File
@@ -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 |
+157
View File
@@ -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
@@ -0,0 +1 @@
uid://srnqsus8pddp
+62 -43
View File
@@ -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
@@ -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),
@@ -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),
@@ -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(
@@ -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)
@@ -0,0 +1 @@
uid://dkjneibuiqjss
+1 -1
View File
@@ -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",
@@ -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(
@@ -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",
]: