diff --git a/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md b/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md
index 4b2af346..7cef3403 100644
--- a/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md
+++ b/docs/plans/2026-07-21-gamewindow-slice-6-update-frame-orchestration.md
@@ -22,9 +22,9 @@ never hidden behind a retry, delay, suppression flag, or reordered callback.
collapse, streaming tick, and rescued-entity rebucketing.
- [x] D — extract dispatcher/raw-mouse/combat input sampling without changing
movement-command or UI-capture semantics.
-- [ ] E — extract the complete local-player teleport owner and wire it between
+- [x] E — extract the complete local-player teleport owner and wire it between
the existing post-network liveness and player-mode auto-entry phases.
-- [ ] F — extract the shared player-mode lifecycle plus fly/chase/player
+- [x] F — extract the shared player-mode lifecycle plus fly/chase/player
camera presentation and cut production over to one
`UpdateFrameOrchestrator`.
- [ ] G — delete the old `OnUpdate` bodies, callback facades, and obsolete
@@ -455,6 +455,20 @@ lines at this checkpoint, down from the slice baseline of 15,723.
- measure line/field/method count. The target is below 8,000 lines, but the
ownership/ordering exit criteria outrank the count.
+**Checkpoint F completed 2026-07-22.** `CameraFrameController` now owns fly
+held input, retail chase adjustment, the inbound-created-player projection
+and second reconcile, both chase-camera publications, and combat-target
+tracking. `RetailLocalPlayerFrameController` and
+`LocalPlayerProjectionController` resolve current identity, session, world,
+shadow, and player slots through focused typed runtimes instead of callbacks
+into `GameWindow`; the camera receives only the two-read player-presentation
+seam. Projection and frame owners remain composition locals, so the window
+retains no duplicate lifecycle state. Focused Release tests are green at
+37/37, the App suite at 2,820 passed / 3 skipped, and the full suite at 7,179
+passed / 5 skipped. All three corrected-diff reviews are clean.
+`GameWindow.cs` is 7,160 lines, down 8,563 lines (54.5%) from the 15,723-line
+slice baseline.
+
### H — release and connected gates
After all three independent corrected-diff reviews are clean:
diff --git a/src/AcDream.App/Combat/CombatCameraTargetSource.cs b/src/AcDream.App/Combat/CombatCameraTargetSource.cs
new file mode 100644
index 00000000..2f2756ed
--- /dev/null
+++ b/src/AcDream.App/Combat/CombatCameraTargetSource.cs
@@ -0,0 +1,53 @@
+using System.Numerics;
+using AcDream.App.Interaction;
+using AcDream.Core.Combat;
+using AcDream.Core.Selection;
+
+namespace AcDream.App.Combat;
+
+internal interface ICombatCameraTargetSource
+{
+ Vector3? GetTrackedTargetPoint();
+}
+
+///
+/// Resolves retail's combat-camera target from canonical combat, selection,
+/// gameplay-option, and world-selection owners.
+///
+///
+/// ClientCombatSystem::UpdateTargetTracking @ 0x0056A950 enables
+/// CameraSet::TrackTarget only for melee/missile combat with the option
+/// enabled and a valid attack target. TrackTarget applies target-local offset
+/// (0, 0, 0.5).
+///
+internal sealed class CombatCameraTargetSource : ICombatCameraTargetSource
+{
+ private readonly ICombatGameplaySettingsSource _settings;
+ private readonly CombatState _combat;
+ private readonly SelectionState _selection;
+ private readonly IWorldSelectionQuery _world;
+
+ public CombatCameraTargetSource(
+ ICombatGameplaySettingsSource settings,
+ CombatState combat,
+ SelectionState selection,
+ IWorldSelectionQuery world)
+ {
+ _settings = settings ?? throw new ArgumentNullException(nameof(settings));
+ _combat = combat ?? throw new ArgumentNullException(nameof(combat));
+ _selection = selection ?? throw new ArgumentNullException(nameof(selection));
+ _world = world ?? throw new ArgumentNullException(nameof(world));
+ }
+
+ public Vector3? GetTrackedTargetPoint()
+ {
+ if (!_settings.ViewCombatTarget
+ || !CombatInputPlanner.SupportsTargetedAttack(_combat.CurrentMode)
+ || _selection.SelectedObjectId is not uint selected)
+ {
+ return null;
+ }
+
+ return _world.GetCombatCameraTargetPoint(selected);
+ }
+}
diff --git a/src/AcDream.App/Combat/LiveCombatAttackOperations.cs b/src/AcDream.App/Combat/LiveCombatAttackOperations.cs
index 0d5ee7ee..c37f4328 100644
--- a/src/AcDream.App/Combat/LiveCombatAttackOperations.cs
+++ b/src/AcDream.App/Combat/LiveCombatAttackOperations.cs
@@ -15,6 +15,7 @@ internal interface ICombatGameplaySettingsSource
{
bool AutoTarget { get; }
bool AutoRepeatAttack { get; }
+ bool ViewCombatTarget { get; }
}
internal sealed class GameplaySettingsState : ICombatGameplaySettingsSource
@@ -22,6 +23,7 @@ internal sealed class GameplaySettingsState : ICombatGameplaySettingsSource
public GameplaySettings Value { get; set; } = GameplaySettings.Default;
public bool AutoTarget => Value.AutoTarget;
public bool AutoRepeatAttack => Value.AutoRepeatAttack;
+ public bool ViewCombatTarget => Value.ViewCombatTarget;
}
internal interface ICombatFeedbackSink
diff --git a/src/AcDream.App/Input/DispatcherCameraInputSource.cs b/src/AcDream.App/Input/DispatcherCameraInputSource.cs
new file mode 100644
index 00000000..4b4e982d
--- /dev/null
+++ b/src/AcDream.App/Input/DispatcherCameraInputSource.cs
@@ -0,0 +1,72 @@
+using AcDream.UI.Abstractions.Input;
+
+namespace AcDream.App.Input;
+
+internal readonly record struct FlyCameraInput(
+ bool Forward,
+ bool Left,
+ bool Backward,
+ bool Right,
+ bool Up,
+ bool Down,
+ bool Boost);
+
+internal readonly record struct ChaseCameraAdjustmentInput(
+ bool ZoomIn,
+ bool ZoomOut,
+ bool Raise,
+ bool Lower);
+
+internal interface ICameraFrameInputSource
+{
+ bool IsAvailable { get; }
+ FlyCameraInput CaptureFly();
+ ChaseCameraAdjustmentInput CaptureChaseAdjustment();
+}
+
+///
+/// Samples held camera actions from the same semantic dispatcher used by
+/// gameplay movement. Binding is a one-time startup edge.
+///
+internal sealed class DispatcherCameraInputSource : ICameraFrameInputSource
+{
+ private InputDispatcher? _dispatcher;
+
+ public bool IsAvailable => _dispatcher is not null;
+
+ public void Bind(InputDispatcher dispatcher)
+ {
+ ArgumentNullException.ThrowIfNull(dispatcher);
+ if (_dispatcher is not null && !ReferenceEquals(_dispatcher, dispatcher))
+ throw new InvalidOperationException(
+ "The camera input source is already bound to another dispatcher.");
+ _dispatcher = dispatcher;
+ }
+
+ public FlyCameraInput CaptureFly()
+ {
+ if (_dispatcher is not { } dispatcher)
+ return default;
+
+ return new FlyCameraInput(
+ dispatcher.IsActionHeld(InputAction.MovementForward),
+ dispatcher.IsActionHeld(InputAction.MovementTurnLeft),
+ dispatcher.IsActionHeld(InputAction.MovementBackup),
+ dispatcher.IsActionHeld(InputAction.MovementTurnRight),
+ dispatcher.IsActionHeld(InputAction.MovementJump),
+ dispatcher.IsActionHeld(InputAction.AcdreamFlyDown),
+ dispatcher.IsActionHeld(InputAction.MovementRunLock));
+ }
+
+ public ChaseCameraAdjustmentInput CaptureChaseAdjustment()
+ {
+ if (_dispatcher is not { } dispatcher)
+ return default;
+
+ return new ChaseCameraAdjustmentInput(
+ dispatcher.IsActionHeld(InputAction.CameraZoomIn),
+ dispatcher.IsActionHeld(InputAction.CameraZoomOut),
+ dispatcher.IsActionHeld(InputAction.CameraRaise),
+ dispatcher.IsActionHeld(InputAction.CameraLower));
+ }
+}
diff --git a/src/AcDream.App/Input/DispatcherMovementInputSource.cs b/src/AcDream.App/Input/DispatcherMovementInputSource.cs
index c5207ab7..b8a2e20f 100644
--- a/src/AcDream.App/Input/DispatcherMovementInputSource.cs
+++ b/src/AcDream.App/Input/DispatcherMovementInputSource.cs
@@ -20,6 +20,7 @@ internal sealed class DispatcherMovementInputSource : IMovementInputSource
_capture = capture;
public bool AutoRunActive { get; private set; }
+ public bool IsAvailable => _dispatcher is not null;
public void Bind(InputDispatcher dispatcher)
{
diff --git a/src/AcDream.App/Input/LocalPlayerFrameRuntime.cs b/src/AcDream.App/Input/LocalPlayerFrameRuntime.cs
new file mode 100644
index 00000000..f659ca88
--- /dev/null
+++ b/src/AcDream.App/Input/LocalPlayerFrameRuntime.cs
@@ -0,0 +1,122 @@
+using AcDream.App.Net;
+using AcDream.App.Physics;
+using AcDream.App.Rendering;
+using AcDream.App.World;
+using AcDream.Core.Physics;
+
+namespace AcDream.App.Input;
+
+///
+/// Focused runtime seam for the local player's retail object and command
+/// phases. The frame controller owns ordering; this seam resolves the current
+/// session-scoped owners without retaining callbacks into the window host.
+///
+internal interface ILocalPlayerPresentationRuntime
+{
+ bool CanPresentPlayer { get; }
+ PlayerMovementController? Controller { get; }
+}
+
+internal interface ILocalPlayerFrameRuntime : ILocalPlayerPresentationRuntime
+{
+ uint ResolveLocalEntityId();
+ void HandleTargeting();
+ bool IsHidden { get; }
+ RetailObjectClockDisposition ObjectClockDisposition { get; }
+ void Project(PlayerMovementController controller, MovementResult movement, bool hidden);
+ void SendPreNetwork(PlayerMovementController controller, MovementResult movement, bool hidden);
+ void SendPostNetwork(PlayerMovementController controller, bool hidden);
+}
+
+///
+/// Production local-player frame runtime composed from canonical typed owners.
+///
+internal sealed class LiveLocalPlayerFrameRuntime : ILocalPlayerFrameRuntime
+{
+ private readonly CameraController _camera;
+ private readonly ILocalPlayerModeSource _mode;
+ private readonly ILocalPlayerControllerSource _controller;
+ private readonly IChaseCameraSource _chase;
+ private readonly DispatcherMovementInputSource _input;
+ private readonly IInputCaptureSource _capture;
+ private readonly LiveEntityRuntime _liveEntities;
+ private readonly ILocalPlayerIdentitySource _identity;
+ private readonly ILocalPlayerPhysicsHostSource _physicsHost;
+ private readonly LocalPlayerProjectionController _projection;
+ private readonly LocalPlayerOutboundController _outbound;
+ private readonly ILiveWorldSessionSource _session;
+
+ public LiveLocalPlayerFrameRuntime(
+ CameraController camera,
+ ILocalPlayerModeSource mode,
+ ILocalPlayerControllerSource controller,
+ IChaseCameraSource chase,
+ DispatcherMovementInputSource input,
+ IInputCaptureSource capture,
+ LiveEntityRuntime liveEntities,
+ ILocalPlayerIdentitySource identity,
+ ILocalPlayerPhysicsHostSource physicsHost,
+ LocalPlayerProjectionController projection,
+ LocalPlayerOutboundController outbound,
+ ILiveWorldSessionSource session)
+ {
+ _camera = camera ?? throw new ArgumentNullException(nameof(camera));
+ _mode = mode ?? throw new ArgumentNullException(nameof(mode));
+ _controller = controller ?? throw new ArgumentNullException(nameof(controller));
+ _chase = chase ?? throw new ArgumentNullException(nameof(chase));
+ _input = input ?? throw new ArgumentNullException(nameof(input));
+ _capture = capture ?? throw new ArgumentNullException(nameof(capture));
+ _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
+ _identity = identity ?? throw new ArgumentNullException(nameof(identity));
+ _physicsHost = physicsHost ?? throw new ArgumentNullException(nameof(physicsHost));
+ _projection = projection ?? throw new ArgumentNullException(nameof(projection));
+ _outbound = outbound ?? throw new ArgumentNullException(nameof(outbound));
+ _session = session ?? throw new ArgumentNullException(nameof(session));
+ }
+
+ public bool CanPresentPlayer =>
+ !_camera.IsFlyMode
+ && _mode.IsPlayerMode
+ && _controller.Controller is not null
+ && _chase.Legacy is not null
+ && _input.IsAvailable
+ && !_capture.DevToolsWantCaptureKeyboard;
+
+ public PlayerMovementController? Controller => _controller.Controller;
+
+ public uint ResolveLocalEntityId() =>
+ _liveEntities.TryGetWorldEntity(_identity.ServerGuid, out var entity)
+ ? entity.Id
+ : 0u;
+
+ public void HandleTargeting() => _physicsHost.Host?.HandleTargetting();
+
+ public bool IsHidden => _liveEntities.IsHidden(_identity.ServerGuid);
+
+ public RetailObjectClockDisposition ObjectClockDisposition =>
+ _liveEntities.GetRootObjectClockDisposition(_identity.ServerGuid);
+
+ public void Project(
+ PlayerMovementController controller,
+ MovementResult movement,
+ bool hidden) =>
+ _projection.Project(controller, movement, hidden);
+
+ public void SendPreNetwork(
+ PlayerMovementController controller,
+ MovementResult movement,
+ bool hidden) =>
+ _outbound.SendPreNetworkActions(
+ _session.CurrentSession,
+ controller,
+ movement,
+ hidden);
+
+ public void SendPostNetwork(
+ PlayerMovementController controller,
+ bool hidden) =>
+ _outbound.SendPostNetworkPosition(
+ _session.CurrentSession,
+ controller,
+ hidden);
+}
diff --git a/src/AcDream.App/Input/LocalPlayerProjectionController.cs b/src/AcDream.App/Input/LocalPlayerProjectionController.cs
index 0d5697bc..8a32c4d7 100644
--- a/src/AcDream.App/Input/LocalPlayerProjectionController.cs
+++ b/src/AcDream.App/Input/LocalPlayerProjectionController.cs
@@ -1,47 +1,77 @@
using AcDream.Core.World;
+using AcDream.App.Physics;
+using AcDream.App.World;
namespace AcDream.App.Input;
+internal interface ILocalPlayerProjectionRuntime
+{
+ WorldEntity? ResolveEntity();
+ int LiveCenterX { get; }
+ int LiveCenterY { get; }
+ void SyncShadow(WorldEntity entity, uint cellId);
+ void Rebucket(uint serverGuid, uint landblockId);
+ bool IsCurrentVisibleProjection(WorldEntity entity);
+ void SuspendShadow(WorldEntity entity);
+}
+
+internal sealed class LiveLocalPlayerProjectionRuntime
+ : ILocalPlayerProjectionRuntime
+{
+ private readonly LiveEntityRuntime _liveEntities;
+ private readonly ILocalPlayerIdentitySource _identity;
+ private readonly LiveWorldOriginState _origin;
+ private readonly LocalPlayerShadowSynchronizer _shadow;
+
+ public LiveLocalPlayerProjectionRuntime(
+ LiveEntityRuntime liveEntities,
+ ILocalPlayerIdentitySource identity,
+ LiveWorldOriginState origin,
+ LocalPlayerShadowSynchronizer shadow)
+ {
+ _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
+ _identity = identity ?? throw new ArgumentNullException(nameof(identity));
+ _origin = origin ?? throw new ArgumentNullException(nameof(origin));
+ _shadow = shadow ?? throw new ArgumentNullException(nameof(shadow));
+ }
+
+ public WorldEntity? ResolveEntity() =>
+ _liveEntities.TryGetWorldEntity(_identity.ServerGuid, out var entity)
+ ? entity
+ : null;
+
+ public int LiveCenterX => _origin.CenterX;
+ public int LiveCenterY => _origin.CenterY;
+
+ public void SyncShadow(WorldEntity entity, uint cellId) =>
+ _shadow.Sync(entity, cellId);
+
+ public void Rebucket(uint serverGuid, uint landblockId) =>
+ _liveEntities.RebucketLiveEntity(serverGuid, landblockId);
+
+ public bool IsCurrentVisibleProjection(WorldEntity entity) =>
+ _shadow.IsCurrentVisibleProjection(entity);
+
+ public void SuspendShadow(WorldEntity entity) => _shadow.Suspend(entity);
+}
+
///
/// Projects the canonical local physics body into world rendering and spatial
/// buckets without advancing it.
///
public sealed class LocalPlayerProjectionController
{
- private readonly Func _resolveEntity;
- private readonly Func _liveCenterX;
- private readonly Func _liveCenterY;
- private readonly Action _syncShadow;
- private readonly Action _rebucket;
- private readonly Func _isCurrentVisibleProjection;
- private readonly Action _suspendShadow;
+ private readonly ILocalPlayerProjectionRuntime _runtime;
- public LocalPlayerProjectionController(
- Func resolveEntity,
- Func liveCenterX,
- Func liveCenterY,
- Action syncShadow,
- Action rebucket,
- Func isCurrentVisibleProjection,
- Action suspendShadow)
- {
- _resolveEntity = resolveEntity ?? throw new ArgumentNullException(nameof(resolveEntity));
- _liveCenterX = liveCenterX ?? throw new ArgumentNullException(nameof(liveCenterX));
- _liveCenterY = liveCenterY ?? throw new ArgumentNullException(nameof(liveCenterY));
- _syncShadow = syncShadow ?? throw new ArgumentNullException(nameof(syncShadow));
- _rebucket = rebucket ?? throw new ArgumentNullException(nameof(rebucket));
- _isCurrentVisibleProjection = isCurrentVisibleProjection
- ?? throw new ArgumentNullException(nameof(isCurrentVisibleProjection));
- _suspendShadow = suspendShadow
- ?? throw new ArgumentNullException(nameof(suspendShadow));
- }
+ internal LocalPlayerProjectionController(ILocalPlayerProjectionRuntime runtime) =>
+ _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
public void Project(
PlayerMovementController controller,
MovementResult movement,
bool hidden)
{
- WorldEntity? entity = _resolveEntity();
+ WorldEntity? entity = _runtime.ResolveEntity();
if (entity is null)
return;
@@ -62,8 +92,8 @@ public sealed class LocalPlayerProjectionController
else
{
System.Numerics.Vector3 position = controller.Position;
- int landblockX = _liveCenterX() + (int)Math.Floor(position.X / 192f);
- int landblockY = _liveCenterY() + (int)Math.Floor(position.Y / 192f);
+ int landblockX = _runtime.LiveCenterX + (int)Math.Floor(position.X / 192f);
+ int landblockY = _runtime.LiveCenterY + (int)Math.Floor(position.Y / 192f);
currentLandblock = (uint)((landblockX << 24) | (landblockY << 16) | 0xFFFF);
}
@@ -76,15 +106,15 @@ public sealed class LocalPlayerProjectionController
// membership, then replaces collision rows only while the object still
// owns a loaded cell. Rebucket is a synchronous callback boundary: it
// can move this incarnation to pending or replace the GUID entirely.
- _rebucket(entity.ServerGuid, currentLandblock);
+ _runtime.Rebucket(entity.ServerGuid, currentLandblock);
if (hidden
|| movement.CellId == 0
- || !_isCurrentVisibleProjection(entity))
+ || !_runtime.IsCurrentVisibleProjection(entity))
{
- _suspendShadow(entity);
+ _runtime.SuspendShadow(entity);
return;
}
- _syncShadow(entity, movement.CellId);
+ _runtime.SyncShadow(entity, movement.CellId);
}
}
diff --git a/src/AcDream.App/Input/MouseLookController.cs b/src/AcDream.App/Input/MouseLookController.cs
index 69c9efbe..c8966f67 100644
--- a/src/AcDream.App/Input/MouseLookController.cs
+++ b/src/AcDream.App/Input/MouseLookController.cs
@@ -73,7 +73,13 @@ internal sealed class SilkMouseLookCursor : IMouseLookCursor
/// The current chase-camera input targets. GameWindow keeps compatibility
/// properties over this one slot until checkpoint F moves camera ownership.
///
-internal sealed class ChaseCameraInputState
+internal interface IChaseCameraSource
+{
+ ChaseCamera? Legacy { get; }
+ RetailChaseCamera? Retail { get; }
+}
+
+internal sealed class ChaseCameraInputState : IChaseCameraSource
{
public ChaseCamera? Legacy { get; set; }
public RetailChaseCamera? Retail { get; set; }
diff --git a/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs b/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs
index a5599bdc..0d8d1356 100644
--- a/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs
+++ b/src/AcDream.App/Input/RetailLocalPlayerFrameController.cs
@@ -14,17 +14,8 @@ namespace AcDream.App.Input;
///
internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFramePhase
{
- private readonly Func _canPresentPlayer;
- private readonly Func _getController;
+ private readonly ILocalPlayerFrameRuntime _runtime;
private readonly IMovementInputSource _movementInput;
- private readonly Func _resolveLocalEntityId;
- private readonly Action _handleTargeting;
- private readonly Func _isHidden;
- private readonly Action _project;
- private readonly Action _sendPreNetwork;
- private readonly Action _sendPostNetwork;
- private readonly Func
- _objectClockDisposition;
private AdvancedFrame? _advancedFrame;
@@ -51,38 +42,12 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram
};
public RetailLocalPlayerFrameController(
- Func canPresentPlayer,
- Func getController,
- IMovementInputSource movementInput,
- Func resolveLocalEntityId,
- Action handleTargeting,
- Func isHidden,
- Action project,
- Action sendPreNetwork,
- Action sendPostNetwork,
- Func?
- objectClockDisposition = null)
+ ILocalPlayerFrameRuntime runtime,
+ IMovementInputSource movementInput)
{
- _canPresentPlayer = canPresentPlayer
- ?? throw new ArgumentNullException(nameof(canPresentPlayer));
- _getController = getController
- ?? throw new ArgumentNullException(nameof(getController));
+ _runtime = runtime ?? throw new ArgumentNullException(nameof(runtime));
_movementInput = movementInput
?? throw new ArgumentNullException(nameof(movementInput));
- _resolveLocalEntityId = resolveLocalEntityId
- ?? throw new ArgumentNullException(nameof(resolveLocalEntityId));
- _handleTargeting = handleTargeting
- ?? throw new ArgumentNullException(nameof(handleTargeting));
- _isHidden = isHidden
- ?? throw new ArgumentNullException(nameof(isHidden));
- _project = project
- ?? throw new ArgumentNullException(nameof(project));
- _sendPreNetwork = sendPreNetwork
- ?? throw new ArgumentNullException(nameof(sendPreNetwork));
- _sendPostNetwork = sendPostNetwork
- ?? throw new ArgumentNullException(nameof(sendPostNetwork));
- _objectClockDisposition = objectClockDisposition
- ?? (() => AcDream.Core.Physics.RetailObjectClockDisposition.Advance);
}
///
@@ -92,15 +57,15 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram
public void AdvanceBeforeNetwork(float deltaSeconds)
{
_advancedFrame = null;
- PlayerMovementController? controller = _getController();
- if (!_canPresentPlayer() || controller is null)
+ PlayerMovementController? controller = _runtime.Controller;
+ if (!_runtime.CanPresentPlayer || controller is null)
return;
if (!float.IsFinite(deltaSeconds) || deltaSeconds <= 0f)
{
- bool rejectedHidden = _isHidden();
+ bool rejectedHidden = _runtime.IsHidden;
MovementResult rejected = controller.CapturePresentationResult();
- _project(controller, rejected, rejectedHidden);
+ _runtime.Project(controller, rejected, rejectedHidden);
_advancedFrame = new AdvancedFrame(
controller,
rejected,
@@ -110,10 +75,10 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram
return;
}
- controller.LocalEntityId = _resolveLocalEntityId();
+ controller.LocalEntityId = _runtime.ResolveLocalEntityId();
- bool hidden = _isHidden();
- if (_objectClockDisposition()
+ bool hidden = _runtime.IsHidden;
+ if (_runtime.ObjectClockDisposition
is AcDream.Core.Physics.RetailObjectClockDisposition.Suspend)
{
// CPhysicsObj::update_object returns before advancing time for a
@@ -122,7 +87,7 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram
// physics tick that retail did not run.
controller.SuspendObjectUpdate(deltaSeconds);
MovementResult paused = controller.CapturePresentationResult();
- _project(controller, paused, hidden);
+ _runtime.Project(controller, paused, hidden);
_advancedFrame = new AdvancedFrame(
controller,
paused,
@@ -133,11 +98,14 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram
}
MovementResult movement = hidden
- ? controller.TickHidden(deltaSeconds, _handleTargeting)
- : controller.Update(deltaSeconds, _movementInput.Capture(), _handleTargeting);
+ ? controller.TickHidden(deltaSeconds, _runtime.HandleTargeting)
+ : controller.Update(
+ deltaSeconds,
+ _movementInput.Capture(),
+ _runtime.HandleTargeting);
- _project(controller, movement, hidden);
- _sendPreNetwork(controller, movement, hidden);
+ _runtime.Project(controller, movement, hidden);
+ _runtime.SendPreNetwork(controller, movement, hidden);
_advancedFrame = new AdvancedFrame(
controller,
movement,
@@ -153,18 +121,18 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram
///
public void RunPostNetworkCommandPhase()
{
- PlayerMovementController? controller = _getController();
- if (!_canPresentPlayer() || controller is null)
+ PlayerMovementController? controller = _runtime.Controller;
+ if (!_runtime.CanPresentPlayer || controller is null)
return;
- bool hidden = _isHidden();
+ bool hidden = _runtime.IsHidden;
if (_advancedFrame is { } advanced
&& ReferenceEquals(advanced.Controller, controller))
{
MovementResult movement = RefreshSpatialFields(
advanced.Movement,
controller);
- _project(controller, movement, hidden);
+ _runtime.Project(controller, movement, hidden);
_advancedFrame = new AdvancedFrame(
controller,
movement,
@@ -176,7 +144,7 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram
return;
}
- _sendPostNetwork(controller, hidden);
+ _runtime.SendPostNetwork(controller, hidden);
}
///
@@ -187,11 +155,11 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram
public bool TryGetPresentationAfterNetwork(out PresentationFrame frame)
{
frame = default;
- PlayerMovementController? controller = _getController();
- if (!_canPresentPlayer() || controller is null)
+ PlayerMovementController? controller = _runtime.Controller;
+ if (!_runtime.CanPresentPlayer || controller is null)
return false;
- bool hidden = _isHidden();
+ bool hidden = _runtime.IsHidden;
if (_advancedFrame is { } advanced
&& ReferenceEquals(advanced.Controller, controller))
{
@@ -206,7 +174,7 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram
}
MovementResult initial = controller.CapturePresentationResult();
- _project(controller, initial, hidden);
+ _runtime.Project(controller, initial, hidden);
frame = new PresentationFrame(initial, hidden, AdvancedBeforeNetwork: false);
return true;
}
diff --git a/src/AcDream.App/Interaction/WorldSelectionQuery.cs b/src/AcDream.App/Interaction/WorldSelectionQuery.cs
index 05df1a4d..cf0e24ae 100644
--- a/src/AcDream.App/Interaction/WorldSelectionQuery.cs
+++ b/src/AcDream.App/Interaction/WorldSelectionQuery.cs
@@ -48,6 +48,7 @@ internal interface IWorldSelectionQuery
bool IsUseable(uint serverGuid);
bool IsPickupable(uint serverGuid);
bool TryGetApproach(uint serverGuid, out InteractionApproach approach);
+ Vector3? GetCombatCameraTargetPoint(uint serverGuid);
}
///
diff --git a/src/AcDream.App/Rendering/CameraFrameController.cs b/src/AcDream.App/Rendering/CameraFrameController.cs
new file mode 100644
index 00000000..ff56239d
--- /dev/null
+++ b/src/AcDream.App/Rendering/CameraFrameController.cs
@@ -0,0 +1,114 @@
+using AcDream.App.Combat;
+using AcDream.App.Input;
+using AcDream.App.Update;
+using AcDream.Core.Rendering;
+
+namespace AcDream.App.Rendering;
+
+///
+/// Owns the per-update fly/chase camera publication tail.
+///
+///
+/// The local object phase normally publishes the player before inbound
+/// traffic. When the player is first created by that inbound pass, the local
+/// frame publishes its initial root here and the spatial reconciler refreshes
+/// child/effect anchors before either chase camera samples it.
+///
+internal sealed class CameraFrameController : ICameraFramePhase
+{
+ private readonly CameraController _camera;
+ private readonly IInputCaptureSource _capture;
+ private readonly ICameraFrameInputSource _input;
+ private readonly ILocalPlayerPresentationRuntime _player;
+ private readonly IChaseCameraSource _chase;
+ private readonly RetailLocalPlayerFrameController _localFrame;
+ private readonly ILiveSpatialReconcilePhase _spatialReconciler;
+ private readonly ICombatCameraTargetSource _combatTarget;
+
+ public CameraFrameController(
+ CameraController camera,
+ IInputCaptureSource capture,
+ ICameraFrameInputSource input,
+ ILocalPlayerPresentationRuntime player,
+ IChaseCameraSource chase,
+ RetailLocalPlayerFrameController localFrame,
+ ILiveSpatialReconcilePhase spatialReconciler,
+ ICombatCameraTargetSource combatTarget)
+ {
+ _camera = camera ?? throw new ArgumentNullException(nameof(camera));
+ _capture = capture ?? throw new ArgumentNullException(nameof(capture));
+ _input = input ?? throw new ArgumentNullException(nameof(input));
+ _player = player ?? throw new ArgumentNullException(nameof(player));
+ _chase = chase ?? throw new ArgumentNullException(nameof(chase));
+ _localFrame = localFrame ?? throw new ArgumentNullException(nameof(localFrame));
+ _spatialReconciler = spatialReconciler
+ ?? throw new ArgumentNullException(nameof(spatialReconciler));
+ _combatTarget = combatTarget ?? throw new ArgumentNullException(nameof(combatTarget));
+ }
+
+ public void Tick(UpdateFrameTiming timing)
+ {
+ if (_capture.DevToolsWantCaptureKeyboard || !_input.IsAvailable)
+ return;
+
+ if (_camera.IsFlyMode)
+ {
+ FlyCameraInput input = _input.CaptureFly();
+ _camera.Fly.Update(
+ timing.SimulationDeltaSeconds,
+ input.Forward,
+ input.Left,
+ input.Backward,
+ input.Right,
+ input.Up,
+ input.Down,
+ input.Boost);
+ return;
+ }
+
+ PlayerMovementController? controller = _player.Controller;
+ ChaseCamera? legacy = _chase.Legacy;
+ RetailChaseCamera? retail = _chase.Retail;
+ if (!_player.CanPresentPlayer || controller is null || legacy is null)
+ return;
+
+ if (CameraDiagnostics.UseRetailChaseCamera && retail is not null)
+ {
+ ChaseCameraAdjustmentInput input = _input.CaptureChaseAdjustment();
+ float adjustment = CameraDiagnostics.CameraAdjustmentSpeed
+ * timing.SimulationDeltaSecondsSingle;
+ if (input.ZoomIn)
+ retail.AdjustDistance(-adjustment);
+ if (input.ZoomOut)
+ retail.AdjustDistance(+adjustment);
+ if (input.Raise)
+ retail.AdjustPitch(+adjustment * 0.02f);
+ if (input.Lower)
+ retail.AdjustPitch(-adjustment * 0.02f);
+ }
+
+ if (!_localFrame.TryGetPresentationAfterNetwork(out var playerFrame))
+ return;
+
+ if (!playerFrame.AdvancedBeforeNetwork)
+ _spatialReconciler.Reconcile();
+
+ MovementResult result = playerFrame.Movement;
+ legacy.Update(
+ result.RenderPosition,
+ controller.Yaw,
+ isOnGround: result.IsOnGround,
+ dt: timing.SimulationDeltaSecondsSingle);
+
+ retail?.Update(
+ result.RenderPosition,
+ controller.Yaw,
+ playerVelocity: controller.BodyVelocity,
+ isOnGround: result.IsOnGround,
+ contactPlaneNormal: controller.ContactPlane.Normal,
+ dt: timing.SimulationDeltaSecondsSingle,
+ cellId: controller.CellId,
+ selfEntityId: controller.LocalEntityId,
+ trackedTargetPoint: _combatTarget.GetTrackedTargetPoint());
+ }
+}
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 7d85fe39..3e8f2ded 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -170,11 +170,10 @@ public sealed class GameWindow : IDisposable
_inboundEntityEvents = new();
private LiveEntityAnimationScheduler _liveAnimationScheduler = null!;
private LiveEntityAnimationPresenter _animationPresenter = null!;
- private readonly AcDream.App.Input.LocalPlayerProjectionController _localPlayerProjection;
private readonly AcDream.App.Input.MovementTruthDiagnosticController
_movementTruthDiagnostics;
private readonly AcDream.App.Input.LocalPlayerOutboundController _localPlayerOutbound;
- private readonly AcDream.App.Input.RetailLocalPlayerFrameController _localPlayerFrame;
+ private AcDream.App.Update.ICameraFramePhase _cameraFrame = null!;
private AcDream.App.World.RetailLiveFrameCoordinator _liveFrameCoordinator = null!;
private AcDream.App.Update.LiveSpatialPresentationReconciler
_liveSpatialReconciler = null!;
@@ -619,6 +618,7 @@ public sealed class GameWindow : IDisposable
private readonly AcDream.App.Input.RetainedUiInputCaptureSlot _retainedInputCapture;
private readonly AcDream.App.Input.CompositeInputCaptureSource _inputCapture;
private readonly AcDream.App.Input.DispatcherMovementInputSource _movementInput;
+ private readonly AcDream.App.Input.DispatcherCameraInputSource _cameraInput = new();
private AcDream.App.Input.IMouseLookCursor? _mouseLookCursor;
private AcDream.App.Input.GameplayInputFrameController? _gameplayInputFrame;
// K.1c: load user-customized bindings from %LOCALAPPDATA%\acdream\keybinds.json,
@@ -759,18 +759,6 @@ public sealed class GameWindow : IDisposable
movement, cellId, update),
(host, targetGuid) =>
_liveEntityMotionBindings.StickToObjectFromWire(host, targetGuid));
- _localPlayerProjection = new AcDream.App.Input.LocalPlayerProjectionController(
- resolveEntity: () =>
- _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var entity)
- ? entity
- : null,
- liveCenterX: () => _liveCenterX,
- liveCenterY: () => _liveCenterY,
- syncShadow: (entity, cellId) => SyncLocalPlayerShadow(entity, cellId),
- rebucket: (serverGuid, landblockId) =>
- _liveEntities?.RebucketLiveEntity(serverGuid, landblockId),
- isCurrentVisibleProjection: IsCurrentVisibleLocalPlayerProjection,
- suspendShadow: SuspendLocalPlayerShadow);
_movementTruthDiagnostics =
new AcDream.App.Input.MovementTruthDiagnosticController(
options.DumpMoveTruth,
@@ -778,32 +766,6 @@ public sealed class GameWindow : IDisposable
_localPlayerIdentity);
_localPlayerOutbound = new AcDream.App.Input.LocalPlayerOutboundController(
_movementTruthDiagnostics);
- _localPlayerFrame = new AcDream.App.Input.RetailLocalPlayerFrameController(
- canPresentPlayer: CanAdvanceLocalPlayer,
- getController: () => _playerController,
- movementInput: _movementInput,
- resolveLocalEntityId: () =>
- _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var entity)
- ? entity.Id
- : 0u,
- handleTargeting: () => _playerHost?.HandleTargetting(),
- isHidden: () => _liveEntities?.IsHidden(_playerServerGuid) == true,
- project: (controller, movement, hidden) =>
- _localPlayerProjection.Project(controller, movement, hidden),
- sendPreNetwork: (controller, movement, hidden) =>
- _localPlayerOutbound.SendPreNetworkActions(
- LiveSession,
- controller,
- movement,
- hidden),
- sendPostNetwork: (controller, hidden) =>
- _localPlayerOutbound.SendPostNetworkPosition(
- LiveSession,
- controller,
- hidden),
- objectClockDisposition: () =>
- _liveEntities?.GetRootObjectClockDisposition(_playerServerGuid)
- ?? AcDream.Core.Physics.RetailObjectClockDisposition.Advance);
}
public void Run()
@@ -896,6 +858,7 @@ public sealed class GameWindow : IDisposable
_inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher(
_kbSource, _mouseSource, _keyBindings);
_movementInput.Bind(_inputDispatcher);
+ _cameraInput.Bind(_inputDispatcher);
_mouseLookCursor = new AcDream.App.Input.SilkMouseLookCursor(firstMouse);
_inputDispatcher.Fired += OnInputAction;
Combat.CombatModeChanged += SetInputCombatScope;
@@ -2675,19 +2638,6 @@ public sealed class GameWindow : IDisposable
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!,
@@ -2708,6 +2658,44 @@ public sealed class GameWindow : IDisposable
_localPlayerIdentity,
_liveWorldOrigin,
_localPlayerShadow);
+ var localPlayerProjection =
+ new AcDream.App.Input.LocalPlayerProjectionController(
+ new AcDream.App.Input.LiveLocalPlayerProjectionRuntime(
+ _liveEntities,
+ _localPlayerIdentity,
+ _liveWorldOrigin,
+ _localPlayerShadowSynchronizer));
+ var localPlayerFrameRuntime =
+ new AcDream.App.Input.LiveLocalPlayerFrameRuntime(
+ _cameraController!,
+ _localPlayerMode,
+ _playerControllerSlot,
+ _chaseCameraInput,
+ _movementInput,
+ _inputCapture,
+ _liveEntities,
+ _localPlayerIdentity,
+ _playerHostSlot,
+ localPlayerProjection,
+ _localPlayerOutbound,
+ _liveSessionController);
+ var localPlayerFrame =
+ new AcDream.App.Input.RetailLocalPlayerFrameController(
+ localPlayerFrameRuntime,
+ _movementInput);
+ var liveObjectFrame = new AcDream.App.Update.LiveObjectFrameController(
+ _inboundEntityEvents,
+ localPlayerFrame,
+ _selectionInteractions,
+ _liveEntities,
+ _localPlayerIdentity,
+ _liveWorldOrigin,
+ _liveAnimationScheduler,
+ _staticAnimationScheduler,
+ _animationPresenter,
+ _animatedEntities,
+ _equippedChildRenderer!,
+ liveEffectFrame);
_viewportAspect.Update(_window!.Size.X, _window.Size.Y);
_playerModeController = new AcDream.App.Input.PlayerModeController(
_localPlayerMode,
@@ -2783,8 +2771,21 @@ public sealed class GameWindow : IDisposable
liveObjectFrame,
_worldState,
_liveSessionController,
- _localPlayerFrame,
+ localPlayerFrame,
_liveSpatialReconciler);
+ _cameraFrame = new AcDream.App.Rendering.CameraFrameController(
+ _cameraController!,
+ _inputCapture,
+ _cameraInput,
+ localPlayerFrameRuntime,
+ _chaseCameraInput,
+ localPlayerFrame,
+ _liveSpatialReconciler,
+ new AcDream.App.Combat.CombatCameraTargetSource(
+ _gameplaySettings,
+ Combat,
+ _selection,
+ _worldSelectionQuery!));
AcDream.App.Net.LiveSessionStartResult liveStart =
_liveSessionController.Start(
_options,
@@ -3288,59 +3289,6 @@ public sealed class GameWindow : IDisposable
timestamps.ForcePosition);
}
- private void SyncLocalPlayerShadow(
- AcDream.Core.World.WorldEntity playerEntity,
- uint cellId,
- bool force = false)
- {
- if (_liveEntities?.IsHidden(_playerServerGuid) == true
- || cellId == 0
- || !IsCurrentVisibleLocalPlayerProjection(playerEntity))
- {
- SuspendLocalPlayerShadow(playerEntity);
- return;
- }
-
- if (!force
- && _localPlayerShadow.Current is { } last
- && last.CellId == cellId
- && System.Numerics.Vector3.DistanceSquared(
- last.Position, playerEntity.Position) <= 1e-4f
- && MathF.Abs(System.Numerics.Quaternion.Dot(
- last.Orientation, playerEntity.Rotation)) >= 0.99999f)
- {
- return;
- }
-
- AcDream.App.Physics.ShadowPositionSynchronizer.Sync(
- _physicsEngine.ShadowObjects,
- playerEntity.Id,
- playerEntity.Position,
- playerEntity.Rotation,
- cellId,
- _liveCenterX,
- _liveCenterY);
- _localPlayerShadow.Set(
- playerEntity.Position,
- playerEntity.Rotation,
- cellId);
- }
-
- private bool IsCurrentVisibleLocalPlayerProjection(
- AcDream.Core.World.WorldEntity playerEntity) =>
- _liveEntities is { } runtime
- && runtime.TryGetRecord(playerEntity.ServerGuid, out var record)
- && ReferenceEquals(record.WorldEntity, playerEntity)
- && runtime.IsCurrentSpatialRootObject(record);
-
- private void SuspendLocalPlayerShadow(
- AcDream.Core.World.WorldEntity playerEntity)
- {
- _physicsEngine.ShadowObjects.Suspend(playerEntity.Id);
- _localPlayerShadow.Clear();
- }
-
-
// #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,
@@ -3584,7 +3532,6 @@ public sealed class GameWindow : IDisposable
AcDream.App.Update.UpdateFrameTiming frameTiming = _updateFrameClock.Advance(
new AcDream.App.Update.UpdateFrameInput(dt));
- double frameSeconds = frameTiming.SimulationDeltaSeconds;
float frameDelta = frameTiming.SimulationDeltaSecondsSingle;
// Retail ScriptManager::AddScriptInternal (0x0051B310) stamps
@@ -3634,131 +3581,9 @@ public sealed class GameWindow : IDisposable
// OnInputAction (Ctrl+Tab) or DebugPanel.
_playerModeAutoEntry?.TryEnter();
- if (_cameraController is null || _input is null) return;
-
- // Phase D.2a / K.1b — suppress game-side input polling when ImGui
- // has keyboard focus (e.g. a text field is active). The InputDispatcher
- // already gates KeyDown/MouseDown via WantCaptureKeyboard internally;
- // this guard adds defense-in-depth for the per-frame IsActionHeld
- // movement poll below (typing "walk" into a chat field shouldn't
- // walk).
- // ImGui dev-tools text fields fully pause game input (incl. autorun) — fine, it's a
- // debug overlay. The RETAIL chat "write mode" does NOT early-return here: the block
- // below still runs so AUTORUN keeps driving the character while you type. Held WASD
- // is silenced at the source instead — InputDispatcher.IsActionHeld returns false
- // while WantCaptureKeyboard (which includes a focused chat input) is set.
- bool suppressGameInput =
- DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard;
- if (suppressGameInput) return;
-
- if (_cameraController.IsFlyMode)
- {
- // K.1b: fly-camera input flows through the dispatcher. Reuses
- // movement actions (Forward/Backup/TurnLeft/TurnRight/Jump/
- // RunLock) — in fly mode "TurnLeft" semantically maps to
- // strafe-left because A/D in fly is strafe, not turn (mouse
- // delta drives fly heading instead).
- if (_inputDispatcher is null) return;
- _cameraController.Fly.Update(
- frameSeconds,
- w: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementForward),
- a: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementTurnLeft),
- s: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementBackup),
- d: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementTurnRight),
- up: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementJump),
- down: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.AcdreamFlyDown),
- boost: _inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.MovementRunLock));
- }
- else if (_playerMode && _playerController is not null && _chaseCamera is not null)
- {
- // Phase B.2 / K.1b: player movement mode — every input flows
- // through the dispatcher. WASD walks/runs, A/D turns, Z/X
- // strafes, Shift runs, Space jumps. Mouse delta NEVER drives
- // character yaw (regression-prevention per K.1b plan §D);
- // MouseDeltaX is hardcoded 0f. RMB held still pans the chase
- // camera (handled in the mouse-move handler via _rmbHeld).
- if (_inputDispatcher is null) return;
-
- // Retail-style held-key offset integration. Only active when
- // retail chase is selected; legacy camera ignores these.
- if (AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera && _retailChaseCamera is not null)
- {
- float adj = AcDream.Core.Rendering.CameraDiagnostics.CameraAdjustmentSpeed * frameDelta;
- if (_inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.CameraZoomIn))
- _retailChaseCamera.AdjustDistance(-adj);
- if (_inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.CameraZoomOut))
- _retailChaseCamera.AdjustDistance(+adj);
- if (_inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.CameraRaise))
- _retailChaseCamera.AdjustPitch(+adj * 0.02f);
- if (_inputDispatcher.IsActionHeld(AcDream.UI.Abstractions.Input.InputAction.CameraLower))
- _retailChaseCamera.AdjustPitch(-adj * 0.02f);
- }
-
- // K-fix1 (2026-04-26): retail-faithful movement semantics.
- // * Default speed = RUN. Forward / backward / strafe all run
- // by default; holding Shift (MovementWalkMode) drops to
- // walk speed.
- // * Q = AUTORUN TOGGLE: pressing Q latches forward-running
- // until Q is pressed again. Handled in OnInputAction; here
- // DispatcherMovementInputSource owns the autorun latch.
- // * Mouse never drives character yaw (K.1b regression-prevention).
- if (!_localPlayerFrame.TryGetPresentationAfterNetwork(out var playerFrame))
- return;
-
- AcDream.App.Input.MovementResult result = playerFrame.Movement;
- bool localPlayerHidden = playerFrame.Hidden;
- if (!playerFrame.AdvancedBeforeNetwork)
- {
- // 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.
- _liveSpatialReconciler.Reconcile();
- }
-
- // Update chase camera(s). The CameraController exposes whichever
- // is currently selected via CameraDiagnostics.UseRetailChaseCamera;
- // both update every frame so toggling the flag swaps instantly
- // with the new camera already warm.
- //
- // Legacy ChaseCamera: pre-K-fix12 args (isOnGround pins Z during
- // jumps as a workaround for the visual feel retail gets from
- // low-stiffness damping).
- _chaseCamera.Update(result.RenderPosition, _playerController.Yaw,
- isOnGround: result.IsOnGround,
- dt: frameDelta);
- // RetailChaseCamera: heading is the player's facing direction
- // projected onto the contact plane when grounded, or the
- // world XY plane when airborne. The contact plane normal
- // tilts the camera basis with terrain; the airborne
- // fallback keeps the basis horizontal during jumps so the
- // player visibly rises in frame without the camera
- // swinging vertically (was the symptom of using raw
- // velocity-vector heading).
- System.Numerics.Vector3? trackedCombatTarget = GetCombatCameraTargetPoint();
- _retailChaseCamera!.Update(result.RenderPosition, _playerController.Yaw,
- playerVelocity: _playerController.BodyVelocity,
- isOnGround: result.IsOnGround,
- contactPlaneNormal: _playerController.ContactPlane.Normal,
- dt: frameDelta,
- cellId: _playerController.CellId,
- selfEntityId: _playerController.LocalEntityId,
- trackedTargetPoint: trackedCombatTarget);
-
-
- // Update the player entity's animation cycle to match current motion.
- }
+ _cameraFrame.Tick(frameTiming);
}
- private bool CanAdvanceLocalPlayer() =>
- _cameraController is not null
- && _input is not null
- && !_cameraController.IsFlyMode
- && _playerMode
- && _playerController is not null
- && _chaseCamera is not null
- && _inputDispatcher is not null
- && !(DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard);
-
private void OnCameraModeChanged(bool _modeBool)
{
if (_gameplayInputFrame?.MouseLookActive == true
@@ -6800,22 +6625,6 @@ public sealed class GameWindow : IDisposable
AcDream.App.Input.MovementResult result) =>
AcDream.App.Input.LocalPlayerOutboundController.BuildRawMotionState(result);
- ///
- /// Resolves retail's combat-camera target. ClientCombatSystem::
- /// UpdateTargetTracking (0x0056A950) enables CameraSet::TrackTarget only
- /// for melee/missile combat with the option enabled and a valid attack
- /// target. TrackTarget applies target-local offset (0,0,0.5).
- ///
- private System.Numerics.Vector3? GetCombatCameraTargetPoint()
- {
- if (!_persistedGameplay.ViewCombatTarget
- || !AcDream.Core.Combat.CombatInputPlanner.SupportsTargetedAttack(Combat.CurrentMode)
- || _selection.SelectedObjectId is not uint selected)
- return null;
-
- return _worldSelectionQuery?.GetCombatCameraTargetPoint(selected);
- }
-
///
/// Item-target-mode world pick at the current cursor. The renderer supplies the
/// exact visible CPhysicsPart equivalents and RetailWorldPicker performs
diff --git a/tests/AcDream.App.Tests/Combat/CombatCameraTargetSourceTests.cs b/tests/AcDream.App.Tests/Combat/CombatCameraTargetSourceTests.cs
new file mode 100644
index 00000000..40932669
--- /dev/null
+++ b/tests/AcDream.App.Tests/Combat/CombatCameraTargetSourceTests.cs
@@ -0,0 +1,69 @@
+using System.Numerics;
+using AcDream.App.Combat;
+using AcDream.App.Interaction;
+using AcDream.Core.Combat;
+using AcDream.Core.Selection;
+
+namespace AcDream.App.Tests.Combat;
+
+public sealed class CombatCameraTargetSourceTests
+{
+ [Fact]
+ public void GetTrackedTargetPoint_RequiresOptionTargetedModeAndSelection()
+ {
+ const uint target = 0x70000001u;
+ Vector3 point = new(10f, 20f, 30f);
+ var settings = new GameplaySettingsState();
+ var combat = new CombatState();
+ var selection = new SelectionState();
+ var world = new TargetQuery(point);
+ var source = new CombatCameraTargetSource(
+ settings,
+ combat,
+ selection,
+ world);
+
+ Assert.Null(source.GetTrackedTargetPoint());
+
+ settings.Value = settings.Value with { ViewCombatTarget = true };
+ combat.SetCombatMode(CombatMode.Melee);
+ selection.Select(target, SelectionChangeSource.World);
+
+ Assert.Equal(point, source.GetTrackedTargetPoint());
+ Assert.Equal(target, world.LastGuid);
+
+ combat.SetCombatMode(CombatMode.Magic);
+ Assert.Null(source.GetTrackedTargetPoint());
+ }
+
+ private sealed class TargetQuery(Vector3 point) : IWorldSelectionQuery
+ {
+ public uint LastGuid { get; private set; }
+ public Vector3? GetCombatCameraTargetPoint(uint serverGuid)
+ {
+ LastGuid = serverGuid;
+ return point;
+ }
+
+ public uint? PickAtCursor(bool includeSelf) => null;
+ public uint? PickAt(float mouseX, float mouseY, bool includeSelf) => null;
+ public void BeginLightingPulse(uint serverGuid) { }
+ public bool TryCaptureIdentity(uint serverGuid, out uint localEntityId)
+ {
+ localEntityId = 0;
+ return false;
+ }
+ public bool IsCurrent(uint serverGuid, uint localEntityId) => false;
+ public string Describe(uint serverGuid) => string.Empty;
+ public bool IsCreature(uint serverGuid) => false;
+ public bool IsHostileMonster(uint serverGuid) => false;
+ public ClosestCombatTarget? FindClosestHostileMonster() => null;
+ public bool IsUseable(uint serverGuid) => false;
+ public bool IsPickupable(uint serverGuid) => false;
+ public bool TryGetApproach(uint serverGuid, out InteractionApproach approach)
+ {
+ approach = default;
+ return false;
+ }
+ }
+}
diff --git a/tests/AcDream.App.Tests/Combat/LiveCombatAttackOperationsTests.cs b/tests/AcDream.App.Tests/Combat/LiveCombatAttackOperationsTests.cs
index 41109816..16532049 100644
--- a/tests/AcDream.App.Tests/Combat/LiveCombatAttackOperationsTests.cs
+++ b/tests/AcDream.App.Tests/Combat/LiveCombatAttackOperationsTests.cs
@@ -120,6 +120,7 @@ public sealed class LiveCombatAttackOperationsTests
{
public bool AutoTarget { get; set; }
public bool AutoRepeatAttack { get; set; }
+ public bool ViewCombatTarget { get; set; }
}
private sealed class FakeLiveSource : ILiveInWorldSource, ILiveWorldSessionSource
diff --git a/tests/AcDream.App.Tests/Input/LocalPlayerProjectionControllerTests.cs b/tests/AcDream.App.Tests/Input/LocalPlayerProjectionControllerTests.cs
index 20575243..b712d4ec 100644
--- a/tests/AcDream.App.Tests/Input/LocalPlayerProjectionControllerTests.cs
+++ b/tests/AcDream.App.Tests/Input/LocalPlayerProjectionControllerTests.cs
@@ -26,14 +26,14 @@ public sealed class LocalPlayerProjectionControllerTests
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty(),
};
- var projection = new LocalPlayerProjectionController(
+ var projection = new LocalPlayerProjectionController(new TestProjectionRuntime(
() => entity,
() => 1,
() => 1,
(_, _) => { },
(_, _) => { },
_ => true,
- _ => { });
+ _ => { }));
projection.Project(
movement,
@@ -64,14 +64,14 @@ public sealed class LocalPlayerProjectionControllerTests
MeshRefs = Array.Empty(),
};
var order = new List();
- var projection = new LocalPlayerProjectionController(
+ var projection = new LocalPlayerProjectionController(new TestProjectionRuntime(
() => entity,
() => 2,
() => 2,
(_, _) => order.Add("shadow"),
(_, _) => order.Add("rebucket"),
_ => false,
- _ => order.Add("suspend"));
+ _ => order.Add("suspend")));
projection.Project(
movement,
@@ -100,7 +100,7 @@ public sealed class LocalPlayerProjectionControllerTests
};
bool visible = false;
var order = new List();
- var projection = new LocalPlayerProjectionController(
+ var projection = new LocalPlayerProjectionController(new TestProjectionRuntime(
() => entity,
() => 2,
() => 2,
@@ -111,7 +111,7 @@ public sealed class LocalPlayerProjectionControllerTests
visible = true;
},
_ => visible,
- _ => order.Add("suspend"));
+ _ => order.Add("suspend")));
projection.Project(
movement,
@@ -120,4 +120,25 @@ public sealed class LocalPlayerProjectionControllerTests
Assert.Equal(["rebucket", "shadow"], order);
}
+
+ private sealed class TestProjectionRuntime(
+ Func resolveEntity,
+ Func liveCenterX,
+ Func liveCenterY,
+ Action syncShadow,
+ Action rebucket,
+ Func isCurrentVisibleProjection,
+ Action suspendShadow) : ILocalPlayerProjectionRuntime
+ {
+ public WorldEntity? ResolveEntity() => resolveEntity();
+ public int LiveCenterX => liveCenterX();
+ public int LiveCenterY => liveCenterY();
+ public void SyncShadow(WorldEntity entity, uint cellId) =>
+ syncShadow(entity, cellId);
+ public void Rebucket(uint serverGuid, uint landblockId) =>
+ rebucket(serverGuid, landblockId);
+ public bool IsCurrentVisibleProjection(WorldEntity entity) =>
+ isCurrentVisibleProjection(entity);
+ public void SuspendShadow(WorldEntity entity) => suspendShadow(entity);
+ }
}
diff --git a/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs b/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs
index 794f40e1..01ce901c 100644
--- a/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs
+++ b/tests/AcDream.App.Tests/Input/RetailLocalPlayerFrameControllerTests.cs
@@ -15,7 +15,7 @@ public sealed class RetailLocalPlayerFrameControllerTests
var calls = new List();
var preNetwork = new List<(PlayerState State, MovementResult Movement)>();
var postNetwork = new List();
- var local = new RetailLocalPlayerFrameController(
+ var local = CreateFrame(
canPresentPlayer: () => true,
getController: () => controller,
movementInput: Input(() => new MovementInput(Forward: true)),
@@ -79,7 +79,7 @@ public sealed class RetailLocalPlayerFrameControllerTests
int projections = 0;
int preNetwork = 0;
int postNetwork = 0;
- var local = new RetailLocalPlayerFrameController(
+ var local = CreateFrame(
canPresentPlayer: () => controller is not null,
getController: () => controller,
movementInput: Input(() => new MovementInput(Forward: true)),
@@ -109,7 +109,7 @@ public sealed class RetailLocalPlayerFrameControllerTests
{
PlayerMovementController controller = CreateController();
Vector3 projectedRoot = default;
- var local = new RetailLocalPlayerFrameController(
+ var local = CreateFrame(
canPresentPlayer: () => true,
getController: () => controller,
movementInput: Input(() => new MovementInput()),
@@ -140,7 +140,7 @@ public sealed class RetailLocalPlayerFrameControllerTests
PlayerMovementController controller = CreateController();
Vector3 initial = controller.Position;
Vector3 positionSeenByTargetManager = new(float.NaN);
- var local = new RetailLocalPlayerFrameController(
+ var local = CreateFrame(
canPresentPlayer: () => true,
getController: () => controller,
movementInput: Input(() => new MovementInput(Forward: true)),
@@ -166,7 +166,7 @@ public sealed class RetailLocalPlayerFrameControllerTests
int projections = 0;
int preNetwork = 0;
int postNetwork = 0;
- var local = new RetailLocalPlayerFrameController(
+ var local = CreateFrame(
canPresentPlayer: () => true,
getController: () => controller,
movementInput: Input(() =>
@@ -201,7 +201,7 @@ public sealed class RetailLocalPlayerFrameControllerTests
public void HiddenPoseIsDirtyOnlyWhenACompleteObjectQuantumRuns()
{
PlayerMovementController controller = CreateController();
- var local = new RetailLocalPlayerFrameController(
+ var local = CreateFrame(
canPresentPlayer: () => true,
getController: () => controller,
movementInput: Input(() => new MovementInput()),
@@ -228,7 +228,7 @@ public sealed class RetailLocalPlayerFrameControllerTests
int projections = 0;
int preNetwork = 0;
int postNetwork = 0;
- var local = new RetailLocalPlayerFrameController(
+ var local = CreateFrame(
canPresentPlayer: () => true,
getController: () => controller,
movementInput: Input(() =>
@@ -297,6 +297,65 @@ public sealed class RetailLocalPlayerFrameControllerTests
private static IMovementInputSource Input(Func capture) =>
new TestMovementInputSource(capture);
+ private static RetailLocalPlayerFrameController CreateFrame(
+ Func canPresentPlayer,
+ Func getController,
+ IMovementInputSource movementInput,
+ Func resolveLocalEntityId,
+ Action handleTargeting,
+ Func isHidden,
+ Action project,
+ Action sendPreNetwork,
+ Action sendPostNetwork,
+ Func? objectClockDisposition = null) =>
+ new(
+ new TestLocalPlayerFrameRuntime(
+ canPresentPlayer,
+ getController,
+ resolveLocalEntityId,
+ handleTargeting,
+ isHidden,
+ project,
+ sendPreNetwork,
+ sendPostNetwork,
+ objectClockDisposition),
+ movementInput);
+
+ private sealed class TestLocalPlayerFrameRuntime(
+ Func canPresentPlayer,
+ Func getController,
+ Func resolveLocalEntityId,
+ Action handleTargeting,
+ Func isHidden,
+ Action project,
+ Action sendPreNetwork,
+ Action sendPostNetwork,
+ Func? objectClockDisposition)
+ : ILocalPlayerFrameRuntime
+ {
+ public bool CanPresentPlayer => canPresentPlayer();
+ public PlayerMovementController? Controller => getController();
+ public uint ResolveLocalEntityId() => resolveLocalEntityId();
+ public void HandleTargeting() => handleTargeting();
+ public bool IsHidden => isHidden();
+ public RetailObjectClockDisposition ObjectClockDisposition =>
+ objectClockDisposition?.Invoke() ?? RetailObjectClockDisposition.Advance;
+
+ public void Project(
+ PlayerMovementController controller,
+ MovementResult movement,
+ bool hidden) => project(controller, movement, hidden);
+
+ public void SendPreNetwork(
+ PlayerMovementController controller,
+ MovementResult movement,
+ bool hidden) => sendPreNetwork(controller, movement, hidden);
+
+ public void SendPostNetwork(
+ PlayerMovementController controller,
+ bool hidden) => sendPostNetwork(controller, hidden);
+ }
+
private sealed class TestMovementInputSource(Func capture)
: IMovementInputSource
{
diff --git a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs
index 75963f82..18d0379b 100644
--- a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs
+++ b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs
@@ -55,6 +55,7 @@ public sealed class SelectionInteractionControllerTests
public ClosestCombatTarget? FindClosestHostileMonster() => Closest;
public bool IsUseable(uint serverGuid) => Useable;
public bool IsPickupable(uint serverGuid) => Pickupable;
+ public Vector3? GetCombatCameraTargetPoint(uint serverGuid) => null;
public bool TryGetApproach(uint serverGuid, out InteractionApproach approach)
{
diff --git a/tests/AcDream.App.Tests/Rendering/CameraFrameControllerTests.cs b/tests/AcDream.App.Tests/Rendering/CameraFrameControllerTests.cs
new file mode 100644
index 00000000..732906b3
--- /dev/null
+++ b/tests/AcDream.App.Tests/Rendering/CameraFrameControllerTests.cs
@@ -0,0 +1,231 @@
+using System.Numerics;
+using AcDream.App.Combat;
+using AcDream.App.Input;
+using AcDream.App.Rendering;
+using AcDream.App.Update;
+using AcDream.Core.Physics;
+
+namespace AcDream.App.Tests.Rendering;
+
+public sealed class CameraFrameControllerTests
+{
+ [Fact]
+ public void FlyMode_UsesOneSemanticHeldInputSnapshot()
+ {
+ CameraController camera = CreateCamera();
+ camera.ToggleFly();
+ Vector3 start = camera.Fly.Position;
+ var input = new InputSource
+ {
+ Fly = new FlyCameraInput(
+ Forward: true,
+ Left: false,
+ Backward: false,
+ Right: false,
+ Up: true,
+ Down: false,
+ Boost: false),
+ };
+ CameraFrameController frame = CreateFrame(camera, input: input);
+
+ frame.Tick(new UpdateFrameTiming(0.5, 0.5f, 0.5));
+
+ Assert.Equal(1, input.FlyCaptures);
+ Assert.InRange(camera.Fly.Position.Y - start.Y, 5.999f, 6.001f);
+ Assert.InRange(camera.Fly.Position.Z - start.Z, 5.999f, 6.001f);
+ }
+
+ [Fact]
+ public void DevToolsKeyboardCapture_PausesFlyCamera()
+ {
+ CameraController camera = CreateCamera();
+ camera.ToggleFly();
+ Vector3 start = camera.Fly.Position;
+ var input = new InputSource
+ {
+ Fly = new FlyCameraInput(true, false, false, false, false, false, false),
+ };
+ CameraFrameController frame = CreateFrame(
+ camera,
+ capture: new CaptureSource { DevToolsKeyboard = true },
+ input: input);
+
+ frame.Tick(new UpdateFrameTiming(1.0, 1f, 1.0));
+
+ Assert.Equal(start, camera.Fly.Position);
+ Assert.Equal(0, input.FlyCaptures);
+ }
+
+ [Fact]
+ public void InboundCreatedPlayer_ProjectsThenReconcilesBeforeCameraPublication()
+ {
+ PlayerMovementController controller = CreatePlayer();
+ var calls = new List();
+ var runtime = new PlayerRuntime(controller, calls);
+ var localFrame = new RetailLocalPlayerFrameController(
+ runtime,
+ new StillMovementInput());
+ CameraController camera = CreateCamera();
+ var legacy = new ChaseCamera();
+ var retail = new RetailChaseCamera();
+ camera.EnterChaseMode(legacy, retail);
+ var chase = new ChaseSource(legacy, retail);
+ var reconciler = new Reconciler(calls);
+ var frame = new CameraFrameController(
+ camera,
+ new CaptureSource(),
+ new InputSource(),
+ runtime,
+ chase,
+ localFrame,
+ reconciler,
+ new CombatTargetSource());
+
+ frame.Tick(new UpdateFrameTiming(1.0 / 60.0, 1f / 60f, 1.0));
+
+ Assert.Equal(["project", "reconcile"], calls);
+ Assert.NotEqual(Vector3.Zero, legacy.Position);
+ Assert.NotEqual(Vector3.Zero, retail.Position);
+ }
+
+ [Fact]
+ public void PreNetworkAdvancedPlayer_DoesNotRunTheInboundCreationReconcile()
+ {
+ PlayerMovementController controller = CreatePlayer();
+ var calls = new List();
+ var runtime = new PlayerRuntime(controller, calls);
+ var localFrame = new RetailLocalPlayerFrameController(
+ runtime,
+ new StillMovementInput());
+ localFrame.AdvanceBeforeNetwork(PhysicsBody.MaxQuantum);
+ calls.Clear();
+ CameraController camera = CreateCamera();
+ var legacy = new ChaseCamera();
+ var retail = new RetailChaseCamera();
+ camera.EnterChaseMode(legacy, retail);
+ var frame = new CameraFrameController(
+ camera,
+ new CaptureSource(),
+ new InputSource(),
+ runtime,
+ new ChaseSource(legacy, retail),
+ localFrame,
+ new Reconciler(calls),
+ new CombatTargetSource());
+
+ frame.Tick(new UpdateFrameTiming(1.0 / 60.0, 1f / 60f, 1.0));
+
+ Assert.Empty(calls);
+ }
+
+ private static CameraFrameController CreateFrame(
+ CameraController camera,
+ CaptureSource? capture = null,
+ InputSource? input = null)
+ {
+ var runtime = new PlayerRuntime(null, []);
+ var localFrame = new RetailLocalPlayerFrameController(
+ runtime,
+ new StillMovementInput());
+ return new CameraFrameController(
+ camera,
+ capture ?? new CaptureSource(),
+ input ?? new InputSource(),
+ runtime,
+ new ChaseSource(null, null),
+ localFrame,
+ new Reconciler([]),
+ new CombatTargetSource());
+ }
+
+ private static CameraController CreateCamera() =>
+ new(new OrbitCamera(), new FlyCamera());
+
+ private static PlayerMovementController CreatePlayer()
+ {
+ var engine = new PhysicsEngine();
+ var heights = new byte[81];
+ Array.Fill(heights, (byte)50);
+ var heightTable = new float[256];
+ for (int i = 0; i < heightTable.Length; i++)
+ heightTable[i] = i;
+ engine.AddLandblock(
+ 0xA9B4FFFFu,
+ new TerrainSurface(heights, heightTable),
+ Array.Empty(),
+ Array.Empty(),
+ 0f,
+ 0f);
+ var controller = new PlayerMovementController(engine);
+ controller.SetPosition(new Vector3(96f, 96f, 50f), 0x0001u);
+ return controller;
+ }
+
+ private sealed class CaptureSource : IInputCaptureSource
+ {
+ public bool DevToolsKeyboard { get; set; }
+ public bool WantCaptureMouse => false;
+ public bool WantCaptureKeyboard => DevToolsKeyboard;
+ public bool DevToolsWantCaptureKeyboard => DevToolsKeyboard;
+ }
+
+ private sealed class InputSource : ICameraFrameInputSource
+ {
+ public bool IsAvailable { get; set; } = true;
+ public FlyCameraInput Fly { get; set; }
+ public ChaseCameraAdjustmentInput Chase { get; set; }
+ public int FlyCaptures { get; private set; }
+ public FlyCameraInput CaptureFly()
+ {
+ FlyCaptures++;
+ return Fly;
+ }
+ public ChaseCameraAdjustmentInput CaptureChaseAdjustment() => Chase;
+ }
+
+ private sealed class ChaseSource(
+ ChaseCamera? legacy,
+ RetailChaseCamera? retail) : IChaseCameraSource
+ {
+ public ChaseCamera? Legacy => legacy;
+ public RetailChaseCamera? Retail => retail;
+ }
+
+ private sealed class StillMovementInput : IMovementInputSource
+ {
+ public MovementInput Capture() => default;
+ }
+
+ private sealed class PlayerRuntime(
+ PlayerMovementController? controller,
+ List calls) : ILocalPlayerFrameRuntime
+ {
+ public bool CanPresentPlayer { get; set; } = controller is not null;
+ public PlayerMovementController? Controller => controller;
+ public uint ResolveLocalEntityId() => 7u;
+ public void HandleTargeting() { }
+ public bool IsHidden => false;
+ public RetailObjectClockDisposition ObjectClockDisposition =>
+ RetailObjectClockDisposition.Advance;
+ public void Project(
+ PlayerMovementController owner,
+ MovementResult movement,
+ bool hidden) => calls.Add("project");
+ public void SendPreNetwork(
+ PlayerMovementController owner,
+ MovementResult movement,
+ bool hidden) { }
+ public void SendPostNetwork(PlayerMovementController owner, bool hidden) { }
+ }
+
+ private sealed class Reconciler(List calls)
+ : ILiveSpatialReconcilePhase
+ {
+ public void Reconcile() => calls.Add("reconcile");
+ }
+
+ private sealed class CombatTargetSource : ICombatCameraTargetSource
+ {
+ public Vector3? GetTrackedTargetPoint() => null;
+ }
+}
diff --git a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs
index ee92d1e7..12c1f907 100644
--- a/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs
+++ b/tests/AcDream.App.Tests/World/UpdateFrameOrchestratorTests.cs
@@ -619,6 +619,64 @@ public sealed class UpdateFrameOrchestratorTests
StringComparison.Ordinal);
}
+ [Fact]
+ public void CameraAndLocalPlayerFrameOwnersUseTypedSeamsWithoutWindowCallbacks()
+ {
+ Type[] owners =
+ [
+ typeof(AcDream.App.Rendering.CameraFrameController),
+ typeof(AcDream.App.Input.RetailLocalPlayerFrameController),
+ typeof(AcDream.App.Input.LocalPlayerProjectionController),
+ typeof(AcDream.App.Input.LiveLocalPlayerFrameRuntime),
+ typeof(AcDream.App.Input.LiveLocalPlayerProjectionRuntime),
+ typeof(AcDream.App.Combat.CombatCameraTargetSource),
+ ];
+
+ foreach (Type owner in owners)
+ {
+ FieldInfo[] fields = owner.GetFields(
+ BindingFlags.Instance | BindingFlags.NonPublic);
+ Assert.DoesNotContain(fields, field => field.FieldType == typeof(GameWindow));
+ Assert.DoesNotContain(
+ fields,
+ field => typeof(Delegate).IsAssignableFrom(field.FieldType));
+ }
+
+ string root = FindRepoRoot();
+ string source = File.ReadAllText(Path.Combine(
+ root,
+ "src",
+ "AcDream.App",
+ "Rendering",
+ "GameWindow.cs"));
+ Assert.Equal(1, CountOccurrences(source, "_cameraFrame.Tick(frameTiming);"));
+ Assert.DoesNotContain("CanAdvanceLocalPlayer", source, StringComparison.Ordinal);
+ Assert.DoesNotContain("GetCombatCameraTargetPoint()", source,
+ StringComparison.Ordinal);
+ Assert.DoesNotContain("_cameraController.Fly.Update(", source,
+ StringComparison.Ordinal);
+ Assert.DoesNotContain("_localPlayerFrame.TryGetPresentationAfterNetwork", source,
+ StringComparison.Ordinal);
+ AssertAppearsInOrder(
+ source,
+ "_localPlayerTeleport!.Tick(frameDelta);",
+ "_playerModeAutoEntry?.TryEnter();",
+ "_cameraFrame.Tick(frameTiming);");
+
+ string cameraSource = File.ReadAllText(Path.Combine(
+ root,
+ "src",
+ "AcDream.App",
+ "Rendering",
+ "CameraFrameController.cs"));
+ AssertAppearsInOrder(
+ cameraSource,
+ "_localFrame.TryGetPresentationAfterNetwork",
+ "_spatialReconciler.Reconcile();",
+ "legacy.Update(",
+ "retail?.Update(");
+ }
+
private static UpdateFrameOrchestrator Create(
List calls,
RecordingTeardown? teardown = null,