refactor(runtime): extract the live object frame

This commit is contained in:
Erik 2026-07-22 00:42:26 +02:00
parent 99a3e819c4
commit 4e4aac2c5a
27 changed files with 1217 additions and 371 deletions

View file

@ -10,7 +10,7 @@ using Silk.NET.Windowing;
namespace AcDream.App.Rendering;
public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
public sealed class GameWindow : IDisposable
{
private static double ClientTimerNow() =>
System.Diagnostics.Stopwatch.GetTimestamp()
@ -152,12 +152,14 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_remoteInboundMotion;
private readonly AcDream.App.World.RetailInboundEventDispatcher
_inboundEntityEvents = new();
private readonly LiveEntityAnimationScheduler _liveAnimationScheduler;
private readonly LiveEntityAnimationPresenter _animationPresenter;
private LiveEntityAnimationScheduler _liveAnimationScheduler = null!;
private LiveEntityAnimationPresenter _animationPresenter = null!;
private readonly AcDream.App.Input.LocalPlayerProjectionController _localPlayerProjection;
private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound;
private readonly AcDream.App.Input.RetailLocalPlayerFrameController _localPlayerFrame;
private readonly AcDream.App.World.RetailLiveFrameCoordinator _liveFrameCoordinator;
private AcDream.App.World.RetailLiveFrameCoordinator _liveFrameCoordinator = null!;
private AcDream.App.Update.LiveSpatialPresentationReconciler
_liveSpatialReconciler = null!;
private readonly AcDream.App.Update.UpdateFrameClock _updateFrameClock = new();
private AcDream.App.Physics.RemoteTeleportController? _remoteTeleportController;
// Step 7 projectile presentation. The controller owns no identity map;
@ -247,6 +249,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
/// appear in this map.
/// </summary>
private readonly LiveEntityAnimationRuntimeView<LiveEntityAnimationState> _animatedEntities;
private readonly AcDream.App.World.LiveEntityRuntimeSlot _liveEntityRuntimeSlot = new();
// MP-Alloc (2026-07-05): reusable per-frame snapshot of _animatedEntities.Keys.
// WbDrawDispatcher.WalkEntitiesInto treats null and an empty set identically
@ -520,11 +523,21 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
private int _weatherDebugStep = 0;
// Phase B.2: player movement mode.
private AcDream.App.Input.PlayerMovementController? _playerController;
private readonly AcDream.App.Input.LocalPlayerControllerSlot _playerControllerSlot = new();
private AcDream.App.Input.PlayerMovementController? _playerController
{
get => _playerControllerSlot.Controller;
set => _playerControllerSlot.Controller = value;
}
private AcDream.App.Rendering.ChaseCamera? _chaseCamera;
private AcDream.App.Rendering.RetailChaseCamera? _retailChaseCamera;
private bool _playerMode;
private uint _playerServerGuid;
private readonly AcDream.App.Input.LocalPlayerIdentityState _localPlayerIdentity = new();
private uint _playerServerGuid
{
get => _localPlayerIdentity.ServerGuid;
set => _localPlayerIdentity.ServerGuid = value;
}
// Retail Default_CharacterOption (acclient.h:3434). PlayerDescription
// replaces this before any in-world drag can normally occur.
private AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1
@ -745,7 +758,8 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_selection = selection ?? throw new System.ArgumentNullException(nameof(selection));
_animationDiagnostics = AnimationPresentationDiagnostics.FromEnvironment();
_uiRegistry = uiRegistry;
_animatedEntities = new LiveEntityAnimationRuntimeView<LiveEntityAnimationState>(() => _liveEntities);
_animatedEntities = new LiveEntityAnimationRuntimeView<LiveEntityAnimationState>(
_liveEntityRuntimeSlot);
SpellBook = new AcDream.Core.Spells.Spellbook();
LocalPlayer = new AcDream.Core.Player.LocalPlayerState(SpellBook);
// #184 Slice 2a: the extracted per-remote DR tick. Its stateful
@ -761,25 +775,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
movement, cellId, update),
(host, targetGuid) =>
_liveEntityMotionBindings.StickToObjectFromWire(host, targetGuid));
var ordinaryPhysicsUpdater =
new AcDream.App.Physics.LiveEntityOrdinaryPhysicsUpdater(
_physicsEngine,
_liveEntityMotionBindings.GetSetupCylinder);
_liveAnimationScheduler = new LiveEntityAnimationScheduler(
() => _liveEntities,
() => _playerServerGuid,
_remotePhysicsUpdater,
ordinaryPhysicsUpdater,
() => _projectileController,
entity => _effectPoses.UpdateRoot(entity),
CaptureAnimationHooks);
_animationPresenter = new LiveEntityAnimationPresenter(
() => _liveEntities,
() => _staticAnimationScheduler,
_effectPoses,
this,
_animationDiagnostics,
options.HidePartIndex);
_localPlayerProjection = new AcDream.App.Input.LocalPlayerProjectionController(
resolveEntity: () =>
_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var entity)
@ -824,16 +819,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
objectClockDisposition: () =>
_liveEntities?.GetRootObjectClockDisposition(_playerServerGuid)
?? AcDream.Core.Physics.RetailObjectClockDisposition.Advance);
_liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator(
AdvanceLiveObjectRuntime,
() =>
{
using AcDream.App.Streaming.GpuWorldState.MutationBatch spatialBatch =
_worldState.BeginMutationBatch();
_liveSessionController?.Tick();
},
_localPlayerFrame.RunPostNetworkCommandPhase,
ReconcileLiveObjectSpatialPresentation);
}
public void Run()
@ -2080,34 +2065,20 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// dat-lookup throws — see C.1.5a spec §6 (error handling) for rationale.
AcDream.App.Rendering.Vfx.EntityEffectController? entityEffects = null;
var staticLiveRootCommitter = new StaticLiveRootCommitter(
() => _liveEntities,
_liveEntityRuntimeSlot,
_physicsEngine.ShadowObjects,
() => (_liveCenterX, _liveCenterY),
entity => _effectPoses.UpdateRoot(entity));
_liveWorldOrigin,
_effectPoses);
var staticAnimationResidency = new LiveStaticAnimationResidency(
_liveEntityRuntimeSlot);
_staticAnimationScheduler = new RetailStaticAnimatingObjectScheduler(
capturedAnimLoader!,
CaptureAnimationHooks,
(entity, poses, availability) =>
_effectPoses.Publish(entity, poses, availability),
entity => entity.ServerGuid == 0
|| (_liveEntities?.TryGetRecord(
entity.ServerGuid,
out LiveEntityRecord resident) == true
&& resident.ProjectionKind is LiveEntityProjectionKind.World
&& ReferenceEquals(resident.WorldEntity, entity)
&& resident.IsSpatiallyProjected
&& resident.IsSpatiallyVisible
&& resident.FullCellId != 0),
_animationHookFrames!.Capture,
_effectPoses.Publish,
staticAnimationResidency.IsResident,
(entity, body) =>
_ = staticLiveRootCommitter.Commit(entity, body),
entity => entity.ServerGuid == 0
? 0UL
: _liveEntities?.TryGetRecord(
entity.ServerGuid,
out LiveEntityRecord resident) == true
&& ReferenceEquals(resident.WorldEntity, entity)
? resident.ProjectionMutationVersion
: ulong.MaxValue);
staticAnimationResidency.ProjectionVersion);
AcDream.App.Rendering.Vfx.ScriptActivationInfo? ResolveActivation(AcDream.Core.World.WorldEntity e)
{
try
@ -2183,6 +2154,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
entityScriptActivator.OnCreate,
entityScriptActivator.OnRemove)),
liveEntityComponentLifecycle);
_liveEntityRuntimeSlot.Bind(_liveEntities);
liveEntityMotionRuntime =
new AcDream.App.Physics.LiveEntityMotionRuntimeController(
_liveEntities,
@ -2204,17 +2176,9 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_projectileController = new AcDream.App.Physics.ProjectileController(
_liveEntities,
_physicsEngine,
setupId =>
{
// State can classify an already-live object as Missile
// outside the spawn handler's lock. DatCollection shares
// one mutable reader cursor with the streaming worker, so
// this resolver must serialize both initial and late bind.
lock (_datLock)
return _dats?.Get<DatReaderWriter.DBObjs.Setup>(setupId);
},
entity => _effectPoses.UpdateRoot(entity),
() => (_liveCenterX, _liveCenterY))
new AcDream.App.Physics.DatProjectileSetupResolver(_dats!, _datLock),
new EntityRootPosePublisher(_effectPoses),
_liveWorldOrigin)
{
DiagnosticSink = message => Console.Error.WriteLine($"projectile: {message}"),
};
@ -2233,6 +2197,29 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_lightingSink!,
setupId => _dats!.Get<DatReaderWriter.DBObjs.Setup>(setupId));
var ordinaryPhysicsUpdater =
new AcDream.App.Physics.LiveEntityOrdinaryPhysicsUpdater(
_physicsEngine,
_liveEntityMotionBindings.GetSetupCylinder);
_liveAnimationScheduler = new LiveEntityAnimationScheduler(
_liveEntities,
_localPlayerIdentity,
_remotePhysicsUpdater,
ordinaryPhysicsUpdater,
_projectileController,
new EntityRootPosePublisher(_effectPoses),
new AnimationHookCaptureSink(_animationHookFrames!));
_animationPresenter = new LiveEntityAnimationPresenter(
_liveEntities,
_staticAnimationScheduler,
_effectPoses,
new LiveAnimationPresentationContext(
_liveEntities,
_localPlayerIdentity,
_playerControllerSlot),
_animationDiagnostics,
_options.HidePartIndex);
parentAcceptance = new AcDream.App.World.DeferredLiveEntityParentAcceptance();
_equippedChildRenderer = new AcDream.App.Rendering.EquippedChildRenderController(
_dats!,
@ -2627,7 +2614,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
_remoteTeleportController!,
_animatedEntities,
new LiveEntityRemoteMotionRuntimeView<RemoteMotion>(
() => _liveEntities),
_liveEntityRuntimeSlot),
_remoteMovementObservations,
_remotePhysicsUpdater,
_remoteInboundMotion,
@ -2667,6 +2654,44 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// gated behind ACDREAM_LIVE=1 so the default run path is unchanged.
_liveWorldOrigin.SetPlaceholder(centerX, centerY);
_liveSessionController = new AcDream.App.Net.LiveSessionController();
var liveEffectFrame = new AcDream.App.Update.LiveEffectFrameController(
_translucencyFades,
_animationHookFrames!,
_entityEffects!,
_particleSink!,
_liveEntityLights!,
_particleVisibility,
_particleSystem!,
_scriptRunner!,
_updateFrameClock,
new AcDream.App.Update.SettingsParticleRangeSource(
_settingsVm,
_persistedDisplay.ParticleRange));
var liveObjectFrame = new AcDream.App.Update.LiveObjectFrameController(
_inboundEntityEvents,
_localPlayerFrame,
_selectionInteractions,
_liveEntities,
_localPlayerIdentity,
_liveWorldOrigin,
_liveAnimationScheduler,
_staticAnimationScheduler,
_animationPresenter,
_animatedEntities,
_equippedChildRenderer!,
liveEffectFrame);
_liveSpatialReconciler =
new AcDream.App.Update.LiveSpatialPresentationReconciler(
_entityEffects!,
_equippedChildRenderer!,
_particleSink!,
_liveEntityLights!);
_liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator(
liveObjectFrame,
_worldState,
_liveSessionController,
_localPlayerFrame,
_liveSpatialReconciler);
AcDream.App.Net.LiveSessionStartResult liveStart =
_liveSessionController.Start(
_options,
@ -3520,7 +3545,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// camera path re-extends the boom. Updating directly from the stale
// source-world viewer made the destination bend across the reveal.
_retailChaseCamera?.ResetViewerToPlayer(snappedPos, _playerController.Yaw);
ReconcileLiveObjectSpatialPresentation();
_liveSpatialReconciler.Reconcile();
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
"PLACED", resolved.CellId, $"forced={forced}");
@ -4203,7 +4228,7 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
// The player was materialized by this frame's inbound pass.
// Its non-advancing initial projection must publish child and
// effect anchors before the first draw too.
ReconcileLiveObjectSpatialPresentation();
_liveSpatialReconciler.Reconcile();
}
// Update chase camera(s). The CameraController exposes whichever
@ -5715,102 +5740,6 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
LastFrameProfile: _frameProfiler.LastReport);
}
/// <summary>
/// Advances the live object's retail UseTime phase before inbound packet
/// dispatch. Final root/part poses and their animation hooks are published
/// together so rendering consumes one coherent update snapshot.
/// </summary>
private void AdvanceLiveObjectRuntime(float dt) =>
_inboundEntityEvents.Run(
this,
dt,
static (window, deltaTime) =>
window.AdvanceLiveObjectRuntimeCore(deltaTime));
private void AdvanceLiveObjectRuntimeCore(float dt)
{
// The local body participates in the same retail CPhysics phase as
// remote objects. Its outbound movement snapshot is completed here,
// before SmartBox dispatch can apply F751/ForcePosition.
_localPlayerFrame.AdvanceBeforeNetwork(dt);
// Retail input ordering: movement edges reach MoveToState before a
// later Use/PickUp action from the same input frame. ACE cancels an
// active server MoveTo chain when it receives MoveToState, so sending
// Use directly from Silk's key callback inverted that order and left
// the client walking to a corpse whose server callback was cancelled.
_selectionInteractions?.DrainOutbound();
System.Numerics.Vector3? playerPosition =
_entitiesByServerGuid.TryGetValue(
_playerServerGuid,
out var ordinaryObjectViewer)
? ordinaryObjectViewer.Position
: null;
IReadOnlyDictionary<uint, LiveEntityAnimationSchedule> animationSchedules =
_liveAnimationScheduler.Tick(
dt,
playerPosition,
_localPlayerFrame.HiddenPartPoseDirty,
_liveCenterX,
_liveCenterY,
_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)
_animationPresenter.Present(animationSchedules);
_equippedChildRenderer?.Tick();
// CPhysicsObj::animate_static_object (0x00513DF0) reaches
// process_hooks only after UpdatePartsInternal and
// UpdateChildrenInternal. Ordinary objects complete AnimationDone in
// their earlier UpdatePositionInternal slot; the static workset has
// this distinct tail.
_staticAnimationScheduler?.ProcessHooks();
// Advance already-active FP hooks before routing hooks emitted by
// this PartArray update. A newly created translucency hook begins at
// its authored Start value this frame; it does not consume dt twice.
_translucencyFades.AdvanceAll(dt);
_animationHookFrames?.Drain();
_entityEffects?.RefreshLiveOwnerPoses();
_particleSink?.RefreshAttachedEmitters();
_liveEntityLights?.Refresh();
if (_particleSystem is not null)
{
var particleRange = _settingsVm?.DisplayDraft.ParticleRange
?? _persistedDisplay.ParticleRange;
float rangeMultiplier = particleRange
== AcDream.UI.Abstractions.Panels.Settings.ParticleRange.Extended
? AcDream.App.Rendering.Vfx.ParticleVisibilityController.ExtendedRangeMultiplier
: 1f;
_particleVisibility.Apply(_particleSystem, rangeMultiplier);
}
// Retail CPhysicsObj::UpdateObjectInternal (0x005156B0) advances
// ParticleManager before ScriptManager. A particle created by a PES
// hook therefore begins simulation on the following object frame.
_particleSystem?.Tick(dt);
_scriptRunner?.Tick(_updateFrameClock.CurrentScriptTime);
}
/// <summary>
/// Re-composes spatial derivatives after authoritative packet handlers
/// move roots. This pass advances no time, animation, scripts, particles,
/// or fades; it only makes children and live anchors agree with the root
/// that the same frame will draw.
/// </summary>
private void ReconcileLiveObjectSpatialPresentation()
{
// Authoritative handlers mutate WorldEntity roots directly. Publish
// those roots before children read their parent's pose; child Tick
// then publishes each newly composed child pose for attached effects.
_entityEffects?.RefreshLiveOwnerPoses();
_equippedChildRenderer?.Tick();
_particleSink?.RefreshAttachedEmitters();
_liveEntityLights?.Refresh();
}
/// <summary>
/// Phase 6.4: advance every animated entity's frame counter by
/// <paramref name="dt"/> * Framerate, wrapping around the cycle's
@ -5855,33 +5784,10 @@ public sealed class GameWindow : IDisposable, ILiveAnimationPresentationContext
&& _animatedEntities.TryGetValue(entity.Id, out var ae)
&& ae.Sequencer is { } sequencer)
{
CaptureAnimationHooks(ae.Entity.Id, sequencer);
_animationHookFrames?.Capture(ae.Entity.Id, sequencer);
}
}
private void CaptureAnimationHooks(
uint ownerLocalId,
AcDream.Core.Physics.AnimationSequencer sequencer) =>
_animationHookFrames?.Capture(ownerLocalId, sequencer);
uint ILiveAnimationPresentationContext.LocalPlayerGuid => _playerServerGuid;
AcDream.Core.Physics.MotionInterpreter?
ILiveAnimationPresentationContext.ResolveMotionInterpreter(
LiveEntityRecord record)
{
if (_liveEntities?.TryGetRecord(record.ServerGuid, out LiveEntityRecord current) != true
|| !ReferenceEquals(current, record))
{
return null;
}
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
// narrowing that dropped settled-open doors / faded-out walls back onto the
// stale Tier-1 rest-pose cache entry (the door/fade "flip-back"). See the