refactor(M03): extract M2 animation playback
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c6oln217sml80
|
||||
@@ -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
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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)
|
||||
@@ -0,0 +1 @@
|
||||
uid://ceurdk7w0kpbd
|
||||
Reference in New Issue
Block a user