refactor(camera): extract the update frame

Move fly/chase publication, combat target tracking, and local player projection behind typed runtime owners. Preserve the inbound-created projection/reconcile barrier while removing GameWindow callbacks and duplicate shadow helpers.
This commit is contained in:
Erik 2026-07-22 03:08:58 +02:00
parent eeb0f6b45c
commit 947c61d2d7
19 changed files with 988 additions and 356 deletions

View file

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

View file

@ -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();
}
/// <summary>
/// Resolves retail's combat-camera target from canonical combat, selection,
/// gameplay-option, and world-selection owners.
/// </summary>
/// <remarks>
/// <c>ClientCombatSystem::UpdateTargetTracking @ 0x0056A950</c> enables
/// <c>CameraSet::TrackTarget</c> only for melee/missile combat with the option
/// enabled and a valid attack target. TrackTarget applies target-local offset
/// (0, 0, 0.5).
/// </remarks>
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);
}
}

View file

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

View file

@ -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();
}
/// <summary>
/// Samples held camera actions from the same semantic dispatcher used by
/// gameplay movement. Binding is a one-time startup edge.
/// </summary>
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));
}
}

View file

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

View file

@ -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;
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// Production local-player frame runtime composed from canonical typed owners.
/// </summary>
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);
}

View file

@ -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);
}
/// <summary>
/// Projects the canonical local physics body into world rendering and spatial
/// buckets without advancing it.
/// </summary>
public sealed class LocalPlayerProjectionController
{
private readonly Func<WorldEntity?> _resolveEntity;
private readonly Func<int> _liveCenterX;
private readonly Func<int> _liveCenterY;
private readonly Action<WorldEntity, uint> _syncShadow;
private readonly Action<uint, uint> _rebucket;
private readonly Func<WorldEntity, bool> _isCurrentVisibleProjection;
private readonly Action<WorldEntity> _suspendShadow;
private readonly ILocalPlayerProjectionRuntime _runtime;
public LocalPlayerProjectionController(
Func<WorldEntity?> resolveEntity,
Func<int> liveCenterX,
Func<int> liveCenterY,
Action<WorldEntity, uint> syncShadow,
Action<uint, uint> rebucket,
Func<WorldEntity, bool> isCurrentVisibleProjection,
Action<WorldEntity> 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);
}
}

View file

@ -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.
/// </summary>
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; }

View file

@ -14,17 +14,8 @@ namespace AcDream.App.Input;
/// </remarks>
internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFramePhase
{
private readonly Func<bool> _canPresentPlayer;
private readonly Func<PlayerMovementController?> _getController;
private readonly ILocalPlayerFrameRuntime _runtime;
private readonly IMovementInputSource _movementInput;
private readonly Func<uint> _resolveLocalEntityId;
private readonly Action _handleTargeting;
private readonly Func<bool> _isHidden;
private readonly Action<PlayerMovementController, MovementResult, bool> _project;
private readonly Action<PlayerMovementController, MovementResult, bool> _sendPreNetwork;
private readonly Action<PlayerMovementController, bool> _sendPostNetwork;
private readonly Func<AcDream.Core.Physics.RetailObjectClockDisposition>
_objectClockDisposition;
private AdvancedFrame? _advancedFrame;
@ -51,38 +42,12 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram
};
public RetailLocalPlayerFrameController(
Func<bool> canPresentPlayer,
Func<PlayerMovementController?> getController,
IMovementInputSource movementInput,
Func<uint> resolveLocalEntityId,
Action handleTargeting,
Func<bool> isHidden,
Action<PlayerMovementController, MovementResult, bool> project,
Action<PlayerMovementController, MovementResult, bool> sendPreNetwork,
Action<PlayerMovementController, bool> sendPostNetwork,
Func<AcDream.Core.Physics.RetailObjectClockDisposition>?
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);
}
/// <summary>
@ -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
/// </summary>
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);
}
/// <summary>
@ -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;
}

View file

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

View file

@ -0,0 +1,114 @@
using AcDream.App.Combat;
using AcDream.App.Input;
using AcDream.App.Update;
using AcDream.Core.Rendering;
namespace AcDream.App.Rendering;
/// <summary>
/// Owns the per-update fly/chase camera publication tail.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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());
}
}

View file

@ -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);
/// <summary>
/// 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).
/// </summary>
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);
}
/// <summary>
/// Item-target-mode world pick at the current cursor. The renderer supplies the
/// exact visible CPhysicsPart equivalents and RetailWorldPicker performs

View file

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

View file

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

View file

@ -26,14 +26,14 @@ public sealed class LocalPlayerProjectionControllerTests
Rotation = Quaternion.Identity,
MeshRefs = Array.Empty<MeshRef>(),
};
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<MeshRef>(),
};
var order = new List<string>();
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<string>();
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<WorldEntity?> resolveEntity,
Func<int> liveCenterX,
Func<int> liveCenterY,
Action<WorldEntity, uint> syncShadow,
Action<uint, uint> rebucket,
Func<WorldEntity, bool> isCurrentVisibleProjection,
Action<WorldEntity> 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);
}
}

View file

@ -15,7 +15,7 @@ public sealed class RetailLocalPlayerFrameControllerTests
var calls = new List<string>();
var preNetwork = new List<(PlayerState State, MovementResult Movement)>();
var postNetwork = new List<PlayerState>();
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<MovementInput> capture) =>
new TestMovementInputSource(capture);
private static RetailLocalPlayerFrameController CreateFrame(
Func<bool> canPresentPlayer,
Func<PlayerMovementController?> getController,
IMovementInputSource movementInput,
Func<uint> resolveLocalEntityId,
Action handleTargeting,
Func<bool> isHidden,
Action<PlayerMovementController, MovementResult, bool> project,
Action<PlayerMovementController, MovementResult, bool> sendPreNetwork,
Action<PlayerMovementController, bool> sendPostNetwork,
Func<RetailObjectClockDisposition>? objectClockDisposition = null) =>
new(
new TestLocalPlayerFrameRuntime(
canPresentPlayer,
getController,
resolveLocalEntityId,
handleTargeting,
isHidden,
project,
sendPreNetwork,
sendPostNetwork,
objectClockDisposition),
movementInput);
private sealed class TestLocalPlayerFrameRuntime(
Func<bool> canPresentPlayer,
Func<PlayerMovementController?> getController,
Func<uint> resolveLocalEntityId,
Action handleTargeting,
Func<bool> isHidden,
Action<PlayerMovementController, MovementResult, bool> project,
Action<PlayerMovementController, MovementResult, bool> sendPreNetwork,
Action<PlayerMovementController, bool> sendPostNetwork,
Func<RetailObjectClockDisposition>? 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<MovementInput> capture)
: IMovementInputSource
{

View file

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

View file

@ -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<string>();
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<string>();
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<CellSurface>(),
Array.Empty<PortalPlane>(),
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<string> 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<string> calls)
: ILiveSpatialReconcilePhase
{
public void Reconcile() => calls.Add("reconcile");
}
private sealed class CombatTargetSource : ICombatCameraTargetSource
{
public Vector3? GetTrackedTargetPoint() => null;
}
}

View file

@ -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<string> calls,
RecordingTeardown? teardown = null,