From 1cb0101a7345ad48a7cfab134db77e41b6294edb Mon Sep 17 00:00:00 2001 From: sindoring Date: Sat, 18 Jul 2026 10:55:47 +0400 Subject: [PATCH] render: extract native M2 animation resource observer --- RENDER.md | 30 +- docs/modules/README.md | 1 + docs/modules/m2-build-dispatch-planner.md | 15 +- docs/modules/m2-build-resource-snapshot.md | 19 +- .../m2-cached-animation-resource-observer.md | 12 +- .../m2-native-animation-resource-observer.md | 245 ++++++++++ docs/modules/m2-prototype-cache-state.md | 11 +- docs/modules/m2-raw-model-repository.md | 19 +- docs/modules/world-renderer.md | 18 +- .../m2_native_animation_resource_observer.gd | 113 +++++ ..._native_animation_resource_observer.gd.uid | 1 + .../streaming/streaming_world_loader.gd | 61 +-- ...y_m2_cached_animation_resource_observer.gd | 13 +- ...y_m2_native_animation_resource_observer.gd | 426 ++++++++++++++++++ ..._native_animation_resource_observer.gd.uid | 1 + src/tools/verify_m2_prototype_cache_state.gd | 18 +- src/tools/verify_m2_raw_model_repository.gd | 9 +- targets/03-renderer-facade.md | 24 +- 18 files changed, 932 insertions(+), 104 deletions(-) create mode 100644 docs/modules/m2-native-animation-resource-observer.md create mode 100644 src/render/m2/m2_native_animation_resource_observer.gd create mode 100644 src/render/m2/m2_native_animation_resource_observer.gd.uid create mode 100644 src/tools/verify_m2_native_animation_resource_observer.gd create mode 100644 src/tools/verify_m2_native_animation_resource_observer.gd.uid diff --git a/RENDER.md b/RENDER.md index 2e51b44..64acdbe 100644 --- a/RENDER.md +++ b/RENDER.md @@ -329,7 +329,7 @@ Native M2 animation first pass for composite doodads: - `M2NativeAnimator` evaluates the selected Stand sequence and applies WoW-style bone matrices: `T(pivot + translation) * R * S * T(-pivot) * parent`. - Vertex influences are resolved through `.skin` local bone indices and the M2 `boneCombos` palette, matching the original section/batch renderer model used by WoW/whoa. -- `StreamingWorldLoader` routes only `gryphonroost` through this native path; simple critters still use the existing GLB allowlist and most M2 world props stay on static MultiMesh. +- `M2NativeAnimationResourceObserver` routes only `gryphonroost` through this native path; simple critters still use the existing GLB allowlist and most M2 world props stay on static MultiMesh. - Current implementation rebuilds the animated `ArrayMesh` on CPU. This is correct enough for the problematic rare doodads and gives us the same data layout that can later move to shader/GPU skinning. Причина: композитные doodad вроде `GryphonRoost` снова ломают визуал при GLB-анимации. До M2-native renderer все world placement M2 должны оставаться статическими. GLB-анимация оставлена только как вручную включаемый debug experiment через allowlist. @@ -1148,12 +1148,28 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' 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. +- Native GryphonRoost raw-data build/debug logging belongs to the sibling native + observer; 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 Native Animation Resource Observer Extraction + +- `M2NativeAnimationResourceObserver` now owns the exact case-insensitive + `gryphonroost` candidate rule, animated prototype/static-only cache checks, + synchronous raw animated read, native builder call, adoption and success log. +- The native attempt remains first. Empty raw data/surfaces and null/childless + builds retain the same static-only fallback; accepted Nodes remain owned by + `M2PrototypeCacheState` until final shutdown. +- `StreamingWorldLoader` retains observer order, typed snapshot construction, + cached-GLB fallback, terminal ResourceLoader polling/finalize, permits, + materialization and SceneTree lifetime. +- The historical unfreed childless builder result is deliberately preserved and + documented as a leak risk. No parser, cache, profile or visible rule changed; + synthetic fixtures are not asset-backed animation or performance evidence. + ## 2026-07-18 M2 Animated Scene Finalizer Extraction - `M2AnimatedSceneFinalizer` now owns terminal animated PackedScene candidate @@ -1228,10 +1244,10 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' - `M2RawModelRepository` now owns the repeated extracted-file and optional native `M2Loader` boundary for static `load_m2` and animated `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, builders, permits and Node/Mesh use; prototype/negative state - is now isolated in `M2PrototypeCacheState`. +- `StreamingWorldLoader` delegates stale-Mesh refresh and static reads directly; + `M2NativeAnimationResourceObserver` delegates native animated reads. The loader + retains normalization, `.tscn/.glb` fallback order, permits and Node/Mesh use; + prototype/negative state is 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. diff --git a/docs/modules/README.md b/docs/modules/README.md index f7cc95b..b8ead11 100644 --- a/docs/modules/README.md +++ b/docs/modules/README.md @@ -25,6 +25,7 @@ | 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 native animation resource observer | Implemented extraction | [`m2-native-animation-resource-observer.md`](m2-native-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-build-dispatch-planner.md b/docs/modules/m2-build-dispatch-planner.md index 891b675..0cbe70e 100644 --- a/docs/modules/m2-build-dispatch-planner.md +++ b/docs/modules/m2-build-dispatch-planner.md @@ -148,7 +148,7 @@ flowchart TB - `M2BuildResourceSnapshot` owns the typed call-local observation contract; dispatch reads availability/pending/missing accessors but no engine references. - Loader owns native-first observation order, action execution and permit use; - static/cached-animation observers own their lookup/request phases. + native/static/cached-animation observers own their resource 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. @@ -192,9 +192,8 @@ remain unchanged. ## Extension points -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 +Static, cached animated and native animated observation now live behind sibling +services that produce `M2BuildResourceSnapshot`. Generic callbacks, signals and a shared state-machine framework remain intentionally excluded. ## Capability status @@ -202,13 +201,14 @@ framework remain intentionally excluded. | Capability | Status | Evidence | Gap/next step | |---|---|---|---| | M2 build action selection | Implemented extraction | Priority/matrix/source/timing verifier | Asset-backed traversal pending | -| Static/cached animation observation and requests | Implemented separately | Observer/cache/pipeline regressions | Native observation remains loader-owned | +| Static/cached/native animation observation | Implemented separately | Observer/cache/pipeline regressions | Asset-backed traversal pending | | 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 | ## Known gaps and risks -- Loader still performs synchronous/native prototype attempts and static request I/O. +- Native prototype attempts remain synchronous through the observer; loader still + owns static request I/O and terminal animated ResourceLoader polling. - Synthetic timing does not measure ResourceLoader, Node or GPU work. - Private traversal, leak, visual and p95/p99 evidence remains unavailable. @@ -219,7 +219,8 @@ 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/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_native_animation_resource_observer.gd` | Native candidate/read/build/cache observation | +| `src/scenes/streaming/streaming_world_loader.gd` | 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 ea46ae4..280e20b 100644 --- a/docs/modules/m2-build-resource-snapshot.md +++ b/docs/modules/m2-build-resource-snapshot.md @@ -28,7 +28,7 @@ static Mesh and terminal missing-model state. The snapshot never owns engine lif ```mermaid flowchart LR - Native[StreamingWorldLoader native observation] --> Snapshot[M2BuildResourceSnapshot] + Native[M2NativeAnimationResourceObserver] --> Snapshot[M2BuildResourceSnapshot] Cached[M2CachedAnimationResourceObserver] --> Snapshot Static[M2StaticBuildResourceObserver] --> Snapshot Snapshot --> Dispatch[M2BuildDispatchPlanner] @@ -112,7 +112,7 @@ sequenceDiagram participant B as M2BuildBatchPlanner participant D as M2BuildDispatchPlanner participant M as M2 materializer - L->>L: resolve native animated prototype + L->>L: delegate native animated prototype observation L->>S: construct native result opt no native prototype L->>L: delegate cached animation observer @@ -149,8 +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 native-first - observation order while cached/static observers own their phases. +- Prototype/cache services retain resource state; the native observer owns its + phase while loader owns native-first ordering among resource observers. - 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. @@ -195,8 +195,8 @@ queue/hitch metrics remain unchanged. ## Extension points `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 +`M2StaticBuildResourceObserver` produces the static phase. Native animation is +produced by `M2NativeAnimationResourceObserver`. The value stays independent of all producers and of generic callback frameworks. ## Capability status @@ -207,14 +207,14 @@ producers and of generic callback frameworks. | Dispatch decision | Implemented separately | Dispatch planner verifier | Asset-backed traversal pending | | Static lookup/request execution | Implemented separately | Static observer verifier | Asset-backed traversal 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 | +| Native animated lookup/build execution | Implemented observer extraction | Native observer lifecycle/source verifier | Asset-backed traversal 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 terminal polling. +- Native parsing/build remains synchronous; loader still owns terminal polling. - Synthetic timing does not measure resource, Node or GPU work. ## Source map @@ -225,7 +225,8 @@ producers and of generic callback frameworks. | `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/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/render/m2/m2_native_animation_resource_observer.gd` | Native prototype observation producer | +| `src/scenes/streaming/streaming_world_loader.gd` | Observer ordering 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 index effb855..9748cad 100644 --- a/docs/modules/m2-cached-animation-resource-observer.md +++ b/docs/modules/m2-cached-animation-resource-observer.md @@ -28,7 +28,7 @@ or record a terminal static-only animation outcome without changing behavior. ```mermaid flowchart LR - Loader[StreamingWorldLoader] --> Native[Native animation attempt] + Loader[StreamingWorldLoader] --> Native[M2NativeAnimationResourceObserver] Native -->|no prototype| Observer[M2CachedAnimationResourceObserver] Prototype[M2PrototypeCacheState] --> Observer Pipeline[M2AnimationLoadPipelineState] --> Observer @@ -151,8 +151,8 @@ flowchart TB - 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. +- The sibling native observer owns the native build attempt. Loader remains owner + of terminal polling/finalize, permits, materialization and SceneTree mutation. ## Errors, cancellation and recovery @@ -206,7 +206,7 @@ behind a dedicated adapter while retaining the pipeline state. | 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 | +| Native animation observation | Implemented sibling extraction | Native observer lifecycle/source verifier | Asset-backed traversal pending | | Animated finalize/preparation | Loader/finalizer split | Pipeline/finalizer regressions | Polling adapter optional | ## Known gaps and risks @@ -225,12 +225,14 @@ behind a dedicated adapter while retaining the pipeline state. | `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/render/m2/m2_native_animation_resource_observer.gd` | Native-first candidate/read/build/cache observation | +| `src/scenes/streaming/streaming_world_loader.gd` | Composition, fallback order, 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-native-animation-resource-observer.md`](m2-native-animation-resource-observer.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) diff --git a/docs/modules/m2-native-animation-resource-observer.md b/docs/modules/m2-native-animation-resource-observer.md new file mode 100644 index 0000000..fb644bc --- /dev/null +++ b/docs/modules/m2-native-animation-resource-observer.md @@ -0,0 +1,245 @@ +# M2 Native Animation Resource Observer + +## Metadata + +| Field | Value | +|---|---| +| Status | Implemented extraction | +| Target/work package | M03 / `M03-RND-M2-NATIVE-ANIMATION-RESOURCE-OBSERVER-001` | +| Owners | Native animation candidate policy, raw read, build and cache outcome | +| Last verified | Worktree `work/sindo-main-codex/m03-m2-native-animation-resource-observer`, 2026-07-18 | +| Profiles/capabilities | Existing native GryphonRoost animated M2 path | + +## Purpose + +Resolve the historical native animated M2 path as one synchronous observation: +select a GryphonRoost candidate, reuse cached state, read animated raw data, +build a detached prototype and publish either the prototype or static-only result. + +## Non-goals + +- Add native animation candidates or change the `gryphonroost` predicate. +- Observe/request cached GLB animation or poll/finalize ResourceLoader work. +- Duplicate, attach, animate or free accepted instances in the SceneTree. +- Consume render permits or change build queue, batching and dispatch policy. +- Change native parsing/building, cache formats, profiles or visible output. + +## Context and boundaries + +```mermaid +flowchart LR + Loader[StreamingWorldLoader] --> Observer[M2NativeAnimationResourceObserver] + Observer --> Repository[M2RawModelRepository] + Observer --> Builder[M2NativeAnimatedBuilder] + Observer --> Cache[M2PrototypeCacheState] + Observer --> Prototype[Detached animated prototype] + Prototype --> Snapshot[M2BuildResourceSnapshot] + Observer -->|no prototype| Cached[M2CachedAnimationResourceObserver] +``` + +The observer is the native-first resource boundary. The loader supplies an +already-normalized path and remains responsible for fallback ordering and for +constructing the snapshot consumed by dispatch. + +## Public API + +| Symbol | Kind | Purpose | Failure behavior | +|---|---|---|---| +| `is_native_animation_candidate(path)` | Pure query | Apply the historical case-insensitive GryphonRoost substring policy | Empty/unmatched path returns false | +| `observe(path, extracted_dir, repository, cache, debug)` | Command/query | Return an exact cached/new native prototype or record static-only fallback | Invalid/non-candidate input returns null without mutation | +| `build_animated_prototype(data, extracted_dir)` | Adapter command | Call the retained native animated builder dependency | Missing builder or invalid result returns null | + +The optional constructor dependency exists for deterministic tests; production +uses `M2NativeAnimatedBuilder` without a new abstraction or runtime setting. + +## Inputs and outputs + +| Direction | Data | Producer | Consumer | Ownership/lifetime | +|---|---|---|---|---| +| Input | Normalized relative M2 path | Loader path adapter | Observer | Copied String; one call | +| Input | Extracted directory | Loader configuration | Repository/builder | Copied String; one call | +| Input | Raw animated Dictionary | Repository | Observer/builder | Call-local value | +| Input | Prototype cache state | Loader composition | Observer | Borrowed service; loader session | +| Output | Cached or adopted prototype | Cache/observer | Loader snapshot | Borrowed exact Node3D reference | +| Output | Static-only transition | Observer | Prototype cache | Copied path; loader session | +| Output | Debug line | Observer | Runtime log | Emitted only when enabled and build succeeds | + +## Data flow + +```mermaid +flowchart TD + Start[Observe normalized path] --> Candidate{Valid native candidate?} + Candidate -->|no| None[Return null] + Candidate -->|yes| Cached{Cached prototype?} + Cached -->|yes| Return[Return exact prototype] + Cached -->|no| Static{Marked static-only?} + Static -->|yes| None + Static -->|no| Read[Read animated raw data] + Read --> Surfaces{Data and animated surfaces?} + Surfaces -->|no| Mark[Mark static-only] + Surfaces -->|yes| Build[Build detached prototype] + Build --> Children{Prototype has children?} + Children -->|no| Mark + Children -->|yes| Adopt[Adopt first cache prototype] + Adopt --> Log{Debug enabled?} + Log -->|yes| Emit[Emit exact native cache fields] + Log -->|no| Return + Emit --> Return + Mark --> None +``` + +## Lifecycle/state + +```mermaid +stateDiagram-v2 + [*] --> Observing + Observing --> Rejected: invalid or non-candidate + Observing --> Cached: positive cache hit + Observing --> StaticOnly: negative cache hit + Observing --> Reading: uncached candidate + Reading --> StaticOnly: no animated surfaces + Reading --> Building: usable raw data + Building --> StaticOnly: null or childless result + Building --> Adopted: valid result + Adopted --> [*] + Cached --> [*] + StaticOnly --> [*] + Rejected --> [*] +``` + +## Main sequence + +```mermaid +sequenceDiagram + participant L as StreamingWorldLoader + participant O as NativeAnimationObserver + participant C as M2PrototypeCacheState + participant R as M2RawModelRepository + participant B as M2NativeAnimatedBuilder + L->>O: observe(path, directory, repository, cache, debug) + O->>C: find animated / is static-only + alt uncached native candidate + O->>R: load_animated_model_data(directory, path) + R-->>O: raw Dictionary + O->>B: build(raw, directory) + alt valid prototype + O->>C: adopt_animated_prototype(path, prototype) + O-->>L: canonical prototype + else unavailable + O->>C: mark_animation_static(path) + O-->>L: null + end + else cached result + O-->>L: prototype or null + end +``` + +## Dependency diagram + +```mermaid +flowchart TB + Observer[M2NativeAnimationResourceObserver] --> Repository[M2RawModelRepository] + Observer --> Builder[M2NativeAnimatedBuilder] + Observer --> Cache[M2PrototypeCacheState] + Loader[StreamingWorldLoader] --> Observer + Observer -. no dependency .-> ResourceLoader + Observer -. no dependency .-> Pipeline[M2AnimationLoadPipelineState] + Observer -. no dependency .-> Scheduler[RenderBudgetScheduler] + Observer -. no dependency .-> SceneTree +``` + +## Ownership, threading and resources + +- Observation and native parsing/building remain synchronous on the renderer + main thread, exactly as before extraction; there is no mid-call cancellation. +- The observer retains no path, raw Dictionary, Node or service reference. +- Prototype cache owns an accepted detached Node until final shutdown; callers + only borrow the canonical reference. +- The historical childless-builder rejection does not free its detached Node. + This deliberately preserved lifetime edge is documented as a remaining leak + risk rather than silently changed during architectural extraction. +- Loader owns snapshots, fallback sequencing, permits, materialization and every + SceneTree mutation. + +## Errors, cancellation and recovery + +| Failure/state | Behavior | Recovery | +|---|---|---| +| Empty/non-candidate path or missing dependency | Return null without state mutation | Correct composition/path | +| Cached prototype | Return exact canonical Node immediately | None | +| Existing static-only outcome | Return null without repeated I/O | New loader session after source repair | +| Empty raw data or animated surfaces | Mark static-only and return null | Repair source/parser and start new session | +| Null/childless build | Mark static-only and return null | Repair builder/data and start new session | +| Duplicate successful build | Cache releases later candidate and returns first | None | +| Cancellation during native call | Not supported; call completes | Loader controls whether the call starts | +| Shutdown | Observer retains nothing | Cache releases adopted Nodes after drains | + +## Configuration and capabilities + +The observer consumes the existing extracted directory and `debug_streaming` +flag. Candidate policy remains the case-insensitive substring `gryphonroost`. +No profile, cache, budget or animation setting is added. + +## Persistence, cache and migration + +No persistence or format changes are introduced. Positive and static-only state +continues to use the shutdown-lifetime prototype cache; no asset rebake is needed. + +## Diagnostics and observability + +A successful debug-enabled build emits the unchanged `M2_NATIVE_ANIM_CACHE` +record with path, surface count, bone count, animation id, sequence index, +activity score and length. Rejections remain silent as before. + +## Verification + +- Dedicated synthetic verifier covers candidate policy, invalid dependencies, + cache identity, static suppression, raw/surface/build failures, preserved + childless lifetime, exact repository/builder arguments, adoption, source + ownership and bounded candidate checks. +- Cached observer, raw repository, prototype cache, snapshot, dispatch, shutdown, + facade, internal-access and render-baseline checks protect adjacent behavior. +- Fidelity evidence is exact control-flow and diagnostic extraction. It is not + original-client animation, visual, memory or asset-backed performance evidence. + +## Extension points + +Terminal cached-GLB polling/finalization can move behind a separate adapter +without changing this native-first contract. Candidate expansion requires its +own fidelity evidence and must not silently change `Blizzlike335` behavior. + +## Capability status + +| Capability | Status | Evidence | Gap/next step | +|---|---|---|---| +| Native candidate selection | Implemented extraction | Policy/source/timing verifier | Additional candidates require fidelity work | +| Raw read/build/cache outcome | Implemented extraction | Failure/identity/adoption verifier | Asset-backed traversal pending | +| Terminal cached ResourceLoader polling | Loader-owned | Existing pipeline regressions | Dedicated finalize adapter | + +## Known gaps and risks + +- Native parsing/building is synchronous and not bounded by a render permit. +- Rejected childless builder Nodes retain the historical unowned lifetime edge. +- Private asset traversal, descriptor/leak pressure, p95/p99 and paired visual/ + animation evidence remain unavailable in this package. + +## Source map + +| Path | Responsibility | +|---|---| +| `src/render/m2/m2_native_animation_resource_observer.gd` | Candidate/cache/raw/build/outcome/logging observation | +| `src/render/m2/m2_raw_model_repository.gd` | Optional native file/parser boundary | +| `src/render/m2/m2_prototype_cache_state.gd` | Positive prototype and static-only ownership | +| `addons/mpq_extractor/loaders/m2_native_animated_builder.gd` | Existing animated prototype construction | +| `src/scenes/streaming/streaming_world_loader.gd` | Composition, native-first fallback order and execution | +| `src/tools/verify_m2_native_animation_resource_observer.gd` | Lifecycle/identity/boundary/timing regression | + +## Related decisions and references + +- [`m2-cached-animation-resource-observer.md`](m2-cached-animation-resource-observer.md) +- [`m2-build-resource-snapshot.md`](m2-build-resource-snapshot.md) +- [`m2-raw-model-repository.md`](m2-raw-model-repository.md) +- [`m2-prototype-cache-state.md`](m2-prototype-cache-state.md) +- [`world-renderer.md`](world-renderer.md) +- [`../../RENDER.md`](../../RENDER.md) +- [`../../targets/roadmap/02-rendering-and-graphics.md`](../../targets/roadmap/02-rendering-and-graphics.md) diff --git a/docs/modules/m2-prototype-cache-state.md b/docs/modules/m2-prototype-cache-state.md index 1131148..4a54bc4 100644 --- a/docs/modules/m2-prototype-cache-state.md +++ b/docs/modules/m2-prototype-cache-state.md @@ -142,7 +142,7 @@ flowchart TB Loader --> Raw[M2RawModelRepository] Observer --> ResourceLoader Loader --> StaticBuilder[M2Builder] - Loader --> AnimatedBuilder[M2NativeAnimatedBuilder] + Observer --> AnimatedBuilder[M2NativeAnimatedBuilder] State -. no dependency .-> Raw State -. no dependency .-> ResourceLoader State -. no dependency .-> StaticBuilder @@ -192,7 +192,7 @@ and native M2 formats are unchanged; no migration or rebake is introduced. - `diagnostic_snapshot` exposes four sorted path arrays without Node references. - Cached eligibility rejection logging belongs to the cached observer; native - animation logs remain loader-owned. + animation success logging belongs to the native observer. - Existing renderer queue metrics remain unchanged because these tables never contributed work counts. - Normalized relative path remains the correlation key. @@ -212,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. Cached animation observation consumes this -service without moving ResourceLoader or builder ownership into prototype state. +prototype-user lifetime rules. Cached and native animation observers consume +this state without moving ResourceLoader or builder ownership into cache state. ## Capability status @@ -237,7 +237,8 @@ service without moving ResourceLoader or builder ownership into prototype state. | 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/render/m2/m2_native_animation_resource_observer.gd` | Animated raw/build and positive/static-only transitions | +| `src/scenes/streaming/streaming_world_loader.gd` | Normalization, observer order, remaining I/O/fallback 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 | diff --git a/docs/modules/m2-raw-model-repository.md b/docs/modules/m2-raw-model-repository.md index 250f1b9..fc6db00 100644 --- a/docs/modules/m2-raw-model-repository.md +++ b/docs/modules/m2-raw-model-repository.md @@ -28,10 +28,12 @@ static or animated Dictionary consumed by existing builders and classifiers. ```mermaid flowchart LR Loader[StreamingWorldLoader] --> Repository[M2RawModelRepository] + Observer[M2NativeAnimationResourceObserver] --> Repository Repository --> File[Extracted M2 file] Repository --> Native[ClassDB M2Loader] Native --> Raw[Raw Dictionary] Raw --> Loader + Raw --> Observer Loader --> Builder[Existing M2 builders/finalizer] ``` @@ -54,7 +56,7 @@ renderer policy and other application layers are forbidden. | Input | Already-normalized relative M2 path String | Loader normalization | Repository | Copied value | One call | | 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 | Animated raw M2 Dictionary | Native `load_m2_animated` | Native animation observer/builder | Fresh native result | One caller operation | | 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 @@ -98,18 +100,18 @@ No request, result or failure state survives a call. ```mermaid sequenceDiagram - participant Loader as StreamingWorldLoader + participant Caller as Loader or NativeAnimationObserver participant Repo as M2RawModelRepository participant File as FileAccess participant Native as M2Loader - Loader->>Repo: load static/animated(directory, path) + Caller->>Repo: load static/animated(directory, path) Repo->>File: file_exists(globalized joined path) alt dependency or file unavailable - Repo-->>Loader: empty Dictionary + Repo-->>Caller: empty Dictionary else available Repo->>Native: instantiate and call exact native method Native-->>Repo: Variant - Repo-->>Loader: Dictionary or empty Dictionary + Repo-->>Caller: Dictionary or empty Dictionary end ``` @@ -118,13 +120,14 @@ sequenceDiagram ```mermaid flowchart TB Loader[StreamingWorldLoader] --> Repository[M2RawModelRepository] + Observer[M2NativeAnimationResourceObserver] --> Repository Repository --> ProjectSettings Repository --> FileAccess Repository --> ClassDB ClassDB --> Native[M2Loader extension] Loader --> Finalizer[M2RuntimeMeshFinalizer] Loader --> StaticBuilder[M2Builder] - Loader --> AnimatedBuilder[M2NativeAnimatedBuilder] + Observer --> AnimatedBuilder[M2NativeAnimatedBuilder] Repository -. no dependency .-> Finalizer Repository -. no dependency .-> StaticBuilder Repository -. no dependency .-> Cache[Renderer caches/queues] @@ -133,7 +136,8 @@ flowchart TB ## Ownership, threading and resources - The repository owns only call-local path, native instance and result values. -- The loader owns path normalization and fallback selection; +- The loader owns path normalization and fallback selection; the native observer + owns the animated raw-read/build decision; `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 @@ -211,6 +215,7 @@ 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/render/m2/m2_native_animation_resource_observer.gd` | Animated raw-data consumer and builder adapter | | `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 | diff --git a/docs/modules/world-renderer.md b/docs/modules/world-renderer.md index 36fdbb8..4fcafc0 100644 --- a/docs/modules/world-renderer.md +++ b/docs/modules/world-renderer.md @@ -157,6 +157,7 @@ from externally reading/writing loader-private queue, task, cache and tile-state | `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 | +| `M2NativeAnimationResourceObserver` | Internal native M2 resource observer | Selects GryphonRoost, reads/builds and publishes prototype/static-only outcome | Synchronous renderer main thread; stateless | Invalid/unavailable candidates return null; failures mark static-only | | `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 | @@ -204,7 +205,7 @@ loader configuration remains transitional composition data, not a caller API. | Internal WMO render cache | Normalized path, cache path and validated Resource | Loader / `WmoRenderResourceCacheState` | Loader lookup, ResourceLoader poll and build queue | State-owned Resource/path references; detached request snapshots | Until transient/full clear | | 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 raw M2 read | Extracted directory and normalized relative path | Loader/native observer via `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 | 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 | @@ -398,9 +399,11 @@ sequenceDiagram `M2StaticBuildResourceObserver` owns static Mesh lookup/request admission and snapshot adoption. `M2CachedAnimationResourceObserver` owns cached animated prototype lookup, GLB policy/request admission and snapshot production. + `M2NativeAnimationResourceObserver` owns native candidate selection, raw read, + detached build and prototype/static-only cache outcome. `M2StaticBatchMaterializer` owns static MultiMesh construction/attachment; the - loader retains native animation observation, action execution, cursor adoption, - root cleanup, budgets and Editor ownership. + loader retains 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 @@ -428,8 +431,8 @@ sequenceDiagram - `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. + snapshot production. The native observer owns synchronous native build; the + loader retains 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. @@ -592,7 +595,8 @@ 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 cached animation resource observer | Implemented extraction | Cache/pending/policy/GLB/request/source/timing contract | Asset-backed traversal and terminal polling extraction pending | +| M2 native animation resource observer | Implemented extraction | Candidate/cache/raw/build/adoption/source/timing contract | Asset-backed traversal/leak/p95/p99 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 | @@ -675,6 +679,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_native_animation_resource_observer.gd` | Native candidate/read/build/cache observation | | `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 | @@ -706,6 +711,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_native_animation_resource_observer.gd` | Native candidate/cache/raw/build/adoption/boundary/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 | diff --git a/src/render/m2/m2_native_animation_resource_observer.gd b/src/render/m2/m2_native_animation_resource_observer.gd new file mode 100644 index 0000000..09a079e --- /dev/null +++ b/src/render/m2/m2_native_animation_resource_observer.gd @@ -0,0 +1,113 @@ +class_name M2NativeAnimationResourceObserver +extends RefCounted + +## Resolves the existing native GryphonRoost animated M2 prototype path. + +const M2_NATIVE_ANIMATED_BUILDER_SCRIPT := preload( + "res://addons/mpq_extractor/loaders/m2_native_animated_builder.gd" +) + +var _native_animated_builder: Object + + +func _init(native_animated_builder: Object = M2_NATIVE_ANIMATED_BUILDER_SCRIPT) -> void: + _native_animated_builder = native_animated_builder + + +## Returns whether the normalized path uses the historical native animation path. +func is_native_animation_candidate(normalized_relative_path: String) -> bool: + return normalized_relative_path.to_lower().contains("gryphonroost") + + +## Returns the exact cached or newly adopted native animated prototype. +## Rejected native candidates are marked static-only for the renderer session. +func observe( + normalized_relative_path: String, + extracted_directory: String, + raw_model_repository: RefCounted, + prototype_cache_state: RefCounted, + debug_logging_enabled: bool = false +) -> Node3D: + if ( + normalized_relative_path.is_empty() + or not is_native_animation_candidate(normalized_relative_path) + or raw_model_repository == null + or prototype_cache_state == null + ): + return null + + var cached_prototype := prototype_cache_state.call( + "find_animated_prototype", + normalized_relative_path + ) as Node3D + if cached_prototype != null: + return cached_prototype + if bool(prototype_cache_state.call( + "is_animation_static", + normalized_relative_path + )): + return null + + var animated_model_data: Dictionary = raw_model_repository.call( + "load_animated_model_data", + extracted_directory, + normalized_relative_path + ) + var animated_surfaces: Array = animated_model_data.get( + "animated_surfaces", + [] + ) + if animated_model_data.is_empty() or animated_surfaces.is_empty(): + prototype_cache_state.call( + "mark_animation_static", + normalized_relative_path + ) + return null + + var prototype := build_animated_prototype( + animated_model_data, + extracted_directory + ) + if prototype == null or prototype.get_child_count() <= 0: + prototype_cache_state.call( + "mark_animation_static", + normalized_relative_path + ) + return null + + prototype = prototype_cache_state.call( + "adopt_animated_prototype", + normalized_relative_path, + prototype + ) as Node3D + if debug_logging_enabled: + print( + ( + "M2_NATIVE_ANIM_CACHE path=%s surfaces=%d bones=%d " + + "anim_id=%d seq=%d score=%d length=%.2f" + ) + % [ + normalized_relative_path, + animated_surfaces.size(), + (animated_model_data.get("bones", []) as Array).size(), + int(animated_model_data.get("animation_id", -1)), + int(animated_model_data.get("animation_sequence_index", -1)), + int(animated_model_data.get("animation_activity_score", 0)), + float(animated_model_data.get("animation_length", 0.0)), + ] + ) + return prototype + + +## Builds one prototype through the exact native animated builder dependency. +func build_animated_prototype( + animated_model_data: Dictionary, + extracted_directory: String +) -> Node3D: + if _native_animated_builder == null: + return null + return _native_animated_builder.call( + "build", + animated_model_data, + extracted_directory + ) as Node3D diff --git a/src/render/m2/m2_native_animation_resource_observer.gd.uid b/src/render/m2/m2_native_animation_resource_observer.gd.uid new file mode 100644 index 0000000..6ff0937 --- /dev/null +++ b/src/render/m2/m2_native_animation_resource_observer.gd.uid @@ -0,0 +1 @@ +uid://bc3kgi23usncx diff --git a/src/scenes/streaming/streaming_world_loader.gd b/src/scenes/streaming/streaming_world_loader.gd index 5af6664..7c780c2 100644 --- a/src/scenes/streaming/streaming_world_loader.gd +++ b/src/scenes/streaming/streaming_world_loader.gd @@ -28,7 +28,6 @@ const WMO_SCENE_RESOURCE_CACHE_STATE_SCRIPT := preload( "res://src/render/wmo/wmo_scene_resource_cache_state.gd" ) const M2_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_builder.gd") -const M2_NATIVE_ANIMATED_BUILDER_SCRIPT := preload("res://addons/mpq_extractor/loaders/m2_native_animated_builder.gd") const M2_NATIVE_ANIMATOR_SCRIPT := preload("res://src/scenes/streaming/m2_native_animator.gd") const STREAMING_FOCUS_SCRIPT := preload("res://src/domain/streaming/streaming_focus.gd") const GODOT_WORLD_POSITION_SCRIPT := preload("res://src/domain/coordinates/godot_world_position.gd") @@ -76,6 +75,9 @@ const M2_STATIC_BUILD_RESOURCE_OBSERVER_SCRIPT := preload( const M2_CACHED_ANIMATION_RESOURCE_OBSERVER_SCRIPT := preload( "res://src/render/m2/m2_cached_animation_resource_observer.gd" ) +const M2_NATIVE_ANIMATION_RESOURCE_OBSERVER_SCRIPT := preload( + "res://src/render/m2/m2_native_animation_resource_observer.gd" +) const M2_RUNTIME_MESH_FINALIZER_SCRIPT := preload( "res://src/render/m2/m2_runtime_mesh_finalizer.gd" ) @@ -289,6 +291,9 @@ var _m2_static_build_resource_observer := ( var _m2_animation_resource_observer := ( M2_CACHED_ANIMATION_RESOURCE_OBSERVER_SCRIPT.new() ) +var _m2_native_animation_resource_observer := ( + M2_NATIVE_ANIMATION_RESOURCE_OBSERVER_SCRIPT.new() +) var _m2_unique_placement_registry := ( M2_UNIQUE_PLACEMENT_REGISTRY_SCRIPT.new() ) @@ -4359,7 +4364,13 @@ func _process_m2_build_jobs() -> void: var resource_snapshot: RefCounted if enable_m2_animated_instances: var native_animated_prototype := ( - _get_or_load_m2_native_animated_prototype(rel_path) + _m2_native_animation_resource_observer.observe( + normalized_rel, + extracted_dir, + _m2_raw_model_repository, + _m2_prototype_cache_state, + debug_streaming + ) ) if native_animated_prototype != null: resource_snapshot = M2_BUILD_RESOURCE_SNAPSHOT_SCRIPT.new( @@ -4849,52 +4860,6 @@ func _get_or_load_m2_prototype(rel_path: String) -> Node3D: ) -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 - 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, - normalized_rel - ) - var animated_surfaces: Array = data.get("animated_surfaces", []) - if data.is_empty() or animated_surfaces.is_empty(): - _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_prototype_cache_state.mark_animation_static(normalized_rel) - return null - - 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, - animated_surfaces.size(), - (data.get("bones", []) as Array).size(), - int(data.get("animation_id", -1)), - int(data.get("animation_sequence_index", -1)), - int(data.get("animation_activity_score", 0)), - float(data.get("animation_length", 0.0)), - ]) - return prototype - - -func _is_m2_native_animation_candidate(normalized_rel: String) -> bool: - return normalized_rel.to_lower().contains("gryphonroost") - - func _get_or_load_m2_material_prototype(normalized_rel: String) -> Node3D: if normalized_rel.is_empty(): return null diff --git a/src/tools/verify_m2_cached_animation_resource_observer.gd b/src/tools/verify_m2_cached_animation_resource_observer.gd index ee26b80..1892477 100644 --- a/src/tools/verify_m2_cached_animation_resource_observer.gd +++ b/src/tools/verify_m2_cached_animation_resource_observer.gd @@ -12,6 +12,9 @@ const PIPELINE_SCRIPT := preload( const OBSERVER_PATH := ( "res://src/render/m2/m2_cached_animation_resource_observer.gd" ) +const NATIVE_OBSERVER_PATH := ( + "res://src/render/m2/m2_native_animation_resource_observer.gd" +) const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd" const FIXTURE_DIRECTORY := "user://verify_m2_cached_animation_observer" @@ -252,6 +255,7 @@ func _verify_glb_safety(failures: Array[String]) -> void: 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) + var native_observer_source := FileAccess.get_file_as_string(NATIVE_OBSERVER_PATH) _expect_true( loader_source.contains("_m2_animation_resource_observer.observe("), "loader delegates cached observation", @@ -269,8 +273,13 @@ func _verify_source_boundaries(failures: Array[String]) -> void: failures ) _expect_true( - loader_source.contains("func _get_or_load_m2_native_animated_prototype"), - "loader retains native observation", + loader_source.contains("_m2_native_animation_resource_observer.observe("), + "loader delegates native observation", + failures + ) + _expect_true( + native_observer_source.contains("func is_native_animation_candidate("), + "native observer retains native candidate policy", failures ) _expect_true( diff --git a/src/tools/verify_m2_native_animation_resource_observer.gd b/src/tools/verify_m2_native_animation_resource_observer.gd new file mode 100644 index 0000000..ea3f2ba --- /dev/null +++ b/src/tools/verify_m2_native_animation_resource_observer.gd @@ -0,0 +1,426 @@ +extends SceneTree + +const OBSERVER_SCRIPT := preload( + "res://src/render/m2/m2_native_animation_resource_observer.gd" +) +const PROTOTYPE_CACHE_SCRIPT := preload( + "res://src/render/m2/m2_prototype_cache_state.gd" +) +const OBSERVER_PATH := ( + "res://src/render/m2/m2_native_animation_resource_observer.gd" +) +const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd" + + +class FakeRawModelRepository extends RefCounted: + var result: Dictionary = {} + var call_count: int = 0 + var last_extracted_directory: String = "" + var last_normalized_relative_path: String = "" + + func load_animated_model_data( + extracted_directory: String, + normalized_relative_path: String + ) -> Dictionary: + call_count += 1 + last_extracted_directory = extracted_directory + last_normalized_relative_path = normalized_relative_path + return result + + +class FakeNativeAnimatedBuilder extends RefCounted: + var result: Node3D = null + var call_count: int = 0 + var last_model_data: Dictionary = {} + var last_extracted_directory: String = "" + + func build( + animated_model_data: Dictionary, + extracted_directory: String + ) -> Node3D: + call_count += 1 + last_model_data = animated_model_data + last_extracted_directory = extracted_directory + return result + + +func _initialize() -> void: + var failures: Array[String] = [] + _verify_candidate_policy(failures) + _verify_missing_dependencies(failures) + _verify_cache_and_negative_lifecycle(failures) + _verify_raw_and_builder_failures(failures) + _verify_successful_adoption(failures) + _verify_source_boundaries(failures) + var elapsed_milliseconds := _verify_bounded_timing(failures) + if not failures.is_empty(): + for failure in failures: + push_error("M2_NATIVE_ANIMATION_RESOURCE_OBSERVER: %s" % failure) + quit(1) + return + print( + "M2_NATIVE_ANIMATION_RESOURCE_OBSERVER PASS " + + "cases=33 iterations=20000 elapsed_ms=%.3f" % elapsed_milliseconds + ) + quit(0) + + +func _verify_candidate_policy(failures: Array[String]) -> void: + var observer: RefCounted = OBSERVER_SCRIPT.new() + _expect_false( + bool(observer.call("is_native_animation_candidate", "")), + "empty path is not candidate", + failures + ) + _expect_true( + bool(observer.call( + "is_native_animation_candidate", + "World/Generic/GryphonRoost/GryphonRoost.M2" + )), + "candidate match ignores case", + failures + ) + _expect_false( + bool(observer.call( + "is_native_animation_candidate", + "creature/gryphon/gryphon.m2" + )), + "unlisted gryphon is not candidate", + failures + ) + + +func _verify_missing_dependencies(failures: Array[String]) -> void: + var path := "world/gryphonroost/dependency.m2" + var builder := FakeNativeAnimatedBuilder.new() + var observer: RefCounted = OBSERVER_SCRIPT.new(builder) + var repository := FakeRawModelRepository.new() + var prototype_cache: RefCounted = PROTOTYPE_CACHE_SCRIPT.new() + _expect_null( + observer.call("observe", path, "res://data/extracted", null, prototype_cache), + "missing repository rejected", + failures + ) + _expect_null( + observer.call("observe", path, "res://data/extracted", repository, null), + "missing cache rejected", + failures + ) + repository.result = _animated_model_data() + observer = OBSERVER_SCRIPT.new(null) + _expect_null( + observer.call( + "observe", path, "res://data/extracted", repository, prototype_cache + ), + "missing builder rejected", + failures + ) + prototype_cache.call("clear_and_release") + + +func _verify_cache_and_negative_lifecycle(failures: Array[String]) -> void: + var builder := FakeNativeAnimatedBuilder.new() + var observer: RefCounted = OBSERVER_SCRIPT.new(builder) + var repository := FakeRawModelRepository.new() + var prototype_cache: RefCounted = PROTOTYPE_CACHE_SCRIPT.new() + _expect_null( + observer.call( + "observe", "", "res://data/extracted", repository, + prototype_cache + ), + "empty observation rejected", + failures + ) + _expect_null( + observer.call( + "observe", "world/not_native.m2", "res://data/extracted", + repository, prototype_cache + ), + "non-candidate rejected", + failures + ) + _expect_equal(repository.call_count, 0, "rejected paths skip repository", failures) + var cached_prototype := Node3D.new() + prototype_cache.call( + "adopt_animated_prototype", + "world/gryphonroost/cached.m2", + cached_prototype + ) + _expect_same( + observer.call( + "observe", "world/gryphonroost/cached.m2", + "res://data/extracted", repository, prototype_cache + ), + cached_prototype, + "cached prototype identity", + failures + ) + prototype_cache.call( + "mark_animation_static", + "world/gryphonroost/static.m2" + ) + _expect_null( + observer.call( + "observe", "world/gryphonroost/static.m2", + "res://data/extracted", repository, prototype_cache + ), + "static-only outcome suppresses load", + failures + ) + _expect_equal(repository.call_count, 0, "cache outcomes skip repository", failures) + prototype_cache.call("clear_and_release") + + +func _verify_raw_and_builder_failures(failures: Array[String]) -> void: + var builder := FakeNativeAnimatedBuilder.new() + var observer: RefCounted = OBSERVER_SCRIPT.new(builder) + var repository := FakeRawModelRepository.new() + var prototype_cache: RefCounted = PROTOTYPE_CACHE_SCRIPT.new() + var empty_path := "world/gryphonroost/empty.m2" + _expect_null( + observer.call( + "observe", empty_path, "res://data/extracted", repository, + prototype_cache + ), + "empty raw data rejects", + failures + ) + _expect_true( + bool(prototype_cache.call("is_animation_static", empty_path)), + "empty raw data marks static", + failures + ) + repository.result = {"animated_surfaces": []} + var surfaces_path := "world/gryphonroost/no_surfaces.m2" + _expect_null( + observer.call( + "observe", surfaces_path, "res://data/extracted", repository, + prototype_cache + ), + "empty animated surfaces reject", + failures + ) + _expect_true( + bool(prototype_cache.call("is_animation_static", surfaces_path)), + "empty surfaces mark static", + failures + ) + repository.result = _animated_model_data() + var null_path := "world/gryphonroost/null_build.m2" + _expect_null( + observer.call( + "observe", null_path, "res://data/extracted", repository, + prototype_cache + ), + "null build rejects", + failures + ) + _expect_true( + bool(prototype_cache.call("is_animation_static", null_path)), + "null build marks static", + failures + ) + var childless_prototype := Node3D.new() + builder.result = childless_prototype + var childless_path := "world/gryphonroost/childless.m2" + _expect_null( + observer.call( + "observe", childless_path, "res://data/extracted", repository, + prototype_cache + ), + "childless build rejects", + failures + ) + _expect_true( + bool(prototype_cache.call("is_animation_static", childless_path)), + "childless build marks static", + failures + ) + _expect_true( + is_instance_valid(childless_prototype), + "historical childless candidate lifetime retained", + failures + ) + childless_prototype.free() + prototype_cache.call("clear_and_release") + + +func _verify_successful_adoption(failures: Array[String]) -> void: + var builder := FakeNativeAnimatedBuilder.new() + var prototype := Node3D.new() + prototype.add_child(Node3D.new()) + builder.result = prototype + var observer: RefCounted = OBSERVER_SCRIPT.new(builder) + var repository := FakeRawModelRepository.new() + repository.result = _animated_model_data() + var prototype_cache: RefCounted = PROTOTYPE_CACHE_SCRIPT.new() + var path := "World/Generic/GryphonRoost/GryphonRoost.M2" + var observed: Node3D = observer.call( + "observe", + path, + "res://custom/extracted", + repository, + prototype_cache + ) + _expect_same(observed, prototype, "successful build identity", failures) + _expect_same( + prototype_cache.call("find_animated_prototype", path), + prototype, + "successful build adopted", + failures + ) + _expect_equal(repository.call_count, 1, "one repository read", failures) + _expect_string_equal( + repository.last_extracted_directory, + "res://custom/extracted", + "repository extracted directory", + failures + ) + _expect_string_equal( + repository.last_normalized_relative_path, + path, + "repository normalized path", + failures + ) + _expect_equal(builder.call_count, 1, "one builder call", failures) + _expect_true( + builder.last_model_data == repository.result, + "builder receives exact raw data", + failures + ) + _expect_string_equal( + builder.last_extracted_directory, + "res://custom/extracted", + "builder extracted directory", + failures + ) + prototype_cache.call("clear_and_release") + + +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_native_animation_resource_observer.observe("), + "loader delegates native observation", + failures + ) + for removed_token in [ + "func _get_or_load_m2_native_animated_prototype", + "func _is_m2_native_animation_candidate", + "const M2_NATIVE_ANIMATED_BUILDER_SCRIPT", + ]: + _expect_false( + loader_source.contains(removed_token), + "loader omits %s" % removed_token, + failures + ) + for required_token in [ + "M2_NATIVE_ANIMATED_BUILDER_SCRIPT", + "\"load_animated_model_data\"", + "\"adopt_animated_prototype\"", + "\"mark_animation_static\"", + "M2_NATIVE_ANIM_CACHE path=%s surfaces=%d bones=%d", + ]: + _expect_true( + observer_source.contains(required_token), + "observer owns %s" % required_token, + failures + ) + for forbidden_token in [ + "ResourceLoader", + "queue_free", + "RenderingServer", + "WorkerThreadPool", + "RenderBudgetScheduler", + "add_child", + ]: + _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 started_microseconds := Time.get_ticks_usec() + for iteration in range(20000): + observer.call( + "is_native_animation_candidate", + "world/model_%d/gryphonroost.m2" % (iteration % 64) + ) + var elapsed_milliseconds := ( + float(Time.get_ticks_usec() - started_microseconds) / 1000.0 + ) + _expect_true( + elapsed_milliseconds < 1000.0, + "20,000 candidate checks remain bounded", + failures + ) + return elapsed_milliseconds + + +func _animated_model_data() -> Dictionary: + return { + "animated_surfaces": [{"surface": 1}], + "bones": [{"bone": 1}, {"bone": 2}], + "animation_id": 7, + "animation_sequence_index": 3, + "animation_activity_score": 9, + "animation_length": 1.25, + } + + +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_null( + actual: Variant, + label: String, + failures: Array[String] +) -> void: + _expect_true(actual == null, label, failures) + + +func _expect_same( + actual: Variant, + expected: Variant, + label: String, + failures: Array[String] +) -> void: + _expect_true(is_same(actual, expected), 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_native_animation_resource_observer.gd.uid b/src/tools/verify_m2_native_animation_resource_observer.gd.uid new file mode 100644 index 0000000..1b3bb4d --- /dev/null +++ b/src/tools/verify_m2_native_animation_resource_observer.gd.uid @@ -0,0 +1 @@ +uid://cto5qa1vh221v diff --git a/src/tools/verify_m2_prototype_cache_state.gd b/src/tools/verify_m2_prototype_cache_state.gd index b78a448..dcb40e3 100644 --- a/src/tools/verify_m2_prototype_cache_state.gd +++ b/src/tools/verify_m2_prototype_cache_state.gd @@ -4,6 +4,12 @@ extends SceneTree 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 NATIVE_OBSERVER_PATH := ( + "res://src/render/m2/m2_native_animation_resource_observer.gd" +) +const CACHED_OBSERVER_PATH := ( + "res://src/render/m2/m2_cached_animation_resource_observer.gd" +) const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd" @@ -93,6 +99,10 @@ func _verify_release_ownership(failures: Array[String]) -> void: 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) + var observer_sources := ( + FileAccess.get_file_as_string(NATIVE_OBSERVER_PATH) + + FileAccess.get_file_as_string(CACHED_OBSERVER_PATH) + ) _expect_true( loader_source.contains("M2_PROTOTYPE_CACHE_STATE_SCRIPT.new()"), "loader composes prototype cache state", @@ -108,13 +118,17 @@ func _verify_ownership_boundaries(failures: Array[String]) -> void: 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(", + "\"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) + _expect_true( + (loader_source + observer_sources).contains(required_loader_rule), + "renderer delegates %s" % required_loader_rule, + failures + ) for forbidden_dependency in [ "ResourceLoader.", "FileAccess.", diff --git a/src/tools/verify_m2_raw_model_repository.gd b/src/tools/verify_m2_raw_model_repository.gd index a353014..2010ba9 100644 --- a/src/tools/verify_m2_raw_model_repository.gd +++ b/src/tools/verify_m2_raw_model_repository.gd @@ -4,6 +4,9 @@ extends SceneTree const REPOSITORY_SCRIPT := preload("res://src/render/m2/m2_raw_model_repository.gd") const REPOSITORY_PATH := "res://src/render/m2/m2_raw_model_repository.gd" +const NATIVE_OBSERVER_PATH := ( + "res://src/render/m2/m2_native_animation_resource_observer.gd" +) const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd" @@ -56,6 +59,7 @@ func _verify_rejected_inputs(failures: Array[String]) -> void: func _verify_ownership_boundaries(failures: Array[String]) -> void: var repository_source := FileAccess.get_file_as_string(REPOSITORY_PATH) + var native_observer_source := FileAccess.get_file_as_string(NATIVE_OBSERVER_PATH) var loader_source := FileAccess.get_file_as_string(LOADER_PATH) _expect_true( loader_source.contains("M2_RAW_MODEL_REPOSITORY_SCRIPT.new()"), @@ -69,7 +73,10 @@ func _verify_ownership_boundaries(failures: Array[String]) -> void: failures ) _expect_equal( - loader_source.count("_m2_raw_model_repository.load_animated_model_data("), + ( + loader_source.count("_m2_raw_model_repository.load_animated_model_data(") + + native_observer_source.count("\"load_animated_model_data\"") + ), 1, "animated caller delegates", failures diff --git a/targets/03-renderer-facade.md b/targets/03-renderer-facade.md index fcae116..8922f0e 100644 --- a/targets/03-renderer-facade.md +++ b/targets/03-renderer-facade.md @@ -60,7 +60,8 @@ Runtime и Editor используют facade; planner/scheduler тестиру `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-CACHED-ANIMATION-RESOURCE-OBSERVER-001` + `M03-RND-M2-CACHED-ANIMATION-RESOURCE-OBSERVER-001`, + `M03-RND-M2-NATIVE-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 @@ -294,6 +295,12 @@ Runtime и Editor используют facade; planner/scheduler тестиру Merge `f79e064` passed post-merge observer (`61.514ms`), animation pipeline, snapshot, dispatch, prototype cache, shutdown, facade, internal-access `30`, manifest `7/7`, documentation `43`, coordination and checkpoint dry-run `7/7`. + M2 native animation resource observer passed `cases=33 iterations=20000 + elapsed_ms=16.881` with exact candidate, dependency, cached/static, raw/surface/build + failure, preserved childless lifetime, adoption, dependency and source cases. + All 57 autonomous headless regressions passed; internal-access remained `30`, + documentation covered `44` module specifications, coordination retained `34` + historical expired-claim warnings and checkpoint dry-run kept all `7/7` plans. M02 terrain-query regression remained green (13 pre-existing expired M00 claim warnings). - Fidelity comparison: all 16 historical operation limits and drain sites were @@ -331,11 +338,14 @@ 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- + Native GryphonRoost observation now preserves case-insensitive substring + selection, cached/static priority, synchronous animated raw read, non-empty + surface and child requirements, first-wins adoption and exact success-log fields. + Cached animated lookup 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. + first through its observer, 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. @@ -456,6 +466,8 @@ Runtime и Editor используют facade; planner/scheduler тестиру 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, + native M2 animation resource observer and loader/raw-repository/prototype-cache + adapters plus synthetic lifecycle and exact-dependency 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 @@ -478,12 +490,14 @@ Runtime и Editor используют facade; planner/scheduler тестиру raw animated-instance materialization is now separated but remains synchronous main-thread work; asset-backed traversal/visual/leak/p95/p99 evidence is pending; + native parsing/build also remains synchronous and the historically unfreed + childless builder result remains a documented leak edge; 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; static and cached-animation - observers produce typed per-operation state, while native observation, action - execution and root cleanup remain in loader; both ResourceLoader finalize + observers produce typed per-operation state, including 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;