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 <codex@openai.com>
This commit is contained in:
parent
9760ff5a8d
commit
f004ac5562
14 changed files with 963 additions and 438 deletions
|
|
@ -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<uint, AcDream.Core.World.WorldEntity, (float Radius, float Height)> _getSetupCylinder;
|
||||
private readonly System.Action<uint, AnimatedEntity, RemoteMotion, System.Numerics.Vector3> _applyServerControlledVelocityCycle;
|
||||
private readonly System.Action<uint, LiveEntityAnimationState, RemoteMotion, System.Numerics.Vector3> _applyServerControlledVelocityCycle;
|
||||
private readonly List<AcDream.App.World.LiveEntityRecord> _spatialRemoteSnapshot = new();
|
||||
|
||||
internal RemotePhysicsUpdater(
|
||||
AcDream.Core.Physics.PhysicsEngine physicsEngine,
|
||||
System.Func<uint, AcDream.Core.World.WorldEntity, (float Radius, float Height)> getSetupCylinder,
|
||||
System.Action<uint, AnimatedEntity, RemoteMotion, System.Numerics.Vector3> applyServerControlledVelocityCycle)
|
||||
System.Action<uint, LiveEntityAnimationState, RemoteMotion, System.Numerics.Vector3> applyServerControlledVelocityCycle)
|
||||
{
|
||||
_physicsEngine = physicsEngine;
|
||||
_getSetupCylinder = getSetupCylinder;
|
||||
|
|
@ -58,7 +58,7 @@ internal sealed class RemotePhysicsUpdater
|
|||
|
||||
/// <summary>
|
||||
/// Advances the retained PositionManager path for every hidden remote,
|
||||
/// including objects without an <c>AnimatedEntity</c> (missiles commonly
|
||||
/// including objects without an <c>LiveEntityAnimationState</c> (missiles commonly
|
||||
/// have no PartArray). Retail gates this path on the live CPhysicsObj, not
|
||||
/// on whether a render-animation owner exists.
|
||||
/// </summary>
|
||||
|
|
@ -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
|
|||
/// </summary>
|
||||
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 <c>CPhysics::object_maint</c>, so a live object with a
|
||||
/// MovementManager or PositionManager must continue even when it has no
|
||||
/// <c>AnimatedEntity</c> presentation component.
|
||||
/// <c>LiveEntityAnimationState</c> presentation component.
|
||||
/// </summary>
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>Startup-resolved diagnostic gates for final PartArray presentation.</summary>
|
||||
internal sealed record AnimationPresentationDiagnostics(
|
||||
bool RemoteVelocityEnabled,
|
||||
bool DumpMotionEnabled,
|
||||
Func<double> 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);
|
||||
}
|
||||
|
|
@ -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.
|
||||
/// </summary>
|
||||
private readonly LiveEntityAnimationRuntimeView<AnimatedEntity> _animatedEntities;
|
||||
private readonly LiveEntityAnimationRuntimeView<LiveEntityAnimationState> _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
|
|||
/// </summary>
|
||||
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
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public required IReadOnlyList<AnimatedPartTemplate> PartTemplate;
|
||||
public required IReadOnlyList<bool> 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<AcDream.Core.Physics.PartTransform>? PreparedSequenceFrames;
|
||||
public bool SequenceAdvancedBeforeAnimationPass;
|
||||
public readonly DatReaderWriter.Types.Frame RootMotionScratch = new();
|
||||
public readonly AcDream.Core.Physics.Motion.MotionDeltaFrame
|
||||
RootMotionDeltaScratch = new();
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <see cref="AcDream.Core.World.WorldEntity.MeshRefs"/> every tick —
|
||||
/// the old list became garbage every frame. This buffer is cleared
|
||||
/// and refilled in place instead; <c>Entity.MeshRefs</c> 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.
|
||||
/// </summary>
|
||||
public readonly List<AcDream.Core.World.MeshRef> MeshRefsScratch = new();
|
||||
/// <summary>Indexed final part poses, including debug-hidden parts.</summary>
|
||||
public readonly List<System.Numerics.Matrix4x4> EffectPartPosesScratch = new();
|
||||
}
|
||||
|
||||
internal readonly record struct AnimatedPartTemplate(
|
||||
uint GfxObjId,
|
||||
IReadOnlyDictionary<uint, uint>? 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<AnimatedEntity>(() => _liveEntities);
|
||||
_animatedEntities = new LiveEntityAnimationRuntimeView<LiveEntityAnimationState>(() => _liveEntities);
|
||||
_remoteDeadReckon = new LiveEntityRemoteMotionRuntimeView<RemoteMotion>(() => _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<AcDream.Core.World.MeshRef>();
|
||||
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<DatReaderWriter.DBObjs.GfxObj>(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<AnimatedPartTemplate> partTemplate,
|
||||
IReadOnlyList<LiveAnimationPartTemplate> partTemplate,
|
||||
IReadOnlyList<bool> partAvailability)
|
||||
{
|
||||
animation.Entity = entity;
|
||||
|
|
@ -4748,6 +4693,7 @@ public sealed class GameWindow : IDisposable
|
|||
animation.Scale = scale;
|
||||
animation.PartTemplate = partTemplate;
|
||||
animation.PartAvailability = partAvailability;
|
||||
animation.InvalidatePresentationPoses();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -5208,7 +5154,7 @@ public sealed class GameWindow : IDisposable
|
|||
/// the VectorUpdate path regardless of arrival order.
|
||||
/// </summary>
|
||||
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
|
|||
|
||||
/// <summary>
|
||||
/// 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
|
|||
/// <paramref name="dt"/> * 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.
|
||||
/// </summary>
|
||||
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<uint, LiveEntityAnimationSchedule> 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<AcDream.Core.Physics.PartTransform>? 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<MeshRef>(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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// R3-W2: binds the animation queue's completion seam to the entity's
|
||||
/// real <c>CMotionInterp::MotionDone</c> consumer
|
||||
/// (<c>CPhysicsObj::MotionDone</c> 0x0050FDB0). Retail owns both queues
|
||||
/// under one CPhysicsObj/CPartArray lifetime; acdream's split owners must
|
||||
/// establish the relationship before the first animation can finish.
|
||||
/// </summary>
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
21
src/AcDream.App/Rendering/ILiveStaticPartFrameSource.cs
Normal file
21
src/AcDream.App/Rendering/ILiveStaticPartFrameSource.cs
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
using AcDream.App.World;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>
|
||||
/// Late presentation handoff from retail's separate
|
||||
/// <c>static_animating_objects</c> workset. Every take is bound to the exact
|
||||
/// live incarnation that prepared the frames.
|
||||
/// </summary>
|
||||
internal interface ILiveStaticPartFrameSource
|
||||
{
|
||||
bool TryTakeLivePartFrames(
|
||||
LiveEntityRecord record,
|
||||
WorldEntity entity,
|
||||
LiveEntityAnimationState animation,
|
||||
ulong objectClockEpoch,
|
||||
ulong projectionMutationVersion,
|
||||
out IReadOnlyList<PartTransform> frames);
|
||||
}
|
||||
403
src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs
Normal file
403
src/AcDream.App/Rendering/LiveEntityAnimationPresenter.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Publishes the final visual and rigid part channels after retail object and
|
||||
/// static-animation scheduling. It advances no time and drains no hooks.
|
||||
/// </summary>
|
||||
internal sealed class LiveEntityAnimationPresenter
|
||||
{
|
||||
private readonly Func<LiveEntityRuntime?> _liveEntities;
|
||||
private readonly Func<ILiveStaticPartFrameSource?> _staticFrames;
|
||||
private readonly EntityEffectPoseRegistry _effectPoses;
|
||||
private readonly ILiveAnimationPresentationContext _context;
|
||||
private readonly AnimationPresentationDiagnostics _diagnostics;
|
||||
private readonly int _hidePartIndex;
|
||||
private readonly List<LiveEntityRecord> _snapshot = new();
|
||||
|
||||
public LiveEntityAnimationPresenter(
|
||||
Func<LiveEntityRuntime?> liveEntities,
|
||||
Func<ILiveStaticPartFrameSource?> 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<uint, LiveEntityAnimationSchedule> 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<PartTransform>? 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<PartTransform> 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<PartTransform>? sequenceFrames,
|
||||
ulong objectClockEpoch,
|
||||
ulong projectionVersion)
|
||||
{
|
||||
int partCount = animation.PartTemplate.Count;
|
||||
EmitPartDiagnostics(record, animation, sequenceFrames, partCount);
|
||||
List<MeshRef> meshRefs = animation.MeshRefsScratch;
|
||||
meshRefs.Clear();
|
||||
List<Matrix4x4> 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<PartTransform>? 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<PartTransform>? 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<PartTransform>? 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;
|
||||
}
|
||||
|
|
@ -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<uint, AnimatedEntity>? prepareAnimation = null)
|
||||
Action<LiveEntityRecord, LiveEntityAnimationState>? 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<PartTransform>? SequenceFrames,
|
||||
float LegacyAdvanceSeconds,
|
||||
bool ComposeParts);
|
||||
bool ComposeParts,
|
||||
LiveEntityRecord? Record = null,
|
||||
WorldEntity? Entity = null,
|
||||
LiveEntityAnimationState? Animation = null,
|
||||
ulong ObjectClockEpoch = 0UL,
|
||||
ulong ProjectionMutationVersion = 0UL);
|
||||
|
|
|
|||
63
src/AcDream.App/Rendering/LiveEntityAnimationState.cs
Normal file
63
src/AcDream.App/Rendering/LiveEntityAnimationState.cs
Normal file
|
|
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Incarnation-owned live <c>CPartArray</c> presentation state. Time, root
|
||||
/// motion, and hooks remain owned by <see cref="LiveEntityAnimationScheduler"/>;
|
||||
/// this component retains the final part-presentation channels only.
|
||||
/// </summary>
|
||||
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<LiveAnimationPartTemplate> PartTemplate;
|
||||
public required IReadOnlyList<bool> PartAvailability;
|
||||
public float CurrFrame;
|
||||
public AnimationSequencer? Sequencer;
|
||||
|
||||
public IReadOnlyList<PartTransform>? PreparedSequenceFrames;
|
||||
public bool SequenceAdvancedBeforeAnimationPass;
|
||||
public readonly Frame RootMotionScratch = new();
|
||||
public readonly MotionDeltaFrame RootMotionDeltaScratch = new();
|
||||
|
||||
/// <summary>Reusable compact drawable channel, consumed before draw.</summary>
|
||||
public readonly List<MeshRef> MeshRefsScratch = new();
|
||||
/// <summary>Reusable indexed rigid/effect channel.</summary>
|
||||
public readonly List<Matrix4x4> EffectPartPosesScratch = new();
|
||||
/// <summary>
|
||||
/// Indexed visible transforms retained separately from the rigid channel
|
||||
/// because Setup DefaultScale affects geometry but not attachments.
|
||||
/// </summary>
|
||||
public readonly List<Matrix4x4> 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<uint, uint>? SurfaceOverrides,
|
||||
bool IsDrawable);
|
||||
|
|
@ -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.
|
||||
/// </summary>
|
||||
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.
|
||||
/// </summary>
|
||||
public bool TryTakeLivePartFrames(
|
||||
uint ownerId,
|
||||
LiveEntityRecord record,
|
||||
WorldEntity entity,
|
||||
LiveEntityAnimationState animation,
|
||||
ulong objectClockEpoch,
|
||||
ulong projectionMutationVersion,
|
||||
out IReadOnlyList<PartTransform> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scheduler-only test seam for DAT/static timing tests which deliberately
|
||||
/// do not construct a <see cref="LiveEntityRuntime"/>. Runtime integration
|
||||
/// must use the exact-incarnation overload above.
|
||||
/// </summary>
|
||||
internal bool TryTakePreparedFramesForTest(
|
||||
uint ownerId,
|
||||
out IReadOnlyList<PartTransform> 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<PartTransform>();
|
||||
return false;
|
||||
}
|
||||
|
||||
public void Unregister(uint ownerId) => _owners.Remove(ownerId);
|
||||
|
||||
public void CopyAnimatedEntityIdsTo(HashSet<uint> destination)
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
-MathF.PI / 2f),
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
};
|
||||
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<GameWindow.AnimatedPartTemplate>(),
|
||||
PartTemplate = Array.Empty<LiveAnimationPartTemplate>(),
|
||||
PartAvailability = Array.Empty<bool>(),
|
||||
};
|
||||
var motion = new GameWindow.RemoteMotion();
|
||||
|
|
@ -205,7 +205,7 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
Rotation = initial,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
};
|
||||
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<GameWindow.AnimatedPartTemplate>(),
|
||||
PartTemplate = Array.Empty<LiveAnimationPartTemplate>(),
|
||||
PartAvailability = Array.Empty<bool>(),
|
||||
};
|
||||
var motion = new GameWindow.RemoteMotion
|
||||
|
|
@ -260,7 +260,7 @@ public sealed class RemotePhysicsUpdaterTests
|
|||
Rotation = initial,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
};
|
||||
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<GameWindow.AnimatedPartTemplate>(),
|
||||
PartTemplate = Array.Empty<LiveAnimationPartTemplate>(),
|
||||
PartAvailability = Array.Empty<bool>(),
|
||||
};
|
||||
var motion = new GameWindow.RemoteMotion();
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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<uint, uint> { [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<uint, LiveEntityAnimationSchedule>
|
||||
{
|
||||
[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<uint, bool> 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<LiveEntityRecord>();
|
||||
first.Live.CopySpatialRootObjectRecordsTo(nested);
|
||||
Assert.Equal(2, nested.Count);
|
||||
};
|
||||
var presenter = Presenter(first.Live, poses, new Context());
|
||||
var schedules = new Dictionary<uint, LiveEntityAnimationSchedule>
|
||||
{
|
||||
[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<uint, LiveEntityAnimationSchedule> Schedules(
|
||||
Fixture fixture,
|
||||
IReadOnlyList<PartTransform> frames) =>
|
||||
new Dictionary<uint, LiveEntityAnimationSchedule>
|
||||
{
|
||||
[fixture.Entity.Id] = Schedule(fixture, frames),
|
||||
};
|
||||
|
||||
private static LiveEntityAnimationSchedule Schedule(
|
||||
Fixture fixture,
|
||||
IReadOnlyList<PartTransform> 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<WorldEntity>()));
|
||||
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<MeshRef>(),
|
||||
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);
|
||||
}
|
||||
|
|
@ -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<GameWindow.AnimatedPartTemplate>(),
|
||||
PartTemplate = Array.Empty<LiveAnimationPartTemplate>(),
|
||||
PartAvailability = Array.Empty<bool>(),
|
||||
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),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue