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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue