From f004ac5562b068d96c9797abb3727df3ac3631b7 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 21 Jul 2026 09:37:02 +0200 Subject: [PATCH] refactor(animation): extract live PartArray presenter Move final live part composition, exact-incarnation schedule handoff, MotionDone binding, and presentation diagnostics out of GameWindow while preserving the retail frame order. Reject stale schedule and completion ABA at the new owner boundary. Co-Authored-By: OpenAI Codex --- .../Physics/RemotePhysicsUpdater.cs | 16 +- .../AnimationPresentationDiagnostics.cs | 21 + src/AcDream.App/Rendering/GameWindow.cs | 445 ++---------------- .../ILiveAnimationPresentationContext.cs | 10 + .../Rendering/ILiveStaticPartFrameSource.cs | 21 + .../Rendering/LiveEntityAnimationPresenter.cs | 403 ++++++++++++++++ .../Rendering/LiveEntityAnimationScheduler.cs | 43 +- .../Rendering/LiveEntityAnimationState.cs | 63 +++ .../RetailStaticAnimatingObjectScheduler.cs | 42 +- .../Physics/RemotePhysicsUpdaterTests.cs | 12 +- .../Rendering/LiveAppearanceAnimationTests.cs | 8 +- .../LiveEntityAnimationPresenterTests.cs | 295 ++++++++++++ .../LiveEntityAnimationSchedulerTests.cs | 12 +- ...tailStaticAnimatingObjectSchedulerTests.cs | 10 +- 14 files changed, 963 insertions(+), 438 deletions(-) create mode 100644 src/AcDream.App/Rendering/AnimationPresentationDiagnostics.cs create mode 100644 src/AcDream.App/Rendering/ILiveAnimationPresentationContext.cs create mode 100644 src/AcDream.App/Rendering/ILiveStaticPartFrameSource.cs create mode 100644 src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs create mode 100644 src/AcDream.App/Rendering/LiveEntityAnimationState.cs create mode 100644 tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs diff --git a/src/AcDream.App/Physics/RemotePhysicsUpdater.cs b/src/AcDream.App/Physics/RemotePhysicsUpdater.cs index 4a4078e0..4c6b039b 100644 --- a/src/AcDream.App/Physics/RemotePhysicsUpdater.cs +++ b/src/AcDream.App/Physics/RemotePhysicsUpdater.cs @@ -1,5 +1,5 @@ using RemoteMotion = AcDream.App.Rendering.GameWindow.RemoteMotion; -using AnimatedEntity = AcDream.App.Rendering.GameWindow.AnimatedEntity; +using LiveEntityAnimationState = AcDream.App.Rendering.LiveEntityAnimationState; namespace AcDream.App.Physics; @@ -40,13 +40,13 @@ internal sealed class RemotePhysicsUpdater private readonly AcDream.Core.Physics.PhysicsEngine _physicsEngine; private readonly System.Func _getSetupCylinder; - private readonly System.Action _applyServerControlledVelocityCycle; + private readonly System.Action _applyServerControlledVelocityCycle; private readonly List _spatialRemoteSnapshot = new(); internal RemotePhysicsUpdater( AcDream.Core.Physics.PhysicsEngine physicsEngine, System.Func getSetupCylinder, - System.Action applyServerControlledVelocityCycle) + System.Action applyServerControlledVelocityCycle) { _physicsEngine = physicsEngine; _getSetupCylinder = getSetupCylinder; @@ -58,7 +58,7 @@ internal sealed class RemotePhysicsUpdater /// /// Advances the retained PositionManager path for every hidden remote, - /// including objects without an AnimatedEntity (missiles commonly + /// including objects without an LiveEntityAnimationState (missiles commonly /// have no PartArray). Retail gates this path on the live CPhysicsObj, not /// on whether a render-animation owner exists. /// @@ -93,7 +93,7 @@ internal sealed class RemotePhysicsUpdater || record.WorldEntity is not { } entity) continue; - AnimatedEntity? animation = record.AnimationRuntime as AnimatedEntity; + LiveEntityAnimationState? animation = record.AnimationRuntime as LiveEntityAnimationState; AcDream.Core.Physics.RetailObjectActivityResult activity = AcDream.Core.Physics.RetailObjectActivityGate.Evaluate( record.ObjectClock, @@ -165,7 +165,7 @@ internal sealed class RemotePhysicsUpdater /// public bool Tick( RemoteMotion rm, - AnimatedEntity ae, + LiveEntityAnimationState ae, float dt, AcDream.Core.Physics.Motion.MotionDeltaFrame rootMotionLocalFrame, int liveCenterX, @@ -188,14 +188,14 @@ internal sealed class RemotePhysicsUpdater /// Canonical ordinary-object tick. Render animation is optional: retail /// walks CPhysics::object_maint, so a live object with a /// MovementManager or PositionManager must continue even when it has no - /// AnimatedEntity presentation component. + /// LiveEntityAnimationState presentation component. /// public bool Tick( RemoteMotion rm, AcDream.Core.World.WorldEntity entity, float objectScale, AcDream.Core.Physics.AnimationSequencer? sequencer, - AnimatedEntity? animationForVelocityCycle, + LiveEntityAnimationState? animationForVelocityCycle, float dt, AcDream.Core.Physics.Motion.MotionDeltaFrame rootMotionLocalFrame, int liveCenterX, diff --git a/src/AcDream.App/Rendering/AnimationPresentationDiagnostics.cs b/src/AcDream.App/Rendering/AnimationPresentationDiagnostics.cs new file mode 100644 index 00000000..bf947aa4 --- /dev/null +++ b/src/AcDream.App/Rendering/AnimationPresentationDiagnostics.cs @@ -0,0 +1,21 @@ +namespace AcDream.App.Rendering; + +/// Startup-resolved diagnostic gates for final PartArray presentation. +internal sealed record AnimationPresentationDiagnostics( + bool RemoteVelocityEnabled, + bool DumpMotionEnabled, + Func Now) +{ + public static AnimationPresentationDiagnostics FromEnvironment() => new( + RemoteVelocityEnabled: + Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1", + DumpMotionEnabled: + Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1", + Now: static () => + (DateTime.UtcNow - DateTime.UnixEpoch).TotalSeconds); + + public static AnimationPresentationDiagnostics Disabled { get; } = new( + false, + false, + static () => 0d); +} diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index cef7eb2e..4efb9c41 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -9,7 +9,7 @@ using Silk.NET.Windowing; namespace AcDream.App.Rendering; -public sealed class GameWindow : IDisposable +public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext { private static double ClientTimerNow() => System.Diagnostics.Stopwatch.GetTimestamp() @@ -182,6 +182,7 @@ public sealed class GameWindow : IDisposable private readonly AcDream.App.World.RetailInboundEventDispatcher _inboundEntityEvents = new(); private readonly LiveEntityAnimationScheduler _liveAnimationScheduler; + private readonly LiveEntityAnimationPresenter _animationPresenter; private readonly AcDream.App.Input.LocalPlayerProjectionController _localPlayerProjection; private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound; private readonly AcDream.App.Input.RetailLocalPlayerFrameController _localPlayerFrame; @@ -278,7 +279,7 @@ public sealed class GameWindow : IDisposable /// Static decorations and entities with no motion table never /// appear in this map. /// - private readonly LiveEntityAnimationRuntimeView _animatedEntities; + private readonly LiveEntityAnimationRuntimeView _animatedEntities; // MP-Alloc (2026-07-05): reusable per-frame snapshot of _animatedEntities.Keys. // WbDrawDispatcher.WalkEntitiesInto treats null and an empty set identically @@ -298,73 +299,9 @@ public sealed class GameWindow : IDisposable /// private readonly AcDream.App.Rendering.Wb.EntityClassificationCache _classificationCache = new(); - // #184 Slice 2a: internal (was private) so the extracted - // RemotePhysicsUpdater in AcDream.App.Physics can take it by type. Matches - // RemoteMotion's existing internal visibility. - internal sealed class AnimatedEntity : AcDream.App.World.ILiveEntityAnimationRuntime - { - public required AcDream.Core.World.WorldEntity Entity; - AcDream.Core.World.WorldEntity AcDream.App.World.ILiveEntityAnimationRuntime.Entity => Entity; - uint AcDream.App.World.ILiveEntityAnimationRuntime.CurrentMotion => - Sequencer?.CurrentMotion ?? 0u; - public required DatReaderWriter.DBObjs.Setup Setup; - public required DatReaderWriter.DBObjs.Animation Animation; - public required int LowFrame; - public required int HighFrame; - public required float Framerate; // frames per second - public required float Scale; // server ObjScale baked into part transforms each tick - /// - /// Per-part identity carried over from the hydration pass: the - /// (post-AnimPartChanges) GfxObjId and the (post-resolution) - /// surface override map. The transform is recomputed every tick - /// from the current animation frame; only these two fields are - /// reused unchanged. - /// - public required IReadOnlyList PartTemplate; - public required IReadOnlyList PartAvailability; - public float CurrFrame; // monotonically increasing within [LowFrame, HighFrame] - public AcDream.Core.Physics.AnimationSequencer? Sequencer; - - // The local player's CPartArray is advanced from - // PlayerMovementController before its 30 Hz physics integration so - // the exact authored root delta enters the collision sweep. Retain - // that same advance's part pose for TickAnimations; advancing the - // sequencer a second time would double both animation time and hooks. - public IReadOnlyList? PreparedSequenceFrames; - public bool SequenceAdvancedBeforeAnimationPass; - public readonly DatReaderWriter.Types.Frame RootMotionScratch = new(); - public readonly AcDream.Core.Physics.Motion.MotionDeltaFrame - RootMotionDeltaScratch = new(); - - /// - /// MP-Alloc (2026-07-05): reusable per-entity MeshRefs buffer. Every - /// animated entity (including idle NPCs on a breathe cycle) used to - /// get a fresh `List<MeshRef>(partCount)` reassigned to - /// every tick — - /// the old list became garbage every frame. This buffer is cleared - /// and refilled in place instead; Entity.MeshRefs is set to - /// this SAME list reference once it's populated (assigning the - /// unchanged reference back is a no-op cost-wise). Safe because - /// TickAnimations runs single-threaded to completion before any - /// draw-side consumer reads MeshRefs, and the only cross-frame - /// consumer (RefreshPaperdollDoll) takes an explicit - /// `new List<MeshRef>(pe.MeshRefs)` VALUE copy — MeshRef is a - /// readonly record struct, so that copy is unaffected by later - /// in-place mutation of this list. - /// - public readonly List MeshRefsScratch = new(); - /// Indexed final part poses, including debug-hidden parts. - public readonly List EffectPartPosesScratch = new(); - } - - internal readonly record struct AnimatedPartTemplate( - uint GfxObjId, - IReadOnlyDictionary? SurfaceOverrides, - bool IsDrawable); - private sealed record AppearanceUpdateState( AcDream.Core.World.WorldEntity Entity, - AnimatedEntity? Animation); + LiveEntityAnimationState? Animation); private AcDream.Core.Physics.IAnimationLoader? _animLoader; @@ -1203,7 +1140,7 @@ public sealed class GameWindow : IDisposable _worldEvents = worldEvents; _selection = selection ?? throw new System.ArgumentNullException(nameof(selection)); _uiRegistry = uiRegistry; - _animatedEntities = new LiveEntityAnimationRuntimeView(() => _liveEntities); + _animatedEntities = new LiveEntityAnimationRuntimeView(() => _liveEntities); _remoteDeadReckon = new LiveEntityRemoteMotionRuntimeView(() => _liveEntities); SpellBook = new AcDream.Core.Spells.Spellbook(); LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook); @@ -1229,6 +1166,13 @@ public sealed class GameWindow : IDisposable () => _projectileController, entity => _effectPoses.UpdateRoot(entity), CaptureAnimationHooks); + _animationPresenter = new LiveEntityAnimationPresenter( + () => _liveEntities, + () => _staticAnimationScheduler, + _effectPoses, + this, + AnimationPresentationDiagnostics.FromEnvironment(), + options.HidePartIndex); _localPlayerProjection = new AcDream.App.Input.LocalPlayerProjectionController( resolveEntity: () => _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var entity) @@ -4140,7 +4084,7 @@ public sealed class GameWindow : IDisposable var meshRefs = new List(); var indexedPartTransforms = new System.Numerics.Matrix4x4[parts.Count]; var indexedPartAvailable = new bool[parts.Count]; - var animatedPartTemplate = new AnimatedPartTemplate[parts.Count]; + var animatedPartTemplate = new LiveAnimationPartTemplate[parts.Count]; var liveBounds = new AcDream.Core.Meshing.LocalBoundsAccumulator(); int dumpClothingTotalTris = 0; for (int partIdx = 0; partIdx < parts.Count; partIdx++) @@ -4160,7 +4104,7 @@ public sealed class GameWindow : IDisposable var gfx = _dats.Get(mr.GfxObjId); bool isDrawable = gfx is not null; indexedPartAvailable[partIdx] = isDrawable; - animatedPartTemplate[partIdx] = new AnimatedPartTemplate( + animatedPartTemplate[partIdx] = new LiveAnimationPartTemplate( mr.GfxObjId, surfaceOverrides, isDrawable); @@ -4419,7 +4363,7 @@ public sealed class GameWindow : IDisposable // Snapshot per-part identity from the hydrated meshRefs so the // tick can rebuild MeshRefs without redoing AnimPartChanges or // texture-override resolution every frame. - AnimatedPartTemplate[] template = animatedPartTemplate; + LiveAnimationPartTemplate[] template = animatedPartTemplate; // Create an AnimationSequencer if we can load the MotionTable. AcDream.Core.Physics.AnimationSequencer? sequencer = null; @@ -4437,7 +4381,7 @@ public sealed class GameWindow : IDisposable } } - _animatedEntities[entity.Id] = new AnimatedEntity + _animatedEntities[entity.Id] = new LiveEntityAnimationState { Entity = entity, Setup = setup, @@ -4492,9 +4436,9 @@ public sealed class GameWindow : IDisposable var sequencer = SpawnMotionInitializer.Create( setup, mtable, _animLoader, spawn.MotionState); - AnimatedPartTemplate[] template = animatedPartTemplate; + LiveAnimationPartTemplate[] template = animatedPartTemplate; - _animatedEntities[entity.Id] = new AnimatedEntity + _animatedEntities[entity.Id] = new LiveEntityAnimationState { Entity = entity, Setup = setup, @@ -4541,7 +4485,7 @@ public sealed class GameWindow : IDisposable setupDefaultLoader); if (sequencer.HasCurrentNode) { - _animatedEntities[entity.Id] = new AnimatedEntity + _animatedEntities[entity.Id] = new LiveEntityAnimationState { Entity = entity, Setup = setup, @@ -4565,7 +4509,8 @@ public sealed class GameWindow : IDisposable // unmatched node that permanently starves later MoveTo/TurnTo work. if (_animatedEntities.TryGetValue(entity.Id, out var registeredAnimation)) { - EnsureMotionDoneBinding(spawn.Guid, registeredAnimation); + if (_liveEntities.TryGetRecord(spawn.Guid, out LiveEntityRecord bindingRecord)) + _animationPresenter.PrepareAnimation(bindingRecord, registeredAnimation); if (isPhysicsStatic && registeredAnimation.Sequencer is { } staticSequencer @@ -4736,11 +4681,11 @@ public sealed class GameWindow : IDisposable } internal static void RebindAnimatedEntityForAppearance( - AnimatedEntity animation, + LiveEntityAnimationState animation, AcDream.Core.World.WorldEntity entity, DatReaderWriter.DBObjs.Setup setup, float scale, - IReadOnlyList partTemplate, + IReadOnlyList partTemplate, IReadOnlyList partAvailability) { animation.Entity = entity; @@ -4748,6 +4693,7 @@ public sealed class GameWindow : IDisposable animation.Scale = scale; animation.PartTemplate = partTemplate; animation.PartAvailability = partAvailability; + animation.InvalidatePresentationPoses(); } /// @@ -5208,7 +5154,7 @@ public sealed class GameWindow : IDisposable /// the VectorUpdate path regardless of arrival order. /// private AcDream.Core.Physics.Motion.MotionTableDispatchSink? EnsureRemoteMotionBindings( - RemoteMotion rm, AnimatedEntity? ae, uint serverGuid) + RemoteMotion rm, LiveEntityAnimationState? ae, uint serverGuid) { AcDream.Core.Physics.AnimationSequencer? sequencer = ae?.Sequencer; if (sequencer is not null && rm.Sink is null) @@ -5812,7 +5758,7 @@ public sealed class GameWindow : IDisposable /// /// Phase 6.6: the server says an entity's motion has changed. Look up - /// the AnimatedEntity for that guid, re-resolve the idle cycle with the + /// the LiveEntityAnimationState for that guid, re-resolve the idle cycle with the /// new (stance, forward-command) override, and if the cycle is still /// animated, swap in the new animation/frame range. Entities not in /// the animated map (static props, entities rejected at spawn time) @@ -6220,7 +6166,7 @@ public sealed class GameWindow : IDisposable DispatchRemoteInboundMotion( AcDream.Core.Net.WorldSession.EntityMotionUpdate update, AcDream.Core.World.WorldEntity entity, - AnimatedEntity? ae, + LiveEntityAnimationState? ae, LiveEntityRecord acceptedRecord, ulong acceptedMovementAuthorityVersion, ulong acceptedVelocityAuthorityVersion) @@ -6557,7 +6503,7 @@ public sealed class GameWindow : IDisposable private void ApplyServerControlledVelocityCycle( uint serverGuid, - AnimatedEntity ae, + LiveEntityAnimationState ae, RemoteMotion rm, System.Numerics.Vector3 velocity) { @@ -7355,7 +7301,7 @@ public sealed class GameWindow : IDisposable // Enqueue only if the canonical ordinary-object workset // will consume the queue. Animation is optional in retail; // LiveEntityAnimationScheduler advances a spatial - // RemoteMotion even when no AnimatedEntity exists. + // RemoteMotion even when no LiveEntityAnimationState exists. bool willBeDrTickedNpc = WillAdvanceRemoteMotion( update.Guid, rmState); @@ -11505,12 +11451,12 @@ public sealed class GameWindow : IDisposable _localPlayerFrame.HiddenPartPoseDirty, _liveCenterX, _liveCenterY, - EnsureMotionDoneBinding); + _animationPresenter.PrepareAnimation); // CPhysics::UseTime (0x00509950) processes the ordinary object table // first, then its distinct static_animating_objects workset. _staticAnimationScheduler?.Tick(dt); if (_animatedEntities.Count > 0) - TickAnimations(animationSchedules); + _animationPresenter.Present(animationSchedules); _equippedChildRenderer?.Tick(); // CPhysicsObj::animate_static_object (0x00513DF0) reaches @@ -11569,7 +11515,7 @@ public sealed class GameWindow : IDisposable /// * Framerate, wrapping around the cycle's /// [LowFrame..HighFrame] interval, then rebuild that entity's /// MeshRefs from the new frame's per-part transforms. Static - /// entities (no AnimatedEntity record) are untouched. The static + /// entities (no LiveEntityAnimationState record) are untouched. The static /// renderer reads the new MeshRefs on the next Draw call. /// private void AdvanceLocalPlayerAnimationRoot( @@ -11588,7 +11534,8 @@ public sealed class GameWindow : IDisposable return; } - EnsureMotionDoneBinding(_playerServerGuid, ae); + if (_liveEntities?.TryGetRecord(_playerServerGuid, out LiveEntityRecord bindingRecord) == true) + _animationPresenter.PrepareAnimation(bindingRecord, ae); DatReaderWriter.Types.Frame rootFrame = ae.RootMotionScratch; rootFrame.Origin = System.Numerics.Vector3.Zero; @@ -11615,320 +11562,22 @@ public sealed class GameWindow : IDisposable AcDream.Core.Physics.AnimationSequencer sequencer) => _animationHookFrames?.Capture(ownerLocalId, sequencer); - private void TickAnimations( - IReadOnlyDictionary animationSchedules) + uint ILiveAnimationPresentationContext.LocalPlayerGuid => _playerServerGuid; + + AcDream.Core.Physics.MotionInterpreter? + ILiveAnimationPresentationContext.ResolveMotionInterpreter( + LiveEntityRecord record) { - // Retail has NO stop-detection heuristic — it relies on the - // server sending explicit UpdateMotion(Ready). The server-side - // stop signal flows the same way as any other motion change: - // alt releases W → MoveToState(Ready) → ACE broadcasts - // UpdateMotion(Ready) + UpdatePosition with zero velocity. - // On our side, only UpdateMotion changes interpreted state; - // UpdatePosition updates physics pose/velocity and never selects an - // animation. Anything else is server buggery (packet loss, ACE bug) - // — don't guess client-side. - foreach (var kv in _animatedEntities) + if (_liveEntities?.TryGetRecord(record.ServerGuid, out LiveEntityRecord current) != true + || !ReferenceEquals(current, record)) { - var ae = kv.Value; - - // The server guid is carried on the entity itself - // (WorldEntity.ServerGuid, set == the _entitiesByServerGuid key at - // construction — see OnLiveEntitySpawnedLocked: `ServerGuid = spawn.Guid` - // and `_entitiesByServerGuid[spawn.Guid] = entity`). Read it directly — - // O(1) — instead of the former reverse ReferenceEquals scan over ALL of - // _entitiesByServerGuid, which made this loop O(animated × all-entities). - // That super-linear cost is the FPS-drops-per-teleport sink: world - // entities accumulate without bound (pruned only on DeleteObject, and ACE - // does not re-send / clear KnownObjects across a teleport), so N climbs - // every hop. ServerGuid defaults to 0 for entities not server-tracked, - // exactly matching the old scan's miss case. - uint serverGuid = ae.Entity.ServerGuid; - - // Defensive for a runtime retained across visual rehydration. - // This is deliberately before every early-out and before - // Sequencer.Advance: no completion may precede its consumer. - EnsureMotionDoneBinding(serverGuid, ae); - - if (_liveEntities?.TryGetRecord( - serverGuid, - out LiveEntityRecord liveRecord) != true) - { - continue; - } - animationSchedules.TryGetValue(kv.Key, out LiveEntityAnimationSchedule schedule); - IReadOnlyList? seqFrames = - schedule.SequenceFrames; - bool composeParts = schedule.ComposeParts; - if (_staticAnimationScheduler?.TryTakeLivePartFrames( - kv.Key, - out var staticPartFrames) == true) - { - seqFrames = staticPartFrames; - composeParts = true; - } - - // ── Get per-part (origin, orientation) from either sequencer or legacy ── - if (ae.Sequencer is not null) - { - // Per-tick sequencer-state diag: prove whether the sequencer - // for the observed retail char actually holds the latest - // motion (= SetCycle landed) OR is stuck on an old motion - // (= something elsewhere is reverting). Throttled to once - // per second per remote. - if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1" - && serverGuid != 0 - && serverGuid != _playerServerGuid) - { - double nowSec = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; - if (_remoteDeadReckon.TryGetValue(serverGuid, out var rmDiag) - && nowSec - rmDiag.LastSeqStateLogTime > 1.0) - { - // D1 (2026-05-03): SEQSTATE has its own throttle clock - // (LastSeqStateLogTime) so it isn't silently swallowed by - // OMEGA_DIAG resetting LastOmegaDiagLogTime when the - // observed remote happens to be turning. - System.Console.WriteLine( - $"[SEQSTATE] guid={serverGuid:X8} CurrentMotion=0x{ae.Sequencer.CurrentMotion:X8} " - + $"CurrentSpeedMod={ae.Sequencer.CurrentSpeedMod:F3}"); - - // #39 fix-3 evidence (2026-05-06): CURRNODE proves - // whether _currNode is actually on the cycle (anim - // ref hash matches FirstCyclic) or stuck somewhere - // else. SCFULL captures _currNode==_firstCyclic only - // at SetCycle return; this captures it per render - // tick so we can see if something resets it later. - var d = ae.Sequencer.CurrentNodeDiag; - int firstHash = ae.Sequencer.FirstCyclicAnimRefHash; - System.Console.WriteLine( - $"[CURRNODE] guid={serverGuid:X8} " - + $"animRef=0x{d.AnimRefHash:X8} firstCyclicAnimRef=0x{firstHash:X8} " - + $"isOnCyclic={d.AnimRefHash == firstHash && firstHash != 0} " - + $"isLooping={d.IsLooping} fr={d.Framerate:F2} " - + $"frame=[{d.StartFrame}..{d.EndFrame}] " - + $"pos={d.FramePosition:F2} qCount={d.QueueCount}"); - - rmDiag.LastSeqStateLogTime = nowSec; - } - } - } - else - { - // Legacy path (entities without a MotionTable / sequencer). - int span = ae.HighFrame - ae.LowFrame; - bool hiddenLegacy = (liveRecord.FinalPhysicsState - & AcDream.Core.Physics.PhysicsStateFlags.Hidden) != 0; - if (span <= 0 && !hiddenLegacy) continue; - if (span > 0 && schedule.LegacyAdvanceSeconds > 0f) - { - ae.CurrFrame += schedule.LegacyAdvanceSeconds * ae.Framerate; - if (ae.CurrFrame > ae.HighFrame) - { - float over = ae.CurrFrame - ae.LowFrame; - ae.CurrFrame = ae.LowFrame + (over % (span + 1)); - } - else if (ae.CurrFrame < ae.LowFrame) - ae.CurrFrame = ae.LowFrame; - } - } - - int partCount = ae.PartTemplate.Count; - - // D5 (Commit A 2026-05-03): PARTSDIAG — proves whether - // PartTemplate.Count diverges from seqFrames.Count (silent - // identity-quat fallback freezes parts → H5) and whether the - // per-part frames returned by Advance actually change between - // Walk and Run cycles. The seqFrames hash is a sum-of-components - // proxy: cheap, unitless, monotonically distinct between cycles - // for any non-degenerate animation. If [PARTSDIAG] shows the - // hash unchanged across a Walk→Run transition while [SEQSTATE] - // shows CurrentMotion flipping, the sequencer is serving stale - // frames despite the cycle being correct. - if (System.Environment.GetEnvironmentVariable("ACDREAM_REMOTE_VEL_DIAG") == "1" - && serverGuid != 0 - && serverGuid != _playerServerGuid - && _remoteDeadReckon.TryGetValue(serverGuid, out var rmParts)) - { - double nowSecParts = (System.DateTime.UtcNow - System.DateTime.UnixEpoch).TotalSeconds; - if (nowSecParts - rmParts.LastPartsDiagLogTime > 1.0) - { - int seqCount = seqFrames?.Count ?? -1; - int setupParts = ae.Setup.Parts.Count; - // #62: B.4c introduced `Animation = null!` for sequencer-driven - // door entities (parallel branch sites at line 2867 + 7892). - // Doors don't currently enter _remoteDeadReckon, so this - // path isn't reachable for them today — but the read was - // unguarded and would NRE the moment something flips. - // Local-var + `is not null` keeps the guard scoped: the - // legacy slerp branch below treats ae.Animation as non- - // null (per its non-nullable declared type) unchanged. - var animMaybeNull = ae.Animation; - int animFrame0Parts = - animMaybeNull is not null && animMaybeNull.PartFrames.Count > 0 - ? animMaybeNull.PartFrames[0].Frames.Count - : -1; - double seqHash = 0.0; - if (seqFrames is not null) - { - for (int hi = 0; hi < seqFrames.Count; hi++) - { - var f = seqFrames[hi]; - seqHash += f.Origin.X + f.Origin.Y + f.Origin.Z - + f.Orientation.X + f.Orientation.Y - + f.Orientation.Z + f.Orientation.W; - } - } - System.Console.WriteLine( - $"[PARTSDIAG] guid={serverGuid:X8} " - + $"pt.Count={partCount} seqFrames.Count={seqCount} " - + $"setup.Parts.Count={setupParts} " - + $"anim.PartFrames[0].Frames.Count={animFrame0Parts} " - + $"seqHash={seqHash:F4}"); - rmParts.LastPartsDiagLogTime = nowSecParts; - } - } - - if (!composeParts) - continue; - - // MP-Alloc (2026-07-05): reuse the entity's cached MeshRefs list - // instead of allocating a fresh List(partCount) every - // tick (see AnimatedEntity.MeshRefsScratch for the safety - // argument — single-threaded tick-before-draw ordering, no - // consumer caches a stale reference or diffs list identity). - var newMeshRefs = ae.MeshRefsScratch; - newMeshRefs.Clear(); - var effectPartPoses = ae.EffectPartPosesScratch; - effectPartPoses.Clear(); - var scaleMat = ae.Scale == 1.0f - ? System.Numerics.Matrix4x4.Identity - : System.Numerics.Matrix4x4.CreateScale(ae.Scale); - - for (int i = 0; i < partCount; i++) - { - System.Numerics.Vector3 origin; - System.Numerics.Quaternion orientation; - - if (seqFrames is not null) - { - // Sequencer path. - if (i < seqFrames.Count) - { - origin = seqFrames[i].Origin; - orientation = seqFrames[i].Orientation; - } - else - { - origin = System.Numerics.Vector3.Zero; - orientation = System.Numerics.Quaternion.Identity; - } - } - else - { - // Legacy slerp path. - int frameIdx = (int)Math.Floor(ae.CurrFrame); - if (frameIdx < ae.LowFrame || frameIdx > ae.HighFrame - || frameIdx >= ae.Animation.PartFrames.Count) - frameIdx = ae.LowFrame; - int nextIdx = frameIdx + 1; - if (nextIdx > ae.HighFrame || nextIdx >= ae.Animation.PartFrames.Count) - nextIdx = ae.LowFrame; - float t = ae.CurrFrame - frameIdx; - if (t < 0f) t = 0f; else if (t > 1f) t = 1f; - var partFrames = ae.Animation.PartFrames[frameIdx].Frames; - var partFramesNext = ae.Animation.PartFrames[nextIdx].Frames; - if (i < partFrames.Count) - { - var f0 = partFrames[i]; - var f1 = i < partFramesNext.Count ? partFramesNext[i] : f0; - origin = System.Numerics.Vector3.Lerp(f0.Origin, f1.Origin, t); - orientation = System.Numerics.Quaternion.Slerp(f0.Orientation, f1.Orientation, t); - } - else - { - origin = System.Numerics.Vector3.Zero; - orientation = System.Numerics.Quaternion.Identity; - } - } - - // Per-part default scale from the Setup, matching SetupMesh.Flatten's - // composition order: scale → rotate → translate. - var defaultScale = i < ae.Setup.DefaultScale.Count - ? ae.Setup.DefaultScale[i] - : System.Numerics.Vector3.One; - - var visualPartTransform = - System.Numerics.Matrix4x4.CreateScale(defaultScale) * - System.Numerics.Matrix4x4.CreateFromQuaternion(orientation) * - System.Numerics.Matrix4x4.CreateTranslation(origin); - - // Bake the entity's ObjScale on top, matching the hydration - // order (PartTransform * scaleMat) — see comment in OnLiveEntitySpawned. - if (ae.Scale != 1.0f) - visualPartTransform *= scaleMat; - - // Retail CPartArray::UpdateParts (0x005190F0) scales only the - // frame origin. Setup DefaultScale is visual gfxobj_scale, - // not part-frame state (SetScaleInternal 0x00518A00). - var rigidPartTransform = - System.Numerics.Matrix4x4.CreateFromQuaternion(orientation) * - System.Numerics.Matrix4x4.CreateTranslation(origin * ae.Scale); - effectPartPoses.Add(rigidPartTransform); - - var template = ae.PartTemplate[i]; - if (!template.IsDrawable - || (_options.HidePartIndex >= 0 && i == _options.HidePartIndex && partCount >= 10)) - { - continue; - } - newMeshRefs.Add(new AcDream.Core.World.MeshRef(template.GfxObjId, visualPartTransform) - { - SurfaceOverrides = template.SurfaceOverrides, - }); - } - - // Re-assigning the SAME list reference every tick is cheap (a - // property store) and keeps this line's shape identical to the - // pre-MP-Alloc code for anyone grepping the history. - ae.Entity.MeshRefs = newMeshRefs; - ae.Entity.SetIndexedPartPoses(effectPartPoses, ae.PartAvailability); - _effectPoses.Publish(ae.Entity, effectPartPoses, ae.PartAvailability); + return null; } - } - - /// - /// R3-W2: binds the animation queue's completion seam to the entity's - /// real CMotionInterp::MotionDone consumer - /// (CPhysicsObj::MotionDone 0x0050FDB0). Retail owns both queues - /// under one CPhysicsObj/CPartArray lifetime; acdream's split owners must - /// establish the relationship before the first animation can finish. - /// - private void EnsureMotionDoneBinding(uint serverGuid, AnimatedEntity ae) - { - if (ae.Sequencer is not { MotionDoneTarget: null } sequencer) - return; - - uint ownerGuid = serverGuid; - // Resolve at callback time: a remote MotionInterpreter can be created - // after its animation runtime, and the player controller is created - // during EnterPlayerModeNow. Eagerly capturing either would preserve - // a legitimate null forever. - sequencer.MotionDoneTarget = (motion, success) => - { - AcDream.Core.Physics.MotionInterpreter? interpreter = null; - if (ownerGuid != 0 && ownerGuid == _playerServerGuid) - interpreter = _playerController?.Motion; - else if (ownerGuid != 0 - && _remoteDeadReckon.TryGetValue(ownerGuid, out var remote)) - interpreter = remote.Motion; - - interpreter?.MotionDone(motion, success); - if (System.Environment.GetEnvironmentVariable("ACDREAM_DUMP_MOTION") == "1") - { - System.Console.WriteLine( - $"[MOTIONDONE] guid={ownerGuid:X8} motion=0x{motion:X8} " - + $"success={success} pending={(interpreter?.MotionsPending() ?? false)}"); - } - }; + if (record.ServerGuid == _playerServerGuid) + return _playerController?.Motion; + return record.RemoteMotionRuntime is RemoteMotion remote + ? remote.Motion + : null; } // IsEntityCurrentlyMoving REMOVED (2026-07-09): it powered a cache-bypass diff --git a/src/AcDream.App/Rendering/ILiveAnimationPresentationContext.cs b/src/AcDream.App/Rendering/ILiveAnimationPresentationContext.cs new file mode 100644 index 00000000..c12236b3 --- /dev/null +++ b/src/AcDream.App/Rendering/ILiveAnimationPresentationContext.cs @@ -0,0 +1,10 @@ +using AcDream.App.World; +using AcDream.Core.Physics; + +namespace AcDream.App.Rendering; + +internal interface ILiveAnimationPresentationContext +{ + uint LocalPlayerGuid { get; } + MotionInterpreter? ResolveMotionInterpreter(LiveEntityRecord record); +} diff --git a/src/AcDream.App/Rendering/ILiveStaticPartFrameSource.cs b/src/AcDream.App/Rendering/ILiveStaticPartFrameSource.cs new file mode 100644 index 00000000..58f20787 --- /dev/null +++ b/src/AcDream.App/Rendering/ILiveStaticPartFrameSource.cs @@ -0,0 +1,21 @@ +using AcDream.App.World; +using AcDream.Core.Physics; +using AcDream.Core.World; + +namespace AcDream.App.Rendering; + +/// +/// Late presentation handoff from retail's separate +/// static_animating_objects workset. Every take is bound to the exact +/// live incarnation that prepared the frames. +/// +internal interface ILiveStaticPartFrameSource +{ + bool TryTakeLivePartFrames( + LiveEntityRecord record, + WorldEntity entity, + LiveEntityAnimationState animation, + ulong objectClockEpoch, + ulong projectionMutationVersion, + out IReadOnlyList frames); +} diff --git a/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs b/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs new file mode 100644 index 00000000..e5673aea --- /dev/null +++ b/src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs @@ -0,0 +1,403 @@ +using System.Numerics; +using AcDream.App.Rendering.Vfx; +using AcDream.App.World; +using AcDream.Core.Physics; +using AcDream.Core.World; + +namespace AcDream.App.Rendering; + +/// +/// Publishes the final visual and rigid part channels after retail object and +/// static-animation scheduling. It advances no time and drains no hooks. +/// +internal sealed class LiveEntityAnimationPresenter +{ + private readonly Func _liveEntities; + private readonly Func _staticFrames; + private readonly EntityEffectPoseRegistry _effectPoses; + private readonly ILiveAnimationPresentationContext _context; + private readonly AnimationPresentationDiagnostics _diagnostics; + private readonly int _hidePartIndex; + private readonly List _snapshot = new(); + + public LiveEntityAnimationPresenter( + Func liveEntities, + Func staticFrames, + EntityEffectPoseRegistry effectPoses, + ILiveAnimationPresentationContext context, + AnimationPresentationDiagnostics diagnostics, + int hidePartIndex) + { + _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); + _staticFrames = staticFrames ?? throw new ArgumentNullException(nameof(staticFrames)); + _effectPoses = effectPoses ?? throw new ArgumentNullException(nameof(effectPoses)); + _context = context ?? throw new ArgumentNullException(nameof(context)); + _diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics)); + _hidePartIndex = hidePartIndex; + } + + public void PrepareAnimation( + LiveEntityRecord record, + LiveEntityAnimationState animation) + { + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(animation); + LiveEntityRuntime? runtime = _liveEntities(); + if (runtime is null + || !runtime.IsCurrentSpatialAnimation(record, animation) + || !ReferenceEquals(record.WorldEntity, animation.Entity) + || animation.Sequencer is not { MotionDoneTarget: null } sequencer) + { + return; + } + + WorldEntity capturedEntity = animation.Entity; + sequencer.MotionDoneTarget = (motion, success) => + { + LiveEntityRuntime? current = _liveEntities(); + if (current is null + || !current.IsCurrentSpatialAnimation(record, animation) + || !ReferenceEquals(record.WorldEntity, capturedEntity) + || !ReferenceEquals(animation.Entity, capturedEntity)) + { + return; + } + + MotionInterpreter? interpreter = _context.ResolveMotionInterpreter(record); + interpreter?.MotionDone(motion, success); + if (_diagnostics.DumpMotionEnabled) + { + Console.WriteLine( + $"[MOTIONDONE] guid={record.ServerGuid:X8} motion=0x{motion:X8} " + + $"success={success} pending={(interpreter?.MotionsPending() ?? false)}"); + } + }; + } + + public void Present( + IReadOnlyDictionary schedules) + { + ArgumentNullException.ThrowIfNull(schedules); + LiveEntityRuntime? runtime = _liveEntities(); + if (runtime is null) + return; + + // Private snapshot: EffectPoseChanged is synchronous and may perform a + // nested runtime-view enumeration. Never borrow that view's scratch. + runtime.CopySpatialRootObjectRecordsTo(_snapshot); + foreach (LiveEntityRecord record in _snapshot) + { + if (!TryGetCurrent(runtime, record, out WorldEntity entity, out LiveEntityAnimationState animation)) + continue; + + PrepareAnimation(record, animation); + if (!TryGetCurrent(runtime, record, entity, animation)) + continue; + + schedules.TryGetValue(entity.Id, out LiveEntityAnimationSchedule schedule); + bool hasOrdinarySchedule = IsCurrentSchedule(runtime, schedule, record, entity, animation); + IReadOnlyList? sequenceFrames = hasOrdinarySchedule + ? schedule.SequenceFrames + : null; + bool composeParts = hasOrdinarySchedule && schedule.ComposeParts; + float legacyAdvanceSeconds = hasOrdinarySchedule + ? schedule.LegacyAdvanceSeconds + : 0f; + ulong objectClockEpoch = record.ObjectClockEpoch; + ulong projectionVersion = record.ProjectionMutationVersion; + + if (_staticFrames()?.TryTakeLivePartFrames( + record, + entity, + animation, + objectClockEpoch, + projectionVersion, + out IReadOnlyList staticPartFrames) == true) + { + if (!TryGetCurrent( + runtime, + record, + entity, + animation, + objectClockEpoch, + projectionVersion)) + { + continue; + } + sequenceFrames = staticPartFrames; + composeParts = true; + } + + if (animation.Sequencer is not null) + { + EmitSequenceDiagnostics(record, animation, sequenceFrames); + } + else + { + int span = animation.HighFrame - animation.LowFrame; + bool hiddenLegacy = (record.FinalPhysicsState & PhysicsStateFlags.Hidden) != 0; + if (span <= 0 && !hiddenLegacy) + continue; + if (span > 0 && legacyAdvanceSeconds > 0f) + { + animation.CurrFrame += legacyAdvanceSeconds * animation.Framerate; + if (animation.CurrFrame > animation.HighFrame) + { + float over = animation.CurrFrame - animation.LowFrame; + animation.CurrFrame = animation.LowFrame + (over % (span + 1)); + } + else if (animation.CurrFrame < animation.LowFrame) + { + animation.CurrFrame = animation.LowFrame; + } + } + } + + if (!composeParts + || !TryGetCurrent( + runtime, + record, + entity, + animation, + objectClockEpoch, + projectionVersion)) + { + continue; + } + + ComposeAndPublish(runtime, record, entity, animation, sequenceFrames, + objectClockEpoch, projectionVersion); + } + } + + private void ComposeAndPublish( + LiveEntityRuntime runtime, + LiveEntityRecord record, + WorldEntity entity, + LiveEntityAnimationState animation, + IReadOnlyList? sequenceFrames, + ulong objectClockEpoch, + ulong projectionVersion) + { + int partCount = animation.PartTemplate.Count; + EmitPartDiagnostics(record, animation, sequenceFrames, partCount); + List meshRefs = animation.MeshRefsScratch; + meshRefs.Clear(); + List rigidPoses = animation.EffectPartPosesScratch; + rigidPoses.Clear(); + Matrix4x4 scale = animation.Scale == 1f + ? Matrix4x4.Identity + : Matrix4x4.CreateScale(animation.Scale); + + for (int i = 0; i < partCount; i++) + { + ResolvePartFrame(animation, sequenceFrames, i, out Vector3 origin, out Quaternion orientation); + Vector3 defaultScale = i < animation.Setup.DefaultScale.Count + ? animation.Setup.DefaultScale[i] + : Vector3.One; + Matrix4x4 visual = Matrix4x4.CreateScale(defaultScale) + * Matrix4x4.CreateFromQuaternion(orientation) + * Matrix4x4.CreateTranslation(origin); + if (animation.Scale != 1f) + visual *= scale; + + Matrix4x4 rigid = Matrix4x4.CreateFromQuaternion(orientation) + * Matrix4x4.CreateTranslation(origin * animation.Scale); + rigidPoses.Add(rigid); + + LiveAnimationPartTemplate template = animation.PartTemplate[i]; + if (!template.IsDrawable + || (_hidePartIndex >= 0 && i == _hidePartIndex && partCount >= 10)) + { + continue; + } + meshRefs.Add(new MeshRef(template.GfxObjId, visual) + { + SurfaceOverrides = template.SurfaceOverrides, + }); + } + + if (!TryGetCurrent(runtime, record, entity, animation, objectClockEpoch, projectionVersion)) + return; + entity.MeshRefs = meshRefs; + entity.SetIndexedPartPoses(rigidPoses, animation.PartAvailability); + if (!TryGetCurrent(runtime, record, entity, animation, objectClockEpoch, projectionVersion)) + return; + _effectPoses.Publish(entity, rigidPoses, animation.PartAvailability); + } + + private static void ResolvePartFrame( + LiveEntityAnimationState animation, + IReadOnlyList? sequenceFrames, + int partIndex, + out Vector3 origin, + out Quaternion orientation) + { + if (sequenceFrames is not null) + { + if (partIndex < sequenceFrames.Count) + { + origin = sequenceFrames[partIndex].Origin; + orientation = sequenceFrames[partIndex].Orientation; + } + else + { + origin = Vector3.Zero; + orientation = Quaternion.Identity; + } + return; + } + + int frameIndex = (int)Math.Floor(animation.CurrFrame); + if (frameIndex < animation.LowFrame + || frameIndex > animation.HighFrame + || frameIndex >= animation.Animation.PartFrames.Count) + { + frameIndex = animation.LowFrame; + } + int nextIndex = frameIndex + 1; + if (nextIndex > animation.HighFrame + || nextIndex >= animation.Animation.PartFrames.Count) + { + nextIndex = animation.LowFrame; + } + float t = Math.Clamp(animation.CurrFrame - frameIndex, 0f, 1f); + var frames = animation.Animation.PartFrames[frameIndex].Frames; + var nextFrames = animation.Animation.PartFrames[nextIndex].Frames; + if (partIndex < frames.Count) + { + var first = frames[partIndex]; + var next = partIndex < nextFrames.Count ? nextFrames[partIndex] : first; + origin = Vector3.Lerp(first.Origin, next.Origin, t); + orientation = Quaternion.Slerp(first.Orientation, next.Orientation, t); + } + else + { + origin = Vector3.Zero; + orientation = Quaternion.Identity; + } + } + + private void EmitSequenceDiagnostics( + LiveEntityRecord record, + LiveEntityAnimationState animation, + IReadOnlyList? sequenceFrames) + { + if (!_diagnostics.RemoteVelocityEnabled + || record.ServerGuid == 0 + || record.ServerGuid == _context.LocalPlayerGuid + || record.RemoteMotionRuntime is null) + { + return; + } + double now = _diagnostics.Now(); + if (now - animation.LastSequenceDiagnosticTime <= 1d) + return; + AnimationSequencer sequencer = animation.Sequencer!; + Console.WriteLine( + $"[SEQSTATE] guid={record.ServerGuid:X8} CurrentMotion=0x{sequencer.CurrentMotion:X8} " + + $"CurrentSpeedMod={sequencer.CurrentSpeedMod:F3}"); + var node = sequencer.CurrentNodeDiag; + int firstHash = sequencer.FirstCyclicAnimRefHash; + Console.WriteLine( + $"[CURRNODE] guid={record.ServerGuid:X8} animRef=0x{node.AnimRefHash:X8} " + + $"firstCyclicAnimRef=0x{firstHash:X8} " + + $"isOnCyclic={node.AnimRefHash == firstHash && firstHash != 0} " + + $"isLooping={node.IsLooping} fr={node.Framerate:F2} " + + $"frame=[{node.StartFrame}..{node.EndFrame}] " + + $"pos={node.FramePosition:F2} qCount={node.QueueCount}"); + animation.LastSequenceDiagnosticTime = now; + } + + private void EmitPartDiagnostics( + LiveEntityRecord record, + LiveEntityAnimationState animation, + IReadOnlyList? sequenceFrames, + int partCount) + { + if (!_diagnostics.RemoteVelocityEnabled + || record.ServerGuid == 0 + || record.ServerGuid == _context.LocalPlayerGuid + || record.RemoteMotionRuntime is null) + { + return; + } + double now = _diagnostics.Now(); + if (now - animation.LastPartDiagnosticTime <= 1d) + return; + int animationPartCount = animation.Animation is not null + && animation.Animation.PartFrames.Count > 0 + ? animation.Animation.PartFrames[0].Frames.Count + : -1; + double hash = 0d; + if (sequenceFrames is not null) + { + foreach (PartTransform frame in sequenceFrames) + { + hash += frame.Origin.X + frame.Origin.Y + frame.Origin.Z + + frame.Orientation.X + frame.Orientation.Y + + frame.Orientation.Z + frame.Orientation.W; + } + } + Console.WriteLine( + $"[PARTSDIAG] guid={record.ServerGuid:X8} pt.Count={partCount} " + + $"seqFrames.Count={sequenceFrames?.Count ?? -1} " + + $"setup.Parts.Count={animation.Setup.Parts.Count} " + + $"anim.PartFrames[0].Frames.Count={animationPartCount} seqHash={hash:F4}"); + animation.LastPartDiagnosticTime = now; + } + + private static bool IsCurrentSchedule( + LiveEntityRuntime runtime, + LiveEntityAnimationSchedule schedule, + LiveEntityRecord record, + WorldEntity entity, + LiveEntityAnimationState animation) => + schedule.ComposeParts + && ReferenceEquals(schedule.Record, record) + && ReferenceEquals(schedule.Entity, entity) + && ReferenceEquals(schedule.Animation, animation) + && TryGetCurrent( + runtime, + record, + entity, + animation, + schedule.ObjectClockEpoch, + schedule.ProjectionMutationVersion); + + private static bool TryGetCurrent( + LiveEntityRuntime runtime, + LiveEntityRecord record, + out WorldEntity entity, + out LiveEntityAnimationState animation) + { + WorldEntity? currentEntity = record.WorldEntity; + LiveEntityAnimationState? currentAnimation = + record.AnimationRuntime as LiveEntityAnimationState; + entity = currentEntity!; + animation = currentAnimation!; + return currentEntity is not null + && currentAnimation is not null + && TryGetCurrent(runtime, record, entity, animation); + } + + private static bool TryGetCurrent( + LiveEntityRuntime runtime, + LiveEntityRecord record, + WorldEntity entity, + LiveEntityAnimationState animation) => + runtime.IsCurrentSpatialAnimation(record, animation) + && ReferenceEquals(record.WorldEntity, entity) + && ReferenceEquals(animation.Entity, entity); + + private static bool TryGetCurrent( + LiveEntityRuntime runtime, + LiveEntityRecord record, + WorldEntity entity, + LiveEntityAnimationState animation, + ulong objectClockEpoch, + ulong projectionVersion) => + TryGetCurrent(runtime, record, entity, animation) + && record.ObjectClockEpoch == objectClockEpoch + && record.ProjectionMutationVersion == projectionVersion; +} diff --git a/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs b/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs index 62857f63..7ff42e05 100644 --- a/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs +++ b/src/AcDream.App/Rendering/LiveEntityAnimationScheduler.cs @@ -5,7 +5,6 @@ using AcDream.Core.Physics; using AcDream.Core.Physics.Motion; using AcDream.Core.World; using DatReaderWriter.Types; -using AnimatedEntity = AcDream.App.Rendering.GameWindow.AnimatedEntity; using RemoteMotion = AcDream.App.Rendering.GameWindow.RemoteMotion; namespace AcDream.App.Rendering; @@ -67,7 +66,7 @@ internal sealed class LiveEntityAnimationScheduler bool localHiddenPartPoseDirty, int liveCenterX, int liveCenterY, - Action? prepareAnimation = null) + Action? prepareAnimation = null) { _schedules.Clear(); LiveEntityRuntime? runtime = _liveEntities(); @@ -83,14 +82,12 @@ internal sealed class LiveEntityAnimationScheduler continue; } - AnimatedEntity? animation = record.AnimationRuntime as AnimatedEntity; + LiveEntityAnimationState? animation = record.AnimationRuntime as LiveEntityAnimationState; RemoteMotion? remote = record.RemoteMotionRuntime as RemoteMotion; ProjectileController.Runtime? projectile = record.ProjectileRuntime as ProjectileController.Runtime; ulong objectClockEpoch = record.ObjectClockEpoch; - if (animation is not null) - prepareAnimation?.Invoke(record.ServerGuid, animation); if (!IsCurrent( runtime, record, @@ -101,6 +98,22 @@ internal sealed class LiveEntityAnimationScheduler objectClockEpoch)) continue; + if (animation is not null) + { + prepareAnimation?.Invoke(record, animation); + if (!IsCurrent( + runtime, + record, + entity, + animation, + remote, + projectile, + objectClockEpoch)) + { + continue; + } + } + LiveEntityAnimationSchedule schedule = AdvanceRecord( runtime, record, @@ -126,7 +139,14 @@ internal sealed class LiveEntityAnimationScheduler projectile, objectClockEpoch)) { - _schedules[entity.Id] = schedule; + _schedules[entity.Id] = schedule with + { + Record = record, + Entity = entity, + Animation = animation, + ObjectClockEpoch = objectClockEpoch, + ProjectionMutationVersion = record.ProjectionMutationVersion, + }; } } @@ -137,7 +157,7 @@ internal sealed class LiveEntityAnimationScheduler LiveEntityRuntime runtime, LiveEntityRecord record, WorldEntity entity, - AnimatedEntity? animation, + LiveEntityAnimationState? animation, RemoteMotion? remote, ProjectileController.Runtime? projectile, float elapsedSeconds, @@ -472,7 +492,7 @@ internal sealed class LiveEntityAnimationScheduler LiveEntityRuntime runtime, LiveEntityRecord record, WorldEntity entity, - AnimatedEntity? animation, + LiveEntityAnimationState? animation, RemoteMotion? remote, ProjectileController.Runtime? projectile, ulong objectClockEpoch) => @@ -506,4 +526,9 @@ internal sealed class LiveEntityAnimationScheduler internal readonly record struct LiveEntityAnimationSchedule( IReadOnlyList? SequenceFrames, float LegacyAdvanceSeconds, - bool ComposeParts); + bool ComposeParts, + LiveEntityRecord? Record = null, + WorldEntity? Entity = null, + LiveEntityAnimationState? Animation = null, + ulong ObjectClockEpoch = 0UL, + ulong ProjectionMutationVersion = 0UL); diff --git a/src/AcDream.App/Rendering/LiveEntityAnimationState.cs b/src/AcDream.App/Rendering/LiveEntityAnimationState.cs new file mode 100644 index 00000000..1d145e85 --- /dev/null +++ b/src/AcDream.App/Rendering/LiveEntityAnimationState.cs @@ -0,0 +1,63 @@ +using System.Numerics; +using AcDream.App.World; +using AcDream.Core.Physics; +using AcDream.Core.Physics.Motion; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.App.Rendering; + +/// +/// Incarnation-owned live CPartArray presentation state. Time, root +/// motion, and hooks remain owned by ; +/// this component retains the final part-presentation channels only. +/// +internal sealed class LiveEntityAnimationState : ILiveEntityAnimationRuntime +{ + public required WorldEntity Entity; + WorldEntity ILiveEntityAnimationRuntime.Entity => Entity; + uint ILiveEntityAnimationRuntime.CurrentMotion => Sequencer?.CurrentMotion ?? 0u; + + public required Setup Setup; + public required Animation Animation; + public required int LowFrame; + public required int HighFrame; + public required float Framerate; + public required float Scale; + public required IReadOnlyList PartTemplate; + public required IReadOnlyList PartAvailability; + public float CurrFrame; + public AnimationSequencer? Sequencer; + + public IReadOnlyList? PreparedSequenceFrames; + public bool SequenceAdvancedBeforeAnimationPass; + public readonly Frame RootMotionScratch = new(); + public readonly MotionDeltaFrame RootMotionDeltaScratch = new(); + + /// Reusable compact drawable channel, consumed before draw. + public readonly List MeshRefsScratch = new(); + /// Reusable indexed rigid/effect channel. + public readonly List EffectPartPosesScratch = new(); + /// + /// Indexed visible transforms retained separately from the rigid channel + /// because Setup DefaultScale affects geometry but not attachments. + /// + public readonly List VisualPartPosesScratch = new(); + public bool PresentationPosesInitialized; + + public double LastSequenceDiagnosticTime; + public double LastPartDiagnosticTime; + + public void InvalidatePresentationPoses() + { + PresentationPosesInitialized = false; + VisualPartPosesScratch.Clear(); + EffectPartPosesScratch.Clear(); + } +} + +internal readonly record struct LiveAnimationPartTemplate( + uint GfxObjId, + IReadOnlyDictionary? SurfaceOverrides, + bool IsDrawable); diff --git a/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs b/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs index 534ba13f..89367ae6 100644 --- a/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs +++ b/src/AcDream.App/Rendering/RetailStaticAnimatingObjectScheduler.cs @@ -1,5 +1,6 @@ using System.Numerics; using AcDream.App.Rendering.Vfx; +using AcDream.App.World; using AcDream.Core.Physics; using AcDream.Core.Physics.Motion; using AcDream.Core.World; @@ -17,7 +18,7 @@ namespace AcDream.App.Rendering; /// resource lifetime. Setup default installation itself is unconditional and /// belongs to PartArray construction; this class owns only the Static workset. /// -internal sealed class RetailStaticAnimatingObjectScheduler +internal sealed class RetailStaticAnimatingObjectScheduler : ILiveStaticPartFrameSource { private const double FrameEpsilon = 0.000199999995; private const float MaximumElapsed = 2f; @@ -162,10 +163,24 @@ internal sealed class RetailStaticAnimatingObjectScheduler /// the single owner of mesh identity, overrides, and appearance rebinding. /// public bool TryTakeLivePartFrames( - uint ownerId, + LiveEntityRecord record, + WorldEntity entity, + LiveEntityAnimationState animation, + ulong objectClockEpoch, + ulong projectionMutationVersion, out IReadOnlyList frames) { + ArgumentNullException.ThrowIfNull(record); + ArgumentNullException.ThrowIfNull(entity); + ArgumentNullException.ThrowIfNull(animation); + uint ownerId = entity.Id; if (_owners.TryGetValue(ownerId, out Owner? owner) + && ReferenceEquals(record.WorldEntity, entity) + && ReferenceEquals(record.AnimationRuntime, animation) + && record.ObjectClockEpoch == objectClockEpoch + && record.ProjectionMutationVersion == projectionMutationVersion + && ReferenceEquals(owner.Entity, entity) + && ReferenceEquals(owner.Sequencer, animation.Sequencer) && owner.Entity.ServerGuid != 0 && owner.PreparedLivePartFrames is { } prepared && IsResidentAtVersion(owner, owner.PendingResidencyVersion)) @@ -182,6 +197,29 @@ internal sealed class RetailStaticAnimatingObjectScheduler return false; } + /// + /// Scheduler-only test seam for DAT/static timing tests which deliberately + /// do not construct a . Runtime integration + /// must use the exact-incarnation overload above. + /// + internal bool TryTakePreparedFramesForTest( + uint ownerId, + out IReadOnlyList frames) + { + if (_owners.TryGetValue(ownerId, out Owner? owner) + && owner.PreparedLivePartFrames is { } prepared + && IsResidentAtVersion(owner, owner.PendingResidencyVersion)) + { + owner.PreparedLivePartFrames = null; + frames = prepared; + return true; + } + if (_owners.TryGetValue(ownerId, out owner)) + InvalidatePending(owner); + frames = Array.Empty(); + return false; + } + public void Unregister(uint ownerId) => _owners.Remove(ownerId); public void CopyAnimatedEntityIdsTo(HashSet destination) diff --git a/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs b/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs index 0f984d79..afc96a3d 100644 --- a/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs +++ b/tests/AcDream.App.Tests/Physics/RemotePhysicsUpdaterTests.cs @@ -139,7 +139,7 @@ public sealed class RemotePhysicsUpdaterTests -MathF.PI / 2f), MeshRefs = Array.Empty(), }; - var animated = new GameWindow.AnimatedEntity + var animated = new LiveEntityAnimationState { Entity = entity, Setup = new Setup(), @@ -148,7 +148,7 @@ public sealed class RemotePhysicsUpdaterTests HighFrame = 0, Framerate = 0f, Scale = 1f, - PartTemplate = Array.Empty(), + PartTemplate = Array.Empty(), PartAvailability = Array.Empty(), }; var motion = new GameWindow.RemoteMotion(); @@ -205,7 +205,7 @@ public sealed class RemotePhysicsUpdaterTests Rotation = initial, MeshRefs = Array.Empty(), }; - var animated = new GameWindow.AnimatedEntity + var animated = new LiveEntityAnimationState { Entity = entity, Setup = new Setup(), @@ -214,7 +214,7 @@ public sealed class RemotePhysicsUpdaterTests HighFrame = 0, Framerate = 0f, Scale = 1f, - PartTemplate = Array.Empty(), + PartTemplate = Array.Empty(), PartAvailability = Array.Empty(), }; var motion = new GameWindow.RemoteMotion @@ -260,7 +260,7 @@ public sealed class RemotePhysicsUpdaterTests Rotation = initial, MeshRefs = Array.Empty(), }; - var animated = new GameWindow.AnimatedEntity + var animated = new LiveEntityAnimationState { Entity = entity, Setup = new Setup(), @@ -269,7 +269,7 @@ public sealed class RemotePhysicsUpdaterTests HighFrame = 0, Framerate = 0f, Scale = 1f, - PartTemplate = Array.Empty(), + PartTemplate = Array.Empty(), PartAvailability = Array.Empty(), }; var motion = new GameWindow.RemoteMotion(); diff --git a/tests/AcDream.App.Tests/Rendering/LiveAppearanceAnimationTests.cs b/tests/AcDream.App.Tests/Rendering/LiveAppearanceAnimationTests.cs index 37fc47ce..4dcda425 100644 --- a/tests/AcDream.App.Tests/Rendering/LiveAppearanceAnimationTests.cs +++ b/tests/AcDream.App.Tests/Rendering/LiveAppearanceAnimationTests.cs @@ -15,7 +15,7 @@ public sealed class LiveAppearanceAnimationTests var animation = new Animation(); var sequencer = new AnimationSequencer(setup, new MotionTable(), new NullLoader()); var oldEntity = Entity(0x70000001u, 0x01000001u); - var state = new GameWindow.AnimatedEntity + var state = new LiveEntityAnimationState { Entity = oldEntity, Setup = setup, @@ -24,7 +24,7 @@ public sealed class LiveAppearanceAnimationTests HighFrame = 9, Framerate = 30f, Scale = 1f, - PartTemplate = [new GameWindow.AnimatedPartTemplate(0x01000001u, null, true)], + PartTemplate = [new LiveAnimationPartTemplate(0x01000001u, null, true)], PartAvailability = [true], CurrFrame = 6.5f, Sequencer = sequencer, @@ -42,8 +42,8 @@ public sealed class LiveAppearanceAnimationTests setup, 1.25f, [ - new GameWindow.AnimatedPartTemplate(0x01000002u, null, true), - new GameWindow.AnimatedPartTemplate(0x01000003u, null, true), + new LiveAnimationPartTemplate(0x01000002u, null, true), + new LiveAnimationPartTemplate(0x01000003u, null, true), ], [true, true]); diff --git a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs new file mode 100644 index 00000000..9eb93aa2 --- /dev/null +++ b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationPresenterTests.cs @@ -0,0 +1,295 @@ +using System.Numerics; +using AcDream.App.Rendering; +using AcDream.App.Rendering.Vfx; +using AcDream.App.Streaming; +using AcDream.App.World; +using AcDream.Core.Net; +using AcDream.Core.Net.Messages; +using AcDream.Core.Physics; +using AcDream.Core.World; +using DatReaderWriter.DBObjs; +using DatReaderWriter.Types; + +namespace AcDream.App.Tests.Rendering; + +public sealed class LiveEntityAnimationPresenterTests +{ + private const uint Guid = 0x70000031u; + private const uint Cell = 0x01010001u; + + [Fact] + public void Present_ComposesDistinctVisualAndRigidChannels() + { + var fixture = Build(partCount: 2, scale: 2f); + fixture.State.Setup.DefaultScale[0] = new Vector3(3f, 4f, 5f); + fixture.State.PartTemplate = + [ + new LiveAnimationPartTemplate(0x01000001u, new Dictionary { [1] = 2 }, true), + new LiveAnimationPartTemplate(0x01000002u, null, false), + ]; + Quaternion rotation = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f); + PartTransform[] frames = + [ + new(new Vector3(1f, 2f, 3f), rotation), + new(new Vector3(4f, 5f, 6f), Quaternion.Identity), + ]; + var poses = new EntityEffectPoseRegistry(); + var presenter = Presenter(fixture.Live, poses, new Context()); + + presenter.Present(Schedules(fixture, frames)); + + Assert.Single(fixture.Entity.MeshRefs); + Matrix4x4 expectedVisual = Matrix4x4.CreateScale(3f, 4f, 5f) + * Matrix4x4.CreateFromQuaternion(rotation) + * Matrix4x4.CreateTranslation(1f, 2f, 3f) + * Matrix4x4.CreateScale(2f); + Matrix4x4 expectedRigid = Matrix4x4.CreateFromQuaternion(rotation) + * Matrix4x4.CreateTranslation(2f, 4f, 6f); + Assert.Equal(expectedVisual, fixture.Entity.MeshRefs[0].PartTransform); + Assert.Equal(expectedRigid, fixture.Entity.IndexedPartTransforms[0]); + Assert.Equal(2, fixture.Entity.IndexedPartTransforms.Count); + Assert.True(poses.TryGetPartPose(fixture.Entity.Id, 0, out Matrix4x4 effectPose)); + Assert.Equal(expectedRigid, effectPose); + Assert.Equal(2u, fixture.Entity.MeshRefs[0].SurfaceOverrides![1]); + } + + [Fact] + public void OldSchedule_IsRejectedForCurrentOwnerEvenWhenIndexedByItsLocalId() + { + var old = Build(partCount: 1); + LiveEntityAnimationSchedule stale = Schedule(old, + [new PartTransform(new Vector3(99f, 0f, 0f), Quaternion.Identity)]); + Assert.True(old.Live.ClearAnimationRuntime(Guid)); + LiveEntityAnimationState replacement = State(old.Entity, partCount: 1, scale: 1f); + old.Live.SetAnimationRuntime(Guid, replacement); + var presenter = Presenter(old.Live, new EntityEffectPoseRegistry(), new Context()); + + presenter.Present(new Dictionary + { + [old.Entity.Id] = stale, + }); + + Assert.Empty(replacement.MeshRefsScratch); + Assert.Empty(replacement.EffectPartPosesScratch); + } + + [Fact] + public void MotionDone_OldComponentCannotResolveReplacementByGuid() + { + var fixture = Build(partCount: 1, withSequencer: true); + var context = new Context(); + var presenter = Presenter(fixture.Live, new EntityEffectPoseRegistry(), context); + presenter.PrepareAnimation(fixture.Record, fixture.State); + Action oldCallback = fixture.State.Sequencer!.MotionDoneTarget!; + + Assert.True(fixture.Live.ClearAnimationRuntime(Guid)); + fixture.Live.SetAnimationRuntime(Guid, State(fixture.Entity, 1, 1f)); + oldCallback(0x10000001u, true); + + Assert.Equal(0, context.ResolveCount); + } + + [Fact] + public void EffectPoseCallback_NestedRuntimeSnapshotDoesNotTruncatePresentation() + { + var first = Build(partCount: 1, guid: Guid); + var second = Add(first.Live, Guid + 1, partCount: 1); + var poses = new EntityEffectPoseRegistry(); + poses.EffectPoseChanged += _ => + { + var nested = new List(); + first.Live.CopySpatialRootObjectRecordsTo(nested); + Assert.Equal(2, nested.Count); + }; + var presenter = Presenter(first.Live, poses, new Context()); + var schedules = new Dictionary + { + [first.Entity.Id] = Schedule(first, + [new PartTransform(Vector3.UnitX, Quaternion.Identity)]), + [second.Entity.Id] = Schedule(second, + [new PartTransform(Vector3.UnitY, Quaternion.Identity)]), + }; + + presenter.Present(schedules); + + Assert.Single(first.Entity.MeshRefs); + Assert.Single(second.Entity.MeshRefs); + } + + private static LiveEntityAnimationPresenter Presenter( + LiveEntityRuntime live, + EntityEffectPoseRegistry poses, + Context context) => new( + () => live, + () => null, + poses, + context, + AnimationPresentationDiagnostics.Disabled, + hidePartIndex: -1); + + private static IReadOnlyDictionary Schedules( + Fixture fixture, + IReadOnlyList frames) => + new Dictionary + { + [fixture.Entity.Id] = Schedule(fixture, frames), + }; + + private static LiveEntityAnimationSchedule Schedule( + Fixture fixture, + IReadOnlyList frames) => new( + frames, + 0f, + true, + fixture.Record, + fixture.Entity, + fixture.State, + fixture.Record.ObjectClockEpoch, + fixture.Record.ProjectionMutationVersion); + + private static Fixture Build( + int partCount, + float scale = 1f, + bool withSequencer = true, + uint guid = Guid) + { + var spatial = new GpuWorldState(); + spatial.AddLandblock(new LoadedLandblock( + 0x0101FFFFu, + new LandBlock(), + Array.Empty())); + var live = new LiveEntityRuntime( + spatial, + new DelegateLiveEntityResourceLifecycle(_ => { }, _ => { })); + return Add(live, guid, partCount, scale, withSequencer); + } + + private static Fixture Add( + LiveEntityRuntime live, + uint guid, + int partCount, + float scale = 1f, + bool withSequencer = true) + { + LiveEntityRecord record = live.RegisterLiveEntity(Spawn(guid)).Record!; + WorldEntity entity = live.MaterializeLiveEntity( + guid, + Cell, + id => new WorldEntity + { + Id = id, + ServerGuid = guid, + SourceGfxObjOrSetupId = 0x02000001u, + Position = Vector3.Zero, + Rotation = Quaternion.Identity, + MeshRefs = Array.Empty(), + ParentCellId = Cell, + })!; + LiveEntityAnimationState state = State(entity, partCount, scale, withSequencer); + entity.SetIndexedPartPoses( + Enumerable.Repeat(Matrix4x4.Identity, partCount).ToArray(), + Enumerable.Repeat(true, partCount).ToArray()); + record.HasPartArray = true; + live.SetAnimationRuntime(guid, state); + return new Fixture(live, record, entity, state); + } + + private static LiveEntityAnimationState State( + WorldEntity entity, + int partCount, + float scale, + bool withSequencer = false) + { + var setup = new Setup(); + for (int i = 0; i < partCount; i++) + { + setup.Parts.Add(0x01000001u + (uint)i); + setup.DefaultScale.Add(Vector3.One); + } + return new LiveEntityAnimationState + { + Entity = entity, + Setup = setup, + Animation = new Animation(), + LowFrame = 0, + HighFrame = 0, + Framerate = 0f, + Scale = scale, + PartTemplate = Enumerable.Range(0, partCount) + .Select(i => new LiveAnimationPartTemplate(0x01000001u + (uint)i, null, true)) + .ToArray(), + PartAvailability = Enumerable.Repeat(true, partCount).ToArray(), + Sequencer = withSequencer + ? new AnimationSequencer(setup, new MotionTable(), new NullLoader()) + : null, + }; + } + + private static WorldSession.EntitySpawn Spawn(uint guid) + { + var position = new CreateObject.ServerPosition(Cell, 0f, 0f, 0f, 1f, 0f, 0f, 0f); + var timestamps = new PhysicsTimestamps(1, 1, 1, 1, 0, 1, 0, 1, 1); + var physics = new PhysicsSpawnData( + RawState: 0u, + Position: position, + Movement: null, + AnimationFrame: null, + SetupTableId: 0x02000001u, + MotionTableId: null, + SoundTableId: null, + PhysicsScriptTableId: null, + Parent: null, + Children: null, + Scale: null, + Friction: null, + Elasticity: null, + Translucency: null, + Velocity: null, + Acceleration: null, + AngularVelocity: null, + DefaultScriptType: null, + DefaultScriptIntensity: null, + Timestamps: timestamps); + return new WorldSession.EntitySpawn( + guid, + position, + 0x02000001u, + [], + [], + [], + null, + null, + "presenter fixture", + null, + null, + null, + PhysicsState: 0u, + InstanceSequence: 1, + MovementSequence: 1, + ServerControlSequence: 1, + PositionSequence: 1, + Physics: physics); + } + + private sealed class Context : ILiveAnimationPresentationContext + { + public uint LocalPlayerGuid => 0x50000001u; + public int ResolveCount { get; private set; } + public MotionInterpreter? ResolveMotionInterpreter(LiveEntityRecord record) + { + ResolveCount++; + return null; + } + } + + private sealed class NullLoader : IAnimationLoader + { + public Animation? LoadAnimation(uint id) => null; + } + + private sealed record Fixture( + LiveEntityRuntime Live, + LiveEntityRecord Record, + WorldEntity Entity, + LiveEntityAnimationState State); +} diff --git a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs index fab06134..1199c2ac 100644 --- a/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/LiveEntityAnimationSchedulerTests.cs @@ -615,7 +615,7 @@ public sealed class LiveEntityAnimationSchedulerTests } private static (LiveEntityRuntime Live, LiveEntityRecord Record, - GameWindow.AnimatedEntity Animation) BuildHiddenAnimated(uint guid) + LiveEntityAnimationState Animation) BuildHiddenAnimated(uint guid) { var spatial = new GpuWorldState(); spatial.AddLandblock(new LoadedLandblock( @@ -640,7 +640,7 @@ public sealed class LiveEntityAnimationSchedulerTests ParentCellId = Cell, })!; var setup = new Setup(); - var animation = new GameWindow.AnimatedEntity + var animation = new LiveEntityAnimationState { Entity = entity, Setup = setup, @@ -649,7 +649,7 @@ public sealed class LiveEntityAnimationSchedulerTests HighFrame = 0, Framerate = 0f, Scale = 1f, - PartTemplate = Array.Empty(), + PartTemplate = Array.Empty(), PartAvailability = Array.Empty(), Sequencer = new AnimationSequencer( setup, @@ -662,7 +662,7 @@ public sealed class LiveEntityAnimationSchedulerTests } private static (LiveEntityRuntime Live, LiveEntityRecord Record, - GameWindow.AnimatedEntity Animation) BuildVisibleAnimatedWithPosFrames( + LiveEntityAnimationState Animation) BuildVisibleAnimatedWithPosFrames( uint guid, Quaternion? rootOrientation = null, Vector3? rootOrigin = null) @@ -694,7 +694,7 @@ public sealed class LiveEntityAnimationSchedulerTests var loader = new DictionaryAnimationLoader(animationId, datAnimation); var sequencer = new AnimationSequencer(setup, new MotionTable(), loader); Assert.True(sequencer.InitializeSetupDefaultAnimation(animationId)); - var animation = new GameWindow.AnimatedEntity + var animation = new LiveEntityAnimationState { Entity = entity, Setup = setup, @@ -705,7 +705,7 @@ public sealed class LiveEntityAnimationSchedulerTests Scale = 1f, PartTemplate = new[] { - new GameWindow.AnimatedPartTemplate( + new LiveAnimationPartTemplate( 0x0100AA01u, SurfaceOverrides: null, IsDrawable: true), diff --git a/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs b/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs index 03b6fd83..eaa181d7 100644 --- a/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs +++ b/tests/AcDream.App.Tests/Rendering/RetailStaticAnimatingObjectSchedulerTests.cs @@ -150,7 +150,7 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests scheduler.Tick(0.02f); scheduler.ProcessHooks(); Assert.Equal(1, captures); - Assert.True(scheduler.TryTakeLivePartFrames(OwnerId, out var frames)); + Assert.True(scheduler.TryTakePreparedFramesForTest(OwnerId, out var frames)); var reference = new AnimationSequencer( setup, @@ -269,7 +269,7 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests Assert.True(sequencer.InitializeSetupDefaultAnimation(alternateAnimationId)); scheduler.Tick(0.01f); - Assert.True(scheduler.TryTakeLivePartFrames(OwnerId, out var frames)); + Assert.True(scheduler.TryTakePreparedFramesForTest(OwnerId, out var frames)); Assert.True(frames[0].Origin.X >= 20f); Vector3 rotatedX = Vector3.Transform(Vector3.UnitX, entity.Rotation); Assert.InRange(rotatedX.X, -0.001f, 0.001f); @@ -312,7 +312,7 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests scheduler.Tick(0.1f); Assert.Equal(0, rootCommits); - Assert.True(scheduler.TryTakeLivePartFrames(OwnerId, out _)); + Assert.True(scheduler.TryTakePreparedFramesForTest(OwnerId, out _)); } [Fact] @@ -349,7 +349,7 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests scheduler.Tick(0.1f); scheduler.ProcessHooks(); - Assert.False(scheduler.TryTakeLivePartFrames(OwnerId, out _)); + Assert.False(scheduler.TryTakePreparedFramesForTest(OwnerId, out _)); Assert.Equal(0, captures); } @@ -422,7 +422,7 @@ public sealed class RetailStaticAnimatingObjectSchedulerTests scheduler.Tick(0.2f); Assert.DoesNotContain("animation_done", order); - Assert.True(scheduler.TryTakeLivePartFrames(OwnerId, out _)); + Assert.True(scheduler.TryTakePreparedFramesForTest(OwnerId, out _)); order.Add("parts_published"); order.Add("children_updated"); scheduler.ProcessHooks();