diff --git a/RENDER.md b/RENDER.md index 174ec7e..2e51b44 100644 --- a/RENDER.md +++ b/RENDER.md @@ -44,6 +44,7 @@ Paired run 2026-07-11 подтвердил крупный coordinate/placement g - `src/render/m2/m2_build_dispatch_planner.gd` - pure animation/static wait, materialization and missing-model advance decision. - `src/render/m2/m2_build_resource_snapshot.gd` - typed per-step animated/static resource observations without engine destruction. - `src/render/m2/m2_static_build_resource_observer.gd` - static Mesh cache lookup, threaded request selection and missing transition. +- `src/render/m2/m2_cached_animation_resource_observer.gd` - cached animated GLB eligibility, threaded request admission and snapshot production. - `src/render/m2/m2_animation_load_pipeline_state.gd` - animated M2 threaded-load request records and completion-order finalize FIFO without I/O or Node ownership. - `src/render/m2/m2_mesh_load_pipeline_state.gd` - static M2 threaded-load request records, terminal statuses and completion-order finalize FIFO without I/O or Mesh ownership. - `src/render/m2/m2_mesh_resource_cache_state.gd` - normalized-path prepared static M2 Mesh references with final-shutdown lifetime. @@ -1134,12 +1135,25 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' order. All three historical `m2_animation` metrics still count both stages. - Shutdown still drains pending ResourceLoader paths before clear; map reset and shutdown preserve the same two clear sites. -- `StreamingWorldLoader` retains GLB eligibility/cache selection, every +- At this extraction stage, `StreamingWorldLoader` retained GLB eligibility/cache selection, every ResourceLoader call, `M2_ANIMATION_FINALIZE` permits, scene instantiation, material repair and prototype/static-fallback decisions. - Cache formats, animation behavior and visible output are unchanged. Synthetic timing is not asset-backed I/O, leak, animation-fidelity or p95/p99 evidence. +## 2026-07-18 M2 Cached Animation Resource Observer Extraction + +- `M2CachedAnimationResourceObserver` now owns cached animated-prototype lookup, + allow/deny matching, historical `.glb` candidate selection, GLB animation/ + primitive/schema safety validation, request admission and static-only fallback. +- The observer produces `M2BuildResourceSnapshot` with the exact borrowed cached + prototype or pending state and retains/frees no engine object. +- Native GryphonRoost raw-data build/debug logging and all terminal polling, + finalization, permits and SceneTree mutation remain loader-owned. +- Defaults, path order, accepted empty/`pivot_prefix_v1` schemas, cache format, + metrics and visible behavior are unchanged. Generated GLB metadata fixtures + are not private asset, leak, p95/p99 or original-client animation evidence. + ## 2026-07-18 M2 Animated Scene Finalizer Extraction - `M2AnimatedSceneFinalizer` now owns terminal animated PackedScene candidate diff --git a/docs/modules/README.md b/docs/modules/README.md index d96ab56..f7cc95b 100644 --- a/docs/modules/README.md +++ b/docs/modules/README.md @@ -24,6 +24,7 @@ | M2 build dispatch planner | Implemented extraction | [`m2-build-dispatch-planner.md`](m2-build-dispatch-planner.md) | | M2 build resource snapshot | Implemented extraction | [`m2-build-resource-snapshot.md`](m2-build-resource-snapshot.md) | | M2 static build resource observer | Implemented extraction | [`m2-static-build-resource-observer.md`](m2-static-build-resource-observer.md) | +| M2 cached animation resource observer | Implemented extraction | [`m2-cached-animation-resource-observer.md`](m2-cached-animation-resource-observer.md) | | M2 build queue | Implemented extraction | [`m2-build-queue.md`](m2-build-queue.md) | | M2 static batch materializer | Implemented extraction | [`m2-static-batch-materializer.md`](m2-static-batch-materializer.md) | | M2 runtime mesh rebuild classifier | Implemented extraction | [`m2-runtime-mesh-rebuild-classifier.md`](m2-runtime-mesh-rebuild-classifier.md) | diff --git a/docs/modules/m2-animation-load-pipeline-state.md b/docs/modules/m2-animation-load-pipeline-state.md index 5e3ce1a..8cca0d8 100644 --- a/docs/modules/m2-animation-load-pipeline-state.md +++ b/docs/modules/m2-animation-load-pipeline-state.md @@ -14,9 +14,10 @@ Own cross-frame bookkeeping between a successful animated M2 ResourceLoader request, terminal polling and budgeted main-thread scene finalization. This is -an exact state extraction; animation eligibility and loading remain in -`StreamingWorldLoader`, while scene finalization and retained Node lifecycle -belong to `M2AnimatedSceneFinalizer` and `M2PrototypeCacheState`. +an exact state extraction; cached animation eligibility and request admission +belong to `M2CachedAnimationResourceObserver`, while scene finalization and +retained Node lifecycle belong to `M2AnimatedSceneFinalizer` and +`M2PrototypeCacheState`. ## Non-goals @@ -29,8 +30,10 @@ belong to `M2AnimatedSceneFinalizer` and `M2PrototypeCacheState`. ```mermaid flowchart LR - Loader[StreamingWorldLoader] --> IO[ResourceLoader] - Loader --> State[M2AnimationLoadPipelineState] + Observer[M2CachedAnimationResourceObserver] --> IO[ResourceLoader request] + Observer --> State[M2AnimationLoadPipelineState] + Loader[StreamingWorldLoader] --> IO + Loader --> State State -->|detached pending records| Loader Loader -->|opaque terminal status| State State -->|completion FIFO| Loader @@ -100,12 +103,13 @@ stateDiagram-v2 ```mermaid sequenceDiagram + participant O as CachedAnimationObserver participant L as StreamingWorldLoader participant R as ResourceLoader participant S as M2AnimationLoadPipelineState participant P as M2PrototypeCacheState - L->>R: load_threaded_request(GLB) - L->>S: remember_request(path, GLB) + O->>R: load_threaded_request(GLB) + O->>S: remember_request(path, GLB) loop frames L->>S: request_records_snapshot() L->>R: load_threaded_get_status(GLB) @@ -128,8 +132,10 @@ sequenceDiagram ```mermaid flowchart TB + Observer[M2CachedAnimationResourceObserver] --> State[M2AnimationLoadPipelineState] + Observer --> Resource[ResourceLoader request] Loader[StreamingWorldLoader] --> State[M2AnimationLoadPipelineState] - Loader --> Resource[ResourceLoader] + Loader --> Resource Loader --> Budget[RenderBudgetScheduler] Loader --> Prototype[M2PrototypeCacheState] State -. no dependency .-> Resource @@ -142,7 +148,7 @@ flowchart TB | Failure | Detection | Behavior | Diagnostic | Recovery | |---|---|---|---|---| | Empty/duplicate request | State guard | Reject unchanged | Contract verifier | Correct caller or request later | -| Request start/cache miss | Loader | No insertion; mark static | Existing loader path | Cache correction/reload | +| Request start/cache miss | Cached observer | No insertion; mark static | Observer contract | Cache correction/reload | | Non-terminal status | Loader | Keep pending | Existing metric | Poll next frame | | Failed terminal load | Popped status | Loader marks static | Existing behavior | Future map/session reload | | Empty defensive path | Loader poll | Discard and mark static | Source contract | Correct producer | @@ -154,7 +160,7 @@ flowchart TB |---|---|---|---|---| | `enable_m2_animated_instances` | `true` | Existing renderer profile | Yes | Enables caller request path | | `m2_animation_finalize_ops_per_tick` | `1` | Quality/custom | Yes | Bounds caller FIFO drain | -| Animated allow/deny/primitive rules | Existing values | Existing renderer profile | Yes | Filter before state insertion | +| Animated allow/deny/primitive rules | Existing values | Existing renderer profile | Yes | Observer filters before state insertion | ## Persistence, cache and migration @@ -188,7 +194,8 @@ sibling service. | Capability | Status | Evidence | Gap/next step | |---|---|---|---| | Animated request/finalize state | Implemented extraction | Synthetic contract/source/timing verifier | Asset-backed traversal/leak/p95/p99 pending | -| ResourceLoader and GLB selection | Existing loader-owned | Adjacent renderer tests | I/O adapter extraction optional | +| Cached request admission and GLB selection | Implemented in observer | Policy/GLB/source verifier | Asset-backed traversal pending | +| Terminal ResourceLoader polling | Existing loader-owned | Shutdown/finalizer regressions | I/O adapter extraction optional | | Animated prototype outcomes | Implemented extraction | Prototype cache verifier | Asset-backed animation fidelity pending | ## Known gaps and risks @@ -202,9 +209,10 @@ sibling service. | Path | Responsibility | |---|---| | `src/render/m2/m2_animation_load_pipeline_state.gd` | Pending records, completion FIFO and metrics | +| `src/render/m2/m2_cached_animation_resource_observer.gd` | Eligibility, GLB selection and request admission | | `src/render/m2/m2_animated_scene_finalizer.gd` | Candidate instantiation, material repair and player validation | | `src/render/m2/m2_prototype_cache_state.gd` | Animated prototype/static-only outcomes | -| `src/scenes/streaming/streaming_world_loader.gd` | Eligibility, I/O, permits, instantiation and adoption | +| `src/scenes/streaming/streaming_world_loader.gd` | Native eligibility/build, polling, permits and adoption | | `src/tools/verify_m2_animation_load_pipeline_state.gd` | Lifecycle/boundary/timing regression | ## Related decisions and references diff --git a/docs/modules/m2-build-batch-planner.md b/docs/modules/m2-build-batch-planner.md index 5125ae4..9ce5eb2 100644 --- a/docs/modules/m2-build-batch-planner.md +++ b/docs/modules/m2-build-batch-planner.md @@ -104,7 +104,8 @@ sequenceDiagram - The planner owns only call-local scalar values and the returned Dictionary. - `M2BuildQueue` owns typed jobs, FIFO ordering, serial numbers and group/offset cursors. `M2BuildDispatchPlanner` owns the pure resource-state action decision; - the loader owns tile checks, resource observation/retry and adoption calls. + the loader owns tile checks and native-first orchestration, while static and + cached animation observers own their observation/retry phases. - Materializers own main-thread Node/MultiMesh construction under loader roots. - The scheduler owns the frame-local `M2_BUILD` counter. - Pure planning is thread-safe, though the current adapter calls it on main thread. @@ -150,8 +151,8 @@ queue depth, build activity and hitch observability. ## Extension points -- A later package may extract resource observation/request orchestration while - retaining the dispatch and typed build-job/FIFO contracts. +- Remaining native resource observation may be extracted while retaining the + dispatch and typed build-job/FIFO contracts. - Spatial-cell batching must use measured culling/performance evidence and must not silently change this model-path batch cursor. diff --git a/docs/modules/m2-build-dispatch-planner.md b/docs/modules/m2-build-dispatch-planner.md index e685b37..891b675 100644 --- a/docs/modules/m2-build-dispatch-planner.md +++ b/docs/modules/m2-build-dispatch-planner.md @@ -12,9 +12,10 @@ ## Purpose -Select one M2 build action after the loader observes animation/static resource -state. The planner makes wait priority, materializer selection and the historical -missing-model serial transition explicit without owning resources or engine work. +Select one M2 build action after renderer observers produce animation/static +resource state. The planner makes wait priority, materializer selection and the +historical missing-model serial transition explicit without owning resources or +engine work. ## Non-goals @@ -146,7 +147,8 @@ flowchart TB - Planner owns call-local comparisons and the returned Dictionary only. - `M2BuildResourceSnapshot` owns the typed call-local observation contract; dispatch reads availability/pending/missing accessors but no engine references. -- Loader owns resource lookup/request order, action execution and permit use. +- Loader owns native-first observation order, action execution and permit use; + static/cached-animation observers own their lookup/request phases. - `M2BuildQueue` owns pending jobs, FIFO keys and progress cursors. - Materializers own main-thread scene construction under loader-owned roots. - Pure calls are thread-safe; the current loader adapter calls on main thread. @@ -190,16 +192,17 @@ remain unchanged. ## Extension points -A later package may move resource observation/request execution behind a service -that produces `M2BuildResourceSnapshot`. Generic callbacks, signals and a shared -state-machine framework remain intentionally excluded. +Static and cached animated observation/request execution now live behind sibling +services that produce `M2BuildResourceSnapshot`. Native animation remains an +orchestrator-owned phase. Generic callbacks, signals and a shared state-machine +framework remain intentionally excluded. ## Capability status | Capability | Status | Evidence | Gap/next step | |---|---|---|---| | M2 build action selection | Implemented extraction | Priority/matrix/source/timing verifier | Asset-backed traversal pending | -| Resource observation and requests | Loader-owned | Cache/pipeline regressions | Typed orchestration extraction pending | +| Static/cached animation observation and requests | Implemented separately | Observer/cache/pipeline regressions | Native observation remains loader-owned | | Queue/cursor state | Implemented separately | M2 build queue verifier | Asset-backed traversal pending | | Materialization | Implemented separately | Static/animated materializer verifiers | GPU/p95/p99 evidence pending | @@ -215,7 +218,8 @@ state-machine framework remain intentionally excluded. |---|---| | `src/render/m2/m2_build_dispatch_planner.gd` | Pure action priority and transition plan | | `src/render/m2/m2_build_resource_snapshot.gd` | Typed per-step resource observation contract | -| `src/scenes/streaming/streaming_world_loader.gd` | Resource observation, action execution, permits and engine lifetime | +| `src/render/m2/m2_cached_animation_resource_observer.gd` | Cached animation observation/request phase | +| `src/scenes/streaming/streaming_world_loader.gd` | Native observation order, action execution, permits and engine lifetime | | `src/render/m2/m2_build_batch_planner.gd` | Batch count and cursor plan | | `src/render/m2/m2_build_queue.gd` | Pending jobs, FIFO and cursor ownership | | `src/tools/verify_m2_build_dispatch_planner.gd` | Matrix, boundary and timing regression | diff --git a/docs/modules/m2-build-resource-snapshot.md b/docs/modules/m2-build-resource-snapshot.md index 41f0652..ea46ae4 100644 --- a/docs/modules/m2-build-resource-snapshot.md +++ b/docs/modules/m2-build-resource-snapshot.md @@ -28,7 +28,9 @@ static Mesh and terminal missing-model state. The snapshot never owns engine lif ```mermaid flowchart LR - Loader[StreamingWorldLoader observations] --> Snapshot[M2BuildResourceSnapshot] + Native[StreamingWorldLoader native observation] --> Snapshot[M2BuildResourceSnapshot] + Cached[M2CachedAnimationResourceObserver] --> Snapshot + Static[M2StaticBuildResourceObserver] --> Snapshot Snapshot --> Dispatch[M2BuildDispatchPlanner] Snapshot --> Loader Loader --> Animated[M2AnimatedInstanceMaterializer] @@ -60,8 +62,8 @@ are forbidden. | Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime | |---|---|---|---|---|---| | Input | Normalized relative path | Loader path adapter | Snapshot/diagnostics | Copied String | Snapshot lifetime | -| Input | Optional animated prototype | Prototype/native animation path | Snapshot/materializer adapter | Borrowed exact Node3D reference | One build operation | -| Input | Animation request pending | Animation pipeline state | Snapshot/dispatch planner | Boolean copy | One build operation | +| Input | Optional animated prototype | Native/cached animation observer | Snapshot/materializer adapter | Borrowed exact Node3D reference | One build operation | +| Input | Animation request pending | Cached animation observer | Snapshot/dispatch planner | Boolean copy | One build operation | | Input | Optional prepared static Mesh | Mesh cache/request path | Snapshot/materializer adapter | Borrowed exact Mesh reference | One build operation | | Input | Static model terminally missing | Prototype cache state | Snapshot/dispatch planner | Boolean copy | One build operation | | Output | Availability/pending/missing values | Snapshot | Dispatch planner | Scalar reads | One dispatch plan | @@ -110,8 +112,12 @@ sequenceDiagram participant B as M2BuildBatchPlanner participant D as M2BuildDispatchPlanner participant M as M2 materializer - L->>L: resolve/request animated prototype - L->>S: construct(path, prototype, pending) + L->>L: resolve native animated prototype + L->>S: construct native result + opt no native prototype + L->>L: delegate cached animation observer + L->>S: receive cached/pending snapshot + end alt animation not pending L->>B: plan batch using snapshot availability opt positive static batch @@ -143,7 +149,8 @@ flowchart TB ## Ownership, threading and resources - Snapshot owns only copied scalar state and temporary references. -- Prototype/cache services retain resource state; loader owns observation order. +- Prototype/cache services retain resource state; loader owns native-first + observation order while cached/static observers own their phases. - Scene roots remain loader/tile-owned; Mesh lifetime follows cache/resource refs. - Dispatch planner reads only snapshot accessors and never mutates the snapshot. - Current construction/adoption occurs on renderer main thread. @@ -187,9 +194,10 @@ queue/hitch metrics remain unchanged. ## Extension points -`M2StaticBuildResourceObserver` now produces the static phase. A later animated -observer may produce the first phase. The value contract remains independent of -both producers and of generic callback frameworks. +`M2CachedAnimationResourceObserver` produces the cached animated phase and +`M2StaticBuildResourceObserver` produces the static phase. Native animation +observation remains loader-owned. The value contract stays independent of all +producers and of generic callback frameworks. ## Capability status @@ -198,14 +206,15 @@ both producers and of generic callback frameworks. | Typed per-step M2 resource observation | Implemented extraction | Identity/adoption/lifetime/source/timing verifier | Asset-backed traversal pending | | Dispatch decision | Implemented separately | Dispatch planner verifier | Asset-backed traversal pending | | Static lookup/request execution | Implemented separately | Static observer verifier | Asset-backed traversal pending | -| Animated lookup/request execution | Loader-owned | Animation regressions | Service extraction pending | +| Cached animated lookup/request execution | Implemented separately | Cached observer verifier | Asset-backed traversal pending | +| Native animated lookup/build execution | Loader-owned | Native renderer regression | Service extraction pending | | Engine lifetime/materialization | Loader/materializer-owned | Lifetime/materializer regressions | GPU/p95/p99 evidence pending | ## Known gaps and risks - Static observation is mutable during its short two-phase construction. - Raw Node3D/Mesh references remain necessary for current materializer APIs. -- Loader still owns synchronous/native prototype attempts and ResourceLoader requests. +- Loader still owns synchronous native prototype attempts and terminal polling. - Synthetic timing does not measure resource, Node or GPU work. ## Source map @@ -215,7 +224,8 @@ both producers and of generic callback frameworks. | `src/render/m2/m2_build_resource_snapshot.gd` | Typed per-step resource observations and diagnostics | | `src/render/m2/m2_build_dispatch_planner.gd` | Snapshot-to-action planning | | `src/render/m2/m2_static_build_resource_observer.gd` | Static snapshot production and requests | -| `src/scenes/streaming/streaming_world_loader.gd` | Animated observation/request execution and materializer borrowing | +| `src/render/m2/m2_cached_animation_resource_observer.gd` | Cached animated snapshot production and requests | +| `src/scenes/streaming/streaming_world_loader.gd` | Native animation observation and materializer borrowing | | `src/tools/verify_m2_build_resource_snapshot.gd` | Identity/adoption/lifetime/boundary/timing regression | ## Related decisions and references diff --git a/docs/modules/m2-cached-animation-resource-observer.md b/docs/modules/m2-cached-animation-resource-observer.md new file mode 100644 index 0000000..effb855 --- /dev/null +++ b/docs/modules/m2-cached-animation-resource-observer.md @@ -0,0 +1,239 @@ +# M2 Cached Animation Resource Observer + +## Metadata + +| Field | Value | +|---|---| +| Status | Implemented extraction | +| Target/work package | M03 / `M03-RND-M2-CACHED-ANIMATION-RESOURCE-OBSERVER-001` | +| Owners | Cached animated M2 eligibility, request admission and initial snapshot | +| Last verified | Worktree `work/sindo-main-codex/m03-m2-cached-animation-resource-observer`, 2026-07-18 | +| Profiles/capabilities | Existing optional cached-GLB animated M2 path | + +## Purpose + +Produce the cached-animation phase of `M2BuildResourceSnapshot`: reuse an exact +animated prototype, wait for a pending request, admit the first safe GLB request, +or record a terminal static-only animation outcome without changing behavior. + +## Non-goals + +- Load/build native GryphonRoost animation or emit its diagnostics. +- Poll/finalize threaded Resources or instantiate/fix imported scenes. +- Own/free prototype Nodes, snapshots, pipeline entries or cache state. +- Materialize instances, consume permits or change animation policy defaults. +- Change GLB layout, cache format, batching, profiles or visible output. + +## Context and boundaries + +```mermaid +flowchart LR + Loader[StreamingWorldLoader] --> Native[Native animation attempt] + Native -->|no prototype| Observer[M2CachedAnimationResourceObserver] + Prototype[M2PrototypeCacheState] --> Observer + Pipeline[M2AnimationLoadPipelineState] --> Observer + Resource[ResourceLoader and GLB JSON] --> Observer + Observer --> Snapshot[M2BuildResourceSnapshot] + Snapshot --> Dispatch[M2BuildDispatchPlanner] +``` + +## Public API + +| Symbol | Kind | Purpose | Failure behavior | +|---|---|---|---| +| `observe(path, cache_dir, max_primitives, allow, deny, prototype_state, pipeline_state, debug)` | Command/query | Produce cached prototype/pending/static-only snapshot | Invalid input returns empty non-pending snapshot | +| `find_eligible_glb_cache_path(...)` | I/O query | Select first existing safe historical candidate | Returns empty String | +| `is_animation_path_allowed(path, allow, deny)` | Pure query | Apply stripped case-insensitive substring policy | Empty path/allowlist rejects; deny wins | +| `cache_resource_paths(cache_dir, path)` | Pure query | Return nested/lowercase/basename GLB candidates | Empty values yield fewer candidates | +| `glb_cache_is_safe_for_runtime_animation(path, max, debug)` | I/O query | Validate animations, primitive limit and schema | Invalid/missing GLB rejects | +| `read_glb_json(path)` | I/O query | Read version-2 GLB JSON chunk | Returns empty Dictionary | +| `glb_primitive_count(gltf)` | Pure query | Count primitives across meshes | Invalid entries contribute zero | +| `glb_animation_schema(gltf)` | Pure query | Read OpenWC schema marker | Missing marker returns empty String | + +## Inputs and outputs + +| Direction | Data | Producer | Consumer | Ownership/lifetime | +|---|---|---|---|---| +| Input | Normalized M2 path/cache directory | Loader | Observer | Copied Strings; one observation | +| Input | Primitive cap and allow/deny patterns | Loader exports | Observer | Copied/read-only call values | +| Input | Prototype and request states | M2 state services | Observer | Borrowed services; map/session | +| Input | Existing GLB cache file | Offline cache pipeline | Observer | Read-only file access | +| Output | Resource snapshot | Observer | Loader/dispatch/materializer | Caller-owned snapshot; one build step | +| Output | Borrowed exact prototype | Prototype state | Snapshot/materializer | No ownership transfer | +| Output | Threaded request record | Observer | Animation pipeline | Pipeline-owned copied paths | +| Output | Static-only transition | Observer | Prototype state | State-owned copied path | + +## Data flow + +```mermaid +flowchart TD + Start[Observe normalized path] --> Valid{Valid composition?} + Valid -->|no| Empty[Empty non-pending snapshot] + Valid -->|yes| Cached{Prototype cached?} + Cached -->|yes| Ready[Snapshot with exact prototype] + Cached -->|no| Static{Already static-only?} + Static -->|yes| Empty + Static -->|no| Pending{Request exists?} + Pending -->|yes| Wait[Pending snapshot] + Pending -->|no| Policy{Allow and not deny?} + Policy -->|no| Mark[Mark static-only] + Policy -->|yes| Candidate[Historical GLB candidates] + Candidate --> Safe{Animations, primitive cap, schema safe?} + Safe -->|no usable| Mark + Safe -->|yes| Request[Threaded request] + Request -->|OK or busy| Remember[Remember and return pending snapshot] + Request -->|error| Mark + Mark --> Empty +``` + +## Lifecycle/state + +```mermaid +stateDiagram-v2 + [*] --> Observing + Observing --> Cached + Observing --> Pending + Observing --> Requested + Observing --> StaticOnly + Observing --> Rejected + Cached --> [*] + Pending --> [*] + Requested --> [*] + StaticOnly --> [*] + Rejected --> [*] +``` + +## Main sequence + +```mermaid +sequenceDiagram + participant L as StreamingWorldLoader + participant O as CachedAnimationObserver + participant C as PrototypeCacheState + participant P as AnimationLoadPipelineState + participant R as ResourceLoader + participant S as M2BuildResourceSnapshot + L->>L: attempt native candidate + alt no native prototype + L->>O: observe(path, cache/policy/state) + O->>C: find animated / static-only + O->>P: has request + opt eligible cache candidate + O->>R: exists + GLB JSON + threaded request + O->>P: remember request + end + O-->>L: snapshot + else native prototype + L->>S: construct native snapshot + end +``` + +## Dependency diagram + +```mermaid +flowchart TB + Observer[M2CachedAnimationResourceObserver] --> Snapshot[M2BuildResourceSnapshot] + Observer --> Prototype[M2PrototypeCacheState] + Observer --> Pipeline[M2AnimationLoadPipelineState] + Observer --> ResourceLoader + Observer --> FileAccess[GLB header and JSON] + Observer -. no dependency .-> Native[M2 native builder/repository] + Observer -. no dependency .-> Finalizer[M2AnimatedSceneFinalizer] + Observer -. no dependency .-> Scheduler[RenderBudgetScheduler] + Observer -. no dependency .-> SceneTree +``` + +## Ownership, threading and resources + +- Observer retains no service, request, snapshot, Resource or Node reference. +- Prototype state retains accepted Nodes and static-only paths until shutdown. +- Pipeline state retains copied request paths until terminal polling/finalize. +- Snapshot borrows the exact cached prototype; observer never frees it. +- Resource existence/request and synchronous GLB JSON inspection run on the + renderer main thread, matching the previous loader behavior. +- Loader remains owner of native animation build, terminal polling/finalize, + permits, materialization and every SceneTree mutation. + +## Errors, cancellation and recovery + +| Failure/state | Behavior | Recovery | +|---|---|---| +| Empty path/dependency | Return empty non-pending snapshot without mutation | Correct composition | +| Cached prototype | Return exact reference immediately | None | +| Existing request | Return pending without duplicate admission | Retry after terminal drain | +| Disallowed/denied path | Mark static-only | New loader session/configuration | +| Missing/invalid/unsafe GLB | Continue candidates, then mark static-only | Repair/rebake cache and restart session | +| Request error | Mark static-only | New loader session/cache repair | +| Tile cancellation | Observer retains nothing | Loader queue remains authoritative | +| Shutdown | Loader drains requests before state clear | New loader starts empty | + +## Configuration and capabilities + +The service consumes existing `m2_cache_dir`, `m2_animated_max_primitives`, +`m2_animated_allowlist_patterns`, `m2_animated_denylist_patterns` and debug flag. +It adds no setting and preserves runtime mutability and defaults. + +## Persistence, cache and migration + +No persistence or cache format changes are introduced. Existing `.glb` candidate +layout and OpenWC schema markers are read unchanged; no rebake is required. + +## Diagnostics and observability + +Unsupported non-empty schemas preserve the historical optional +`M2_ANIM_REJECT` debug line. Snapshot and pipeline diagnostics remain detached; +queue and hitch metrics are unchanged. + +## Verification + +- Dedicated verifier covers invalid/cached identity/static/pending/missing + lifecycle, allow/deny semantics, exact path order, generated GLB header/JSON, + animation presence, primitive cap, accepted/rejected schemas, source boundaries + and bounded timing. +- Snapshot, dispatch, pipeline, prototype, shutdown, facade, internal-access and + checkpoint regressions protect adjacent behavior. +- Fidelity evidence is exact policy/I/O extraction. No original-client visual or + animation parity claim is made. + +## Extension points + +Native animation observation can become a sibling service without changing this +cached-GLB contract. Terminal ResourceLoader polling/finalization may later move +behind a dedicated adapter while retaining the pipeline state. + +## Capability status + +| Capability | Status | Evidence | Gap/next step | +|---|---|---|---| +| Cached animation observation/request | Implemented extraction | Lifecycle/policy/GLB/source/timing verifier | Asset-backed traversal pending | +| Native animation observation | Existing loader-owned | Native renderer regression | Separate extraction | +| Animated finalize/preparation | Loader/finalizer split | Pipeline/finalizer regressions | Polling adapter optional | + +## Known gaps and risks + +- A successful asynchronous request is not started by the unit fixture because + leaving it undrained leaks process work; source and pipeline tests cover admission. +- Generated GLB metadata fixtures validate policy, not Godot import fidelity. +- Private traversal, leak/descriptor pressure, p95/p99 and paired visuals remain + unavailable without private assets. + +## Source map + +| Path | Responsibility | +|---|---| +| `src/render/m2/m2_cached_animation_resource_observer.gd` | Cached GLB policy, request and snapshot production | +| `src/render/m2/m2_build_resource_snapshot.gd` | Typed per-step observation value | +| `src/render/m2/m2_animation_load_pipeline_state.gd` | Pending request/finalize state | +| `src/render/m2/m2_prototype_cache_state.gd` | Prototype and static-only outcomes | +| `src/scenes/streaming/streaming_world_loader.gd` | Native attempt, composition, finalize and execution | +| `src/tools/verify_m2_cached_animation_resource_observer.gd` | Lifecycle/policy/GLB/boundary/timing regression | + +## Related decisions and references + +- [`m2-build-resource-snapshot.md`](m2-build-resource-snapshot.md) +- [`m2-animation-load-pipeline-state.md`](m2-animation-load-pipeline-state.md) +- [`m2-prototype-cache-state.md`](m2-prototype-cache-state.md) +- [`m2-animated-scene-finalizer.md`](m2-animated-scene-finalizer.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-prototype-cache-state.md b/docs/modules/m2-prototype-cache-state.md index 3d429e7..1131148 100644 --- a/docs/modules/m2-prototype-cache-state.md +++ b/docs/modules/m2-prototype-cache-state.md @@ -28,7 +28,9 @@ paths and paths whose animation fallback is static-only. ```mermaid flowchart LR Loader[StreamingWorldLoader] --> State[M2PrototypeCacheState] + Observer[M2 resource observers] --> State Resource[ResourceLoader / raw builders] --> Loader + Resource --> Observer State --> Static[Static prototype Node3D] State --> Animated[Animated prototype Node3D] State --> Missing[Missing-model path set] @@ -62,7 +64,7 @@ Mesh traversal and other application layers are forbidden. | 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 | +| Input | Missing/static-only outcome | Loader/observer failure 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 | @@ -135,9 +137,10 @@ sequenceDiagram ```mermaid flowchart TB Loader[StreamingWorldLoader] --> State[M2PrototypeCacheState] + Observer[M2 resource observers] --> State State --> Node3D Loader --> Raw[M2RawModelRepository] - Loader --> ResourceLoader + Observer --> ResourceLoader Loader --> StaticBuilder[M2Builder] Loader --> AnimatedBuilder[M2NativeAnimatedBuilder] State -. no dependency .-> Raw @@ -165,7 +168,7 @@ flowchart TB | 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 | +| Animation unavailable/unsafe | Observer/native 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 | @@ -177,8 +180,8 @@ flowchart TB | 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. +Animation enablement, candidate/allow/deny rules and cache paths remain loader +configuration consumed by the cached observer. Per-frame permits remain loader-owned. ## Persistence, cache and migration @@ -188,7 +191,8 @@ 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. +- Cached eligibility rejection logging belongs to the cached observer; 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. @@ -208,8 +212,8 @@ and native M2 formats are unchanged; no migration or rebake is introduced. ## 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. +prototype-user lifetime rules. Cached animation observation consumes this +service without moving ResourceLoader or builder ownership into prototype state. ## Capability status diff --git a/docs/modules/world-renderer.md b/docs/modules/world-renderer.md index 890c5f5..36fdbb8 100644 --- a/docs/modules/world-renderer.md +++ b/docs/modules/world-renderer.md @@ -143,6 +143,7 @@ from externally reading/writing loader-private queue, task, cache and tile-state | `M2BuildDispatchPlanner.plan_step` | Internal pure M2 service | Selects wait, animated/static materialization or no-Node advancement from observed resource state | Main/any thread; stateless | Pending animation has priority; unresolved static Mesh waits | | `M2BuildResourceSnapshot` | Internal M2 value contract | Carries per-step normalized path, animated/static references and pending/missing observations | Renderer main thread; one build operation | Values retained exactly; release never frees resources | | `M2StaticBuildResourceObserver.observe` | Internal M2 I/O service | Produces cached/pending/requested/missing static snapshot phase | Renderer main thread; stateless | Invalid composition rejected; no candidate marks missing | +| `M2CachedAnimationResourceObserver.observe` | Internal M2 I/O service | Produces cached/pending/static-only animated snapshot phase | Renderer main thread; stateless | Invalid composition returns empty snapshot; no safe candidate marks static-only | | `M2BuildQueue` / `M2BuildJob` | Internal M2 pending-state service | Own typed root/groups/cursor jobs and FIFO/stale tile keys | Renderer main thread; map session | Invalid enqueue rejected; stale keys drain independently of jobs | | `M2StaticBatchMaterializer.materialize_batch` | Internal M2 scene-materialization service | Builds and attaches one prepared-Mesh MultiMesh transform slice | Renderer main thread; stateless after each call | Invalid/empty input returns null; bounds are caller precondition | | `M2RuntimeMeshRebuildClassifier` | Internal memoized M2 service | Detects billboard/UV-rotation metadata requiring stale cached-mesh rebuild | Renderer main thread; cached until reset | Invalid variants/indices skipped; first path decision wins | @@ -193,7 +194,7 @@ loader configuration remains transitional composition data, not a caller API. | Internal grouping | Tile origin, M2 names and placements | Loader / `M2PlacementGrouper` | Loader worker result/build job | Fresh Dictionary/Transform3D arrays | One grouping task | | Internal batch plan | Transform count/offset, path kind and limits | Loader / `M2BuildBatchPlanner` | Loader materialization/cursor adapter | Fresh scalar Dictionary | One build operation | | Internal M2 dispatch plan | Batch count and observed animation/static resource state | Loader / `M2BuildDispatchPlanner` | Loader queue/materializer/progress adapter | Fresh scalar/action Dictionary | One build operation | -| Internal M2 resource observation | Normalized path, optional prototype/Mesh and pending/missing flags | Loader / `M2BuildResourceSnapshot` | Dispatch planner and loader materializer adapter | Snapshot borrows exact engine references | One build operation | +| Internal M2 resource observation | Normalized path, optional prototype/Mesh and pending/missing flags | Loader / M2 observers / `M2BuildResourceSnapshot` | Dispatch planner and loader materializer adapter | Snapshot borrows exact engine references | One build operation | | Internal M2 pending build | Tile key, M2 root, grouped transforms and cursors | Loader / `M2BuildQueue` | Loader readiness, planner and materializer adapters | Queue-owned job/keys and strong references | Until finish/cancel/clear/replacement | | Internal static M2 materialization | Parent, prepared Mesh, ordered transform slice and render settings | Loader / `M2StaticBatchMaterializer` | Attached MultiMeshInstance3D | Parent owns node/MultiMesh; exact Mesh reference retained | One main-thread build batch | | Internal WMO placement | Path, MODF placement, tile/index | Loader / `WmoPlacementResolver` | WMO caches, registry and three instance adapters | Value-only String/Transform3D | Lookup/placement lifetime | @@ -204,7 +205,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 animated M2 load | Normalized path, cached GLB path and opaque terminal status | Loader / `M2AnimationLoadPipelineState` | Loader poll/finalize adapters | State-owned String/status records; detached pending snapshots | Request through terminal finalize/reset | +| Internal animated M2 load | Normalized path, cached GLB path and opaque terminal status | Cached observer / loader / `M2AnimationLoadPipelineState` | Loader poll/finalize adapters | State-owned String/status records; detached pending snapshots | Request through terminal finalize/reset | | Internal animated M2 candidate | Loaded PackedScene, static material root and detached candidate | Loader / `M2AnimatedSceneFinalizer` | Loader prototype adoption/static fallback | Finalizer owns until rejection or exact-root transfer | One main-thread finalize permit | | Internal animated M2 playback | Prototype/instance native animators, path/index and player inventory | Materializer/finalizer / `M2AnimationPlaybackController` | Native and imported animation state | Borrowed Nodes; detached optional diagnostics | One duplicated instance startup | | Internal animated M2 materialization | Parent, prototype, ordered transform slice and render settings | Loader / `M2AnimatedInstanceMaterializer` | Attached animated batch and indexed diagnostics | Parent owns batch; caller owns detached diagnostics | One main-thread build batch | @@ -395,9 +396,11 @@ sequenceDiagram `M2BuildQueue` owns typed pending jobs, FIFO/stale keys, grouped-transform references and group/offset/serial cursors without freeing engine objects. `M2StaticBuildResourceObserver` owns static Mesh lookup/request admission and - snapshot adoption. `M2StaticBatchMaterializer` owns static MultiMesh - construction/attachment; the loader retains animated observation/requests, - action execution, cursor adoption, root cleanup, budgets and Editor ownership. + snapshot adoption. `M2CachedAnimationResourceObserver` owns cached animated + prototype lookup, GLB policy/request admission and snapshot production. + `M2StaticBatchMaterializer` owns static MultiMesh construction/attachment; the + loader retains native animation observation, action execution, cursor adoption, + root cleanup, budgets and Editor ownership. - `WmoPlacementResolver` is stateless and owns only call-local cache-key, identity and transform values. `WmoPlacementRegistry` owns only placement-key reference sets. `WmoRenderBuildStepPlanner` owns only a call-local operation @@ -422,6 +425,11 @@ sequenceDiagram cache path selection, request admission and initial snapshot adoption. The loader retains ResourceLoader polling/finalize, permits and terminal adoption; prototype cache state owns shared missing outcomes. +- `M2AnimationLoadPipelineState` owns animated M2 pending Resource paths, opaque + terminal statuses and completion-order finalize FIFO. The cached animation + observer owns allow/deny/path/GLB selection, request admission and initial + snapshot production. The loader retains native animation build, ResourceLoader + polling/finalize, permits and terminal adoption. - `M2MeshResourceCacheState` owns prepared static Mesh references and releases them at the existing final-shutdown site. Prototype state and materialization belong to the sibling cache service and loader respectively. @@ -584,6 +592,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m | M2 build dispatch planner | Implemented extraction | Scene-free priority/action/transition/source/timing contract | Resource observation/orchestration and asset-backed traversal remain pending | | M2 build resource snapshot | Implemented extraction | Typed identity/adoption/lifetime/source/timing contract | Resource observation service and asset-backed traversal remain pending | | M2 static build resource observer | Implemented extraction | Cache/pending/path/request/source/timing contract | Asset-backed traversal and finalize extraction pending | +| M2 cached animation resource observer | Implemented extraction | Cache/pending/policy/GLB/request/source/timing contract | Native observation and asset-backed traversal pending | | M2 build queue | Implemented extraction | Typed lifecycle/FIFO/progress/lifetime/source/timing contract | Resource dispatch and asset-backed traversal remain pending | | M2 animation load pipeline state | Implemented extraction | Synthetic lifecycle/FIFO/source/timing contract | Asset-backed traversal/leak/animation-fidelity/p95/p99 pending | | M2 animated scene finalizer | Implemented extraction | Synthetic type/lifetime/material/player/source/timing contract | Asset-backed GLB traversal/material/animation comparison pending | @@ -658,6 +667,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m | `src/render/m2/m2_build_dispatch_planner.gd` | M2 wait/materializer/advance action priority and transition plan | | `src/render/m2/m2_build_resource_snapshot.gd` | Typed per-step animated/static observations and borrowed references | | `src/render/m2/m2_static_build_resource_observer.gd` | Static Mesh lookup/request/missing snapshot production | +| `src/render/m2/m2_cached_animation_resource_observer.gd` | Cached animated GLB policy/request/snapshot production | | `src/render/m2/m2_build_job.gd` | M2 root/groups references and group/offset/serial progress | | `src/render/m2/m2_build_queue.gd` | Keyed pending jobs and FIFO/stale tile-key lifecycle | | `src/render/m2/m2_mesh_load_pipeline_state.gd` | Static M2 pending Resource paths, terminal statuses and finalize FIFO | @@ -689,6 +699,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m | `src/tools/verify_m2_build_dispatch_planner.gd` | M2 dispatch priority/action/transition/boundary/timing regression | | `src/tools/verify_m2_build_resource_snapshot.gd` | M2 observation identity/adoption/lifetime/boundary/timing regression | | `src/tools/verify_m2_static_build_resource_observer.gd` | Static lookup/request/path/boundary/timing regression | +| `src/tools/verify_m2_cached_animation_resource_observer.gd` | Cached animation lifecycle/policy/GLB/boundary/timing regression | | `src/tools/verify_m2_build_queue.gd` | M2 job/FIFO/progress/lifetime/boundary/timing regression | | `src/tools/verify_m2_mesh_load_pipeline_state.gd` | Static M2 request/terminal/FIFO/boundary/timing regression | | `src/tools/verify_m2_mesh_resource_cache_state.gd` | Static M2 Mesh cache ownership/lifetime/boundary/timing regression | diff --git a/src/render/m2/m2_cached_animation_resource_observer.gd b/src/render/m2/m2_cached_animation_resource_observer.gd new file mode 100644 index 0000000..8295268 --- /dev/null +++ b/src/render/m2/m2_cached_animation_resource_observer.gd @@ -0,0 +1,238 @@ +class_name M2CachedAnimationResourceObserver +extends RefCounted + +## Observes or requests the cached-GLB animated phase of one M2 build step. + +const M2_BUILD_RESOURCE_SNAPSHOT_SCRIPT := preload( + "res://src/render/m2/m2_build_resource_snapshot.gd" +) + + +## Returns a snapshot containing the exact cached prototype or pending state. +## Invalid composition and terminal failures produce a non-pending snapshot. +func observe( + normalized_relative_path: String, + cache_directory: String, + maximum_primitive_count: int, + allowlist_patterns: PackedStringArray, + denylist_patterns: PackedStringArray, + prototype_cache_state: RefCounted, + load_pipeline_state: RefCounted, + debug_logging_enabled: bool = false +) -> RefCounted: + var empty_snapshot: RefCounted = M2_BUILD_RESOURCE_SNAPSHOT_SCRIPT.new( + normalized_relative_path, + null, + false + ) + if ( + normalized_relative_path.is_empty() + or prototype_cache_state == null + or load_pipeline_state == null + ): + return empty_snapshot + + var cached_prototype := prototype_cache_state.call( + "find_animated_prototype", + normalized_relative_path + ) as Node3D + if cached_prototype != null: + return M2_BUILD_RESOURCE_SNAPSHOT_SCRIPT.new( + normalized_relative_path, + cached_prototype, + false + ) + if bool(prototype_cache_state.call( + "is_animation_static", + normalized_relative_path + )): + return empty_snapshot + if bool(load_pipeline_state.call("has_request", normalized_relative_path)): + return M2_BUILD_RESOURCE_SNAPSHOT_SCRIPT.new( + normalized_relative_path, + null, + true + ) + + var cache_resource_path := find_eligible_glb_cache_path( + normalized_relative_path, + cache_directory, + maximum_primitive_count, + allowlist_patterns, + denylist_patterns, + debug_logging_enabled + ) + if cache_resource_path.is_empty(): + prototype_cache_state.call( + "mark_animation_static", + normalized_relative_path + ) + return empty_snapshot + + var request_error := ResourceLoader.load_threaded_request( + cache_resource_path, + "", + false, + ResourceLoader.CACHE_MODE_REUSE + ) + if request_error == OK or request_error == ERR_BUSY: + load_pipeline_state.call( + "remember_request", + normalized_relative_path, + cache_resource_path + ) + return M2_BUILD_RESOURCE_SNAPSHOT_SCRIPT.new( + normalized_relative_path, + null, + true + ) + + prototype_cache_state.call("mark_animation_static", normalized_relative_path) + return empty_snapshot + + +## Returns the first eligible historical `.glb` cache candidate. +func find_eligible_glb_cache_path( + normalized_relative_path: String, + cache_directory: String, + maximum_primitive_count: int, + allowlist_patterns: PackedStringArray, + denylist_patterns: PackedStringArray, + debug_logging_enabled: bool = false +) -> String: + if not is_animation_path_allowed( + normalized_relative_path, + allowlist_patterns, + denylist_patterns + ): + return "" + for cache_resource_path in cache_resource_paths( + cache_directory, + normalized_relative_path + ): + if not ResourceLoader.exists(cache_resource_path): + continue + if glb_cache_is_safe_for_runtime_animation( + cache_resource_path, + maximum_primitive_count, + debug_logging_enabled + ): + return cache_resource_path + return "" + + +## Applies the historical stripped, case-insensitive allow/deny substring policy. +func is_animation_path_allowed( + normalized_relative_path: String, + allowlist_patterns: PackedStringArray, + denylist_patterns: PackedStringArray +) -> bool: + if normalized_relative_path.is_empty() or allowlist_patterns.is_empty(): + return false + var lowercase_path := normalized_relative_path.to_lower() + var allowed := false + for pattern in allowlist_patterns: + var needle := String(pattern).strip_edges().to_lower() + if not needle.is_empty() and lowercase_path.contains(needle): + allowed = true + break + if not allowed: + return false + for pattern in denylist_patterns: + var needle := String(pattern).strip_edges().to_lower() + if not needle.is_empty() and lowercase_path.contains(needle): + return false + return true + + +## Returns historical nested/lowercase/basename `.glb` candidates without I/O. +func cache_resource_paths( + cache_directory: String, + relative_path: String +) -> PackedStringArray: + var normalized := relative_path.replace("\\", "/") + var lowercase := normalized.to_lower() + var stems := [ + normalized.get_basename(), + lowercase.get_basename(), + normalized.get_file().get_basename(), + lowercase.get_file().get_basename(), + ] + var result := PackedStringArray() + for stem in stems: + if stem.is_empty(): + continue + var cache_resource_path := cache_directory.path_join(stem + ".glb") + if not result.has(cache_resource_path): + result.append(cache_resource_path) + return result + + +## Checks animation presence, primitive limit and accepted OpenWC schema. +func glb_cache_is_safe_for_runtime_animation( + cache_resource_path: String, + maximum_primitive_count: int, + debug_logging_enabled: bool = false +) -> bool: + var gltf := read_glb_json(cache_resource_path) + if gltf.is_empty(): + return false + var animations: Array = gltf.get("animations", []) + if animations.is_empty(): + return false + var primitive_count := glb_primitive_count(gltf) + if maximum_primitive_count > 0 and primitive_count > maximum_primitive_count: + return false + var schema := glb_animation_schema(gltf) + if schema == "pivot_prefix_v1" or schema.is_empty(): + return true + if debug_logging_enabled: + print( + "M2_ANIM_REJECT schema=%s primitives=%d cache=%s" + % [schema, primitive_count, cache_resource_path] + ) + return false + + +## Reads the JSON chunk of a version-2 GLB or returns an empty Dictionary. +func read_glb_json(cache_resource_path: String) -> Dictionary: + var absolute_path := ProjectSettings.globalize_path(cache_resource_path) + if not FileAccess.file_exists(absolute_path): + return {} + var file := FileAccess.open(absolute_path, FileAccess.READ) + if file == null or file.get_length() < 20: + return {} + var magic := file.get_32() + var version := file.get_32() + file.get_32() + if magic != 0x46546c67 or version != 2: + return {} + var json_length := int(file.get_32()) + var chunk_type := file.get_32() + if json_length <= 0 or chunk_type != 0x4e4f534a: + return {} + var parsed: Variant = JSON.parse_string( + file.get_buffer(json_length).get_string_from_utf8() + ) + return parsed as Dictionary if parsed is Dictionary else {} + + +## Counts all glTF Mesh primitives using the historical safety rule. +func glb_primitive_count(gltf: Dictionary) -> int: + var count := 0 + var meshes: Array = gltf.get("meshes", []) + for mesh_variant in meshes: + if mesh_variant is Dictionary: + var primitives: Array = (mesh_variant as Dictionary).get( + "primitives", + [] + ) + count += primitives.size() + return count + + +## Returns the OpenWC animation schema marker from parsed glTF metadata. +func glb_animation_schema(gltf: Dictionary) -> String: + var asset: Dictionary = gltf.get("asset", {}) + var extras: Dictionary = asset.get("extras", {}) + return String(extras.get("openwc_m2_anim_schema", "")) diff --git a/src/render/m2/m2_cached_animation_resource_observer.gd.uid b/src/render/m2/m2_cached_animation_resource_observer.gd.uid new file mode 100644 index 0000000..fea2b47 --- /dev/null +++ b/src/render/m2/m2_cached_animation_resource_observer.gd.uid @@ -0,0 +1 @@ +uid://d0ptej41k2smt diff --git a/src/scenes/streaming/streaming_world_loader.gd b/src/scenes/streaming/streaming_world_loader.gd index d19af51..5af6664 100644 --- a/src/scenes/streaming/streaming_world_loader.gd +++ b/src/scenes/streaming/streaming_world_loader.gd @@ -73,6 +73,9 @@ const M2_STATIC_BATCH_MATERIALIZER_SCRIPT := preload( const M2_STATIC_BUILD_RESOURCE_OBSERVER_SCRIPT := preload( "res://src/render/m2/m2_static_build_resource_observer.gd" ) +const M2_CACHED_ANIMATION_RESOURCE_OBSERVER_SCRIPT := preload( + "res://src/render/m2/m2_cached_animation_resource_observer.gd" +) const M2_RUNTIME_MESH_FINALIZER_SCRIPT := preload( "res://src/render/m2/m2_runtime_mesh_finalizer.gd" ) @@ -283,6 +286,9 @@ var _m2_build_dispatch_planner := M2_BUILD_DISPATCH_PLANNER_SCRIPT.new() var _m2_static_build_resource_observer := ( M2_STATIC_BUILD_RESOURCE_OBSERVER_SCRIPT.new() ) +var _m2_animation_resource_observer := ( + M2_CACHED_ANIMATION_RESOURCE_OBSERVER_SCRIPT.new() +) var _m2_unique_placement_registry := ( M2_UNIQUE_PLACEMENT_REGISTRY_SCRIPT.new() ) @@ -4350,20 +4356,34 @@ func _process_m2_build_jobs() -> void: var groups := _m2_build_queue_state.groups_for(key) var transforms: Array = groups.get(rel_path, []) var offset := _m2_build_queue_state.transform_offset_for(key) - var animated_prototype: Node3D = null + var resource_snapshot: RefCounted if enable_m2_animated_instances: - animated_prototype = _get_or_load_m2_native_animated_prototype(rel_path) - if animated_prototype == null: - animated_prototype = _get_or_load_m2_animated_prototype(rel_path) - var animation_request_pending := ( - enable_m2_animated_instances - and _m2_animation_load_pipeline_state.has_request(normalized_rel) - ) - var resource_snapshot: RefCounted = M2_BUILD_RESOURCE_SNAPSHOT_SCRIPT.new( - normalized_rel, - animated_prototype, - animation_request_pending - ) + var native_animated_prototype := ( + _get_or_load_m2_native_animated_prototype(rel_path) + ) + if native_animated_prototype != null: + resource_snapshot = M2_BUILD_RESOURCE_SNAPSHOT_SCRIPT.new( + normalized_rel, + native_animated_prototype, + false + ) + else: + resource_snapshot = _m2_animation_resource_observer.observe( + normalized_rel, + m2_cache_dir, + m2_animated_max_primitives, + m2_animated_allowlist_patterns, + m2_animated_denylist_patterns, + _m2_prototype_cache_state, + _m2_animation_load_pipeline_state, + debug_streaming + ) + else: + resource_snapshot = M2_BUILD_RESOURCE_SNAPSHOT_SCRIPT.new( + normalized_rel, + null, + false + ) var batch_plan: Dictionary = {} var batch_count := 0 if not bool(resource_snapshot.call("animation_request_pending")): @@ -4875,75 +4895,6 @@ func _is_m2_native_animation_candidate(normalized_rel: String) -> bool: return normalized_rel.to_lower().contains("gryphonroost") -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 - 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_pipeline_state.has_request(normalized_rel): - return null - - _request_m2_animation_load(normalized_rel) - return null - - -func _request_m2_animation_load(normalized_rel: String) -> void: - if normalized_rel.is_empty() or _m2_animation_load_pipeline_state.has_request(normalized_rel): - return - var cache_res_path := _find_m2_animated_glb_cache_path(normalized_rel) - if cache_res_path.is_empty(): - _m2_prototype_cache_state.mark_animation_static(normalized_rel) - return - var err := ResourceLoader.load_threaded_request( - cache_res_path, - "", - false, - ResourceLoader.CACHE_MODE_REUSE) - if err == OK or err == ERR_BUSY: - _m2_animation_load_pipeline_state.remember_request(normalized_rel, cache_res_path) - else: - _m2_prototype_cache_state.mark_animation_static(normalized_rel) - - -func _find_m2_animated_glb_cache_path(normalized_rel: String) -> String: - if not _is_m2_animation_allowed(normalized_rel): - return "" - if _is_m2_animation_denied(normalized_rel): - return "" - for cache_res_path in _get_m2_cache_resource_paths(normalized_rel, [".glb"]): - if not ResourceLoader.exists(cache_res_path): - continue - if _glb_cache_is_safe_for_runtime_animation(cache_res_path): - return cache_res_path - return "" - - -func _is_m2_animation_allowed(normalized_rel: String) -> bool: - if m2_animated_allowlist_patterns.is_empty(): - return false - var lower := normalized_rel.to_lower() - for pattern in m2_animated_allowlist_patterns: - var needle := String(pattern).strip_edges().to_lower() - if not needle.is_empty() and lower.contains(needle): - return true - return false - - -func _is_m2_animation_denied(normalized_rel: String) -> bool: - var lower := normalized_rel.to_lower() - for pattern in m2_animated_denylist_patterns: - var needle := String(pattern).strip_edges().to_lower() - if not needle.is_empty() and lower.contains(needle): - return true - return false - - func _get_or_load_m2_material_prototype(normalized_rel: String) -> Node3D: if normalized_rel.is_empty(): return null @@ -4967,62 +4918,6 @@ func _get_or_load_m2_material_prototype(normalized_rel: String) -> Node3D: return null -func _glb_cache_is_safe_for_runtime_animation(cache_res_path: String) -> bool: - var abs_path := ProjectSettings.globalize_path(cache_res_path) - if not FileAccess.file_exists(abs_path): - return false - var file := FileAccess.open(abs_path, FileAccess.READ) - if file == null or file.get_length() < 20: - return false - var magic := file.get_32() - var version := file.get_32() - file.get_32() - if magic != 0x46546c67 or version != 2: - return false - var json_len := int(file.get_32()) - var chunk_type := file.get_32() - if json_len <= 0 or chunk_type != 0x4e4f534a: - return false - var json_text := file.get_buffer(json_len).get_string_from_utf8() - var parsed = JSON.parse_string(json_text) - if not (parsed is Dictionary): - return false - var gltf := parsed as Dictionary - var animations: Array = gltf.get("animations", []) - if animations.is_empty(): - return false - var primitive_count := _glb_primitive_count(gltf) - if m2_animated_max_primitives > 0 and primitive_count > m2_animated_max_primitives: - return false - var schema := _glb_animation_schema(gltf) - if schema == "pivot_prefix_v1": - return true - # Older simple critter GLBs were generated before the schema marker existed. - # Accept them only after allowlist/denylist filtering and primitive-count checks. - if schema.is_empty(): - return true - if debug_streaming: - print("M2_ANIM_REJECT schema=%s primitives=%d cache=%s" % [schema, primitive_count, cache_res_path]) - return false - - -func _glb_primitive_count(gltf: Dictionary) -> int: - var count := 0 - var meshes: Array = gltf.get("meshes", []) - for mesh_variant in meshes: - if not (mesh_variant is Dictionary): - continue - var primitives: Array = (mesh_variant as Dictionary).get("primitives", []) - count += primitives.size() - return count - - -func _glb_animation_schema(gltf: Dictionary) -> String: - var asset: Dictionary = gltf.get("asset", {}) - var extras: Dictionary = asset.get("extras", {}) - return String(extras.get("openwc_m2_anim_schema", "")) - - func _get_or_load_wmo_prototype(rel_path: String) -> Node3D: var normalized_rel := rel_path.replace("\\", "/") if normalized_rel.is_empty(): diff --git a/src/tools/verify_m2_animation_load_pipeline_state.gd b/src/tools/verify_m2_animation_load_pipeline_state.gd index 3fbc3b8..4c20d2a 100644 --- a/src/tools/verify_m2_animation_load_pipeline_state.gd +++ b/src/tools/verify_m2_animation_load_pipeline_state.gd @@ -5,6 +5,9 @@ extends SceneTree const PIPELINE_SCRIPT := preload("res://src/render/m2/m2_animation_load_pipeline_state.gd") const PIPELINE_PATH := "res://src/render/m2/m2_animation_load_pipeline_state.gd" +const OBSERVER_PATH := ( + "res://src/render/m2/m2_cached_animation_resource_observer.gd" +) const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd" @@ -80,14 +83,19 @@ func _verify_discard_metrics_clear_and_diagnostics(failures: Array[String]) -> v func _verify_ownership_boundaries(failures: Array[String]) -> void: var pipeline_source := FileAccess.get_file_as_string(PIPELINE_PATH) + var observer_source := FileAccess.get_file_as_string(OBSERVER_PATH) var loader_source := FileAccess.get_file_as_string(LOADER_PATH) _expect_true(loader_source.contains("M2_ANIMATION_LOAD_PIPELINE_STATE_SCRIPT.new()"), "loader composes pipeline state", failures) _expect_false(loader_source.contains("var _m2_animation_load_requests:"), "legacy request field removed", failures) _expect_false(loader_source.contains("var _m2_animation_finalize_queue:"), "legacy finalize field removed", failures) _expect_equal(loader_source.count("_m2_animation_load_pipeline_state.total_work_count()"), 3, "three existing metrics delegate", failures) _expect_equal(loader_source.count("_m2_animation_load_pipeline_state.clear()"), 2, "two existing clear sites delegate", failures) + _expect_true( + observer_source.contains("ResourceLoader.load_threaded_request("), + "cached observer owns request admission", + failures + ) for retained_loader_rule in [ - "ResourceLoader.load_threaded_request(", "ResourceLoader.load_threaded_get_status(path)", "ResourceLoader.load_threaded_get(path)", "RENDER_BUDGET_SCHEDULER_SCRIPT.M2_ANIMATION_FINALIZE", diff --git a/src/tools/verify_m2_build_resource_snapshot.gd b/src/tools/verify_m2_build_resource_snapshot.gd index a35352c..c8fedb1 100644 --- a/src/tools/verify_m2_build_resource_snapshot.gd +++ b/src/tools/verify_m2_build_resource_snapshot.gd @@ -9,6 +9,9 @@ const SNAPSHOT_SCRIPT := preload( const SNAPSHOT_PATH := "res://src/render/m2/m2_build_resource_snapshot.gd" const DISPATCH_PATH := "res://src/render/m2/m2_build_dispatch_planner.gd" const STATIC_OBSERVER_PATH := "res://src/render/m2/m2_static_build_resource_observer.gd" +const CACHED_ANIMATION_OBSERVER_PATH := ( + "res://src/render/m2/m2_cached_animation_resource_observer.gd" +) const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd" @@ -136,6 +139,9 @@ func _verify_loader_and_dependency_boundaries(failures: Array[String]) -> void: var loader_source := FileAccess.get_file_as_string(LOADER_PATH) var dispatch_source := FileAccess.get_file_as_string(DISPATCH_PATH) var static_observer_source := FileAccess.get_file_as_string(STATIC_OBSERVER_PATH) + var cached_observer_source := FileAccess.get_file_as_string( + CACHED_ANIMATION_OBSERVER_PATH + ) var snapshot_source := FileAccess.get_file_as_string(SNAPSHOT_PATH) for delegated_token in [ "M2_BUILD_RESOURCE_SNAPSHOT_SCRIPT.new(", @@ -152,6 +158,11 @@ func _verify_loader_and_dependency_boundaries(failures: Array[String]) -> void: "static observer adopts snapshot", failures ) + _expect_true( + cached_observer_source.contains("M2_BUILD_RESOURCE_SNAPSHOT_SCRIPT.new("), + "cached animation observer produces snapshot", + failures + ) _expect_true( dispatch_source.contains("resource_snapshot.call(\"animation_request_pending\")") and dispatch_source.contains("resource_snapshot.call(\"has_static_mesh\")"), diff --git a/src/tools/verify_m2_cached_animation_resource_observer.gd b/src/tools/verify_m2_cached_animation_resource_observer.gd new file mode 100644 index 0000000..ee26b80 --- /dev/null +++ b/src/tools/verify_m2_cached_animation_resource_observer.gd @@ -0,0 +1,406 @@ +extends SceneTree + +const OBSERVER_SCRIPT := preload( + "res://src/render/m2/m2_cached_animation_resource_observer.gd" +) +const PROTOTYPE_CACHE_SCRIPT := preload( + "res://src/render/m2/m2_prototype_cache_state.gd" +) +const PIPELINE_SCRIPT := preload( + "res://src/render/m2/m2_animation_load_pipeline_state.gd" +) +const OBSERVER_PATH := ( + "res://src/render/m2/m2_cached_animation_resource_observer.gd" +) +const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd" +const FIXTURE_DIRECTORY := "user://verify_m2_cached_animation_observer" + +var _fixture_paths: PackedStringArray = PackedStringArray() + + +func _initialize() -> void: + var failures: Array[String] = [] + _verify_observation_lifecycle(failures) + _verify_path_policy_and_order(failures) + _verify_glb_safety(failures) + _verify_source_boundaries(failures) + var elapsed_milliseconds := _verify_bounded_timing(failures) + _remove_fixtures() + if not failures.is_empty(): + for failure in failures: + push_error("M2_CACHED_ANIMATION_RESOURCE_OBSERVER: %s" % failure) + quit(1) + return + print( + "M2_CACHED_ANIMATION_RESOURCE_OBSERVER PASS " + + "cases=33 iterations=20000 elapsed_ms=%.3f" % elapsed_milliseconds + ) + quit(0) + + +func _verify_observation_lifecycle(failures: Array[String]) -> void: + var observer: RefCounted = OBSERVER_SCRIPT.new() + var prototype_cache: RefCounted = PROTOTYPE_CACHE_SCRIPT.new() + var pipeline: RefCounted = PIPELINE_SCRIPT.new() + var invalid_snapshot: RefCounted = observer.call( + "observe", "", "res://none", 3, PackedStringArray(), + PackedStringArray(), prototype_cache, pipeline + ) + _expect_false( + bool(invalid_snapshot.call("animation_request_pending")), + "invalid path is not pending", + failures + ) + var prototype := Node3D.new() + prototype_cache.call( + "adopt_animated_prototype", + "creature/fish/a.m2", + prototype + ) + var cached_snapshot: RefCounted = observer.call( + "observe", "creature/fish/a.m2", "res://none", 3, + PackedStringArray(["creature/fish/"]), PackedStringArray(), + prototype_cache, pipeline + ) + _expect_true( + is_same(cached_snapshot.call("animated_prototype"), prototype), + "cached prototype identity", + failures + ) + prototype_cache.call("mark_animation_static", "creature/fish/static.m2") + var static_snapshot: RefCounted = observer.call( + "observe", "creature/fish/static.m2", "res://none", 3, + PackedStringArray(["creature/fish/"]), PackedStringArray(), + prototype_cache, pipeline + ) + _expect_false( + bool(static_snapshot.call("animation_request_pending")), + "static outcome is not pending", + failures + ) + pipeline.call( + "remember_request", + "creature/fish/pending.m2", + "res://pending.glb" + ) + var pending_snapshot: RefCounted = observer.call( + "observe", "creature/fish/pending.m2", "res://none", 3, + PackedStringArray(["creature/fish/"]), PackedStringArray(), + prototype_cache, pipeline + ) + _expect_true( + bool(pending_snapshot.call("animation_request_pending")), + "existing request remains pending", + failures + ) + var unavailable_snapshot: RefCounted = observer.call( + "observe", "creature/fish/unavailable.m2", "res://none", 3, + PackedStringArray(["creature/fish/"]), PackedStringArray(), + prototype_cache, pipeline + ) + _expect_false( + bool(unavailable_snapshot.call("animation_request_pending")), + "missing candidate is not pending", + failures + ) + _expect_true( + bool(prototype_cache.call( + "is_animation_static", + "creature/fish/unavailable.m2" + )), + "missing candidate marks static", + failures + ) + pipeline.call("clear") + prototype_cache.call("clear_and_release") + + +func _verify_path_policy_and_order(failures: Array[String]) -> void: + var observer: RefCounted = OBSERVER_SCRIPT.new() + _expect_false( + bool(observer.call( + "is_animation_path_allowed", "creature/fish/a.m2", + PackedStringArray(), PackedStringArray() + )), + "empty allowlist rejects", + failures + ) + _expect_true( + bool(observer.call( + "is_animation_path_allowed", "Creature/Fish/A.M2", + PackedStringArray([" creature/fish/ "]), PackedStringArray() + )), + "allowlist strips and ignores case", + failures + ) + _expect_false( + bool(observer.call( + "is_animation_path_allowed", "creature/fish/boss/a.m2", + PackedStringArray(["creature/fish/"]), + PackedStringArray([" FISH/BOSS "]) + )), + "denylist wins", + failures + ) + var paths: PackedStringArray = observer.call( + "cache_resource_paths", + "res://cache", + "World/Critter/Foo.M2" + ) + _expect_true( + paths == PackedStringArray([ + "res://cache/World/Critter/Foo.glb", + "res://cache/world/critter/foo.glb", + "res://cache/Foo.glb", + "res://cache/foo.glb", + ]), + "historical GLB candidate order", + failures + ) + + +func _verify_glb_safety(failures: Array[String]) -> void: + var observer: RefCounted = OBSERVER_SCRIPT.new() + _expect_false( + bool(observer.call( + "glb_cache_is_safe_for_runtime_animation", + "user://missing.glb", + 3 + )), + "missing GLB rejects", + failures + ) + var pivot_path := _write_glb_fixture( + "pivot.glb", + _make_gltf(2, "pivot_prefix_v1", true) + ) + _expect_true( + bool(observer.call( + "glb_cache_is_safe_for_runtime_animation", pivot_path, 3 + )), + "pivot-prefix schema accepts", + failures + ) + var legacy_path := _write_glb_fixture( + "legacy.glb", + _make_gltf(1, "", true) + ) + _expect_true( + bool(observer.call( + "glb_cache_is_safe_for_runtime_animation", legacy_path, 3 + )), + "legacy empty schema accepts", + failures + ) + var unknown_path := _write_glb_fixture( + "unknown.glb", + _make_gltf(1, "future_schema", true) + ) + _expect_false( + bool(observer.call( + "glb_cache_is_safe_for_runtime_animation", unknown_path, 3 + )), + "unknown schema rejects", + failures + ) + var complex_path := _write_glb_fixture( + "complex.glb", + _make_gltf(4, "pivot_prefix_v1", true) + ) + _expect_false( + bool(observer.call( + "glb_cache_is_safe_for_runtime_animation", complex_path, 3 + )), + "primitive limit rejects", + failures + ) + _expect_true( + bool(observer.call( + "glb_cache_is_safe_for_runtime_animation", complex_path, 0 + )), + "non-positive primitive limit disables cap", + failures + ) + var static_path := _write_glb_fixture( + "static.glb", + _make_gltf(1, "pivot_prefix_v1", false) + ) + _expect_false( + bool(observer.call( + "glb_cache_is_safe_for_runtime_animation", static_path, 3 + )), + "GLB without animations rejects", + failures + ) + _expect_equal( + int(observer.call("glb_primitive_count", _make_gltf(5, "", true))), + 5, + "primitive count retained", + failures + ) + _expect_string_equal( + String(observer.call( + "glb_animation_schema", + _make_gltf(1, "pivot_prefix_v1", true) + )), + "pivot_prefix_v1", + "schema retained", + failures + ) + + +func _verify_source_boundaries(failures: Array[String]) -> void: + var loader_source := FileAccess.get_file_as_string(LOADER_PATH) + var observer_source := FileAccess.get_file_as_string(OBSERVER_PATH) + _expect_true( + loader_source.contains("_m2_animation_resource_observer.observe("), + "loader delegates cached observation", + failures + ) + for removed_function in [ + "func _get_or_load_m2_animated_prototype", + "func _request_m2_animation_load", + "func _find_m2_animated_glb_cache_path", + "func _glb_cache_is_safe_for_runtime_animation", + ]: + _expect_false( + loader_source.contains(removed_function), + "loader omits %s" % removed_function, + failures + ) + _expect_true( + loader_source.contains("func _get_or_load_m2_native_animated_prototype"), + "loader retains native observation", + failures + ) + _expect_true( + observer_source.contains("ResourceLoader.load_threaded_request("), + "observer owns request admission", + failures + ) + for forbidden_token in [ + "queue_free", + "RenderingServer", + "WorkerThreadPool", + "M2_NATIVE_ANIMATED_BUILDER", + "M2RawModelRepository", + "RenderBudgetScheduler", + ]: + _expect_false( + observer_source.contains(forbidden_token), + "observer omits %s" % forbidden_token, + failures + ) + + +func _verify_bounded_timing(failures: Array[String]) -> float: + var observer: RefCounted = OBSERVER_SCRIPT.new() + var allowlist := PackedStringArray(["creature/fish/", "world/critter/"]) + var denylist := PackedStringArray(["boss"]) + var started_microseconds := Time.get_ticks_usec() + for iteration in range(20000): + var path := "Creature/Fish/model_%d.m2" % (iteration % 64) + observer.call( + "is_animation_path_allowed", + path, + allowlist, + denylist + ) + observer.call("cache_resource_paths", "res://cache", path) + var elapsed_milliseconds := ( + float(Time.get_ticks_usec() - started_microseconds) / 1000.0 + ) + _expect_true( + elapsed_milliseconds < 1000.0, + "20,000 policy/path observations remain bounded", + failures + ) + return elapsed_milliseconds + + +func _make_gltf( + primitive_count: int, + schema: String, + has_animation: bool +) -> Dictionary: + var primitives: Array[Dictionary] = [] + for _index in range(primitive_count): + primitives.append({}) + var gltf := { + "asset": {"version": "2.0", "extras": {}}, + "meshes": [{"primitives": primitives}], + "animations": [{}] if has_animation else [], + } + if not schema.is_empty(): + (gltf["asset"]["extras"] as Dictionary)[ + "openwc_m2_anim_schema" + ] = schema + return gltf + + +func _write_glb_fixture(file_name: String, gltf: Dictionary) -> String: + var absolute_directory := ProjectSettings.globalize_path(FIXTURE_DIRECTORY) + DirAccess.make_dir_recursive_absolute(absolute_directory) + var resource_path := FIXTURE_DIRECTORY.path_join(file_name) + var absolute_path := ProjectSettings.globalize_path(resource_path) + var json_bytes := JSON.stringify(gltf).to_utf8_buffer() + while json_bytes.size() % 4 != 0: + json_bytes.append(0x20) + var file := FileAccess.open(absolute_path, FileAccess.WRITE) + if file == null: + return resource_path + file.store_32(0x46546c67) + file.store_32(2) + file.store_32(20 + json_bytes.size()) + file.store_32(json_bytes.size()) + file.store_32(0x4e4f534a) + file.store_buffer(json_bytes) + file.close() + _fixture_paths.append(absolute_path) + return resource_path + + +func _remove_fixtures() -> void: + for absolute_path in _fixture_paths: + if FileAccess.file_exists(absolute_path): + DirAccess.remove_absolute(absolute_path) + var absolute_directory := ProjectSettings.globalize_path(FIXTURE_DIRECTORY) + if DirAccess.dir_exists_absolute(absolute_directory): + DirAccess.remove_absolute(absolute_directory) + + +func _expect_true( + condition: bool, + label: String, + failures: Array[String] +) -> void: + if not condition: + failures.append(label) + + +func _expect_false( + condition: bool, + label: String, + failures: Array[String] +) -> void: + _expect_true(not condition, label, failures) + + +func _expect_equal( + actual: int, + expected: int, + label: String, + failures: Array[String] +) -> void: + if actual != expected: + failures.append("%s expected=%d actual=%d" % [label, expected, actual]) + + +func _expect_string_equal( + actual: String, + expected: String, + label: String, + failures: Array[String] +) -> void: + if actual != expected: + failures.append("%s expected=%s actual=%s" % [label, expected, actual]) diff --git a/src/tools/verify_m2_cached_animation_resource_observer.gd.uid b/src/tools/verify_m2_cached_animation_resource_observer.gd.uid new file mode 100644 index 0000000..19cb725 --- /dev/null +++ b/src/tools/verify_m2_cached_animation_resource_observer.gd.uid @@ -0,0 +1 @@ +uid://cbviicmix3kb0 diff --git a/targets/03-renderer-facade.md b/targets/03-renderer-facade.md index a1f7f0b..87d41a7 100644 --- a/targets/03-renderer-facade.md +++ b/targets/03-renderer-facade.md @@ -59,7 +59,8 @@ Runtime и Editor используют facade; planner/scheduler тестиру `M03-RND-M2-BUILD-QUEUE-001`, `M03-RND-M2-BUILD-DISPATCH-PLANNER-001`, `M03-RND-M2-BUILD-RESOURCE-SNAPSHOT-001`, - `M03-RND-M2-STATIC-BUILD-RESOURCE-OBSERVER-001` + `M03-RND-M2-STATIC-BUILD-RESOURCE-OBSERVER-001`, + `M03-RND-M2-CACHED-ANIMATION-RESOURCE-OBSERVER-001` - Commands: dedicated scheduler/planner/facade/focus/coordinate/manifest/shutdown headless verifiers; Godot cold editor parse; coordination, documentation and diff gates. - Results: scheduler `cases=6 iterations=20000 elapsed_ms=10.216`; planner @@ -283,6 +284,13 @@ Runtime и Editor используют facade; planner/scheduler тестиру Merge `a043c79` passed post-merge observer (`64.202ms`), snapshot, dispatch, Mesh pipeline/cache, prototype cache, shutdown, facade, internal-access `30`, manifest `7/7`, documentation `42`, coordination and checkpoint dry-run `7/7`. + M2 cached animation resource observer passed `cases=33 iterations=20000 + elapsed_ms=61.745` with cached-prototype identity, static/pending/missing + lifecycle, stripped case-insensitive allow/deny precedence, exact historical + GLB path order and generated GLB animation/primitive/schema safety fixtures. + All 56 autonomous headless regressions passed; internal-access remained `30`, + documentation covered `43` module specifications and checkpoint dry-run kept + all `7/7` plans. Coordination passed with `34` historical expired-claim warnings. M02 terrain-query regression remained green (13 pre-existing expired M00 claim warnings). - Fidelity comparison: all 16 historical operation limits and drain sites were @@ -320,6 +328,11 @@ Runtime и Editor используют facade; planner/scheduler тестиру One build operation now carries the exact normalized path, optional animated prototype, pending-animation state, optional static Mesh and missing outcome in a typed snapshot; releasing it never frees either borrowed engine resource. + Cached animated lookup now preserves cached/static/pending priority, empty- + allowlist rejection, denylist precedence, normalized/lowercase/basename path + order, animation presence, primitive cap, accepted empty/`pivot_prefix_v1` + schema rules and request/static-only transitions. Native GryphonRoost remains + first and loader-owned, so selection and visible behavior are unchanged. WMO cache-key normalization, positive MODF identity with tile/index fallback and world position/Euler/unclamped-scale transforms are unchanged across lightweight render, cached-scene and live-prototype paths. @@ -438,6 +451,8 @@ Runtime и Editor используют facade; planner/scheduler тестиру stateless M2 build dispatch planner and loader resource-observation/action adapter, typed M2 build resource snapshot and loader/dispatch/materializer adapters, static M2 build resource observer and loader/cache/pipeline/snapshot adapters, + cached M2 animation resource observer and loader/prototype/pipeline/snapshot + adapters plus generated GLB metadata regression fixtures, expanded facade/internal-access/coordinate verifiers; work-package claims and this evidence. - Remaining risks: worker concurrency and stale-result validation remain streamer-owned; cancellation stops new permits but does not interrupt @@ -454,18 +469,19 @@ Runtime и Editor используют facade; planner/scheduler тестиру culling-driven spatial-cell grouping; M2 rebuild classification and static request/finalize bookkeeping, prepared Mesh cache ownership and first-Mesh traversal plus refresh/rebuild/fallback, raw native M2 I/O and prototype/ - negative cache state are separated, but synchronous parsing, ResourceLoader, - animation request state and Mesh adoption remain in the loader call path; raw + negative cache state are separated, but synchronous native parsing/build and + Mesh adoption remain in the loader call path; cached animation eligibility/ + request admission is extracted while terminal ResourceLoader polling remains; + raw animated-instance materialization is now separated but remains synchronous main-thread work; asset-backed traversal/visual/leak/p95/p99 evidence is pending; static MultiMesh materialization is also separated but remains synchronous; headless timing does not measure transform upload GPU cost and spatial-cell batching still needs culling/performance evidence; typed M2 pending state is - separated and dispatch decisions are explicit, while resource observation/ - requests, action execution and root cleanup remain in loader; resource state is - typed per operation but its producing service is not yet extracted; - static lookup/request production is extracted, while animated observation and - both ResourceLoader finalize drains remain loader-owned; + separated and dispatch decisions are explicit; static and cached-animation + observers produce typed per-operation state, while native observation, action + execution and root cleanup remain in loader; both ResourceLoader finalize + drains remain loader-owned; WMO ResourceLoader/FileAccess I/O, live fallback and group materialization remain in the loader; asset-backed WMO placement/portal/material/leak/p95/p99 evidence remains pending;