From 1b450dc58059b5e0886c6d55bd32f2286a16f1ca Mon Sep 17 00:00:00 2001 From: sindoring Date: Sat, 18 Jul 2026 01:14:17 +0400 Subject: [PATCH 1/2] refactor(M03): extract M2 animated scene finalizer --- RENDER.md | 17 ++ docs/modules/README.md | 1 + docs/modules/m2-animated-scene-finalizer.md | 207 ++++++++++++++++++ .../m2-animation-load-pipeline-state.md | 12 +- docs/modules/m2-prototype-cache-state.md | 1 + docs/modules/world-renderer.md | 7 +- src/render/m2/m2_animated_scene_finalizer.gd | 115 ++++++++++ .../m2/m2_animated_scene_finalizer.gd.uid | 1 + .../streaming/streaming_world_loader.gd | 110 ++-------- .../verify_m2_animated_scene_finalizer.gd | 204 +++++++++++++++++ .../verify_m2_animated_scene_finalizer.gd.uid | 1 + 11 files changed, 581 insertions(+), 95 deletions(-) create mode 100644 docs/modules/m2-animated-scene-finalizer.md create mode 100644 src/render/m2/m2_animated_scene_finalizer.gd create mode 100644 src/render/m2/m2_animated_scene_finalizer.gd.uid create mode 100644 src/tools/verify_m2_animated_scene_finalizer.gd create mode 100644 src/tools/verify_m2_animated_scene_finalizer.gd.uid diff --git a/RENDER.md b/RENDER.md index 861c2c3..6b7c68a 100644 --- a/RENDER.md +++ b/RENDER.md @@ -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_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_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_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. @@ -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 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 - `M2MeshResourceCacheState` now owns normalized-path references to prepared diff --git a/docs/modules/README.md b/docs/modules/README.md index b4f47c8..7a04b8e 100644 --- a/docs/modules/README.md +++ b/docs/modules/README.md @@ -22,6 +22,7 @@ | 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 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 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) | diff --git a/docs/modules/m2-animated-scene-finalizer.md b/docs/modules/m2-animated-scene-finalizer.md new file mode 100644 index 0000000..3560b46 --- /dev/null +++ b/docs/modules/m2-animated-scene-finalizer.md @@ -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) diff --git a/docs/modules/m2-animation-load-pipeline-state.md b/docs/modules/m2-animation-load-pipeline-state.md index d49bc32..5e3ce1a 100644 --- a/docs/modules/m2-animation-load-pipeline-state.md +++ b/docs/modules/m2-animation-load-pipeline-state.md @@ -14,8 +14,9 @@ Own cross-frame bookkeeping between a successful animated M2 ResourceLoader request, terminal polling and budgeted main-thread scene finalization. This is -an exact state extraction; animation eligibility, loading and Node lifecycle -remain in `StreamingWorldLoader` and `M2PrototypeCacheState`. +an exact state extraction; animation eligibility and loading remain in +`StreamingWorldLoader`, while scene finalization and retained Node lifecycle +belong to `M2AnimatedSceneFinalizer` and `M2PrototypeCacheState`. ## Non-goals @@ -178,8 +179,9 @@ no rebake or migration is required. ## Extension points -ResourceLoader polling or animated-scene finalization may later move behind -separate adapters without changing this value-only state contract. +ResourceLoader polling may later move behind a separate adapter without +changing this value-only state contract. Animated-scene finalization is now a +sibling service. ## Capability status @@ -200,12 +202,14 @@ separate adapters without changing this value-only state contract. | Path | Responsibility | |---|---| | `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/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 | ## 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-mesh-load-pipeline-state.md`](m2-mesh-load-pipeline-state.md) - [`world-renderer.md`](world-renderer.md) diff --git a/docs/modules/m2-prototype-cache-state.md b/docs/modules/m2-prototype-cache-state.md index 996c770..3d429e7 100644 --- a/docs/modules/m2-prototype-cache-state.md +++ b/docs/modules/m2-prototype-cache-state.md @@ -240,6 +240,7 @@ this service without moving ResourceLoader or builder ownership into it. ## Related decisions and references - [`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-mesh-resource-cache-state.md`](m2-mesh-resource-cache-state.md) - [`m2-mesh-load-pipeline-state.md`](m2-mesh-load-pipeline-state.md) diff --git a/docs/modules/world-renderer.md b/docs/modules/world-renderer.md index 21692a6..362efb2 100644 --- a/docs/modules/world-renderer.md +++ b/docs/modules/world-renderer.md @@ -7,7 +7,7 @@ | Status | Partial | | Target/work package | M00 baseline; `M01-RND-STREAMING-FOCUS-001`; `M01-QAR-SERVER-SPAWN-RENDERER-001`; M03 facade/planner/scheduler/internal-access/ground/environment/entity packages; M03 terrain packages; M03 M2 packages; M03 WMO placement package | | Owners | Renderer workstream / milestone integrator | -| Last verified | Worktree `work/sindo-main-codex/m03-m2-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 | ## 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 | | `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 | +| `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 | | `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 | @@ -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 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 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 | | 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 | @@ -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 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 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 | | 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 | @@ -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/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_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_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 | @@ -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_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_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_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 | diff --git a/src/render/m2/m2_animated_scene_finalizer.gd b/src/render/m2/m2_animated_scene_finalizer.gd new file mode 100644 index 0000000..4f53f7c --- /dev/null +++ b/src/render/m2/m2_animated_scene_finalizer.gd @@ -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 diff --git a/src/render/m2/m2_animated_scene_finalizer.gd.uid b/src/render/m2/m2_animated_scene_finalizer.gd.uid new file mode 100644 index 0000000..84903c7 --- /dev/null +++ b/src/render/m2/m2_animated_scene_finalizer.gd.uid @@ -0,0 +1 @@ +uid://cjjim3v675w66 diff --git a/src/scenes/streaming/streaming_world_loader.gd b/src/scenes/streaming/streaming_world_loader.gd index e69f8f6..f3d6051 100644 --- a/src/scenes/streaming/streaming_world_loader.gd +++ b/src/scenes/streaming/streaming_world_loader.gd @@ -75,6 +75,9 @@ const M2_MESH_LOAD_PIPELINE_STATE_SCRIPT := preload( const M2_ANIMATION_LOAD_PIPELINE_STATE_SCRIPT := preload( "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( "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_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_animated_scene_finalizer := M2_ANIMATED_SCENE_FINALIZER_SCRIPT.new() var _wmo_build_jobs: Dictionary = {} var _wmo_build_queue: Array = [] var _tile_loading_tasks: Dictionary = {} @@ -4238,20 +4242,21 @@ func _drain_m2_animation_loads() -> void: var path := String(pending.get("path", "")) var resource: Resource = ResourceLoader.load_threaded_get(path) - if resource is PackedScene: - var node = (resource as PackedScene).instantiate() - if node is Node3D: - _repair_m2_animated_materials(normalized_rel, node as Node3D) - var players := _find_animation_players_recursive(node) - if not players.is_empty(): - _m2_prototype_cache_state.adopt_animated_prototype( + var candidate := _m2_animated_scene_finalizer.instantiate_candidate(resource) + if candidate != null: + var material_source := _get_or_load_m2_material_prototype(normalized_rel) + _m2_animated_scene_finalizer.repair_materials(candidate, material_source) + var finalization := _m2_animated_scene_finalizer.finalize_candidate(candidate) + var prototype: Node3D = finalization.get("prototype", null) + 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, - node as Node3D - ) - if debug_streaming: - print("M2_ANIM_CACHE path=%s cache=%s players=%d" % [normalized_rel, path, players.size()]) - continue - node.free() + path, + int(finalization.get("animation_player_count", 0)), + ]) + continue _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)), ]) - var players := _find_animation_players_recursive(root) + var players := _m2_animated_scene_finalizer.animation_players_in_subtree(root) for player in players: var animation_name := _choose_default_m2_animation(player, rel_path) 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 "" -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: if not _m2_build_jobs.has(key): 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: if root == null: 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: continue 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 -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: if normalized_rel.is_empty(): return null @@ -5049,42 +5015,6 @@ func _get_or_load_m2_material_prototype(normalized_rel: String) -> Node3D: 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: var abs_path := ProjectSettings.globalize_path(cache_res_path) if not FileAccess.file_exists(abs_path): diff --git a/src/tools/verify_m2_animated_scene_finalizer.gd b/src/tools/verify_m2_animated_scene_finalizer.gd new file mode 100644 index 0000000..257cd8b --- /dev/null +++ b/src/tools/verify_m2_animated_scene_finalizer.gd @@ -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) diff --git a/src/tools/verify_m2_animated_scene_finalizer.gd.uid b/src/tools/verify_m2_animated_scene_finalizer.gd.uid new file mode 100644 index 0000000..ab6d9e3 --- /dev/null +++ b/src/tools/verify_m2_animated_scene_finalizer.gd.uid @@ -0,0 +1 @@ +uid://bx51wu1f6o5je From bc2bd06abcba310a5c50ff23757985c3eab83d75 Mon Sep 17 00:00:00 2001 From: sindoring Date: Sat, 18 Jul 2026 01:14:32 +0400 Subject: [PATCH 2/2] coord(M03): hand off M2 animated scene finalizer --- ...M03-RND-M2-ANIMATED-SCENE-FINALIZER-001.md | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/coordination/claims/M03-RND-M2-ANIMATED-SCENE-FINALIZER-001.md b/coordination/claims/M03-RND-M2-ANIMATED-SCENE-FINALIZER-001.md index bbf63c6..0da5e35 100644 --- a/coordination/claims/M03-RND-M2-ANIMATED-SCENE-FINALIZER-001.md +++ b/coordination/claims/M03-RND-M2-ANIMATED-SCENE-FINALIZER-001.md @@ -78,14 +78,24 @@ main-thread finalizer. ## Status -- State: claimed -- Done: candidate/material/player ownership boundary audited on current master -- Next: implement finalizer, migrate adapters, verify and document +- State: ready for integration +- Done: finalizer, loader adapters, lifecycle/material verifier and required documentation +- Next: integrate on master and run post-merge acceptance gates - Blocked by: ## Handoff -- Commit: -- Results: -- Remaining risks: -- Documentation updated: +- Commit: `1b450dc` +- Results: animated scene finalizer passes 13 invalid/type/lifetime/traversal/ + material/transfer/source cases and 10,000 depth-eight queries in 40.126 ms; + 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`. + +