refactor(update): cut over the frame orchestrator

Reduce GameWindow.OnUpdate to one typed orchestration call and remove transitive window callbacks from teardown, liveness, teleport placement, live presentation, auto-entry, and entity packet routing. Preserve the frozen retail object/network order while making the production owner graph explicit and testable.
This commit is contained in:
Erik 2026-07-22 03:31:38 +02:00
parent 947c61d2d7
commit e91f310279
18 changed files with 864 additions and 348 deletions

View file

@ -134,7 +134,6 @@ public sealed class GameWindow : IDisposable
private AcDream.App.Streaming.StreamingController? _streamingController;
private AcDream.App.Streaming.StreamingOriginRecenterCoordinator?
_streamingOriginRecenter;
private AcDream.App.Update.IStreamingFramePhase _streamingFrame = null!;
private AcDream.App.Streaming.WorldRevealCoordinator? _worldReveal;
private readonly AcDream.App.Streaming.DeferredLocalPlayerTeleportNetworkSink
_localPlayerTeleportSink = new();
@ -173,10 +172,7 @@ public sealed class GameWindow : IDisposable
private readonly AcDream.App.Input.MovementTruthDiagnosticController
_movementTruthDiagnostics;
private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound;
private AcDream.App.Update.ICameraFramePhase _cameraFrame = null!;
private AcDream.App.World.RetailLiveFrameCoordinator _liveFrameCoordinator = null!;
private AcDream.App.Update.LiveSpatialPresentationReconciler
_liveSpatialReconciler = null!;
private AcDream.App.Update.UpdateFrameOrchestrator _updateFrameOrchestrator = 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;
@ -186,6 +182,7 @@ public sealed class GameWindow : IDisposable
_liveEntityProjectionWithdrawal;
private AcDream.App.World.LiveEntityHydrationController? _liveEntityHydration;
private AcDream.App.Physics.LiveEntityNetworkUpdateController? _liveEntityNetworkUpdates;
private AcDream.App.Net.LiveEntitySessionController _liveEntitySessionEvents = null!;
private readonly AcDream.App.Physics.DeferredLiveEntityMotionRuntimeBindings
_liveEntityMotionBindings = new();
@ -2170,36 +2167,32 @@ public sealed class GameWindow : IDisposable
_entityEffects = entityEffects;
entityEffects.DiagnosticSink = message =>
Console.Error.WriteLine($"vfx: {message}");
var partArrayLifecycle =
new AcDream.App.Rendering.LiveEntityPartArrayLifecycle(
_animatedEntities);
_liveEntityPresentation = new AcDream.App.World.LiveEntityPresentationController(
_liveEntities,
_physicsEngine.ShadowObjects,
entityEffects.PlayTypedFromHiddenTransition,
_equippedChildRenderer.SetDirectChildrenNoDraw,
_liveEntityMotionBindings.ClearTargetForHiddenEntity,
() => (_liveCenterX, _liveCenterY),
handlePartArrayEnterWorld: localEntityId =>
{
if (_animatedEntities.TryGetValue(localEntityId, out var animated))
animated.Sequencer?.Manager.HandleEnterWorld();
});
_liveWorldOrigin.GetCenter,
handlePartArrayEnterWorld: partArrayLifecycle.HandleEnterWorld);
var remoteShadowPlacement =
new AcDream.App.Physics.RemoteShadowPlacementSynchronizer(
_remotePhysicsUpdater,
_liveWorldOrigin);
var remoteTeleportPresentation =
new AcDream.App.Physics.RemoteTeleportPlacementPresentation(
_liveEntityPresentation);
_remoteTeleportController = new AcDream.App.Physics.RemoteTeleportController(
_physicsEngine,
_liveEntities,
_liveEntityMotionBindings.GetSetupCylinder,
CellLocalForSeed,
(entity, remote, cellId) => _remotePhysicsUpdater.SyncRemoteShadowToBody(
entity.Id,
remote,
_liveCenterX,
_liveCenterY,
cellId),
(guid, generation, deferShadowRestore) =>
_liveEntityPresentation.CompleteAuthoritativePlacement(
guid,
generation,
deferShadowRestore),
(guid, generation) =>
_liveEntityPresentation.BeginAuthoritativePlacement(guid, generation));
_liveWorldOrigin.CellLocalForSeed,
remoteShadowPlacement.Sync,
remoteTeleportPresentation.Complete,
remoteTeleportPresentation.Begin);
_equippedChildRenderer.ProjectionPoseReady += guid =>
_liveEntityLights.OnAttachedPoseReady(guid);
_hookRouter.Register(entityEffects);
@ -2484,9 +2477,14 @@ public sealed class GameWindow : IDisposable
_streamingController,
_worldState,
_worldReveal,
() => _playerServerGuid,
_localPlayerIdentity,
sealedDungeonCells,
Console.WriteLine);
_liveSessionController = new AcDream.App.Net.LiveSessionController();
var localPhysicsTimestamps =
new AcDream.App.World.LiveSessionLocalPhysicsTimestampPublisher(
_localPlayerIdentity,
_liveSessionController);
var liveEntityTeardown =
new AcDream.App.World.LiveEntityRuntimeTeardownController(
_liveEntities,
@ -2503,8 +2501,15 @@ public sealed class GameWindow : IDisposable
_physicsEngine.ShadowObjects,
_liveEntityLights!,
_classificationCache,
() => _playerServerGuid);
_localPlayerIdentity);
liveEntityComponentLifecycle.Bind(liveEntityTeardown);
var liveEntityDeletion =
new AcDream.App.World.LiveEntityDeletionController(
_liveEntities,
Objects,
liveEntityTeardown,
_localPlayerIdentity,
_options.DumpLiveSpawns ? Console.WriteLine : null);
_liveEntityHydration = new AcDream.App.World.LiveEntityHydrationController(
_liveEntities,
Objects,
@ -2518,9 +2523,9 @@ public sealed class GameWindow : IDisposable
_liveEntityPresentation!),
originCoordinator,
networkUpdateBridge,
liveEntityTeardown,
PublishLocalPhysicsTimestamps,
() => _playerServerGuid,
localPhysicsTimestamps,
_localPlayerIdentity,
liveEntityDeletion,
_options.DumpLiveSpawns ? Console.WriteLine : null);
_liveEntityNetworkUpdates =
new AcDream.App.Physics.LiveEntityNetworkUpdateController(
@ -2546,18 +2551,24 @@ public sealed class GameWindow : IDisposable
_combatTargetController,
_liveWorldOrigin,
_localPlayerTeleportSink,
() => _playerController,
_playerControllerSlot,
_localPlayerOutbound,
() => _playerHost,
() => _playerServerGuid,
_playerHostSlot,
_localPlayerIdentity,
_updateFrameClock,
() => LiveSession,
PublishLocalPhysicsTimestamps,
_liveSessionController,
localPhysicsTimestamps.Publish,
_movementTruthDiagnostics);
_liveEntityLiveness = new AcDream.App.World.LiveEntityLivenessController(
_liveEntities,
() => _playerServerGuid,
candidate => _liveEntityHydration.OnPrune(candidate));
_localPlayerIdentity,
liveEntityDeletion);
_liveEntitySessionEvents = new AcDream.App.Net.LiveEntitySessionController(
_inboundEntityEvents,
_liveEntityHydration,
_liveEntityNetworkUpdates,
_localPlayerTeleportSink,
_entityEffects!);
parentAcceptance!.Bind(_liveEntityHydration.TryAcceptParentForProjection);
networkUpdateBridge.Bind(_liveEntityNetworkUpdates);
_equippedChildRenderer.EntityReady += candidate =>
@ -2573,7 +2584,6 @@ public sealed class GameWindow : IDisposable
// CreateObject messages into _worldGameState as they arrive. Entirely
// gated behind ACDREAM_LIVE=1 so the default run path is unchanged.
_liveWorldOrigin.SetPlaceholder(centerX, centerY);
_liveSessionController = new AcDream.App.Net.LiveSessionController();
_combatAttackOperations.Bind(
new AcDream.App.Combat.LiveCombatAttackOperations(
Combat,
@ -2609,7 +2619,7 @@ public sealed class GameWindow : IDisposable
mouseLookController,
new AcDream.App.Input.CombatAttackInputFrameAdapter(
_combatAttackController!));
_streamingFrame = new AcDream.App.Streaming.StreamingFrameController(
var streamingFrame = new AcDream.App.Streaming.StreamingFrameController(
_options.LiveMode,
_localPlayerMode,
_playerControllerSlot,
@ -2638,7 +2648,7 @@ public sealed class GameWindow : IDisposable
new AcDream.App.Update.SettingsParticleRangeSource(
_settingsVm,
_persistedDisplay.ParticleRange));
_liveSpatialReconciler =
var liveSpatialReconciler =
new AcDream.App.Update.LiveSpatialPresentationReconciler(
_entityEffects!,
_equippedChildRenderer!,
@ -2721,16 +2731,15 @@ public sealed class GameWindow : IDisposable
_localPlayerSkills,
_viewportAspect);
_playerModeAutoEntry = new AcDream.App.Input.PlayerModeAutoEntry(
isLiveInWorld: () => _liveSessionController.IsInWorld,
isPlayerEntityPresent: () =>
_liveEntities.MaterializedWorldEntities.ContainsKey(
_playerServerGuid),
isPlayerControllerReady: () => true,
// Retail SmartBox::UseTime @ 0x00455410 completes player
// position only after the destination cell load clears.
isWorldReady: LoginWorldReady,
enterPlayerMode: _playerModeController.EnterFromAutoEntry,
isPlayerModeActive: () => _localPlayerMode.IsPlayerMode);
new AcDream.App.Input.LivePlayerModeAutoEntryContext(
_liveSessionController,
_liveEntities,
_localPlayerIdentity,
_worldReveal
?? throw new InvalidOperationException(
"World reveal was not composed before player auto-entry."),
_localPlayerMode,
_playerModeController));
_playerModeController.BindAutoEntry(_playerModeAutoEntry);
var localTeleportPresentation =
new AcDream.App.Streaming.LocalPlayerTeleportPresentation(
@ -2758,7 +2767,7 @@ public sealed class GameWindow : IDisposable
_playerHostSlot,
_chaseCameraInput,
_liveWorldOrigin,
_liveSpatialReconciler),
liveSpatialReconciler),
new AcDream.App.Streaming.LocalPlayerTeleportSession(
_liveSessionController),
localTeleportPresentation);
@ -2767,25 +2776,41 @@ public sealed class GameWindow : IDisposable
// remains only as the staged-startup fallback used by shutdown when
// composition fails before the transfer.
_portalTunnel = null;
_liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator(
var liveFrameCoordinator = new AcDream.App.World.RetailLiveFrameCoordinator(
liveObjectFrame,
_worldState,
_liveSessionController,
localPlayerFrame,
_liveSpatialReconciler);
_cameraFrame = new AcDream.App.Rendering.CameraFrameController(
liveSpatialReconciler);
var cameraFrame = new AcDream.App.Rendering.CameraFrameController(
_cameraController!,
_inputCapture,
_cameraInput,
localPlayerFrameRuntime,
_chaseCameraInput,
localPlayerFrame,
_liveSpatialReconciler,
liveSpatialReconciler,
new AcDream.App.Combat.CombatCameraTargetSource(
_gameplaySettings,
Combat,
_selection,
_worldSelectionQuery!));
_updateFrameOrchestrator = new AcDream.App.Update.UpdateFrameOrchestrator(
new AcDream.App.Update.LiveEntityTeardownFramePhase(
_liveEntities),
new AcDream.App.Update.ConsoleUpdateFrameFailureSink(),
_updateFrameClock,
new AcDream.App.Update.PhysicsScriptClockPublisher(_scriptRunner!),
streamingFrame,
_gameplayInputFrame,
liveFrameCoordinator,
new AcDream.App.Update.LiveEntityLivenessFramePhase(
_liveEntityLiveness,
new AcDream.App.Update.StopwatchClientMonotonicTimeSource()),
_localPlayerTeleport,
new AcDream.App.Update.PlayerModeAutoEntryFramePhase(
_playerModeAutoEntry),
cameraFrame);
AcDream.App.Net.LiveSessionStartResult liveStart =
_liveSessionController.Start(
_options,
@ -2958,7 +2983,7 @@ public sealed class GameWindow : IDisposable
{
events = new AcDream.App.Net.LiveSessionEventRouter(
session,
CreateLiveEntitySessionSink(),
_liveEntitySessionEvents.CreateSink(),
new AcDream.App.Net.LiveEnvironmentSessionSink(
OnEnvironChanged,
ticks =>
@ -2987,44 +3012,6 @@ public sealed class GameWindow : IDisposable
}
}
private AcDream.App.Net.LiveEntitySessionSink CreateLiveEntitySessionSink() => new(
Spawned: spawn => _inboundEntityEvents.Run(
_liveEntityHydration!,
spawn,
static (hydration, value) => hydration.OnCreate(value)),
Deleted: deletion => _inboundEntityEvents.Run(
_liveEntityHydration!, deletion,
static (hydration, value) => hydration.OnDelete(value)),
PickedUp: pickup => _inboundEntityEvents.Run(
_liveEntityHydration!, pickup,
static (hydration, value) => hydration.OnPickup(value)),
MotionUpdated: motion => _inboundEntityEvents.Run(
_liveEntityNetworkUpdates!, motion,
static (updates, value) => updates.OnMotion(value)),
PositionUpdated: position => _inboundEntityEvents.Run(
_liveEntityNetworkUpdates!, position,
static (updates, value) => updates.OnPosition(value)),
VectorUpdated: vector => _inboundEntityEvents.Run(
_liveEntityNetworkUpdates!, vector,
static (updates, value) => updates.OnVector(value)),
StateUpdated: state => _inboundEntityEvents.Run(
_liveEntityNetworkUpdates!, state,
static (updates, value) => updates.OnState(value)),
ParentUpdated: parent => _inboundEntityEvents.Run(
_liveEntityHydration!, parent,
static (hydration, value) => hydration.OnParent(value)),
TeleportStarted: teleport => _inboundEntityEvents.Run(
_localPlayerTeleportSink,
teleport,
static (sink, value) => sink.OnTeleportStarted(value)),
AppearanceUpdated: appearance => _inboundEntityEvents.Run(
_liveEntityHydration!, appearance,
static (hydration, value) => hydration.OnAppearance(value)),
PlayPhysicsScript: script => _inboundEntityEvents.Run(
this, script, static (window, value) => window.OnPlayScriptReceived(value)),
PlayPhysicsScriptType: message => _inboundEntityEvents.Run(
this, message, static (window, value) => window._entityEffects?.HandleTyped(value)));
private AcDream.App.Net.LiveInventorySessionBindings
CreateLiveInventorySessionBindings() => new(
Objects,
@ -3276,47 +3263,6 @@ public sealed class GameWindow : IDisposable
doll.MeshRefs = reposed;
}
private void PublishLocalPhysicsTimestamps(
uint guid,
AcDream.App.World.AcceptedPhysicsTimestamps timestamps)
{
AcDream.Core.Net.WorldSession? session = LiveSession;
if (guid != _playerServerGuid || session is null) return;
session.PublishAcceptedLocalPhysicsTimestamps(
timestamps.Instance,
timestamps.ServerControlledMove,
timestamps.Teleport,
timestamps.ForcePosition);
}
// #145: the LANDBLOCK-relative (cell-local) position used to SEED the player
// body's cell-relative CellPosition. This is the ONE place the streaming center
// (_liveCenter) is allowed to touch the physics frame — at the placement seam,
// converting the render-frame world position into the wire's (cell, local). After
// seeding, physics carries (cell, local) forward without ever reading _liveCenter.
private System.Numerics.Vector3 CellLocalForSeed(System.Numerics.Vector3 worldPos, uint cellId)
{
int lbX = (int)((cellId >> 24) & 0xFFu);
int lbY = (int)((cellId >> 16) & 0xFFu);
var origin = new System.Numerics.Vector3(
(lbX - _liveCenterX) * 192f,
(lbY - _liveCenterY) * 192f,
0f);
return worldPos - origin;
}
private bool LoginWorldReady()
{
return TryGetLoginWorldCell(out uint cell)
&& EvaluateWorldRevealReadiness(cell).IsReady;
}
private AcDream.App.Streaming.WorldRevealReadinessSnapshot EvaluateWorldRevealReadiness(
uint destinationCell)
{
return _worldReveal?.Evaluate(destinationCell) ?? default;
}
private bool TryGetLoginWorldCell(out uint cell)
{
cell = 0;
@ -3329,20 +3275,6 @@ public sealed class GameWindow : IDisposable
return cell != 0;
}
/// <summary>
/// Server-sent direct PhysicsScript (F754). EntityEffectController owns
/// GUID translation and pre-materialization queueing.
///
/// <para>
/// F754 remains a direct PhysicsScript DID. It is never resolved through a
/// typed PhysicsScriptTable.
/// </para>
/// </summary>
private void OnPlayScriptReceived(AcDream.Core.Net.Messages.PlayPhysicsScript message)
{
_entityEffects?.HandleDirect(message);
}
private void UpdateSkyPes(
float dayFraction,
AcDream.Core.World.DayGroupData? dayGroup,
@ -3512,76 +3444,10 @@ public sealed class GameWindow : IDisposable
}
private void OnUpdate(double dt)
{
using var _updStage = _frameProfiler.BeginStage(AcDream.App.Diagnostics.FrameStage.Update);
// A resource callback can request deletion while the presentation
// owner is inside a synchronous resume/suspend transition. The first
// unregister leaves a canonical teardown tombstone; drain it only now,
// after that callback stack has unwound on the same update thread.
try
{
_liveEntityHydration?.RetryPendingTeardowns();
}
catch (AggregateException error)
{
// The tombstone retains every unfinished owner step. Report this
// attempt, but keep the frame advancing so the next update can
// retry after transient renderer/plugin teardown failures.
Console.Error.WriteLine($"[live-entity-teardown] {error}");
}
AcDream.App.Update.UpdateFrameTiming frameTiming = _updateFrameClock.Advance(
using var _updStage = _frameProfiler.BeginStage(
AcDream.App.Diagnostics.FrameStage.Update);
_updateFrameOrchestrator.Tick(
new AcDream.App.Update.UpdateFrameInput(dt));
float frameDelta = frameTiming.SimulationDeltaSecondsSingle;
// Retail ScriptManager::AddScriptInternal (0x0051B310) stamps
// Timer::cur_time at the packet/default-script call site. Publish this
// update frame's clock before streaming and network dispatch, then
// reuse the same timestamp for animation hooks and the later drain.
_scriptRunner?.PublishTime(frameTiming.ScriptTime);
// Streaming publishes before inbound CreateObject dispatch so newly
// accepted live projections can enter a resident bucket this frame.
_streamingFrame.Tick();
// Input callbacks feed the current object tick. Packets already read
// by Core.Net remain queued until the retail object/physics phase has
// consumed that input and published its final pose.
_gameplayInputFrame!.Tick(frameTiming);
// Drain pending live-session traffic AFTER streaming so any incoming
// CreateObject events find their landblock already loaded in
// GpuWorldState. Non-blocking — returns immediately if no datagrams
// are in the kernel buffer. Fires EntitySpawned events synchronously.
// Step 2: routed through the controller; functionally identical.
// Retail SmartBox::UseTime (0x00455410) advances CObjectMaint and
// CPhysics before it drains the inbound event queue. Keep the complete
// live-object phase on that side of the barrier too. In particular,
// the recall action retires before ACE's Hidden SetState freezes its
// PartArray at the teleport boundary.
_liveFrameCoordinator.Tick(frameDelta);
_liveEntityLiveness?.Tick(ClientTimerNow());
// Retail teleport transit runs after streaming and inbound state so a
// newly resident destination can materialize in this same frame. The
// focused owner also converges an F751 that arrived before the local
// player projection/controller became available.
_localPlayerTeleport!.Tick(frameDelta);
// Phase K.1a — tick the input dispatcher so Hold-type bindings
// re-fire while their chord is held. K.1b adds the subscribers
// that actually consume the events.
// Phase D.5.3a — advance the selected-object overlay flash (0.25s green pulse
// on selection, then revert). No-op when nothing is flashing.
// Retained panel-local timers advance through RetailUiRuntime.Tick on the draw seam.
// Phase K.2 — auto-enter player mode at login. The guard
// returns true on the firing tick (one-shot); subsequent ticks
// are no-ops. Skipped offline (no active session → IsLiveInWorld
// predicate stays false). Cancelled by manual fly-toggle in
// OnInputAction (Ctrl+Tab) or DebugPanel.
_playerModeAutoEntry?.TryEnter();
_cameraFrame.Tick(frameTiming);
}
private void OnCameraModeChanged(bool _modeBool)