From 9b73571dcb82d6cbe8e352129b3ba09a40ed1616 Mon Sep 17 00:00:00 2001 From: sindoring Date: Sat, 18 Jul 2026 01:25:54 +0400 Subject: [PATCH] refactor(M03): extract M2 animation playback --- RENDER.md | 15 ++ docs/modules/README.md | 1 + docs/modules/m2-animated-scene-finalizer.md | 5 +- .../m2-animation-playback-controller.md | 212 ++++++++++++++++ docs/modules/m2-build-batch-planner.md | 3 +- docs/modules/world-renderer.md | 7 +- .../m2/m2_animation_playback_controller.gd | 114 +++++++++ .../m2_animation_playback_controller.gd.uid | 1 + .../streaming/streaming_world_loader.gd | 113 +++------ .../verify_m2_animated_scene_finalizer.gd | 2 +- ...verify_m2_animation_playback_controller.gd | 226 ++++++++++++++++++ ...fy_m2_animation_playback_controller.gd.uid | 1 + 12 files changed, 615 insertions(+), 85 deletions(-) create mode 100644 docs/modules/m2-animation-playback-controller.md create mode 100644 src/render/m2/m2_animation_playback_controller.gd create mode 100644 src/render/m2/m2_animation_playback_controller.gd.uid create mode 100644 src/tools/verify_m2_animation_playback_controller.gd create mode 100644 src/tools/verify_m2_animation_playback_controller.gd.uid diff --git a/RENDER.md b/RENDER.md index 6b7c68a..64c1b2a 100644 --- a/RENDER.md +++ b/RENDER.md @@ -37,6 +37,7 @@ Paired run 2026-07-11 подтвердил крупный coordinate/placement g - `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_playback_controller.gd` - deterministic per-instance phase, default animation selection and native/imported playback mutation. - `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. @@ -1115,6 +1116,20 @@ $exe = Join-Path $env:TEMP 'godot-4.6.1-openwc\Godot_v4.6.1-stable_win64.exe' - 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-18 M2 Animation Playback Controller Extraction + +- `M2AnimationPlaybackController` now owns the stable path/index phase formula, + ordinary/fish/bird animation-name priority, substring/first-name fallbacks and + AnimationPlayer linear-loop/play/seek mutation. +- Exact-script native animators retain depth-first pairing, the same five copied + fields, optional `prepare_runtime`, phase assignment and debug-state sampling. +- Native debug records are detached and sampled only when `debug_streaming` is + enabled; `StreamingWorldLoader` retains path normalization and exact log text. +- Loader still duplicates/transforms/attaches instances, applies visibility, + shadows and Editor ownership, owns build cursors/permits and obtains player inventory. +- Cache formats, playback priorities, phase behavior and visible rules are unchanged. + Synthetic timing is 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 7a04b8e..8c6fedf 100644 --- a/docs/modules/README.md +++ b/docs/modules/README.md @@ -23,6 +23,7 @@ | 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 playback controller | Implemented extraction | [`m2-animation-playback-controller.md`](m2-animation-playback-controller.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 index 3560b46..aed3719 100644 --- a/docs/modules/m2-animated-scene-finalizer.md +++ b/docs/modules/m2-animated-scene-finalizer.md @@ -172,7 +172,8 @@ versions and prototype lifetimes are unchanged; no rebake is required. ## Extension points -Animation playback policy and native animator cloning remain separate services. +Animation playback policy and native animator field copying now belong to +`M2AnimationPlaybackController`; instance/batch materialization remains loader-owned. ## Capability status @@ -193,6 +194,7 @@ Animation playback policy and native animator cloning remain separate services. | Path | Responsibility | |---|---| | `src/render/m2/m2_animated_scene_finalizer.gd` | Candidate ownership, traversal, material repair and validation | +| `src/render/m2/m2_animation_playback_controller.gd` | Accepted-instance playback and native animator startup | | `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 | @@ -200,6 +202,7 @@ Animation playback policy and native animator cloning remain separate services. ## Related decisions and references +- [`m2-animation-playback-controller.md`](m2-animation-playback-controller.md) - [`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) diff --git a/docs/modules/m2-animation-playback-controller.md b/docs/modules/m2-animation-playback-controller.md new file mode 100644 index 0000000..8927c74 --- /dev/null +++ b/docs/modules/m2-animation-playback-controller.md @@ -0,0 +1,212 @@ +# M2 Animation Playback Controller + +## Metadata + +| Field | Value | +|---|---| +| Status | Implemented extraction | +| Target/work package | M03 / `M03-RND-M2-ANIMATION-PLAYBACK-001` | +| Owners | Per-instance AnimationPlayer/native animator playback mutation | +| Last verified | Worktree `work/sindo-main-codex/m03-m2-animation-playback`, 2026-07-18 | +| Profiles/capabilities | Imported GLB and native experimental animated M2 instances | + +## Purpose + +Apply deterministic playback to one already duplicated M2 instance: copy native +animator runtime fields, derive a stable phase, prepare/phase native animators, +select an imported animation and configure linear looping/playback/seek. + +## Non-goals + +- Duplicate, name, transform or attach instances/batch roots. +- Apply visibility, shadows or Editor ownership. +- Load/finalize/cache animated scenes or decide eligibility. +- Change animation priorities, phase formula or native animator implementation. + +## Context and boundaries + +```mermaid +flowchart LR + Prototype[Animated prototype] --> Loader[StreamingWorldLoader materializer] + Loader -->|source and duplicate| Playback[M2AnimationPlaybackController] + Finalizer[M2AnimatedSceneFinalizer player inventory] --> Playback + Playback --> Native[M2NativeAnimator mutation] + Playback --> Imported[AnimationPlayer mutation] +``` + +Allowed dependencies are supplied Nodes, Script identity, AnimationPlayer and +Animation resources. ResourceLoader, files, workers, caches, scheduler, +MultiMesh, SceneTree attachment and application layers are forbidden. + +## Public API + +| Symbol | Kind | Purpose | Thread/lifetime | Errors | +|---|---|---|---|---| +| `copy_native_animator_data(source_root, target_root, script)` | Command | Copy five runtime fields in depth-first pair order | Main thread; borrowed nodes | Null/script mismatch yields no pairs | +| `start_instance_playback(root, path, index, script, players, collect_diagnostics)` | Command/query | Start native/imported playback and optionally return native state | Main thread; one instance | Null/empty inventories no-op | +| `native_animators_in_subtree(root, script)` | Query | Exact-script depth-first inventory | Main thread; borrowed nodes | Null root/script returns empty | +| `phase_for_instance(path, index)` | Pure query | Stable phase in `[0, 1)` | Any thread; scalar | Historical hash behavior retained | +| `choose_default_animation(player, path)` | Pure engine query | Apply exact ordinary/fish/bird/fallback priority | Main thread; borrowed player | Null/no animations returns empty | + +## Inputs and outputs + +| Direction | Contract/data | Producer | Consumer | Ownership | Thread/lifetime | +|---|---|---|---|---|---| +| Input | Prototype and duplicate subtrees | Loader materializer | Native field copy | Borrowed Nodes | One duplicate | +| Input | Exact native animator Script | Loader composition | Native inventory | Borrowed Script | Call-local | +| Input | Relative path and instance index | Build batch adapter | Phase/selection | Copied values | One start | +| Input | AnimationPlayer inventory | Animated scene finalizer traversal | Imported playback | Borrowed players | One start | +| Output | Mutated native/imported playback | Controller | Rendered instance | Nodes retain state/resources | Instance lifetime | +| Output | Detached native diagnostic records | Controller | Loader log adapter | Caller-owned Dictionaries | Debug call | + +Side effects are native field assignment, prepare/phase calls, animation loop +mutation, play and seek. The service retains no inputs. + +## Data flow + +```mermaid +flowchart TD + Identity[Path and index] --> Phase[Stable hash phase] + NativeInventory[Exact-script native inventory] --> Prepare[prepare runtime if available] + Phase --> NativePhase[set native phase] + Players[AnimationPlayers] --> Select[Choose path-specific default] + Select --> Loop[Set every animation LOOP_LINEAR] + Loop --> Play[Play selected name] + Phase --> Seek[Seek positive-length selection] + NativePhase --> Diagnostics{Debug requested?} + Diagnostics -->|yes| Snapshot[Detached runtime state] +``` + +## Lifecycle/state + +```mermaid +stateDiagram-v2 + [*] --> Unstarted + Unstarted --> Prepared: copy native data and start + Prepared --> Playing: native phase and/or AnimationPlayer play + Playing --> Playing: repeated deterministic start + Playing --> [*]: instance owner releases subtree +``` + +The controller itself is stateless; lifecycle labels describe borrowed instance state. + +## Main sequence + +```mermaid +sequenceDiagram + participant L as StreamingWorldLoader + participant F as M2AnimatedSceneFinalizer + participant P as M2AnimationPlaybackController + participant N as M2NativeAnimator + participant A as AnimationPlayer + L->>P: copy_native_animator_data(prototype, duplicate, script) + L->>F: animation_players_in_subtree(duplicate) + F-->>L: ordered players + L->>P: start_instance_playback(path, index, players, debug) + P->>N: prepare_runtime and set_phase + P->>A: choose, loop, play and seek + P-->>L: optional detached native diagnostics + L->>L: preserve existing log format +``` + +## Dependency diagram + +```mermaid +flowchart TB + Loader[StreamingWorldLoader] --> Playback[M2AnimationPlaybackController] + Finalizer[M2AnimatedSceneFinalizer] --> Loader + Playback --> Engine[Node / Script / AnimationPlayer / Animation] + Loader --> Batch[SceneTree batch materialization] + Loader --> NativeScript[M2NativeAnimator script] + Playback -. no dependency .-> ResourceLoader + Playback -. no dependency .-> Cache[M2PrototypeCacheState] + Playback -. no dependency .-> Scheduler[RenderBudgetScheduler] +``` + +## Ownership, threading and resources + +- Loader owns prototype duplication, instance/batch roots and SceneTree attachment. +- Finalizer traversal supplies borrowed AnimationPlayer references. +- Controller borrows Nodes/Script/players only for the synchronous call. +- Native arrays are assigned by reference exactly as before extraction. +- Diagnostic Dictionaries are deep-duplicated before return. +- Main thread performs all engine-object mutation; pure phase math is thread-safe. + +## Errors, cancellation and recovery + +| Failure | Detection | Behavior | Diagnostic | Recovery | +|---|---|---|---|---| +| Missing roots/script | Inventory guard | No native copy/start | Contract fixture | Correct composition | +| Fewer target animators | Pair-count minimum | Copy available pairs only | Native copy fixture | Rebuild import/prototype | +| No matching name | Ordered fallback | Substring, first name, then empty | Selection fixtures | Add compatible animation | +| Empty animation selection | Empty result | Skip loop/play/seek for that player | Selection fixture | Static/native path continues | +| Zero-length selected animation | Length guard | Play without seek | Playback contract | Imported timing remains engine-owned | +| Cancellation/shutdown | Outside service | No retained state | N/A | Loader owns subtree release | + +## Configuration and capabilities + +| Setting/capability | Default | Profile | Runtime mutable | Effect | +|---|---|---|---|---| +| Phase buckets | 1000 | All | No | Stable per path/index desynchronization | +| Loop mode | `LOOP_LINEAR` | All | No | Every available imported animation loops | +| Native diagnostics | `debug_streaming` | Debug | Yes | Samples state only when requested | + +## Persistence, cache and migration + +No state or format is serialized. GLB/native cache formats, prototype lifetime +and material versions are unchanged; no rebake is required. + +## Diagnostics and observability + +- Optional native states remain detached and contain existing prepared, + processing, mesh, bone, surface and length fields. +- Loader retains `M2_NATIVE_ANIMATOR` log formatting and path normalization. +- Controller emits no logs or metrics. + +## Verification + +- `verify_m2_animation_playback_controller.gd` covers exact phase, ordinary/ + fish/bird priorities, substring/first fallback, loop/play/seek, native exact- + script order, five-field copy, phase, detached diagnostics and boundaries. +- Finalizer/build/prototype/material/shutdown regressions protect adjacent behavior. +- Fidelity evidence is exact policy/mutation extraction; no private asset or + original-client animation comparison is claimed. +- Performance budget: 20,000 phase-and-selection pairs under one second. + +## Extension points + +Server-driven animation state may later replace default selection through a +separate presentation contract; this fallback controller must remain available +for world doodads and compatibility fixtures. + +## Capability status + +| Capability | Status | Evidence | Gap/next step | +|---|---|---|---| +| Imported default selection/playback | Implemented extraction | Synthetic priority/play/seek verifier | Asset-backed animation-name corpus pending | +| Native data/phase startup | Implemented extraction | Exact-reference/native state verifier | Native visual fidelity remains experimental | +| Instance/batch materialization | Existing loader-owned | Adjacent build tests | Safe extraction next | + +## Known gaps and risks + +- Hash phase intentionally depends on existing Godot String hashing behavior. +- Default-name heuristics are not a complete WoW animation-state mapping. +- No proprietary traversal, animation timing comparison, p95/p99 or paired-client run exists. + +## Source map + +| Path | Responsibility | +|---|---| +| `src/render/m2/m2_animation_playback_controller.gd` | Phase, selection, native copy/start and imported playback | +| `src/render/m2/m2_animated_scene_finalizer.gd` | Accepted prototype/player inventory | +| `src/scenes/streaming/m2_native_animator.gd` | Experimental native deformation runtime | +| `src/scenes/streaming/streaming_world_loader.gd` | Instance duplication/attachment and diagnostic formatting | +| `src/tools/verify_m2_animation_playback_controller.gd` | Policy/mutation/boundary/timing regression | + +## Related decisions and references + +- [`m2-animated-scene-finalizer.md`](m2-animated-scene-finalizer.md) +- [`m2-build-batch-planner.md`](m2-build-batch-planner.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-build-batch-planner.md b/docs/modules/m2-build-batch-planner.md index 1f710aa..f631213 100644 --- a/docs/modules/m2-build-batch-planner.md +++ b/docs/modules/m2-build-batch-planner.md @@ -174,7 +174,8 @@ queue depth, build activity and hitch observability. | Path | Responsibility | |---|---| | `src/render/m2/m2_build_batch_planner.gd` | Pure limit/count/cursor planning | -| `src/scenes/streaming/streaming_world_loader.gd` | Build job, queue, resources, materialization and budgets | +| `src/scenes/streaming/streaming_world_loader.gd` | Build job, queue, resources, instance materialization and budgets | +| `src/render/m2/m2_animation_playback_controller.gd` | Animated-instance phase/selection/native/imported playback | | `src/render/m2/m2_placement_grouper.gd` | Produces grouped transform arrays | | `src/tools/verify_m2_build_batch_planner.gd` | Formula, source and timing regression | diff --git a/docs/modules/world-renderer.md b/docs/modules/world-renderer.md index 362efb2..5072bef 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-animated-scene-finalizer`, 2026-07-18 | +| Last verified | Worktree `work/sindo-main-codex/m03-m2-animation-playback`, 2026-07-18 | | Profiles/capabilities | `Performance`, `Balanced`, `High`, `Custom`; Blizzlike fidelity incomplete | ## Purpose @@ -135,6 +135,7 @@ from externally reading/writing loader-private queue, task, cache and tile-state | `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 | +| `M2AnimationPlaybackController` | Internal M2 playback service | Applies stable phase, default imported animation and native animator startup | Renderer main thread; stateless after each call | Missing inventories/names no-op through historical fallbacks | | `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 | @@ -187,6 +188,7 @@ loader configuration remains transitional composition data, not a caller API. | 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 animated M2 playback | Prototype/instance native animators, path/index and player inventory | Loader/finalizer / `M2AnimationPlaybackController` | Native and imported animation state | Borrowed Nodes; detached optional diagnostics | One duplicated instance startup | | 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 | @@ -544,6 +546,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m | 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 animation playback controller | Implemented extraction | Synthetic phase/selection/loop/native-copy/source/timing contract | Asset-backed animation timing/name/native 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 | @@ -607,6 +610,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m | `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_animation_playback_controller.gd` | Per-instance phase, selection, native copy/start and imported playback | | `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 | @@ -630,6 +634,7 @@ Exact exported settings and cache versions remain documented in [`../../RENDER.m | `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_animation_playback_controller.gd` | Animated M2 phase/selection/playback/native/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_animation_playback_controller.gd b/src/render/m2/m2_animation_playback_controller.gd new file mode 100644 index 0000000..21e45fd --- /dev/null +++ b/src/render/m2/m2_animation_playback_controller.gd @@ -0,0 +1,114 @@ +class_name M2AnimationPlaybackController +extends RefCounted + +## Applies deterministic playback to one animated M2 instance and copies the +## runtime fields of exact-script native animators. The caller owns instance +## duplication, SceneTree attachment and diagnostic log formatting. + + +## Copies the five historical runtime fields between matching native animators +## in depth-first order. References are intentionally retained exactly. +func copy_native_animator_data( + source_root: Node, + target_root: Node, + native_animator_script: Script +) -> void: + var source_animators := native_animators_in_subtree(source_root, native_animator_script) + var target_animators := native_animators_in_subtree(target_root, native_animator_script) + for animator_index in range(mini(source_animators.size(), target_animators.size())): + var source := source_animators[animator_index] + var target := target_animators[animator_index] + target.mesh_instance_path = source.mesh_instance_path + target.bones = source.bones + target.surfaces = source.surfaces + target.animation_length = source.animation_length + target.playback_speed = source.playback_speed + + +## Prepares native animators, applies deterministic phase, and starts supplied +## AnimationPlayers with the historical loop/name/seek rules. Runtime debug +## state is sampled only when requested and returned as detached Dictionaries. +func start_instance_playback( + root: Node, + relative_path: String, + instance_index: int, + native_animator_script: Script, + animation_players: Array[AnimationPlayer], + collect_native_diagnostics: bool +) -> Array[Dictionary]: + var phase := phase_for_instance(relative_path, instance_index) + var native_diagnostics: Array[Dictionary] = [] + for animator in native_animators_in_subtree(root, native_animator_script): + if animator.has_method("prepare_runtime"): + animator.prepare_runtime() + animator.set_phase(phase) + if collect_native_diagnostics and animator.has_method("runtime_debug_state"): + var diagnostic_variant = animator.runtime_debug_state() + if diagnostic_variant is Dictionary: + native_diagnostics.append((diagnostic_variant as Dictionary).duplicate(true)) + + for player in animation_players: + if player == null: + continue + var animation_name := choose_default_animation(player, relative_path) + if animation_name.is_empty(): + continue + for available_name in player.get_animation_list(): + var available_animation := player.get_animation(available_name) + if available_animation != null: + available_animation.loop_mode = Animation.LOOP_LINEAR + player.play(animation_name) + var selected_animation := player.get_animation(animation_name) + if selected_animation != null and selected_animation.length > 0.0: + player.seek(selected_animation.length * phase, true) + return native_diagnostics + + +## Returns exact-script native animators in depth-first preorder. +func native_animators_in_subtree( + root: Node, + native_animator_script: Script +) -> Array[Node]: + var native_animators: Array[Node] = [] + if root != null and native_animator_script != null: + _collect_native_animators(root, native_animator_script, native_animators) + return native_animators + + +## Returns the existing stable path/index phase in the inclusive-lower, +## exclusive-upper range [0, 1). +func phase_for_instance(relative_path: String, instance_index: int) -> float: + return float(abs(("%s:%d" % [relative_path, instance_index]).hash()) % 1000) / 1000.0 + + +## Chooses the historical default animation name for ordinary, fish and bird +## paths, then case-insensitive substring and first-name fallbacks. +func choose_default_animation(player: AnimationPlayer, relative_path: String = "") -> String: + if player == null: + return "" + var lower_path := relative_path.to_lower() + var candidates: Array[String] = ["Stand", "Idle", "Run", "Walk"] + if lower_path.contains("fish"): + candidates = ["Run", "Walk", "Swim", "Stand", "Idle", "Death"] + elif lower_path.contains("eagle") or lower_path.contains("bird") or lower_path.contains("gull"): + candidates = ["Run", "Walk", "Swim", "Stand", "Idle"] + for candidate in candidates: + if player.has_animation(candidate): + return candidate + var available_names := player.get_animation_list() + for candidate in ["run", "walk", "swim", "stand", "idle", "loop"]: + for available_name in available_names: + if String(available_name).to_lower().contains(candidate): + return String(available_name) + return String(available_names[0]) if not available_names.is_empty() else "" + + +func _collect_native_animators( + node: Node, + native_animator_script: Script, + result: Array[Node] +) -> void: + if node.get_script() == native_animator_script: + result.append(node) + for child in node.get_children(): + _collect_native_animators(child, native_animator_script, result) diff --git a/src/render/m2/m2_animation_playback_controller.gd.uid b/src/render/m2/m2_animation_playback_controller.gd.uid new file mode 100644 index 0000000..0e28afd --- /dev/null +++ b/src/render/m2/m2_animation_playback_controller.gd.uid @@ -0,0 +1 @@ +uid://c6oln217sml80 diff --git a/src/scenes/streaming/streaming_world_loader.gd b/src/scenes/streaming/streaming_world_loader.gd index f3d6051..adc2806 100644 --- a/src/scenes/streaming/streaming_world_loader.gd +++ b/src/scenes/streaming/streaming_world_loader.gd @@ -78,6 +78,9 @@ const M2_ANIMATION_LOAD_PIPELINE_STATE_SCRIPT := preload( const M2_ANIMATED_SCENE_FINALIZER_SCRIPT := preload( "res://src/render/m2/m2_animated_scene_finalizer.gd" ) +const M2_ANIMATION_PLAYBACK_CONTROLLER_SCRIPT := preload( + "res://src/render/m2/m2_animation_playback_controller.gd" +) const M2_MESH_RESOURCE_CACHE_STATE_SCRIPT := preload( "res://src/render/m2/m2_mesh_resource_cache_state.gd" ) @@ -278,6 +281,7 @@ 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 _m2_animation_playback_controller := M2_ANIMATION_PLAYBACK_CONTROLLER_SCRIPT.new() var _wmo_build_jobs: Dictionary = {} var _wmo_build_queue: Array = [] var _tile_loading_tasks: Dictionary = {} @@ -4438,9 +4442,35 @@ func _materialize_m2_animated_batch( instance.transform = transforms[start + i] _apply_visibility_range_recursive(instance, m2_visibility_range) _apply_shadow_cast_recursive(instance, m2_cast_shadows) - _copy_m2_native_animator_data(prototype, instance) + _m2_animation_playback_controller.copy_native_animator_data( + prototype, + instance, + M2_NATIVE_ANIMATOR_SCRIPT + ) batch_root.add_child(instance) - _start_m2_animations(instance, rel_path, start + i) + var animation_players := _m2_animated_scene_finalizer.animation_players_in_subtree( + instance + ) + var native_diagnostics := _m2_animation_playback_controller.start_instance_playback( + instance, + rel_path, + start + i, + M2_NATIVE_ANIMATOR_SCRIPT, + animation_players, + debug_streaming + ) + if debug_streaming: + for state in native_diagnostics: + print("M2_NATIVE_ANIMATOR path=%s instance=%d prepared=%s processing=%s mesh=%s bones=%d surfaces=%d length=%.2f" % [ + _normalize_m2_rel_path(rel_path), + start + i, + str(state.get("prepared", false)), + str(state.get("processing", false)), + str(state.get("has_mesh", false)), + int(state.get("bones", 0)), + int(state.get("surfaces", 0)), + float(state.get("length", 0.0)), + ]) if batch_root.get_child_count() <= 0: batch_root.queue_free() @@ -4449,85 +4479,6 @@ func _materialize_m2_animated_batch( _set_editor_owner_recursive(batch_root) -func _start_m2_animations(root: Node, rel_path: String, instance_index: int) -> void: - var phase := float(abs(("%s:%d" % [rel_path, instance_index]).hash()) % 1000) / 1000.0 - var native_animators := _find_m2_native_animators_recursive(root) - for animator in native_animators: - if animator.has_method("prepare_runtime"): - animator.prepare_runtime() - animator.set_phase(phase) - if debug_streaming and animator.has_method("runtime_debug_state"): - var state: Dictionary = animator.runtime_debug_state() - print("M2_NATIVE_ANIMATOR path=%s instance=%d prepared=%s processing=%s mesh=%s bones=%d surfaces=%d length=%.2f" % [ - _normalize_m2_rel_path(rel_path), - instance_index, - str(state.get("prepared", false)), - str(state.get("processing", false)), - str(state.get("has_mesh", false)), - int(state.get("bones", 0)), - int(state.get("surfaces", 0)), - float(state.get("length", 0.0)), - ]) - - 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(): - continue - for name in player.get_animation_list(): - var animation := player.get_animation(name) - if animation != null: - animation.loop_mode = Animation.LOOP_LINEAR - player.play(animation_name) - var animation := player.get_animation(animation_name) - if animation != null and animation.length > 0.0: - player.seek(animation.length * phase, true) - - -func _copy_m2_native_animator_data(source_root: Node, target_root: Node) -> void: - var source_animators := _find_m2_native_animators_recursive(source_root) - var target_animators := _find_m2_native_animators_recursive(target_root) - for i in range(mini(source_animators.size(), target_animators.size())): - var source := source_animators[i] - var target := target_animators[i] - target.mesh_instance_path = source.mesh_instance_path - target.bones = source.bones - target.surfaces = source.surfaces - target.animation_length = source.animation_length - target.playback_speed = source.playback_speed - - -func _find_m2_native_animators_recursive(root: Node) -> Array[Node]: - var result: Array[Node] = [] - _collect_m2_native_animators(root, result) - return result - - -func _collect_m2_native_animators(node: Node, result: Array[Node]) -> void: - if node.get_script() == M2_NATIVE_ANIMATOR_SCRIPT: - result.append(node) - for child in node.get_children(): - _collect_m2_native_animators(child, result) - - -func _choose_default_m2_animation(player: AnimationPlayer, rel_path: String = "") -> String: - var lower := rel_path.to_lower() - var candidates: Array[String] = ["Stand", "Idle", "Run", "Walk"] - if lower.contains("fish"): - candidates = ["Run", "Walk", "Swim", "Stand", "Idle", "Death"] - elif lower.contains("eagle") or lower.contains("bird") or lower.contains("gull"): - candidates = ["Run", "Walk", "Swim", "Stand", "Idle"] - for candidate in candidates: - if player.has_animation(candidate): - return candidate - var names := player.get_animation_list() - for candidate in ["run", "walk", "swim", "stand", "idle", "loop"]: - for name in names: - if String(name).to_lower().contains(candidate): - return String(name) - return String(names[0]) if not names.is_empty() else "" - - func _finish_m2_build_job(key: String) -> void: if not _m2_build_jobs.has(key): return diff --git a/src/tools/verify_m2_animated_scene_finalizer.gd b/src/tools/verify_m2_animated_scene_finalizer.gd index 257cd8b..60ea222 100644 --- a/src/tools/verify_m2_animated_scene_finalizer.gd +++ b/src/tools/verify_m2_animated_scene_finalizer.gd @@ -128,7 +128,7 @@ func _verify_ownership_boundaries(failures: Array[String]) -> void: "_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)", + "_m2_animated_scene_finalizer.animation_players_in_subtree(", ]: _expect_equal(loader_source.count(delegated_call), 1, "single loader delegation: %s" % delegated_call, failures) for retained_loader_rule in [ diff --git a/src/tools/verify_m2_animation_playback_controller.gd b/src/tools/verify_m2_animation_playback_controller.gd new file mode 100644 index 0000000..bfd594b --- /dev/null +++ b/src/tools/verify_m2_animation_playback_controller.gd @@ -0,0 +1,226 @@ +extends SceneTree + +## Synthetic phase, selection, playback, native-copy and boundary regression for +## animated M2 instance playback. + +const CONTROLLER_SCRIPT := preload("res://src/render/m2/m2_animation_playback_controller.gd") +const NATIVE_ANIMATOR_SCRIPT := preload("res://src/scenes/streaming/m2_native_animator.gd") +const CONTROLLER_PATH := "res://src/render/m2/m2_animation_playback_controller.gd" +const LOADER_PATH := "res://src/scenes/streaming/streaming_world_loader.gd" + + +func _initialize() -> void: + var failures: Array[String] = [] + _verify_phase_contract(failures) + _verify_animation_selection_priority(failures) + _verify_player_loop_play_and_seek(failures) + _verify_native_copy_start_and_diagnostics(failures) + _verify_ownership_boundaries(failures) + var elapsed_milliseconds := _verify_bounded_timing(failures) + if not failures.is_empty(): + for failure in failures: + push_error("M2_ANIMATION_PLAYBACK_CONTROLLER: %s" % failure) + quit(1) + return + print( + "M2_ANIMATION_PLAYBACK_CONTROLLER PASS cases=15 iterations=20000 elapsed_ms=%.3f" + % elapsed_milliseconds + ) + quit(0) + + +func _verify_phase_contract(failures: Array[String]) -> void: + var controller: RefCounted = CONTROLLER_SCRIPT.new() + for fixture in [ + {"path": "creature/fish/fish.m2", "index": 0}, + {"path": "world/critter/eagle.m2", "index": 17}, + {"path": "World\\Critter\\Bird.m2", "index": -3}, + ]: + var path := String(fixture["path"]) + var index := int(fixture["index"]) + var expected := float(abs(("%s:%d" % [path, index]).hash()) % 1000) / 1000.0 + var actual := float(controller.call("phase_for_instance", path, index)) + _expect_float_equal(actual, expected, "exact phase formula", failures) + _expect_true(actual >= 0.0 and actual < 1.0, "phase in unit interval", failures) + + +func _verify_animation_selection_priority(failures: Array[String]) -> void: + var controller: RefCounted = CONTROLLER_SCRIPT.new() + var ordinary := _make_player(["Idle", "Stand", "Run"]) + _expect_string_equal(String(controller.call("choose_default_animation", ordinary, "world/tree.m2")), "Stand", "ordinary priority", failures) + var fish := _make_player(["Stand", "Swim", "Death"]) + _expect_string_equal(String(controller.call("choose_default_animation", fish, "creature/fish/a.m2")), "Swim", "fish priority", failures) + var bird := _make_player(["Idle", "Walk", "Swim"]) + _expect_string_equal(String(controller.call("choose_default_animation", bird, "creature/eagle/a.m2")), "Walk", "bird priority", failures) + var substring := _make_player(["Custom_Run_Loop"]) + _expect_string_equal(String(controller.call("choose_default_animation", substring, "world/a.m2")), "Custom_Run_Loop", "substring fallback", failures) + var arbitrary := _make_player(["Zeta"]) + _expect_string_equal(String(controller.call("choose_default_animation", arbitrary, "world/a.m2")), "Zeta", "first-name fallback", failures) + var empty_player := AnimationPlayer.new() + _expect_string_equal(String(controller.call("choose_default_animation", empty_player, "")), "", "empty player rejected", failures) + ordinary.free() + fish.free() + bird.free() + substring.free() + arbitrary.free() + empty_player.free() + + +func _verify_player_loop_play_and_seek(failures: Array[String]) -> void: + var controller: RefCounted = CONTROLLER_SCRIPT.new() + var fixture_root := Node3D.new() + var player := _make_player(["Stand", "Other"]) + var stand := player.get_animation("Stand") + var other := player.get_animation("Other") + stand.length = 2.0 + other.length = 1.0 + stand.loop_mode = Animation.LOOP_NONE + other.loop_mode = Animation.LOOP_NONE + fixture_root.add_child(player) + get_root().add_child(fixture_root) + var phase := float(controller.call("phase_for_instance", "world/test.m2", 5)) + var diagnostics: Array[Dictionary] = controller.call( + "start_instance_playback", + fixture_root, + "world/test.m2", + 5, + NATIVE_ANIMATOR_SCRIPT, + [player] as Array[AnimationPlayer], + false + ) + _expect_true(diagnostics.is_empty(), "no native diagnostics without animators", failures) + _expect_equal(stand.loop_mode, Animation.LOOP_LINEAR, "selected loop linear", failures) + _expect_equal(other.loop_mode, Animation.LOOP_LINEAR, "all loops linear", failures) + _expect_string_equal(player.current_animation, "Stand", "selected animation plays", failures) + _expect_float_equal(player.current_animation_position, stand.length * phase, "selected animation seeks to phase", failures) + fixture_root.free() + + +func _verify_native_copy_start_and_diagnostics(failures: Array[String]) -> void: + var controller: RefCounted = CONTROLLER_SCRIPT.new() + var source_root := Node3D.new() + var source_branch := Node.new() + var source_animator: Node = NATIVE_ANIMATOR_SCRIPT.new() + source_root.add_child(source_branch) + source_branch.add_child(source_animator) + source_animator.mesh_instance_path = NodePath("../SourceMesh") + source_animator.bones = [{"parent": -1}] + source_animator.surfaces = [{"vertices": PackedVector3Array()}] + source_animator.animation_length = 8.0 + source_animator.playback_speed = 1.5 + + var target_root := Node3D.new() + var target_branch := Node.new() + var target_animator: Node = NATIVE_ANIMATOR_SCRIPT.new() + target_root.add_child(target_branch) + target_branch.add_child(target_animator) + controller.call("copy_native_animator_data", source_root, target_root, NATIVE_ANIMATOR_SCRIPT) + _expect_true(target_animator.mesh_instance_path == source_animator.mesh_instance_path, "native path copied", failures) + _expect_same(target_animator.bones, source_animator.bones, "native bones reference copied", failures) + _expect_same(target_animator.surfaces, source_animator.surfaces, "native surfaces reference copied", failures) + _expect_float_equal(target_animator.animation_length, 8.0, "native length copied", failures) + _expect_float_equal(target_animator.playback_speed, 1.5, "native speed copied", failures) + + var native_inventory: Array[Node] = controller.call("native_animators_in_subtree", target_root, NATIVE_ANIMATOR_SCRIPT) + _expect_equal(native_inventory.size(), 1, "exact-script native animator discovered", failures) + var phase := float(controller.call("phase_for_instance", "world/native.m2", 9)) + var diagnostics: Array[Dictionary] = controller.call( + "start_instance_playback", + target_root, + "world/native.m2", + 9, + NATIVE_ANIMATOR_SCRIPT, + [] as Array[AnimationPlayer], + true + ) + _expect_equal(diagnostics.size(), 1, "requested native diagnostic returned", failures) + _expect_float_equal(float(target_animator.get("_time")), 8.0 * phase, "native phase applied", failures) + diagnostics[0]["bones"] = 999 + _expect_equal(int(target_animator.runtime_debug_state()["bones"]), 1, "native diagnostic detached", failures) + source_root.free() + target_root.free() + + +func _verify_ownership_boundaries(failures: Array[String]) -> void: + var controller_source := FileAccess.get_file_as_string(CONTROLLER_PATH) + var loader_source := FileAccess.get_file_as_string(LOADER_PATH) + _expect_true(loader_source.contains("M2_ANIMATION_PLAYBACK_CONTROLLER_SCRIPT.new()"), "loader composes playback controller", failures) + for removed_loader_function in [ + "func _start_m2_animations(", + "func _copy_m2_native_animator_data(", + "func _find_m2_native_animators_recursive(", + "func _choose_default_m2_animation(", + ]: + _expect_false(loader_source.contains(removed_loader_function), "legacy helper removed: %s" % removed_loader_function, failures) + _expect_equal(loader_source.count("_m2_animation_playback_controller.copy_native_animator_data("), 1, "native copy delegates once", failures) + _expect_equal(loader_source.count("_m2_animation_playback_controller.start_instance_playback("), 1, "playback delegates once", failures) + for retained_loader_rule in [ + "prototype.duplicate(Node.DUPLICATE_SIGNALS | Node.DUPLICATE_GROUPS | Node.DUPLICATE_SCRIPTS)", + "batch_root.add_child(instance)", + "_apply_visibility_range_recursive(instance, m2_visibility_range)", + "_apply_shadow_cast_recursive(instance, m2_cast_shadows)", + "_set_editor_owner_recursive(batch_root)", + "M2_NATIVE_ANIMATOR path=%s instance=%d", + ]: + _expect_true(loader_source.contains(retained_loader_rule), "loader retains %s" % retained_loader_rule, failures) + for forbidden_dependency in [ + "ResourceLoader.", + "FileAccess.", + "WorkerThreadPool.", + "MultiMesh", + "add_child(", + "_m2_prototype_cache_state", + "_render_budget_scheduler", + ]: + _expect_false(controller_source.contains(forbidden_dependency), "controller omits %s ownership" % forbidden_dependency, failures) + + +func _verify_bounded_timing(failures: Array[String]) -> float: + var controller: RefCounted = CONTROLLER_SCRIPT.new() + var player := _make_player(["Stand", "Custom_Run"]) + var started_microseconds := Time.get_ticks_usec() + for iteration in range(20000): + controller.call("phase_for_instance", "world/model.m2", iteration) + controller.call("choose_default_animation", player, "world/model.m2") + var elapsed_milliseconds := float(Time.get_ticks_usec() - started_microseconds) / 1000.0 + player.free() + _expect_true(elapsed_milliseconds < 1000.0, "20000 phase/selection pairs under 1 second", failures) + return elapsed_milliseconds + + +func _make_player(animation_names: Array[String]) -> AnimationPlayer: + var player := AnimationPlayer.new() + var library := AnimationLibrary.new() + for animation_name in animation_names: + library.add_animation(animation_name, Animation.new()) + player.add_animation_library("", library) + return player + + +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_float_equal(actual: float, expected: float, label: String, failures: Array[String]) -> void: + if not is_equal_approx(actual, expected): + failures.append("%s expected=%.6f actual=%.6f" % [label, expected, actual]) + + +func _expect_string_equal(actual: String, expected: String, label: String, failures: Array[String]) -> void: + if actual != expected: + failures.append("%s expected=%s actual=%s" % [label, expected, actual]) + + +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_animation_playback_controller.gd.uid b/src/tools/verify_m2_animation_playback_controller.gd.uid new file mode 100644 index 0000000..e187c3b --- /dev/null +++ b/src/tools/verify_m2_animation_playback_controller.gd.uid @@ -0,0 +1 @@ +uid://ceurdk7w0kpbd