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:
Erik 2026-07-21 09:37:02 +02:00
parent 9760ff5a8d
commit f004ac5562
14 changed files with 963 additions and 438 deletions

View file

@ -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,

View file

@ -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);
}

View file

@ -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&lt;MeshRef&gt;(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&lt;MeshRef&gt;(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

View file

@ -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);
}

View 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);
}

View 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;
}

View file

@ -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);

View 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);

View file

@ -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)