merge M03 M2 animated scene finalizer

This commit is contained in:
2026-07-18 01:14:40 +04:00
12 changed files with 598 additions and 102 deletions
+17
View File
@@ -36,6 +36,7 @@ Paired run 2026-07-11 подтвердил крупный coordinate/placement g
- `src/render/liquid/adt_water_load_pipeline_state.gd` - ADT water pending FIFO/dedupe, active task IDs and worker-safe parsed-result mailbox without parser or Node ownership. - `src/render/liquid/adt_water_load_pipeline_state.gd` - ADT water pending FIFO/dedupe, active task IDs and worker-safe parsed-result mailbox without parser or Node ownership.
- `src/render/liquid/adt_water_scene_finalizer.gd` - stateless main-thread ADT water build/attach and optional persisted Editor ownership through the existing ADTBuilder. - `src/render/liquid/adt_water_scene_finalizer.gd` - stateless main-thread ADT water build/attach and optional persisted Editor ownership through the existing ADTBuilder.
- `src/render/m2/m2_runtime_mesh_rebuild_classifier.gd` - memoized billboard/UV-rotation decision for stale cached M2 runtime mesh refresh. - `src/render/m2/m2_runtime_mesh_rebuild_classifier.gd` - memoized billboard/UV-rotation decision for stale cached M2 runtime mesh refresh.
- `src/render/m2/m2_animated_scene_finalizer.gd` - animated PackedScene candidate ownership, historical material repair and AnimationPlayer validation.
- `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_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_load_pipeline_state.gd` - static M2 threaded-load request records, terminal statuses and completion-order finalize FIFO without I/O or Mesh ownership.
- `src/render/m2/m2_mesh_resource_cache_state.gd` - normalized-path prepared static M2 Mesh references with final-shutdown lifetime. - `src/render/m2/m2_mesh_resource_cache_state.gd` - normalized-path prepared static M2 Mesh references with final-shutdown lifetime.
@@ -1098,6 +1099,22 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe'
- Cache formats, animation behavior and visible output are unchanged. Synthetic - Cache formats, animation behavior and visible output are unchanged. Synthetic
timing is not asset-backed I/O, leak, animation-fidelity or p95/p99 evidence. timing is not asset-backed I/O, leak, animation-fidelity or p95/p99 evidence.
## 2026-07-18 M2 Animated Scene Finalizer Extraction
- `M2AnimatedSceneFinalizer` now owns terminal animated PackedScene candidate
instantiation, depth-first Mesh/AnimationPlayer traversal, static-prototype
material override mapping and rejected detached-root destruction.
- Material repair retains source/target depth-first ordering, source-index clamp,
per-surface override priority and first discovered material fallback.
- A candidate still requires at least one AnimationPlayer. Accepted exact Node3D
and player count return to the loader; invalid candidates are freed, including
the previously unhandled non-Node3D imported-root case.
- `StreamingWorldLoader` retains ResourceLoader I/O/statuses, finalize permits,
material-prototype selection/loading, prototype adoption/static fallback,
animation playback policy and diagnostic logging.
- Cache formats, profiles and visible rules are unchanged. Synthetic scene and
material fixtures are not asset-backed animation-fidelity or p95/p99 evidence.
## 2026-07-17 M2 Mesh Resource Cache State Extraction ## 2026-07-17 M2 Mesh Resource Cache State Extraction
- `M2MeshResourceCacheState` now owns normalized-path references to prepared - `M2MeshResourceCacheState` now owns normalized-path references to prepared
@@ -78,14 +78,24 @@ main-thread finalizer.
## Status ## Status
- State: claimed - State: ready for integration
- Done: candidate/material/player ownership boundary audited on current master - Done: finalizer, loader adapters, lifecycle/material verifier and required documentation
- Next: implement finalizer, migrate adapters, verify and document - Next: integrate on master and run post-merge acceptance gates
- Blocked by: - Blocked by:
## Handoff ## Handoff
- Commit: - Commit: `1b450dc`
- Results: - Results: animated scene finalizer passes 13 invalid/type/lifetime/traversal/
- Remaining risks: material/transfer/source cases and 10,000 depth-eight queries in 40.126 ms;
- Documentation updated: all 48 autonomous headless verifiers pass, while the proprietary ADT placement
probe is unavailable without `data/extracted`; checkpoint dry-run passes 7/7;
documentation and coordination gates pass; loader-private inventory remains 30.
- Remaining risks: proprietary GLB traversal, material/animation comparison,
descriptor pressure, leaks and p95/p99 remain unavailable; no paired original-
client visual comparison is claimed. Positional material mapping is unchanged.
- Documentation updated: inline API; `m2-animated-scene-finalizer.md` with
data-flow, lifecycle, sequence and dependency diagrams; animation pipeline,
prototype/world-renderer specs, module registry and `RENDER.md`.
<!-- OPENWC_HANDOFF:READY:M03-RND-M2-ANIMATED-SCENE-FINALIZER-001:1b450dc -->
+1
View File
@@ -22,6 +22,7 @@
| M2 placement grouper | Implemented extraction | [`m2-placement-grouper.md`](m2-placement-grouper.md) | | M2 placement grouper | Implemented extraction | [`m2-placement-grouper.md`](m2-placement-grouper.md) |
| M2 build batch planner | Implemented extraction | [`m2-build-batch-planner.md`](m2-build-batch-planner.md) | | M2 build batch planner | Implemented extraction | [`m2-build-batch-planner.md`](m2-build-batch-planner.md) |
| M2 runtime mesh rebuild classifier | Implemented extraction | [`m2-runtime-mesh-rebuild-classifier.md`](m2-runtime-mesh-rebuild-classifier.md) | | M2 runtime mesh rebuild classifier | Implemented extraction | [`m2-runtime-mesh-rebuild-classifier.md`](m2-runtime-mesh-rebuild-classifier.md) |
| M2 animated scene finalizer | Implemented extraction | [`m2-animated-scene-finalizer.md`](m2-animated-scene-finalizer.md) |
| M2 animation load pipeline state | Implemented extraction | [`m2-animation-load-pipeline-state.md`](m2-animation-load-pipeline-state.md) | | M2 animation load pipeline state | Implemented extraction | [`m2-animation-load-pipeline-state.md`](m2-animation-load-pipeline-state.md) |
| M2 mesh load pipeline state | Implemented extraction | [`m2-mesh-load-pipeline-state.md`](m2-mesh-load-pipeline-state.md) | | M2 mesh load pipeline state | Implemented extraction | [`m2-mesh-load-pipeline-state.md`](m2-mesh-load-pipeline-state.md) |
| M2 mesh resource cache state | Implemented extraction | [`m2-mesh-resource-cache-state.md`](m2-mesh-resource-cache-state.md) | | M2 mesh resource cache state | Implemented extraction | [`m2-mesh-resource-cache-state.md`](m2-mesh-resource-cache-state.md) |
+207
View File
@@ -0,0 +1,207 @@
# M2 Animated Scene Finalizer
## Metadata
| Field | Value |
|---|---|
| Status | Implemented extraction |
| Target/work package | M03 / `M03-RND-M2-ANIMATED-SCENE-FINALIZER-001` |
| Owners | Detached animated candidate, material repair and player validation |
| Last verified | Worktree `work/sindo-main-codex/m03-m2-animated-scene-finalizer`, 2026-07-18 |
| Profiles/capabilities | Existing optional cached-GLB animated M2 path |
## Purpose
Finalize one loaded animated M2 scene on the main thread: instantiate a valid
detached Node3D candidate, copy the historical static-prototype material mapping
and accept only candidates containing AnimationPlayer descendants.
## Non-goals
- Call ResourceLoader or interpret threaded-load statuses.
- Choose animation eligibility, cache paths, permits or material prototypes.
- Adopt prototypes, mark static fallback or select/play animations.
- Process native M2 animators or static Mesh rebuilds.
## Context and boundaries
```mermaid
flowchart LR
Loader[StreamingWorldLoader] -->|loaded Resource and material source| Finalizer[M2AnimatedSceneFinalizer]
Finalizer -->|accepted Node3D and player count| Loader
Loader --> Cache[M2PrototypeCacheState]
```
Allowed dependencies are Godot scene/resource/material types. ResourceLoader,
filesystem, workers, scheduler, builders, raw repositories, prototype cache and
other application layers are forbidden.
## Public API
| Symbol | Kind | Purpose | Thread/lifetime | Errors |
|---|---|---|---|---|
| `instantiate_candidate(resource)` | Ownership query | Instantiate PackedScene as detached Node3D | Main thread; caller receives valid candidate | Unsupported/invalid null; created wrong-type root freed |
| `repair_materials(animated_root, material_source_root)` | Command | Apply historical material overrides | Main thread; borrowed roots | Null/empty roots no-op |
| `finalize_candidate(candidate_root)` | Ownership command/query | Require AnimationPlayer and transfer root/count | Main thread; detached candidate | Null empty; rejected candidate freed |
| `mesh_instances_in_subtree(root)` | Query | Depth-first MeshInstance3D inventory | Main thread; borrowed subtree | Null returns empty |
| `animation_players_in_subtree(root)` | Query | Depth-first AnimationPlayer inventory | Main thread; borrowed subtree | Null returns empty |
## Inputs and outputs
| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime |
|---|---|---|---|---|---|
| Input | Loaded Resource | Loader I/O adapter | Candidate instantiator | Borrowed Resource | One finalize permit |
| Input | Static material prototype Node3D | Loader cache/build adapter | Material repair | Borrowed Node | One repair call |
| Input | Detached animated candidate | Instantiator | Repair/final validation | Finalizer then caller/release | One attempt |
| Output | Accepted prototype and player count | Finalizer | Loader adoption/log adapter | Exact Node transferred | Shutdown cache lifetime |
| Output | Depth-first engine-node arrays | Traversal | Loader preparation/playback | Borrowed references | One call |
Side effects are PackedScene instantiation, surface override assignment and
synchronous destruction of rejected detached roots.
## Data flow
```mermaid
flowchart TD
Resource[Loaded Resource] --> Packed{PackedScene?}
Packed -->|no| Reject[Return null]
Packed -->|yes| Instantiate[Instantiate]
Instantiate --> Root{Node3D?}
Root -->|no| FreeWrong[Free root and reject]
Root -->|yes| Repair[Repair materials]
Repair --> Players{AnimationPlayer exists?}
Players -->|no| FreeCandidate[Free and reject]
Players -->|yes| Transfer[Transfer exact root and count]
```
## Lifecycle/state
```mermaid
stateDiagram-v2
[*] --> Absent
Absent --> Candidate: instantiate valid scene
Candidate --> Candidate: repair materials
Candidate --> Accepted: player found
Candidate --> Released: no player
Accepted --> [*]: ownership transferred
Released --> [*]
```
## Main sequence
```mermaid
sequenceDiagram
participant L as StreamingWorldLoader
participant F as M2AnimatedSceneFinalizer
participant C as M2PrototypeCacheState
L->>F: instantiate_candidate(Resource)
F-->>L: detached Node3D or null
L->>L: get static material prototype
L->>F: repair_materials(candidate, source)
L->>F: finalize_candidate(candidate)
alt accepted
F-->>L: exact Node3D and player count
L->>C: adopt animated prototype
else rejected
F-->>L: empty; candidate freed
L->>C: mark animation static
end
```
## Dependency diagram
```mermaid
flowchart TB
Loader[StreamingWorldLoader] --> Finalizer[M2AnimatedSceneFinalizer]
Finalizer --> Engine[PackedScene / Node3D / Mesh / Material / AnimationPlayer]
Loader --> Resource[ResourceLoader]
Loader --> Budget[RenderBudgetScheduler]
Loader --> Prototype[M2PrototypeCacheState]
Finalizer -. no dependency .-> Resource
Finalizer -. no dependency .-> Budget
Finalizer -. no dependency .-> Prototype
```
## Ownership, threading and resources
- Every method runs synchronously on the renderer main thread.
- A valid candidate is detached and finalizer-owned until acceptance.
- Acceptance transfers the exact Node3D to the loader/prototype cache path.
- Rejection frees the candidate synchronously, including wrong-type roots.
- Traversal results borrow Nodes; source materials remain Resource-owned.
## Errors, cancellation and recovery
| Failure | Detection | Behavior | Diagnostic | Recovery |
|---|---|---|---|---|
| Null/unsupported Resource | Type guard | Return null | Dedicated verifier | Loader marks static |
| Wrong root type | Instantiated type guard | Free and return null | Node-count regression | Rebuild cache |
| No material source/meshes | Null/empty traversal | Leave materials unchanged | Material fixture | Imported materials remain |
| Missing source surface | Material lookup | First source material fallback | Mapping fixture | Repair static source |
| No AnimationPlayer | Descendant inventory | Free and return empty | Lifetime regression | Loader marks static |
| Shutdown/cancellation | Not owned | No retained state | N/A | Loader drains first |
## Configuration and capabilities
| Setting/capability | Default | Profile | Runtime mutable | Effect |
|---|---|---|---|---|
| Finalize permit | `m2_animation_finalize_ops_per_tick = 1` | Existing profiles | Yes | Caller bounds calls |
| Material mapping | Depth-first/index-clamped | All | No | Preserves current appearance |
| Required players | At least one | All | No | Rejects non-animated scenes |
## Persistence, cache and migration
The service retains no state and serializes nothing. GLB/cache formats, material
versions and prototype lifetimes are unchanged; no rebake is required.
## Diagnostics and observability
- Accepted result reports player count for the existing `M2_ANIM_CACHE` log.
- Traversal order is deterministic depth-first preorder.
- The service owns no logging or metrics.
## Verification
- `verify_m2_animated_scene_finalizer.gd` covers invalid Resources, wrong-root
release, exact transfer, player traversal/count, override priority, fallback,
source-index mapping, source boundaries and timing.
- Adjacent animation pipeline/prototype/shutdown/material/build tests protect callers.
- Fidelity evidence is exact mapping/lifecycle extraction; no asset-backed or
original-client visual parity claim is made.
- Performance budget: 10,000 depth-eight player inventories under one second.
## Extension points
Animation playback policy and native animator cloning remain separate services.
## Capability status
| Capability | Status | Evidence | Gap/next step |
|---|---|---|---|
| PackedScene candidate | Implemented extraction | Type/lifetime fixture | Proprietary corrupt corpus pending |
| Material repair | Implemented extraction | Exact-reference mapping | Asset-backed comparison pending |
| Player validation | Implemented extraction | Nested traversal/transfer | Multi-player GLB corpus pending |
## Known gaps and risks
- Material matching remains positional rather than semantic.
- Accepted prototypes remain unbounded until final loader shutdown.
- No private traversal, descriptor-pressure, p95/p99 or paired-client run exists.
## Source map
| Path | Responsibility |
|---|---|
| `src/render/m2/m2_animated_scene_finalizer.gd` | Candidate ownership, traversal, material repair and validation |
| `src/render/m2/m2_animation_load_pipeline_state.gd` | Pending/terminal records before finalization |
| `src/render/m2/m2_prototype_cache_state.gd` | Accepted prototype/static-only outcomes |
| `src/scenes/streaming/streaming_world_loader.gd` | I/O, permits, material source, adoption and logs |
| `src/tools/verify_m2_animated_scene_finalizer.gd` | Scene/material/lifetime/boundary/timing regression |
## Related decisions and references
- [`m2-animation-load-pipeline-state.md`](m2-animation-load-pipeline-state.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)
@@ -14,8 +14,9 @@
Own cross-frame bookkeeping between a successful animated M2 ResourceLoader Own cross-frame bookkeeping between a successful animated M2 ResourceLoader
request, terminal polling and budgeted main-thread scene finalization. This is request, terminal polling and budgeted main-thread scene finalization. This is
an exact state extraction; animation eligibility, loading and Node lifecycle an exact state extraction; animation eligibility and loading remain in
remain in `StreamingWorldLoader` and `M2PrototypeCacheState`. `StreamingWorldLoader`, while scene finalization and retained Node lifecycle
belong to `M2AnimatedSceneFinalizer` and `M2PrototypeCacheState`.
## Non-goals ## Non-goals
@@ -178,8 +179,9 @@ no rebake or migration is required.
## Extension points ## Extension points
ResourceLoader polling or animated-scene finalization may later move behind ResourceLoader polling may later move behind a separate adapter without
separate adapters without changing this value-only state contract. changing this value-only state contract. Animated-scene finalization is now a
sibling service.
## Capability status ## Capability status
@@ -200,12 +202,14 @@ separate adapters without changing this value-only state contract.
| Path | Responsibility | | Path | Responsibility |
|---|---| |---|---|
| `src/render/m2/m2_animation_load_pipeline_state.gd` | Pending records, completion FIFO and metrics | | `src/render/m2/m2_animation_load_pipeline_state.gd` | Pending records, completion FIFO and metrics |
| `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/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` | Eligibility, I/O, permits, instantiation and adoption |
| `src/tools/verify_m2_animation_load_pipeline_state.gd` | Lifecycle/boundary/timing regression | | `src/tools/verify_m2_animation_load_pipeline_state.gd` | Lifecycle/boundary/timing regression |
## Related decisions and references ## Related decisions and references
- [`m2-animated-scene-finalizer.md`](m2-animated-scene-finalizer.md)
- [`m2-prototype-cache-state.md`](m2-prototype-cache-state.md) - [`m2-prototype-cache-state.md`](m2-prototype-cache-state.md)
- [`m2-mesh-load-pipeline-state.md`](m2-mesh-load-pipeline-state.md) - [`m2-mesh-load-pipeline-state.md`](m2-mesh-load-pipeline-state.md)
- [`world-renderer.md`](world-renderer.md) - [`world-renderer.md`](world-renderer.md)
+1
View File
@@ -240,6 +240,7 @@ this service without moving ResourceLoader or builder ownership into it.
## Related decisions and references ## Related decisions and references
- [`m2-raw-model-repository.md`](m2-raw-model-repository.md) - [`m2-raw-model-repository.md`](m2-raw-model-repository.md)
- [`m2-animated-scene-finalizer.md`](m2-animated-scene-finalizer.md)
- [`m2-animation-load-pipeline-state.md`](m2-animation-load-pipeline-state.md) - [`m2-animation-load-pipeline-state.md`](m2-animation-load-pipeline-state.md)
- [`m2-mesh-resource-cache-state.md`](m2-mesh-resource-cache-state.md) - [`m2-mesh-resource-cache-state.md`](m2-mesh-resource-cache-state.md)
- [`m2-mesh-load-pipeline-state.md`](m2-mesh-load-pipeline-state.md) - [`m2-mesh-load-pipeline-state.md`](m2-mesh-load-pipeline-state.md)
+6 -1
View File
@@ -7,7 +7,7 @@
| Status | Partial | | Status | Partial |
| Target/work package | M00 baseline; `M01-RND-STREAMING-FOCUS-001`; `M01-QAR-SERVER-SPAWN-RENDERER-001`; M03 facade/planner/scheduler/internal-access/ground/environment/entity packages; M03 terrain packages; M03 M2 packages; M03 WMO placement package | | Target/work package | M00 baseline; `M01-RND-STREAMING-FOCUS-001`; `M01-QAR-SERVER-SPAWN-RENDERER-001`; M03 facade/planner/scheduler/internal-access/ground/environment/entity packages; M03 terrain packages; M03 M2 packages; M03 WMO placement package |
| Owners | Renderer workstream / milestone integrator | | Owners | Renderer workstream / milestone integrator |
| Last verified | Worktree `work/sindo-main-codex/m03-m2-animation-load-pipeline`, 2026-07-17 | | Last verified | Worktree `work/sindo-main-codex/m03-m2-animated-scene-finalizer`, 2026-07-18 |
| Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete | | Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete |
## Purpose ## Purpose
@@ -134,6 +134,7 @@ from externally reading/writing loader-private queue, task, cache and tile-state
| `M2BuildBatchPlanner.plan_batch` | Internal pure M2 service | Selects static/animated batch count and next group cursor | Main/any thread; stateless | Non-positive selected limit clamps to one; empty range completes | | `M2BuildBatchPlanner.plan_batch` | Internal pure M2 service | Selects static/animated batch count and next group cursor | Main/any thread; stateless | Non-positive selected limit clamps to one; empty range completes |
| `M2RuntimeMeshRebuildClassifier` | Internal memoized M2 service | Detects billboard/UV-rotation metadata requiring stale cached-mesh rebuild | Renderer main thread; cached until reset | Invalid variants/indices skipped; first path decision wins | | `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 |
| `M2AnimationLoadPipelineState` | Internal M2 async-state service | Owns animated scene pending request records and terminal finalize FIFO | Renderer main thread; map/session | Empty/duplicate/unknown transitions rejected | | `M2AnimationLoadPipelineState` | Internal M2 async-state service | Owns animated scene pending request records and terminal finalize FIFO | Renderer main thread; map/session | Empty/duplicate/unknown transitions rejected |
| `M2AnimatedSceneFinalizer` | Internal M2 scene-finalization service | Instantiates candidates, repairs materials and requires AnimationPlayer descendants | Renderer main thread; stateless after each call | Invalid/rejected detached roots are freed |
| `M2MeshLoadPipelineState` | Internal M2 async-state service | Owns static Mesh pending request records and terminal finalize FIFO | Renderer main thread; map/session | Empty/duplicate/unknown transitions rejected | | `M2MeshLoadPipelineState` | Internal M2 async-state service | Owns static Mesh pending request records and terminal finalize FIFO | Renderer main thread; map/session | Empty/duplicate/unknown transitions rejected |
| `M2MeshResourceCacheState` | Internal M2 Resource cache | Owns prepared static Mesh references by normalized path | Renderer main thread; final shutdown | Empty path/null Mesh rejected; same path replaces | | `M2MeshResourceCacheState` | Internal M2 Resource cache | Owns prepared static Mesh references by normalized path | Renderer main thread; final shutdown | Empty path/null Mesh rejected; same path replaces |
| `M2MeshResourceExtractor` | Internal M2 scene/resource service | Selects first Mesh from direct Resource, PackedScene or Node subtree | Renderer main thread; stateless except temporary instance | Invalid/no Mesh returns null; temporary PackedScene root freed | | `M2MeshResourceExtractor` | Internal M2 scene/resource service | Selects first Mesh from direct Resource, PackedScene or Node subtree | Renderer main thread; stateless except temporary instance | Invalid/no Mesh returns null; temporary PackedScene root freed |
@@ -185,6 +186,7 @@ loader configuration remains transitional composition data, not a caller API.
| 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 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 / `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 | 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 M2 prototype state | Normalized path, adopted static/animated Node or negative outcome | Loader / `M2PrototypeCacheState` | Loader reuse/fallback adapters | State-owned strong Node refs and Strings; detached diagnostics | Until final shutdown | | Internal M2 prototype state | Normalized path, adopted static/animated Node or negative outcome | Loader / `M2PrototypeCacheState` | Loader reuse/fallback adapters | State-owned strong Node refs and Strings; detached diagnostics | Until final shutdown |
| Output | Desired tile/detail operations | Streamer plan application | Finalize queues | Loader-owned | Cross-frame | | Output | Desired tile/detail operations | Streamer plan application | Finalize queues | Loader-owned | Cross-frame |
| Output | Terrain/M2/WMO/liquid instances | Loader/builders | Godot world/renderer | Loader/world owner | Main-thread attach | | Output | Terrain/M2/WMO/liquid instances | Loader/builders | Godot world/renderer | Loader/world owner | Main-thread attach |
@@ -541,6 +543,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
| M2 placement grouper | Implemented extraction | Scene-free validation/order/transform/source/timing contract | Worker/build state, spatial cells and asset-backed p95/p99 remain pending | | M2 placement grouper | Implemented extraction | Scene-free validation/order/transform/source/timing contract | Worker/build state, spatial cells and asset-backed p95/p99 remain pending |
| M2 build batch planner | Implemented extraction | Scene-free limit/count/cursor/source/timing contract | Queue/resource state, spatial cells and asset-backed p95/p99 remain pending | | M2 build batch planner | Implemented extraction | Scene-free limit/count/cursor/source/timing contract | Queue/resource state, spatial cells and asset-backed p95/p99 remain pending |
| M2 animation load pipeline state | Implemented extraction | Synthetic lifecycle/FIFO/source/timing contract | Asset-backed traversal/leak/animation-fidelity/p95/p99 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 |
| M2 prototype cache state | Implemented extraction | Synthetic identity/negative/lifecycle/source/timing plus shutdown contract | Asset-backed traversal/leak/p95/p99 pending | | M2 prototype cache state | Implemented extraction | Synthetic identity/negative/lifecycle/source/timing plus shutdown contract | Asset-backed traversal/leak/p95/p99 pending |
| WMO placement resolver | Implemented extraction | Scene-free path/identity/transform/source/timing contract | Asset-backed comparison pending | | WMO placement resolver | Implemented extraction | Scene-free path/identity/transform/source/timing contract | Asset-backed comparison pending |
| WMO placement registry | Implemented extraction | Scene-free ownership/lifecycle/source/timing contract | Build/resource state and asset-backed cross-tile corpus pending | | WMO placement registry | Implemented extraction | Scene-free ownership/lifecycle/source/timing contract | Build/resource state and asset-backed cross-tile corpus pending |
@@ -603,6 +606,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
| `src/render/liquid/adt_water_scene_finalizer.gd` | Main-thread ADT water build, tile attachment and optional editor ownership | | `src/render/liquid/adt_water_scene_finalizer.gd` | Main-thread ADT water build, tile attachment and optional editor ownership |
| `src/render/m2/m2_runtime_mesh_rebuild_classifier.gd` | Billboard/UV-rotation stale cached-mesh rebuild decision and memoization | | `src/render/m2/m2_runtime_mesh_rebuild_classifier.gd` | Billboard/UV-rotation stale cached-mesh rebuild decision and memoization |
| `src/render/m2/m2_animation_load_pipeline_state.gd` | Animated M2 pending Resource paths, terminal statuses and finalize FIFO | | `src/render/m2/m2_animation_load_pipeline_state.gd` | Animated M2 pending Resource paths, terminal statuses and finalize FIFO |
| `src/render/m2/m2_animated_scene_finalizer.gd` | Animated scene candidate ownership, material repair and player validation |
| `src/render/m2/m2_mesh_load_pipeline_state.gd` | Static M2 pending Resource paths, terminal statuses and finalize FIFO | | `src/render/m2/m2_mesh_load_pipeline_state.gd` | Static M2 pending Resource paths, terminal statuses and finalize FIFO |
| `src/render/m2/m2_mesh_resource_cache_state.gd` | Prepared static M2 Mesh references and final-shutdown release | | `src/render/m2/m2_mesh_resource_cache_state.gd` | Prepared static M2 Mesh references and final-shutdown release |
| `src/render/m2/m2_mesh_resource_extractor.gd` | First-Mesh selection and temporary PackedScene instance lifetime | | `src/render/m2/m2_mesh_resource_extractor.gd` | First-Mesh selection and temporary PackedScene instance lifetime |
@@ -625,6 +629,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m
| `src/tools/verify_adt_water_scene_finalizer.gd` | Synthetic ADT water scene/ownership/boundary/timing regression | | `src/tools/verify_adt_water_scene_finalizer.gd` | Synthetic ADT water scene/ownership/boundary/timing regression |
| `src/tools/verify_m2_runtime_mesh_rebuild_classifier.gd` | M2 rebuild predicate/cache/boundary/timing regression | | `src/tools/verify_m2_runtime_mesh_rebuild_classifier.gd` | M2 rebuild predicate/cache/boundary/timing regression |
| `src/tools/verify_m2_animation_load_pipeline_state.gd` | Animated M2 request/terminal/FIFO/boundary/timing regression | | `src/tools/verify_m2_animation_load_pipeline_state.gd` | Animated M2 request/terminal/FIFO/boundary/timing regression |
| `src/tools/verify_m2_animated_scene_finalizer.gd` | Animated M2 candidate/material/player/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_load_pipeline_state.gd` | Static M2 request/terminal/FIFO/boundary/timing regression |
| `src/tools/verify_m2_mesh_resource_cache_state.gd` | Static M2 Mesh cache ownership/lifetime/boundary/timing regression | | `src/tools/verify_m2_mesh_resource_cache_state.gd` | Static M2 Mesh cache ownership/lifetime/boundary/timing regression |
| `src/tools/verify_m2_mesh_resource_extractor.gd` | M2 direct/PackedScene/subtree order/lifetime/boundary/timing regression | | `src/tools/verify_m2_mesh_resource_extractor.gd` | M2 direct/PackedScene/subtree order/lifetime/boundary/timing regression |
@@ -0,0 +1,115 @@
class_name M2AnimatedSceneFinalizer
extends RefCounted
## Instantiates and validates detached animated M2 scene candidates and repairs
## their surface materials from an existing static material prototype.
## The caller owns ResourceLoader I/O, permits and prototype cache adoption.
## Instantiates one PackedScene as a detached Node3D candidate. Unsupported
## Resources return null; a created non-Node3D root is freed before return.
func instantiate_candidate(resource: Resource) -> Node3D:
if not (resource is PackedScene):
return null
var candidate_root := (resource as PackedScene).instantiate()
if candidate_root is Node3D:
return candidate_root as Node3D
if candidate_root != null:
candidate_root.free()
return null
## Applies the historical depth-first source-to-target material mapping. A null
## source or target leaves the candidate unchanged.
func repair_materials(animated_root: Node3D, material_source_root: Node3D) -> void:
if animated_root == null or material_source_root == null:
return
var source_meshes := mesh_instances_in_subtree(material_source_root)
var target_meshes := mesh_instances_in_subtree(animated_root)
if source_meshes.is_empty() or target_meshes.is_empty():
return
var fallback_material := _first_mesh_material(source_meshes)
for target_index in target_meshes.size():
var target := target_meshes[target_index]
if target == null or target.mesh == null:
continue
var source := source_meshes[mini(target_index, source_meshes.size() - 1)]
for surface_index in target.mesh.get_surface_count():
var material := _mesh_instance_surface_material(source, surface_index)
if material == null:
material = fallback_material
if material != null:
target.set_surface_override_material(surface_index, material)
## Accepts a detached candidate only when it contains an AnimationPlayer.
## Rejected candidates are freed synchronously. A successful result transfers
## the exact prototype reference and its AnimationPlayer count to the caller.
func finalize_candidate(candidate_root: Node3D) -> Dictionary:
if candidate_root == null:
return {}
var animation_players := animation_players_in_subtree(candidate_root)
if animation_players.is_empty():
candidate_root.free()
return {}
return {
"prototype": candidate_root,
"animation_player_count": animation_players.size(),
}
## Returns MeshInstance3D descendants in depth-first preorder, including root.
func mesh_instances_in_subtree(root: Node) -> Array[MeshInstance3D]:
var mesh_instances: Array[MeshInstance3D] = []
if root != null:
_collect_mesh_instances(root, mesh_instances)
return mesh_instances
## Returns AnimationPlayer descendants in depth-first preorder, including root.
func animation_players_in_subtree(root: Node) -> Array[AnimationPlayer]:
var animation_players: Array[AnimationPlayer] = []
if root != null:
_collect_animation_players(root, animation_players)
return animation_players
func _collect_mesh_instances(node: Node, result: Array[MeshInstance3D]) -> void:
if node is MeshInstance3D:
result.append(node as MeshInstance3D)
for child in node.get_children():
_collect_mesh_instances(child, result)
func _collect_animation_players(node: Node, result: Array[AnimationPlayer]) -> void:
if node is AnimationPlayer:
result.append(node as AnimationPlayer)
for child in node.get_children():
_collect_animation_players(child, result)
func _first_mesh_material(meshes: Array[MeshInstance3D]) -> Material:
for mesh_instance in meshes:
if mesh_instance == null or mesh_instance.mesh == null:
continue
for surface_index in mesh_instance.mesh.get_surface_count():
var material := _mesh_instance_surface_material(mesh_instance, surface_index)
if material != null:
return material
return null
func _mesh_instance_surface_material(
mesh_instance: MeshInstance3D,
surface_index: int
) -> Material:
if mesh_instance == null or mesh_instance.mesh == null:
return null
if surface_index < mesh_instance.get_surface_override_material_count():
var override_material := mesh_instance.get_surface_override_material(surface_index)
if override_material != null:
return override_material
if surface_index < mesh_instance.mesh.get_surface_count():
return mesh_instance.mesh.surface_get_material(surface_index)
return null
@@ -0,0 +1 @@
uid://cjjim3v675w66
+20 -90
View File
@@ -75,6 +75,9 @@ const M2_MESH_LOAD_PIPELINE_STATE_SCRIPT := preload(
const M2_ANIMATION_LOAD_PIPELINE_STATE_SCRIPT := preload( const M2_ANIMATION_LOAD_PIPELINE_STATE_SCRIPT := preload(
"res://src/render/m2/m2_animation_load_pipeline_state.gd" "res://src/render/m2/m2_animation_load_pipeline_state.gd"
) )
const M2_ANIMATED_SCENE_FINALIZER_SCRIPT := preload(
"res://src/render/m2/m2_animated_scene_finalizer.gd"
)
const M2_MESH_RESOURCE_CACHE_STATE_SCRIPT := preload( const M2_MESH_RESOURCE_CACHE_STATE_SCRIPT := preload(
"res://src/render/m2/m2_mesh_resource_cache_state.gd" "res://src/render/m2/m2_mesh_resource_cache_state.gd"
) )
@@ -274,6 +277,7 @@ var _m2_mesh_resource_cache_state := M2_MESH_RESOURCE_CACHE_STATE_SCRIPT.new()
var _m2_mesh_resource_extractor := M2_MESH_RESOURCE_EXTRACTOR_SCRIPT.new() var _m2_mesh_resource_extractor := M2_MESH_RESOURCE_EXTRACTOR_SCRIPT.new()
var _m2_mesh_load_pipeline_state := M2_MESH_LOAD_PIPELINE_STATE_SCRIPT.new() var _m2_mesh_load_pipeline_state := M2_MESH_LOAD_PIPELINE_STATE_SCRIPT.new()
var _m2_animation_load_pipeline_state := M2_ANIMATION_LOAD_PIPELINE_STATE_SCRIPT.new() var _m2_animation_load_pipeline_state := M2_ANIMATION_LOAD_PIPELINE_STATE_SCRIPT.new()
var _m2_animated_scene_finalizer := M2_ANIMATED_SCENE_FINALIZER_SCRIPT.new()
var _wmo_build_jobs: Dictionary = {} var _wmo_build_jobs: Dictionary = {}
var _wmo_build_queue: Array = [] var _wmo_build_queue: Array = []
var _tile_loading_tasks: Dictionary = {} var _tile_loading_tasks: Dictionary = {}
@@ -4238,20 +4242,21 @@ func _drain_m2_animation_loads() -> void:
var path := String(pending.get("path", "")) var path := String(pending.get("path", ""))
var resource: Resource = ResourceLoader.load_threaded_get(path) var resource: Resource = ResourceLoader.load_threaded_get(path)
if resource is PackedScene: var candidate := _m2_animated_scene_finalizer.instantiate_candidate(resource)
var node = (resource as PackedScene).instantiate() if candidate != null:
if node is Node3D: var material_source := _get_or_load_m2_material_prototype(normalized_rel)
_repair_m2_animated_materials(normalized_rel, node as Node3D) _m2_animated_scene_finalizer.repair_materials(candidate, material_source)
var players := _find_animation_players_recursive(node) var finalization := _m2_animated_scene_finalizer.finalize_candidate(candidate)
if not players.is_empty(): var prototype: Node3D = finalization.get("prototype", null)
_m2_prototype_cache_state.adopt_animated_prototype( if prototype != null:
_m2_prototype_cache_state.adopt_animated_prototype(normalized_rel, prototype)
if debug_streaming:
print("M2_ANIM_CACHE path=%s cache=%s players=%d" % [
normalized_rel, normalized_rel,
node as Node3D path,
) int(finalization.get("animation_player_count", 0)),
if debug_streaming: ])
print("M2_ANIM_CACHE path=%s cache=%s players=%d" % [normalized_rel, path, players.size()]) continue
continue
node.free()
_m2_prototype_cache_state.mark_animation_static(normalized_rel) _m2_prototype_cache_state.mark_animation_static(normalized_rel)
@@ -4464,7 +4469,7 @@ func _start_m2_animations(root: Node, rel_path: String, instance_index: int) ->
float(state.get("length", 0.0)), float(state.get("length", 0.0)),
]) ])
var players := _find_animation_players_recursive(root) var players := _m2_animated_scene_finalizer.animation_players_in_subtree(root)
for player in players: for player in players:
var animation_name := _choose_default_m2_animation(player, rel_path) var animation_name := _choose_default_m2_animation(player, rel_path)
if animation_name.is_empty(): if animation_name.is_empty():
@@ -4523,19 +4528,6 @@ func _choose_default_m2_animation(player: AnimationPlayer, rel_path: String = ""
return String(names[0]) if not names.is_empty() else "" return String(names[0]) if not names.is_empty() else ""
func _find_animation_players_recursive(root: Node) -> Array[AnimationPlayer]:
var result: Array[AnimationPlayer] = []
_collect_animation_players(root, result)
return result
func _collect_animation_players(node: Node, result: Array[AnimationPlayer]) -> void:
if node is AnimationPlayer:
result.append(node as AnimationPlayer)
for child in node.get_children():
_collect_animation_players(child, result)
func _finish_m2_build_job(key: String) -> void: func _finish_m2_build_job(key: String) -> void:
if not _m2_build_jobs.has(key): if not _m2_build_jobs.has(key):
return return
@@ -4673,7 +4665,7 @@ func _prepare_m2_mesh_for_runtime(normalized_rel: String, mesh: Mesh) -> Mesh:
func _prepare_m2_node_for_runtime(normalized_rel: String, root: Node3D) -> void: func _prepare_m2_node_for_runtime(normalized_rel: String, root: Node3D) -> void:
if root == null: if root == null:
return return
for mesh_instance in _find_mesh_instances_recursive(root): for mesh_instance in _m2_animated_scene_finalizer.mesh_instances_in_subtree(root):
if mesh_instance == null or mesh_instance.mesh == null: if mesh_instance == null or mesh_instance.mesh == null:
continue continue
var prepared := _prepare_m2_mesh_for_runtime(normalized_rel, mesh_instance.mesh) var prepared := _prepare_m2_mesh_for_runtime(normalized_rel, mesh_instance.mesh)
@@ -5000,32 +4992,6 @@ func _is_m2_animation_denied(normalized_rel: String) -> bool:
return false return false
func _repair_m2_animated_materials(normalized_rel: String, animated_root: Node3D) -> void:
if animated_root == null:
return
var material_root := _get_or_load_m2_material_prototype(normalized_rel)
if material_root == null:
return
var source_meshes := _find_mesh_instances_recursive(material_root)
var target_meshes := _find_mesh_instances_recursive(animated_root)
if source_meshes.is_empty() or target_meshes.is_empty():
return
var fallback_material := _first_mesh_material(source_meshes)
for i in target_meshes.size():
var target := target_meshes[i]
if target == null or target.mesh == null:
continue
var source := source_meshes[mini(i, source_meshes.size() - 1)]
for surface_idx in target.mesh.get_surface_count():
var material := _mesh_instance_surface_material(source, surface_idx)
if material == null:
material = fallback_material
if material != null:
target.set_surface_override_material(surface_idx, material)
func _get_or_load_m2_material_prototype(normalized_rel: String) -> Node3D: func _get_or_load_m2_material_prototype(normalized_rel: String) -> Node3D:
if normalized_rel.is_empty(): if normalized_rel.is_empty():
return null return null
@@ -5049,42 +5015,6 @@ func _get_or_load_m2_material_prototype(normalized_rel: String) -> Node3D:
return null return null
func _find_mesh_instances_recursive(root: Node) -> Array[MeshInstance3D]:
var result: Array[MeshInstance3D] = []
_collect_mesh_instances(root, result)
return result
func _collect_mesh_instances(node: Node, result: Array[MeshInstance3D]) -> void:
if node is MeshInstance3D:
result.append(node as MeshInstance3D)
for child in node.get_children():
_collect_mesh_instances(child, result)
func _first_mesh_material(meshes: Array[MeshInstance3D]) -> Material:
for mesh_instance in meshes:
if mesh_instance == null or mesh_instance.mesh == null:
continue
for surface_idx in mesh_instance.mesh.get_surface_count():
var material := _mesh_instance_surface_material(mesh_instance, surface_idx)
if material != null:
return material
return null
func _mesh_instance_surface_material(mesh_instance: MeshInstance3D, surface_idx: int) -> Material:
if mesh_instance == null or mesh_instance.mesh == null:
return null
if surface_idx < mesh_instance.get_surface_override_material_count():
var override_material := mesh_instance.get_surface_override_material(surface_idx)
if override_material != null:
return override_material
if surface_idx < mesh_instance.mesh.get_surface_count():
return mesh_instance.mesh.surface_get_material(surface_idx)
return null
func _glb_cache_is_safe_for_runtime_animation(cache_res_path: String) -> bool: func _glb_cache_is_safe_for_runtime_animation(cache_res_path: String) -> bool:
var abs_path := ProjectSettings.globalize_path(cache_res_path) var abs_path := ProjectSettings.globalize_path(cache_res_path)
if not FileAccess.file_exists(abs_path): if not FileAccess.file_exists(abs_path):
@@ -0,0 +1,204 @@
extends SceneTree
## Synthetic candidate, material, ownership and timing regression for animated
## M2 scene finalization.
const FINALIZER_SCRIPT := preload("res://src/render/m2/m2_animated_scene_finalizer.gd")
const FINALIZER_PATH := "res://src/render/m2/m2_animated_scene_finalizer.gd"
const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd"
func _initialize() -> void:
var failures: Array[String] = []
_verify_candidate_instantiation_and_invalid_lifetime(failures)
_verify_animation_validation_and_transfer(failures)
_verify_material_mapping(failures)
_verify_ownership_boundaries(failures)
var elapsed_milliseconds := _verify_bounded_timing(failures)
if not failures.is_empty():
for failure in failures:
push_error("M2_ANIMATED_SCENE_FINALIZER: %s" % failure)
quit(1)
return
print(
"M2_ANIMATED_SCENE_FINALIZER PASS cases=13 iterations=10000 elapsed_ms=%.3f"
% elapsed_milliseconds
)
quit(0)
func _verify_candidate_instantiation_and_invalid_lifetime(failures: Array[String]) -> void:
var finalizer: RefCounted = FINALIZER_SCRIPT.new()
_expect_same(finalizer.call("instantiate_candidate", null), null, "null Resource rejected", failures)
_expect_same(finalizer.call("instantiate_candidate", Resource.new()), null, "unsupported Resource rejected", failures)
var invalid_source := Node.new()
var invalid_scene := PackedScene.new()
_expect_equal(invalid_scene.pack(invalid_source), OK, "non-Node3D scene packs", failures)
invalid_source.free()
var node_count_before := int(Performance.get_monitor(Performance.OBJECT_NODE_COUNT))
_expect_same(finalizer.call("instantiate_candidate", invalid_scene), null, "non-Node3D root rejected", failures)
var node_count_after := int(Performance.get_monitor(Performance.OBJECT_NODE_COUNT))
_expect_equal(node_count_after, node_count_before, "invalid instantiated root freed", failures)
var valid_source := Node3D.new()
var valid_scene := PackedScene.new()
_expect_equal(valid_scene.pack(valid_source), OK, "Node3D scene packs", failures)
valid_source.free()
var candidate: Node3D = finalizer.call("instantiate_candidate", valid_scene)
_expect_true(candidate != null, "Node3D candidate returned", failures)
if candidate != null:
candidate.free()
func _verify_animation_validation_and_transfer(failures: Array[String]) -> void:
var finalizer: RefCounted = FINALIZER_SCRIPT.new()
var empty_candidate := Node3D.new()
var empty_candidate_id := empty_candidate.get_instance_id()
var rejected: Dictionary = finalizer.call("finalize_candidate", empty_candidate)
_expect_true(rejected.is_empty(), "candidate without AnimationPlayer rejected", failures)
_expect_false(is_instance_id_valid(empty_candidate_id), "rejected candidate freed", failures)
var animated_candidate := Node3D.new()
var branch := Node3D.new()
var first_player := AnimationPlayer.new()
var second_player := AnimationPlayer.new()
animated_candidate.add_child(branch)
branch.add_child(first_player)
animated_candidate.add_child(second_player)
var players: Array[AnimationPlayer] = finalizer.call(
"animation_players_in_subtree",
animated_candidate
)
_expect_equal(players.size(), 2, "nested players discovered", failures)
_expect_same(players[0], first_player, "player depth-first order retained", failures)
var accepted: Dictionary = finalizer.call("finalize_candidate", animated_candidate)
_expect_same(accepted.get("prototype"), animated_candidate, "exact candidate transferred", failures)
_expect_equal(int(accepted.get("animation_player_count", 0)), 2, "player count transferred", failures)
animated_candidate.free()
func _verify_material_mapping(failures: Array[String]) -> void:
var finalizer: RefCounted = FINALIZER_SCRIPT.new()
var material_source_root := Node3D.new()
var source_first := MeshInstance3D.new()
var source_second := MeshInstance3D.new()
source_first.mesh = _make_triangle_mesh(2)
source_second.mesh = _make_triangle_mesh(1)
var first_override := StandardMaterial3D.new()
var second_surface_material := StandardMaterial3D.new()
source_first.set_surface_override_material(0, first_override)
source_second.mesh.surface_set_material(0, second_surface_material)
material_source_root.add_child(source_first)
material_source_root.add_child(source_second)
var animated_root := Node3D.new()
var target_first := MeshInstance3D.new()
var target_second := MeshInstance3D.new()
target_first.mesh = _make_triangle_mesh(2)
target_second.mesh = _make_triangle_mesh(2)
animated_root.add_child(target_first)
animated_root.add_child(target_second)
var target_meshes: Array[MeshInstance3D] = finalizer.call(
"mesh_instances_in_subtree",
animated_root
)
_expect_same(target_meshes[0], target_first, "mesh depth-first order retained", failures)
finalizer.call("repair_materials", animated_root, material_source_root)
_expect_same(target_first.get_surface_override_material(0), first_override, "source override has priority", failures)
_expect_same(target_first.get_surface_override_material(1), first_override, "missing source surface uses first fallback", failures)
_expect_same(target_second.get_surface_override_material(0), second_surface_material, "source index maps to target index", failures)
_expect_same(target_second.get_surface_override_material(1), first_override, "clamped source missing surface uses fallback", failures)
animated_root.free()
material_source_root.free()
func _verify_ownership_boundaries(failures: Array[String]) -> void:
var finalizer_source := FileAccess.get_file_as_string(FINALIZER_PATH)
var loader_source := FileAccess.get_file_as_string(LOADER_PATH)
_expect_true(loader_source.contains("M2_ANIMATED_SCENE_FINALIZER_SCRIPT.new()"), "loader composes finalizer", failures)
for removed_loader_function in [
"func _repair_m2_animated_materials(",
"func _find_animation_players_recursive(",
"func _find_mesh_instances_recursive(",
]:
_expect_false(loader_source.contains(removed_loader_function), "legacy helper removed: %s" % removed_loader_function, failures)
for delegated_call in [
"_m2_animated_scene_finalizer.instantiate_candidate(resource)",
"_m2_animated_scene_finalizer.repair_materials(candidate, material_source)",
"_m2_animated_scene_finalizer.finalize_candidate(candidate)",
"_m2_animated_scene_finalizer.mesh_instances_in_subtree(root)",
"_m2_animated_scene_finalizer.animation_players_in_subtree(root)",
]:
_expect_equal(loader_source.count(delegated_call), 1, "single loader delegation: %s" % delegated_call, failures)
for retained_loader_rule in [
"ResourceLoader.load_threaded_get(path)",
"RENDER_BUDGET_SCHEDULER_SCRIPT.M2_ANIMATION_FINALIZE",
"_get_or_load_m2_material_prototype(normalized_rel)",
"_m2_prototype_cache_state.adopt_animated_prototype(",
"_m2_prototype_cache_state.mark_animation_static(normalized_rel)",
"M2_ANIM_CACHE path=%s cache=%s players=%d",
]:
_expect_true(loader_source.contains(retained_loader_rule), "loader retains %s" % retained_loader_rule, failures)
for forbidden_dependency in [
"ResourceLoader.",
"FileAccess.",
"WorkerThreadPool.",
"_m2_prototype_cache_state",
"_render_budget_scheduler",
"M2Builder",
"M2RawModelRepository",
]:
_expect_false(finalizer_source.contains(forbidden_dependency), "finalizer omits %s ownership" % forbidden_dependency, failures)
func _verify_bounded_timing(failures: Array[String]) -> float:
var finalizer: RefCounted = FINALIZER_SCRIPT.new()
var root := Node3D.new()
var branch := root
for _depth_index in range(8):
var child_branch := Node3D.new()
branch.add_child(child_branch)
branch = child_branch
branch.add_child(AnimationPlayer.new())
var started_microseconds := Time.get_ticks_usec()
for _iteration in range(10000):
finalizer.call("animation_players_in_subtree", root)
var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0
root.free()
_expect_true(elapsed_milliseconds < 1000.0, "10000 subtree queries under 1 second", failures)
return elapsed_milliseconds
func _make_triangle_mesh(surface_count: int) -> ArrayMesh:
var mesh := ArrayMesh.new()
for _surface_index in range(surface_count):
var arrays := []
arrays.resize(Mesh.ARRAY_MAX)
arrays[Mesh.ARRAY_VERTEX] = PackedVector3Array([
Vector3.ZERO,
Vector3.RIGHT,
Vector3.UP,
])
arrays[Mesh.ARRAY_INDEX] = PackedInt32Array([0, 1, 2])
mesh.add_surface_from_arrays(Mesh.PRIMITIVE_TRIANGLES, arrays)
return mesh
func _expect_true(condition: bool, label: String, failures: Array[String]) -> void:
if not condition:
failures.append(label)
func _expect_false(condition: bool, label: String, failures: Array[String]) -> void:
_expect_true(not condition, label, failures)
func _expect_equal(actual: int, expected: int, label: String, failures: Array[String]) -> void:
if actual != expected:
failures.append("%s expected=%d actual=%d" % [label, expected, actual])
func _expect_same(actual: Variant, expected: Variant, label: String, failures: Array[String]) -> void:
if not is_same(actual, expected):
failures.append(label)
@@ -0,0 +1 @@
uid://bx51wu1f6o5je