refactor(input): extract the gameplay frame

This commit is contained in:
Erik 2026-07-22 01:38:40 +02:00
parent 0bc9fda9de
commit c557038353
24 changed files with 2433 additions and 559 deletions

View file

@ -4,6 +4,56 @@ using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Combat;
internal interface ICombatAttackOperations
{
bool CanStartAttack();
void PrepareAttackRequest();
bool SendAttack(AttackHeight height, float power);
void SendCancelAttack();
bool IsDualWield { get; }
bool PlayerReadyForAttack { get; }
bool AutoRepeatAttack { get; }
}
internal sealed class DelegateCombatAttackOperations : ICombatAttackOperations
{
private readonly Func<bool> _canStartAttack;
private readonly Action _prepareAttackRequest;
private readonly Func<AttackHeight, float, bool> _sendAttack;
private readonly Action _sendCancelAttack;
private readonly Func<bool> _isDualWield;
private readonly Func<bool> _playerReadyForAttack;
private readonly Func<bool> _autoRepeatAttack;
public DelegateCombatAttackOperations(
Func<bool> canStartAttack,
Func<AttackHeight, float, bool> sendAttack,
Action? prepareAttackRequest,
Action? sendCancelAttack,
Func<bool>? isDualWield,
Func<bool>? playerReadyForAttack,
Func<bool>? autoRepeatAttack)
{
_canStartAttack = canStartAttack
?? throw new ArgumentNullException(nameof(canStartAttack));
_sendAttack = sendAttack ?? throw new ArgumentNullException(nameof(sendAttack));
_prepareAttackRequest = prepareAttackRequest ?? (() => { });
_sendCancelAttack = sendCancelAttack ?? (() => { });
_isDualWield = isDualWield ?? (() => false);
_playerReadyForAttack = playerReadyForAttack ?? (() => true);
_autoRepeatAttack = autoRepeatAttack ?? (() => false);
}
public bool CanStartAttack() => _canStartAttack();
public void PrepareAttackRequest() => _prepareAttackRequest();
public bool SendAttack(AttackHeight height, float power) =>
_sendAttack(height, power);
public void SendCancelAttack() => _sendCancelAttack();
public bool IsDualWield => _isDualWield();
public bool PlayerReadyForAttack => _playerReadyForAttack();
public bool AutoRepeatAttack => _autoRepeatAttack();
}
/// <summary>
/// Owns retail's basic-combat attack request and power-bar state machine.
/// UI buttons and keyboard actions both enter through <see cref="PressAttack"/>
@ -26,13 +76,7 @@ public sealed class CombatAttackController : IDisposable
public const float DesiredPowerStep = 1f / 6f;
private readonly CombatState _combat;
private readonly Func<bool> _canStartAttack;
private readonly Action _prepareAttackRequest;
private readonly Func<AttackHeight, float, bool> _sendAttack;
private readonly Action _sendCancelAttack;
private readonly Func<bool> _isDualWield;
private readonly Func<bool> _playerReadyForAttack;
private readonly Func<bool> _autoRepeatAttack;
private readonly ICombatAttackOperations _operations;
private readonly Func<double> _now;
private bool _buildInProgress;
@ -57,15 +101,27 @@ public sealed class CombatAttackController : IDisposable
Func<bool>? playerReadyForAttack = null,
Func<bool>? autoRepeatAttack = null,
Func<double>? now = null)
: this(
combat,
new DelegateCombatAttackOperations(
canStartAttack,
sendAttack,
prepareAttackRequest,
sendCancelAttack,
isDualWield,
playerReadyForAttack,
autoRepeatAttack),
now)
{
}
internal CombatAttackController(
CombatState combat,
ICombatAttackOperations operations,
Func<double>? now = null)
{
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
_canStartAttack = canStartAttack ?? throw new ArgumentNullException(nameof(canStartAttack));
_prepareAttackRequest = prepareAttackRequest ?? (() => { });
_sendAttack = sendAttack ?? throw new ArgumentNullException(nameof(sendAttack));
_sendCancelAttack = sendCancelAttack ?? (() => { });
_isDualWield = isDualWield ?? (() => false);
_playerReadyForAttack = playerReadyForAttack ?? (() => true);
_autoRepeatAttack = autoRepeatAttack ?? (() => false);
_operations = operations ?? throw new ArgumentNullException(nameof(operations));
_now = now ?? (() => Stopwatch.GetTimestamp() / (double)Stopwatch.Frequency);
_combat.CombatModeChanged += OnCombatModeChanged;
@ -196,7 +252,7 @@ public sealed class CombatAttackController : IDisposable
&& !_repeatAttacking)
return;
_sendCancelAttack();
_operations.SendCancelAttack();
_repeatAttacking = false;
if (_buildInProgress)
@ -219,7 +275,7 @@ public sealed class CombatAttackController : IDisposable
if (!_buildInProgress)
return;
if (!_playerReadyForAttack())
if (!_operations.PlayerReadyForAttack)
{
if (_attackRequestInProgress)
{
@ -258,7 +314,7 @@ public sealed class CombatAttackController : IDisposable
private void StartAttackRequest()
{
if (!CombatInputPlanner.SupportsTargetedAttack(_combat.CurrentMode)
|| !_canStartAttack())
|| !_operations.CanStartAttack())
return;
// Retail StartAttackRequest (0x0056C040) stores request-in-progress
@ -267,7 +323,7 @@ public sealed class CombatAttackController : IDisposable
// player movement object and must run before any later attack send.
_attackRequestInProgress = true;
_requestedAttackPower = 1f;
_prepareAttackRequest();
_operations.PrepareAttackRequest();
_currentBuildIsAutomatic = false;
AttemptStartBuildingAttack();
}
@ -276,7 +332,7 @@ public sealed class CombatAttackController : IDisposable
{
if (_buildInProgress
|| _attackServerResponsePending
|| !_playerReadyForAttack())
|| !_operations.PlayerReadyForAttack)
return;
StartPowerBarBuild();
}
@ -292,7 +348,7 @@ public sealed class CombatAttackController : IDisposable
{
if (!_buildInProgress)
return 0f;
double duration = _isDualWield()
double duration = _operations.IsDualWield
? DualWieldPowerUpSeconds
: AttackPowerUpSeconds;
return (float)Math.Clamp((_now() - _buildStartTime) / duration, 0d, 1d);
@ -301,13 +357,15 @@ public sealed class CombatAttackController : IDisposable
private void ExecuteAttack(AttackHeight height, bool setServerPending)
{
StopBuild();
if (!_sendAttack(height, Math.Clamp(_requestedAttackPower, 0f, 1f)))
if (!_operations.SendAttack(
height,
Math.Clamp(_requestedAttackPower, 0f, 1f)))
{
ResetPowerBar();
return;
}
if (_autoRepeatAttack())
if (_operations.AutoRepeatAttack)
_repeatAttacking = true;
_attackServerResponsePending = setServerPending;
}
@ -330,7 +388,7 @@ public sealed class CombatAttackController : IDisposable
_repeatAttacking = false;
if (!_attackRequestInProgress
&& _autoRepeatAttack()
&& _operations.AutoRepeatAttack
&& _repeatAttacking)
{
if (Math.Abs(_requestedAttackPower - DesiredPower) >= 0.01f)
@ -338,7 +396,7 @@ public sealed class CombatAttackController : IDisposable
ExecuteAttack(RequestedHeight, setServerPending: false);
}
if (!_autoRepeatAttack() || !_repeatAttacking)
if (!_operations.AutoRepeatAttack || !_repeatAttacking)
{
_repeatAttacking = false;
ResetPowerBar();

View file

@ -0,0 +1,116 @@
using System.Numerics;
using AcDream.App.Input;
using AcDream.App.World;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Physics;
using AcDream.Core.Selection;
using AcDream.Core.World;
namespace AcDream.App.Combat;
/// <summary>
/// Focused automatic-attack target owner. It shares canonical selection and
/// live-entity state without retaining the broader interaction controller.
/// </summary>
internal sealed class CombatAttackTargetSource : ICombatAttackTargetSource
{
private readonly SelectionState _selection;
private readonly LiveEntityRuntime _liveEntities;
private readonly ClientObjectTable _objects;
private readonly ILocalPlayerIdentitySource _player;
public CombatAttackTargetSource(
SelectionState selection,
LiveEntityRuntime liveEntities,
ClientObjectTable objects,
ILocalPlayerIdentitySource player)
{
_selection = selection ?? throw new ArgumentNullException(nameof(selection));
_liveEntities = liveEntities
?? throw new ArgumentNullException(nameof(liveEntities));
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_player = player ?? throw new ArgumentNullException(nameof(player));
}
public uint? SelectedObjectId => _selection.SelectedObjectId;
public uint? GetSelectedOrClosestCombatTarget(bool autoTarget)
{
if (_selection.SelectedObjectId is { } selected
&& IsHostileMonster(selected))
{
return selected;
}
if (!autoTarget)
return null;
(uint Guid, float DistanceSquared)? closest = FindClosestHostileMonster();
if (closest is not { } best)
{
_selection.Clear(SelectionChangeSource.Keyboard);
return null;
}
_selection.Select(best.Guid, SelectionChangeSource.Keyboard);
string? name = _objects.Get(best.Guid)?.Name;
string label = string.IsNullOrWhiteSpace(name)
? $"0x{best.Guid:X8}"
: name;
Console.WriteLine(
$"combat: selected target 0x{best.Guid:X8} {label} dist={MathF.Sqrt(best.DistanceSquared):F1}");
return best.Guid;
}
private (uint Guid, float DistanceSquared)? FindClosestHostileMonster()
{
if (!_liveEntities.TryGetWorldEntity(
_player.ServerGuid,
out WorldEntity playerEntity))
{
return null;
}
(uint Guid, float DistanceSquared)? best = null;
foreach ((uint guid, WorldEntity entity) in _liveEntities.WorldEntities)
{
if (!IsHostileMonster(guid))
continue;
float distanceSquared = Vector3.DistanceSquared(
entity.Position,
playerEntity.Position);
if (best is null || distanceSquared < best.Value.DistanceSquared)
best = (guid, distanceSquared);
}
return best;
}
private bool IsHostileMonster(uint serverGuid)
{
uint playerGuid = _player.ServerGuid;
if (serverGuid == playerGuid
|| !_liveEntities.TryGetInteractionEligibleRecord(
serverGuid,
out LiveEntityRecord record)
|| record.WorldEntity is not { } entity)
{
return false;
}
if (_liveEntities.TryGetAnimationRuntime(entity.Id, out var animation)
&& animation.CurrentMotion == MotionCommand.Dead)
{
return false;
}
ClientObject? candidate = _objects.Get(serverGuid);
return candidate is not null
&& (candidate.Type & ItemType.Creature) != 0
&& CombatTargetPolicy.IsHostileMonster(
playerGuid,
_objects.Get(playerGuid),
candidate);
}
}

View file

@ -0,0 +1,180 @@
using AcDream.App.Input;
using AcDream.App.Net;
using AcDream.Core.Combat;
using AcDream.UI.Abstractions.Panels.Settings;
namespace AcDream.App.Combat;
internal interface ICombatAttackTargetSource
{
uint? SelectedObjectId { get; }
uint? GetSelectedOrClosestCombatTarget(bool autoTarget);
}
internal interface ICombatGameplaySettingsSource
{
bool AutoTarget { get; }
bool AutoRepeatAttack { get; }
}
internal sealed class GameplaySettingsState : ICombatGameplaySettingsSource
{
public GameplaySettings Value { get; set; } = GameplaySettings.Default;
public bool AutoTarget => Value.AutoTarget;
public bool AutoRepeatAttack => Value.AutoRepeatAttack;
}
internal interface ICombatFeedbackSink
{
void Show(string message);
}
internal sealed class CombatFeedbackSlot : ICombatFeedbackSink
{
public AcDream.UI.Abstractions.Panels.Debug.DebugVM? ViewModel { get; set; }
public void Show(string message) => ViewModel?.AddToast(message);
}
internal sealed class CombatAttackOperationsSlot : ICombatAttackOperations
{
private ICombatAttackOperations? _owner;
public void Bind(ICombatAttackOperations owner)
{
ArgumentNullException.ThrowIfNull(owner);
if (_owner is not null && !ReferenceEquals(_owner, owner))
throw new InvalidOperationException(
"Combat attack operations are already bound.");
_owner = owner;
}
public bool CanStartAttack() => _owner?.CanStartAttack() == true;
public void PrepareAttackRequest() => _owner?.PrepareAttackRequest();
public bool SendAttack(AttackHeight height, float power) =>
_owner?.SendAttack(height, power) == true;
public void SendCancelAttack() => _owner?.SendCancelAttack();
public bool IsDualWield => _owner?.IsDualWield == true;
public bool PlayerReadyForAttack => _owner?.PlayerReadyForAttack == true;
public bool AutoRepeatAttack => _owner?.AutoRepeatAttack == true;
}
/// <summary>
/// Production combat request dependencies. The attack state machine retains
/// this typed owner rather than callbacks into the application window.
/// </summary>
internal sealed class LiveCombatAttackOperations : ICombatAttackOperations
{
private readonly CombatState _combat;
private readonly ICombatAttackTargetSource _targets;
private readonly ICombatGameplaySettingsSource _settings;
private readonly ILocalPlayerControllerSource _player;
private readonly LocalPlayerOutboundController _outbound;
private readonly ILiveInWorldSource _inWorld;
private readonly ILiveWorldSessionSource _session;
private readonly ICombatFeedbackSink _feedback;
public LiveCombatAttackOperations(
CombatState combat,
ICombatAttackTargetSource targets,
ICombatGameplaySettingsSource settings,
ILocalPlayerControllerSource player,
LocalPlayerOutboundController outbound,
ILiveInWorldSource inWorld,
ILiveWorldSessionSource session,
ICombatFeedbackSink feedback)
{
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
_targets = targets ?? throw new ArgumentNullException(nameof(targets));
_settings = settings ?? throw new ArgumentNullException(nameof(settings));
_player = player ?? throw new ArgumentNullException(nameof(player));
_outbound = outbound ?? throw new ArgumentNullException(nameof(outbound));
_inWorld = inWorld ?? throw new ArgumentNullException(nameof(inWorld));
_session = session ?? throw new ArgumentNullException(nameof(session));
_feedback = feedback ?? throw new ArgumentNullException(nameof(feedback));
}
public bool IsDualWield =>
_player.Controller?.Motion.InterpretedState.CurrentStyle
== CombatInputPlanner.DualWieldCombatStyle;
public bool PlayerReadyForAttack
{
get
{
if (_player.Controller is not { } controller)
return false;
var motion = controller.Motion.InterpretedState;
return CombatInputPlanner.PlayerInReadyPositionForAttack(
_combat.CurrentMode,
motion.CurrentStyle,
motion.ForwardCommand);
}
}
public bool AutoRepeatAttack => _settings.AutoRepeatAttack;
public bool CanStartAttack()
{
if (!_inWorld.IsInWorld)
return false;
if (!CombatInputPlanner.SupportsTargetedAttack(_combat.CurrentMode))
{
_feedback.Show("Enter melee or missile combat first");
Console.WriteLine(
"combat: attack ignored; not in melee/missile combat mode");
return false;
}
if (_targets.GetSelectedOrClosestCombatTarget(_settings.AutoTarget) is null)
{
_feedback.Show("No monster target");
Console.WriteLine("combat: attack ignored; no creature target found");
return false;
}
return true;
}
public bool SendAttack(AttackHeight height, float power)
{
if (!CanStartAttack()
|| _session.CurrentSession is not { } session
|| _targets.SelectedObjectId is not { } target)
{
return false;
}
power = Math.Clamp(power, 0f, 1f);
if (_combat.CurrentMode == CombatMode.Missile)
{
session.SendMissileAttack(target, height, power);
Console.WriteLine(
$"combat: missile attack target=0x{target:X8} height={height} accuracy={power:F2}");
}
else
{
session.SendMeleeAttack(target, height, power);
Console.WriteLine(
$"combat: melee attack target=0x{target:X8} height={height} power={power:F2}");
}
return true;
}
public void SendCancelAttack() =>
_session.CurrentSession?.SendCancelAttack();
public void PrepareAttackRequest()
{
if (_player.Controller is not { } controller
|| !controller.PrepareForAttackRequest())
{
return;
}
_outbound.TrySendMovement(
_session.CurrentSession,
controller,
controller.CaptureMovementResult(mouseLookEvent: false));
}
}

View file

@ -0,0 +1,83 @@
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Input;
internal interface IMovementInputSource
{
MovementInput Capture();
}
/// <summary>
/// Owns held movement sampling and the retail autorun latch. The input
/// dispatcher remains the only keyboard/mouse-button state source.
/// </summary>
internal sealed class DispatcherMovementInputSource : IMovementInputSource
{
private readonly IInputCaptureSource? _capture;
private InputDispatcher? _dispatcher;
public DispatcherMovementInputSource(IInputCaptureSource? capture = null) =>
_capture = capture;
public bool AutoRunActive { get; private set; }
public void Bind(InputDispatcher dispatcher)
{
ArgumentNullException.ThrowIfNull(dispatcher);
if (_dispatcher is not null && !ReferenceEquals(_dispatcher, dispatcher))
throw new InvalidOperationException(
"The movement input source is already bound to another dispatcher.");
_dispatcher = dispatcher;
}
public MovementInput Capture()
{
// Devtools owns the whole gameplay keyboard while active, including
// a latched autorun. Retained chat owns physical key state only;
// retail's autorun latch continues until an explicit cancel action.
if (_capture?.DevToolsWantCaptureKeyboard == true)
return default;
if (_dispatcher is not { } dispatcher)
return default;
bool walking = dispatcher.IsActionHeld(InputAction.MovementWalkMode);
bool forward = dispatcher.IsActionHeld(InputAction.MovementForward);
return new MovementInput(
Forward: forward || AutoRunActive,
Backward: dispatcher.IsActionHeld(InputAction.MovementBackup),
StrafeLeft: dispatcher.IsActionHeld(InputAction.MovementStrafeLeft),
StrafeRight: dispatcher.IsActionHeld(InputAction.MovementStrafeRight),
TurnLeft: dispatcher.IsActionHeld(InputAction.MovementTurnLeft),
TurnRight: dispatcher.IsActionHeld(InputAction.MovementTurnRight),
Run: !walking,
Jump: dispatcher.IsActionHeld(InputAction.MovementJump));
}
/// <summary>
/// Applies the press-only autorun policy at the same point in the semantic
/// action pipeline as the former GameWindow body.
/// </summary>
/// <returns>True when the action is fully consumed.</returns>
public bool HandlePressedAction(InputAction action)
{
if (action == InputAction.MovementRunLock)
{
AutoRunActive = !AutoRunActive;
return true;
}
if (AutoRunActive && action is (
InputAction.MovementBackup
or InputAction.MovementStop
or InputAction.MovementStrafeLeft
or InputAction.MovementStrafeRight))
{
AutoRunActive = false;
}
return false;
}
public void ResetSession() => AutoRunActive = false;
}

View file

@ -0,0 +1,87 @@
using AcDream.App.Combat;
using AcDream.App.Update;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Input;
internal interface ICombatInputFrameController
{
void Tick();
void HandleMovementInput(InputAction action, ActivationType activation);
bool HandleInputAction(InputAction action, ActivationType activation);
}
internal sealed class CombatAttackInputFrameAdapter : ICombatInputFrameController
{
private readonly CombatAttackController _owner;
public CombatAttackInputFrameAdapter(CombatAttackController owner) =>
_owner = owner ?? throw new ArgumentNullException(nameof(owner));
public void Tick() => _owner.Tick();
public void HandleMovementInput(InputAction action, ActivationType activation) =>
_owner.HandleMovementInput(action, activation);
public bool HandleInputAction(InputAction action, ActivationType activation) =>
_owner.HandleInputAction(action, activation);
}
/// <summary>
/// Owns the complete pre-object input frame: held semantic dispatch, one raw
/// mouse-look sample, and combat attack intent.
/// </summary>
internal sealed class GameplayInputFrameController : IGameplayInputFramePhase
{
private readonly InputDispatcher? _dispatcher;
private readonly DispatcherMovementInputSource _movement;
private readonly IMouseLookInputFrameController? _mouseLook;
private readonly ICombatInputFrameController _combat;
public GameplayInputFrameController(
InputDispatcher? dispatcher,
DispatcherMovementInputSource movement,
IMouseLookInputFrameController? mouseLook,
ICombatInputFrameController combat)
{
_dispatcher = dispatcher;
_movement = movement ?? throw new ArgumentNullException(nameof(movement));
_mouseLook = mouseLook;
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
}
public bool MouseLookActive => _mouseLook?.Active == true;
public void Tick(UpdateFrameTiming timing)
{
_ = timing;
_dispatcher?.Tick();
_mouseLook?.Tick();
_combat.Tick();
}
public bool HandlePointerAction(InputAction action, ActivationType activation) =>
_mouseLook?.HandlePointerAction(action, activation) == true;
public bool HandleCombatAction(InputAction action, ActivationType activation)
{
// ACCmdInterp::HandleNewForwardMovement @ 0x0058B1F0 aborts
// automatic combat before the movement command enters the interpreter.
_combat.HandleMovementInput(action, activation);
return _combat.HandleInputAction(action, activation);
}
public bool HandlePressedMovementAction(InputAction action) =>
_movement.HandlePressedAction(action);
public void QueueRawMouseDelta(float dx, float dy) =>
_mouseLook?.QueueRawDelta(dx, dy);
public void EndMouseLook() => _mouseLook?.EndForLifecycle();
public void ResetSession()
{
_mouseLook?.ResetSession();
_movement.ResetSession();
}
}

View file

@ -0,0 +1,74 @@
using AcDream.App.UI;
namespace AcDream.App.Input;
internal interface IInputCaptureSource
{
bool WantCaptureMouse { get; }
bool WantCaptureKeyboard { get; }
bool DevToolsWantCaptureKeyboard { get; }
}
internal sealed class DevToolsInputCaptureSource
{
private readonly bool _enabled;
public DevToolsInputCaptureSource(bool enabled) => _enabled = enabled;
public bool WantCaptureMouse =>
_enabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse;
public bool WantCaptureKeyboard =>
_enabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard;
}
internal sealed class RetainedUiInputCaptureSlot
{
public UiRoot? Root { get; set; }
public bool WantCaptureMouse => Root?.WantsMouse ?? false;
public bool WantCaptureKeyboard => Root?.WantsKeyboard ?? false;
}
internal sealed class CompositeInputCaptureSource : IInputCaptureSource
{
private readonly DevToolsInputCaptureSource _devTools;
private readonly RetainedUiInputCaptureSlot _retained;
public CompositeInputCaptureSource(
DevToolsInputCaptureSource devTools,
RetainedUiInputCaptureSlot retained)
{
_devTools = devTools ?? throw new ArgumentNullException(nameof(devTools));
_retained = retained ?? throw new ArgumentNullException(nameof(retained));
}
public bool WantCaptureMouse =>
_devTools.WantCaptureMouse || _retained.WantCaptureMouse;
public bool WantCaptureKeyboard =>
DevToolsWantCaptureKeyboard || _retained.WantCaptureKeyboard;
public bool DevToolsWantCaptureKeyboard =>
_devTools.WantCaptureKeyboard;
}
internal sealed class DelegateInputCaptureSource : IInputCaptureSource
{
private readonly Func<bool> _wantCaptureMouse;
private readonly Func<bool> _wantCaptureKeyboard;
public DelegateInputCaptureSource(
Func<bool> wantCaptureMouse,
Func<bool> wantCaptureKeyboard)
{
_wantCaptureMouse = wantCaptureMouse
?? throw new ArgumentNullException(nameof(wantCaptureMouse));
_wantCaptureKeyboard = wantCaptureKeyboard
?? throw new ArgumentNullException(nameof(wantCaptureKeyboard));
}
public bool WantCaptureMouse => _wantCaptureMouse();
public bool WantCaptureKeyboard => _wantCaptureKeyboard();
public bool DevToolsWantCaptureKeyboard => false;
}

View file

@ -11,13 +11,18 @@ namespace AcDream.App.Input;
/// </summary>
public sealed class LocalPlayerOutboundController
{
private readonly Action<string, uint, MovementResult, Vector3, uint, byte> _diagnostic;
private readonly IMovementTruthDiagnosticSink _diagnostic;
internal LocalPlayerOutboundController(IMovementTruthDiagnosticSink diagnostic)
{
_diagnostic = diagnostic
?? throw new ArgumentNullException(nameof(diagnostic));
}
public LocalPlayerOutboundController(
Action<string, uint, MovementResult, Vector3, uint, byte> diagnostic)
{
_diagnostic = diagnostic
?? throw new ArgumentNullException(nameof(diagnostic));
_diagnostic = new DelegateMovementTruthDiagnosticSink(diagnostic);
}
/// <summary>
@ -107,7 +112,7 @@ public sealed class LocalPlayerOutboundController
teleportSequence: session.TeleportSequence,
forcePositionSequence: session.ForcePositionSequence,
lastContact: contactByte);
_diagnostic(
_diagnostic.OnOutbound(
"AP",
sequence,
movement,
@ -193,7 +198,7 @@ public sealed class LocalPlayerOutboundController
forcePositionSequence: session.ForcePositionSequence,
contact: contactByte != 0,
standingLongjump: false);
_diagnostic(
_diagnostic.OnOutbound(
"MTS",
sequence,
movement,

View file

@ -0,0 +1,262 @@
using AcDream.App.Net;
using AcDream.App.Rendering;
using AcDream.Core.Rendering;
using AcDream.UI.Abstractions.Input;
using Silk.NET.Input;
namespace AcDream.App.Input;
internal interface IInputMonotonicClock
{
float NowSeconds { get; }
}
internal sealed class EnvironmentInputMonotonicClock : IInputMonotonicClock
{
public float NowSeconds => (float)(Environment.TickCount64 / 1000.0);
}
internal interface IPointerPositionSource
{
float X { get; }
float Y { get; }
}
internal sealed class PointerPositionState : IPointerPositionSource
{
public float X { get; set; }
public float Y { get; set; }
}
internal interface IMouseLookCursor
{
bool HasSavedMode { get; }
void Hide();
void Restore();
}
internal interface IMouseLookInputFrameController
{
bool Active { get; }
bool HandlePointerAction(InputAction action, ActivationType activation);
void QueueRawDelta(float dx, float dy);
void Tick();
void EndAndRestoreCursor();
void EndForLifecycle();
void ResetSession();
}
internal sealed class SilkMouseLookCursor : IMouseLookCursor
{
private readonly IMouse _mouse;
private CursorMode? _savedMode;
public SilkMouseLookCursor(IMouse mouse) =>
_mouse = mouse ?? throw new ArgumentNullException(nameof(mouse));
public bool HasSavedMode => _savedMode.HasValue;
public void Hide()
{
_savedMode = _mouse.Cursor.CursorMode;
_mouse.Cursor.CursorMode = CursorMode.Hidden;
}
public void Restore()
{
_mouse.Cursor.CursorMode = _savedMode ?? CursorMode.Normal;
_savedMode = null;
}
}
/// <summary>
/// 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
{
public ChaseCamera? Legacy { get; set; }
public RetailChaseCamera? Retail { get; set; }
public float Sensitivity { get; set; } = 0.15f;
public bool RmbOrbitHeld { get; set; }
}
/// <summary>
/// Owns instant mouse-look state, raw-sample timing/filtering, movement
/// transition sends, and cursor capture across every exit path.
/// </summary>
internal sealed class MouseLookController : IMouseLookInputFrameController
{
private readonly IMouseSource _mouseSource;
private readonly IPointerPositionSource _pointer;
private readonly ILocalPlayerModeSource _playerMode;
private readonly ILocalPlayerControllerSource _playerController;
private readonly CameraController _camera;
private readonly ChaseCameraInputState _chase;
private readonly IMovementInputSource _movementInput;
private readonly LocalPlayerOutboundController _outbound;
private readonly ILiveWorldSessionSource _session;
private readonly IMouseLookCursor _cursor;
private readonly IInputMonotonicClock _clock;
private readonly MouseLookState _state;
private bool _lastWantCaptureMouse;
public MouseLookController(
IMouseSource mouseSource,
IPointerPositionSource pointer,
ILocalPlayerModeSource playerMode,
ILocalPlayerControllerSource playerController,
CameraController camera,
ChaseCameraInputState chase,
IMovementInputSource movementInput,
LocalPlayerOutboundController outbound,
ILiveWorldSessionSource session,
IMouseLookCursor cursor,
IInputMonotonicClock clock)
{
_mouseSource = mouseSource ?? throw new ArgumentNullException(nameof(mouseSource));
_pointer = pointer ?? throw new ArgumentNullException(nameof(pointer));
_playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode));
_playerController = playerController
?? throw new ArgumentNullException(nameof(playerController));
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
_chase = chase ?? throw new ArgumentNullException(nameof(chase));
_movementInput = movementInput
?? throw new ArgumentNullException(nameof(movementInput));
_outbound = outbound ?? throw new ArgumentNullException(nameof(outbound));
_session = session ?? throw new ArgumentNullException(nameof(session));
_cursor = cursor ?? throw new ArgumentNullException(nameof(cursor));
_clock = clock ?? throw new ArgumentNullException(nameof(clock));
_state = new MouseLookState(ApplyHorizontalAdjustment);
}
public bool Active => _state.Active;
public bool HandlePointerAction(InputAction action, ActivationType activation)
{
if (action == InputAction.AcdreamRmbOrbitHold)
{
if (activation == ActivationType.Press)
_chase.RmbOrbitHeld = _playerMode.IsPlayerMode && _camera.IsChaseMode;
else if (activation == ActivationType.Release)
_chase.RmbOrbitHeld = false;
return true;
}
if (action != InputAction.CameraInstantMouseLook)
return false;
if (activation == ActivationType.Press)
Begin();
else if (activation == ActivationType.Release)
EndAndRestoreCursor();
return true;
}
public void QueueRawDelta(float dx, float dy) => _state.QueueDelta(dx, dy);
public void Tick()
{
bool wantCaptureMouse = _mouseSource.WantCaptureMouse;
if (wantCaptureMouse != _lastWantCaptureMouse)
{
if (wantCaptureMouse && _state.Active)
EndAndRestoreCursor();
_state.OnWantCaptureMouseChanged(wantCaptureMouse);
_lastWantCaptureMouse = wantCaptureMouse;
}
float nowSeconds = _clock.NowSeconds;
if (!_state.TryTakeRawSample(nowSeconds, out float rawX, out float rawY))
return;
PlayerMovementController? controller = _playerController.Controller;
if (rawX == 0f && rawY == 0f)
controller?.StopMouseDrift(_movementInput.Capture());
(float filteredX, float filteredY) =
CameraDiagnostics.UseRetailChaseCamera && _chase.Retail is { } retail
? retail.FilterMouseDelta(rawX, rawY, weight: 0.5f, nowSec: nowSeconds)
: (rawX, rawY);
_state.ApplyDelta(filteredX, _chase.Sensitivity);
if (_chase.Retail is { } retailCamera)
retailCamera.AdjustPitch(filteredY * 0.003f * _chase.Sensitivity);
else
_chase.Legacy?.AdjustPitch(filteredY * 0.003f * _chase.Sensitivity);
}
public void EndAndRestoreCursor()
{
bool stateWasActive = _state.Active;
_state.Release();
PlayerMovementController? controller = _playerController.Controller;
if (controller is not null && controller.EndMouseLook(_movementInput.Capture()))
{
_outbound.TrySendMovement(
_session.CurrentSession,
controller,
controller.CaptureMovementResult(mouseLookEvent: false));
}
if (stateWasActive || _cursor.HasSavedMode)
_cursor.Restore();
}
public void EndForLifecycle()
{
EndAndRestoreCursor();
_chase.RmbOrbitHeld = false;
}
/// <summary>
/// Session teardown releases presentation capture without emitting a
/// movement packet into the ending session, matching the prior reset edge.
/// </summary>
public void ResetSession()
{
bool stateWasActive = _state.Active;
_state.Release();
_chase.RmbOrbitHeld = false;
_lastWantCaptureMouse = false;
if (stateWasActive || _cursor.HasSavedMode)
_cursor.Restore();
}
private void Begin()
{
PlayerMovementController? controller = _playerController.Controller;
if (!_playerMode.IsPlayerMode
|| !_camera.IsChaseMode
|| controller is not { State: PlayerState.InWorld })
{
return;
}
float nowSeconds = _clock.NowSeconds;
_state.Press(
_pointer.X,
_pointer.Y,
_mouseSource.WantCaptureMouse,
nowSeconds);
if (!_state.Active)
return;
if (!controller.BeginMouseLook(_movementInput.Capture()))
{
_state.Release();
return;
}
_outbound.TrySendMovement(
_session.CurrentSession,
controller,
controller.CaptureMovementResult(mouseLookEvent: false));
_cursor.Hide();
}
private void ApplyHorizontalAdjustment(float adjustment) =>
_playerController.Controller?.SubmitMouseTurnAdjustment(
adjustment,
_movementInput.Capture());
}

View file

@ -0,0 +1,161 @@
using System.Numerics;
using AcDream.Core.Net;
namespace AcDream.App.Input;
internal interface IMovementTruthDiagnosticSink
{
void OnOutbound(
string kind,
uint sequence,
MovementResult result,
Vector3 wirePosition,
uint wireCellId,
byte contactByte);
void OnServerEcho(
WorldSession.EntityPositionUpdate update,
Vector3 serverWorldPosition);
void ResetSession();
}
internal sealed class MovementTruthDiagnosticController
: IMovementTruthDiagnosticSink
{
private readonly bool _enabled;
private readonly ILocalPlayerControllerSource _player;
private readonly ILocalPlayerIdentitySource _identity;
private MovementTruthOutbound? _lastOutbound;
private readonly record struct MovementTruthOutbound(
string Kind,
uint Sequence,
DateTime TimeUtc,
Vector3 LocalWorldPosition,
Vector3 WirePosition,
uint WireCellId,
bool IsOnGround,
byte ContactByte,
Vector3 Velocity);
public MovementTruthDiagnosticController(
bool enabled,
ILocalPlayerControllerSource player,
ILocalPlayerIdentitySource identity)
{
_enabled = enabled;
_player = player ?? throw new ArgumentNullException(nameof(player));
_identity = identity ?? throw new ArgumentNullException(nameof(identity));
}
public void OnOutbound(
string kind,
uint sequence,
MovementResult result,
Vector3 wirePosition,
uint wireCellId,
byte contactByte)
{
if (!_enabled)
return;
Vector3 velocity = _player.Controller?.BodyVelocity ?? Vector3.Zero;
_lastOutbound = new MovementTruthOutbound(
kind,
sequence,
DateTime.UtcNow,
result.Position,
wirePosition,
wireCellId,
result.IsOnGround,
contactByte,
velocity);
Console.WriteLine(FormattableString.Invariant(
$"move-truth OUT kind={kind} seq={sequence} local={Fmt(result.Position)} localCell=0x{result.CellId:X8} wire={Fmt(wirePosition)} wireCell=0x{wireCellId:X8} grounded={result.IsOnGround} contact={contactByte} vel={Fmt(velocity)} f={FmtCmd(result.ForwardCommand)} s={FmtCmd(result.SidestepCommand)} t={FmtCmd(result.TurnCommand)}"));
}
public void OnServerEcho(
WorldSession.EntityPositionUpdate update,
Vector3 serverWorldPosition)
{
if (!_enabled || update.Guid != _identity.ServerGuid)
return;
DateTime now = DateTime.UtcNow;
PlayerMovementController? controller = _player.Controller;
Vector3? localPosition = controller?.Position;
uint? localCellId = controller?.CellId;
Vector3? deltaLocal = localPosition.HasValue
? serverWorldPosition - localPosition.Value
: null;
string localText = localPosition.HasValue ? Fmt(localPosition.Value) : "-";
string localCellText = localCellId.HasValue
? FormattableString.Invariant($"0x{localCellId.Value:X8}")
: "-";
string deltaLocalText = deltaLocal.HasValue ? Fmt(deltaLocal.Value) : "-";
string deltaLocalLength = deltaLocal.HasValue
? FormattableString.Invariant($"{deltaLocal.Value.Length():F3}")
: "-";
string lastText = "-";
if (_lastOutbound is { } last)
{
Vector3 deltaOut = serverWorldPosition - last.LocalWorldPosition;
double ageMs = (now - last.TimeUtc).TotalMilliseconds;
lastText = FormattableString.Invariant(
$"{last.Kind}:{last.Sequence} ageMs={ageMs:F0} outGrounded={last.IsOnGround} outContact={last.ContactByte} outCell=0x{last.WireCellId:X8} deltaOut={Fmt(deltaOut)} distOut={deltaOut.Length():F3}");
}
string state = controller?.State.ToString() ?? "-";
string velocityText = update.Velocity.HasValue
? Fmt(update.Velocity.Value)
: "-";
Console.WriteLine(FormattableString.Invariant(
$"move-truth ECHO guid=0x{update.Guid:X8} server={Fmt(serverWorldPosition)} serverCell=0x{update.Position.LandblockId:X8} local={localText} localCell={localCellText} deltaLocal={deltaLocalText} distLocal={deltaLocalLength} serverVel={velocityText} state={state} lastOut={lastText}"));
}
public void ResetSession() => _lastOutbound = null;
private static string Fmt(Vector3 value) =>
FormattableString.Invariant(
$"({value.X:F3},{value.Y:F3},{value.Z:F3})");
private static string FmtCmd(uint? command) =>
command.HasValue
? FormattableString.Invariant($"0x{command.Value:X8}")
: "-";
}
internal sealed class DelegateMovementTruthDiagnosticSink
: IMovementTruthDiagnosticSink
{
private readonly Action<string, uint, MovementResult, Vector3, uint, byte>
_outbound;
public DelegateMovementTruthDiagnosticSink(
Action<string, uint, MovementResult, Vector3, uint, byte> outbound) =>
_outbound = outbound ?? throw new ArgumentNullException(nameof(outbound));
public void OnOutbound(
string kind,
uint sequence,
MovementResult result,
Vector3 wirePosition,
uint wireCellId,
byte contactByte) =>
_outbound(kind, sequence, result, wirePosition, wireCellId, contactByte);
public void OnServerEcho(
WorldSession.EntityPositionUpdate update,
Vector3 serverWorldPosition)
{
}
public void ResetSession()
{
}
}

View file

@ -16,7 +16,7 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram
{
private readonly Func<bool> _canPresentPlayer;
private readonly Func<PlayerMovementController?> _getController;
private readonly Func<MovementInput> _captureInput;
private readonly IMovementInputSource _movementInput;
private readonly Func<uint> _resolveLocalEntityId;
private readonly Action _handleTargeting;
private readonly Func<bool> _isHidden;
@ -53,7 +53,7 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram
public RetailLocalPlayerFrameController(
Func<bool> canPresentPlayer,
Func<PlayerMovementController?> getController,
Func<MovementInput> captureInput,
IMovementInputSource movementInput,
Func<uint> resolveLocalEntityId,
Action handleTargeting,
Func<bool> isHidden,
@ -67,8 +67,8 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram
?? throw new ArgumentNullException(nameof(canPresentPlayer));
_getController = getController
?? throw new ArgumentNullException(nameof(getController));
_captureInput = captureInput
?? throw new ArgumentNullException(nameof(captureInput));
_movementInput = movementInput
?? throw new ArgumentNullException(nameof(movementInput));
_resolveLocalEntityId = resolveLocalEntityId
?? throw new ArgumentNullException(nameof(resolveLocalEntityId));
_handleTargeting = handleTargeting
@ -134,7 +134,7 @@ internal sealed class RetailLocalPlayerFrameController : IPostNetworkCommandFram
MovementResult movement = hidden
? controller.TickHidden(deltaSeconds, _handleTargeting)
: controller.Update(deltaSeconds, _captureInput(), _handleTargeting);
: controller.Update(deltaSeconds, _movementInput.Capture(), _handleTargeting);
_project(controller, movement, hidden);
_sendPreNetwork(controller, movement, hidden);

View file

@ -11,18 +11,15 @@ namespace AcDream.App.Input;
/// <see cref="InputDispatcher"/>.
///
/// <para>
/// We don't link Hexa.NET.ImGui or ImGuiNET directly here — the
/// constructor takes two delegates so the App.Rendering layer can
/// proxy <c>ImGui.GetIO().WantCaptureMouse</c> via whichever ImGui
/// package is currently active without leaking the type onto the
/// abstraction interface.
/// Production receives a typed capture source, keeping retained-UI and
/// developer-UI ownership outside the Silk adapter. The public delegate
/// overload remains as a compatibility seam for standalone consumers.
/// </para>
/// </summary>
public sealed class SilkMouseSource : IMouseSource
{
private readonly IMouse _mouse;
private readonly Func<bool> _wantCaptureMouse;
private readonly Func<bool> _wantCaptureKeyboard;
private readonly IInputCaptureSource _capture;
private float _lastX;
private float _lastY;
private bool _haveLastPos;
@ -32,10 +29,21 @@ public sealed class SilkMouseSource : IMouseSource
public event Action<float, float>? MouseMove;
public event Action<float>? Scroll;
/// <summary>Caller-supplied probe for the current modifier mask. Reused
/// from the keyboard source so mouse events carry consistent modifier
/// state.</summary>
public Func<ModifierMask> ModifierProbe { get; set; } = () => ModifierMask.None;
/// <summary>The keyboard source that owns the current modifier mask, so
/// mouse events and keyboard events resolve identical chords.</summary>
public IKeyboardSource? ModifierSource { get; set; }
internal SilkMouseSource(
IMouse mouse,
IInputCaptureSource capture,
IKeyboardSource modifierSource)
{
_mouse = mouse ?? throw new ArgumentNullException(nameof(mouse));
_capture = capture ?? throw new ArgumentNullException(nameof(capture));
ModifierSource = modifierSource
?? throw new ArgumentNullException(nameof(modifierSource));
Subscribe();
}
public SilkMouseSource(
IMouse mouse,
@ -43,11 +51,16 @@ public sealed class SilkMouseSource : IMouseSource
Func<bool> wantCaptureKeyboard)
{
_mouse = mouse ?? throw new ArgumentNullException(nameof(mouse));
_wantCaptureMouse = wantCaptureMouse ?? throw new ArgumentNullException(nameof(wantCaptureMouse));
_wantCaptureKeyboard = wantCaptureKeyboard ?? throw new ArgumentNullException(nameof(wantCaptureKeyboard));
_capture = new DelegateInputCaptureSource(
wantCaptureMouse,
wantCaptureKeyboard);
Subscribe();
}
_mouse.MouseDown += (_, btn) => MouseDown?.Invoke(btn, ModifierProbe());
_mouse.MouseUp += (_, btn) => MouseUp?.Invoke(btn, ModifierProbe());
private void Subscribe()
{
_mouse.MouseDown += (_, btn) => MouseDown?.Invoke(btn, ReadModifiers());
_mouse.MouseUp += (_, btn) => MouseUp?.Invoke(btn, ReadModifiers());
_mouse.MouseMove += (_, pos) =>
{
float dx, dy;
@ -69,8 +82,11 @@ public sealed class SilkMouseSource : IMouseSource
_mouse.Scroll += (_, scroll) => Scroll?.Invoke(scroll.Y);
}
private ModifierMask ReadModifiers() =>
ModifierSource?.CurrentModifiers ?? ModifierMask.None;
public bool IsHeld(MouseButton button) => _mouse.IsButtonPressed(button);
public bool WantCaptureMouse => _wantCaptureMouse();
public bool WantCaptureKeyboard => _wantCaptureKeyboard();
public bool WantCaptureMouse => _capture.WantCaptureMouse;
public bool WantCaptureKeyboard => _capture.WantCaptureKeyboard;
}

View file

@ -4,3 +4,8 @@ internal interface ILiveInWorldSource
{
bool IsInWorld { get; }
}
internal interface ILiveWorldSessionSource
{
AcDream.Core.Net.WorldSession? CurrentSession { get; }
}

View file

@ -161,7 +161,8 @@ internal sealed class ProductionLiveSessionOperations : ILiveSessionOperations
internal sealed class LiveSessionController
: IDisposable,
ILiveSessionFramePhase,
ILiveInWorldSource
ILiveInWorldSource,
ILiveWorldSessionSource
{
private sealed class SessionScope(
WorldSession session,

View file

@ -58,8 +58,7 @@ internal sealed class LiveEntityNetworkUpdateController
private readonly Func<WorldSession?> _session;
private readonly LiveEntityInboundAuthorityGate _authorityGate;
private readonly Action<WorldSession.EntityPositionUpdate> _aimTeleportDestination;
private readonly Action<WorldSession.EntityPositionUpdate, System.Numerics.Vector3>
_dumpMovementTruthServerEcho;
private readonly IMovementTruthDiagnosticSink _movementTruthDiagnostics;
private PlayerMovementController? _playerController => _playerControllerSource();
private EntityPhysicsHost? _playerHost => _playerHostSource();
@ -106,7 +105,7 @@ internal sealed class LiveEntityNetworkUpdateController
Func<WorldSession?> session,
Action<uint, AcceptedPhysicsTimestamps> publishTimestamps,
Action<WorldSession.EntityPositionUpdate> aimTeleportDestination,
Action<WorldSession.EntityPositionUpdate, System.Numerics.Vector3> dumpMovementTruthServerEcho)
IMovementTruthDiagnosticSink movementTruthDiagnostics)
{
_liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities));
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
@ -140,7 +139,8 @@ internal sealed class LiveEntityNetworkUpdateController
liveEntities,
publishTimestamps);
_aimTeleportDestination = aimTeleportDestination ?? throw new ArgumentNullException(nameof(aimTeleportDestination));
_dumpMovementTruthServerEcho = dumpMovementTruthServerEcho ?? throw new ArgumentNullException(nameof(dumpMovementTruthServerEcho));
_movementTruthDiagnostics = movementTruthDiagnostics
?? throw new ArgumentNullException(nameof(movementTruthDiagnostics));
}
internal void ResetSessionState() => _authorityGate.ResetSessionState();
@ -1106,7 +1106,7 @@ internal sealed class LiveEntityNetworkUpdateController
var rot = timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.ForcePosition
? entity.Rotation
: new System.Numerics.Quaternion(p.RotationX, p.RotationY, p.RotationZ, p.RotationW);
_dumpMovementTruthServerEcho(update, worldPos);
_movementTruthDiagnostics.OnServerEcho(update, worldPos);
bool remoteHardTeleport = update.Guid != _playerServerGuid
&& timestamps.TeleportHookRequired;

View file

@ -34,8 +34,17 @@ public sealed class GameWindow : IDisposable
private CameraController? _cameraController;
private IMouse? _capturedMouse;
private IDatReaderWriter? _dats;
private float _lastMouseX;
private float _lastMouseY;
private readonly AcDream.App.Input.PointerPositionState _pointerPosition = new();
private float _lastMouseX
{
get => _pointerPosition.X;
set => _pointerPosition.X = value;
}
private float _lastMouseY
{
get => _pointerPosition.Y;
set => _pointerPosition.Y = value;
}
private Shader? _meshShader;
private TextureCache? _textureCache;
/// <summary>Phase N.4+: WB-backed rendering pipeline adapter. Always non-null
@ -156,6 +165,8 @@ public sealed class GameWindow : IDisposable
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.World.RetailLiveFrameCoordinator _liveFrameCoordinator = null!;
@ -434,6 +445,10 @@ public sealed class GameWindow : IDisposable
// Phase D.2b — retained host + composition runtime. Null unless ACDREAM_RETAIL_UI=1.
private AcDream.App.UI.UiHost? _uiHost;
private AcDream.App.UI.RetailUiRuntime? _retailUiRuntime;
private readonly AcDream.App.Combat.CombatAttackOperationsSlot
_combatAttackOperations = new();
private readonly AcDream.App.Combat.CombatFeedbackSlot
_combatFeedback = new();
private AcDream.App.Combat.CombatAttackController? _combatAttackController;
private AcDream.App.Combat.CombatTargetController? _combatTargetController;
private AcDream.App.UI.ItemInteractionController? _itemInteractionController;
@ -452,11 +467,8 @@ public sealed class GameWindow : IDisposable
// _panelHost does. Self-subscribes to CombatState in its ctor, so
// disposing isn't required (panel host holds the only ref).
private AcDream.UI.Abstractions.Panels.Debug.DebugVM? _debugVm;
// DevToolsEnabled + DumpMoveTruthEnabled now read through _options
// (RuntimeOptions.DevTools / DumpMoveTruth). Kept the same names for
// local readability via expression-bodied properties.
// DevToolsEnabled reads through typed RuntimeOptions.
private bool DevToolsEnabled => _options.DevTools;
private bool DumpMoveTruthEnabled => _options.DumpMoveTruth;
// Slice 3: the reset transaction remains cached by the host, while the
// lifecycle controller owns each exact event/command/session generation.
@ -530,8 +542,17 @@ public sealed class GameWindow : IDisposable
get => _playerControllerSlot.Controller;
set => _playerControllerSlot.Controller = value;
}
private AcDream.App.Rendering.ChaseCamera? _chaseCamera;
private AcDream.App.Rendering.RetailChaseCamera? _retailChaseCamera;
private readonly AcDream.App.Input.ChaseCameraInputState _chaseCameraInput = new();
private AcDream.App.Rendering.ChaseCamera? _chaseCamera
{
get => _chaseCameraInput.Legacy;
set => _chaseCameraInput.Legacy = value;
}
private AcDream.App.Rendering.RetailChaseCamera? _retailChaseCamera
{
get => _chaseCameraInput.Retail;
set => _chaseCameraInput.Retail = value;
}
private readonly AcDream.App.Input.LocalPlayerModeState _localPlayerMode = new();
private bool _playerMode
{
@ -549,21 +570,8 @@ public sealed class GameWindow : IDisposable
private AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1
_characterOptions1 =
AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1.Default;
private MovementTruthOutbound? _lastMovementTruthOutbound;
private readonly AcDream.App.Physics.LocalPlayerShadowState _localPlayerShadow = new();
private readonly record struct MovementTruthOutbound(
string Kind,
uint Sequence,
System.DateTime TimeUtc,
System.Numerics.Vector3 LocalWorldPosition,
uint LocalCellId,
System.Numerics.Vector3 WirePosition,
uint WireCellId,
bool IsOnGround,
byte ContactByte,
System.Numerics.Vector3 Velocity);
// K-fix7 (2026-04-26): server-authoritative Run + Jump skill values
// received from PlayerDescription. -1 = "not yet received, fall back
// to the controller's default (env-var or hardcoded 200/300)".
@ -577,36 +585,25 @@ public sealed class GameWindow : IDisposable
// Phase D.2b-C — live character-sheet assembly + raise flow (extracted
// feature class; GameWindow only wires it). Null unless ACDREAM_RETAIL_UI=1.
private AcDream.App.UI.Layout.CharacterSheetProvider? _characterSheetProvider;
// K.1b: this field is RESERVED — written when entering / leaving player
// mode and previously fed mouse-X into MovementInput.MouseDeltaX. Now
// never consumed by MovementInput (mouse never drives character yaw —
// K.1b regression-prevention). Kept around as plumbing for the future
// K.2 MMB-mouse-look path which will re-enable mouse → character-yaw
// when MMB is held. The pragma silences the dead-write warning until K.2
// wires the read-side back in.
#pragma warning disable CS0414 // assigned but never used — see comment above
private float _playerMouseDeltaX;
#pragma warning restore CS0414
// Mouse sensitivity multipliers — one per camera mode because the visual
// feel is very different. Adjust via F8 / F9 for whichever mode is
// currently active. Chase default is low because the character + camera
// rotating together is overwhelming at fly speeds.
private float _sensChase = 0.15f;
private float _sensChase
{
get => _chaseCameraInput.Sensitivity;
set => _chaseCameraInput.Sensitivity = value;
}
private float _sensFly = 1.0f;
private float _sensOrbit = 1.0f;
// Right-mouse-button held → free-orbit the chase camera around the
// player without turning the character. Release leaves the camera at
// the orbited position (no snap back).
private bool _rmbHeld;
// K-fix1 (2026-04-26): autorun is a TOGGLE — Press Q to start
// forward-running, press Q again (or any movement-cancel key like
// X / S / Backward / Forward) to stop. Mirrors retail's
// AutoRun action. While true, MovementInput.Forward is forced
// true regardless of W's state.
private bool _autoRunActive;
private bool _rmbHeld
{
get => _chaseCameraInput.RmbOrbitHeld;
}
// Phase K.2 — auto-enter player mode after a successful login. Armed
// by ApplyLiveSessionEnteredWorld; ticked from
@ -617,21 +614,6 @@ public sealed class GameWindow : IDisposable
// the bool here.
private AcDream.App.Input.PlayerModeAutoEntry? _playerModeAutoEntry;
// Phase K.2 — MMB-hold instant mouse-look state. Live throughout
// the session; flips Active on Press/Release. Defense-in-depth on
// ImGui's WantCaptureMouse — the dispatcher already filters, but
// OnWantCaptureMouseChanged also suspends the state if a panel
// claims focus mid-hold.
private AcDream.UI.Abstractions.Input.MouseLookState? _mouseLook;
// Tracks the previous WantCaptureMouse value so we can fire the
// changed-edge callback once per transition (vs every frame).
private bool _lastWantCaptureMouse;
// Cursor mode prior to entering MMB mouse-look. Restored on
// release so the user lands back in the same camera mode as
// before (raw for chase/fly, normal for orbit). Set non-null while
// mouse-look is active.
private Silk.NET.Input.CursorMode? _mouseLookSavedCursorMode;
// Phase K.1b — single input path. Every keyboard/mouse-button reaction
// flows through InputDispatcher.Fired (see OnInputAction below) or
// IsActionHeld (per-frame polling for movement). The legacy direct
@ -641,6 +623,11 @@ public sealed class GameWindow : IDisposable
private AcDream.App.Input.SilkKeyboardSource? _kbSource;
private AcDream.App.Input.SilkMouseSource? _mouseSource;
private AcDream.UI.Abstractions.Input.InputDispatcher? _inputDispatcher;
private readonly AcDream.App.Input.RetainedUiInputCaptureSlot _retainedInputCapture;
private readonly AcDream.App.Input.CompositeInputCaptureSource _inputCapture;
private readonly AcDream.App.Input.DispatcherMovementInputSource _movementInput;
private AcDream.App.Input.IMouseLookCursor? _mouseLookCursor;
private AcDream.App.Input.GameplayInputFrameController? _gameplayInputFrame;
// K.1c: load user-customized bindings from %LOCALAPPDATA%\acdream\keybinds.json,
// falling back to the retail-faithful defaults if the file is missing
// or corrupt. This is THE single source of truth for the keymap at
@ -750,6 +737,12 @@ public sealed class GameWindow : IDisposable
AcDream.App.Plugins.BufferedUiRegistry? uiRegistry = null)
{
_options = options ?? throw new System.ArgumentNullException(nameof(options));
_retainedInputCapture = new AcDream.App.Input.RetainedUiInputCaptureSlot();
_inputCapture = new AcDream.App.Input.CompositeInputCaptureSource(
new AcDream.App.Input.DevToolsInputCaptureSource(options.DevTools),
_retainedInputCapture);
_movementInput = new AcDream.App.Input.DispatcherMovementInputSource(
_inputCapture);
_datDir = options.DatDir;
_worldGameState = worldGameState;
_worldEvents = worldEvents;
@ -785,16 +778,17 @@ public sealed class GameWindow : IDisposable
_liveEntities?.RebucketLiveEntity(serverGuid, landblockId),
isCurrentVisibleProjection: IsCurrentVisibleLocalPlayerProjection,
suspendShadow: SuspendLocalPlayerShadow);
_movementTruthDiagnostics =
new AcDream.App.Input.MovementTruthDiagnosticController(
options.DumpMoveTruth,
_playerControllerSlot,
_localPlayerIdentity);
_localPlayerOutbound = new AcDream.App.Input.LocalPlayerOutboundController(
DumpMovementTruthOutbound);
_movementTruthDiagnostics);
_localPlayerFrame = new AcDream.App.Input.RetailLocalPlayerFrameController(
canPresentPlayer: CanAdvanceLocalPlayer,
getController: () => _playerController,
captureInput: () =>
{
_playerMouseDeltaX = 0f;
return CaptureMovementInput();
},
movementInput: _movementInput,
resolveLocalEntityId: () =>
_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var entity)
? entity.Id
@ -904,30 +898,16 @@ public sealed class GameWindow : IDisposable
_kbSource = new AcDream.App.Input.SilkKeyboardSource(firstKb);
_mouseSource = new AcDream.App.Input.SilkMouseSource(
firstMouse,
wantCaptureMouse: () => (DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse)
|| (_uiHost?.Root.WantsMouse ?? false),
wantCaptureKeyboard: () => (DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard)
|| (_uiHost?.Root.WantsKeyboard ?? false));
_mouseSource.ModifierProbe = () => _kbSource.CurrentModifiers;
_inputCapture,
_kbSource);
_inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher(
_kbSource, _mouseSource, _keyBindings);
_movementInput.Bind(_inputDispatcher);
_mouseLookCursor = new AcDream.App.Input.SilkMouseLookCursor(firstMouse);
_inputDispatcher.Fired += OnInputAction;
Combat.CombatModeChanged += SetInputCombatScope;
SetInputCombatScope(Combat.CurrentMode);
// Retail CameraSet::ToggleMouseLook / Rotate (0x00457490 /
// 0x00458310): the callback submits a filtered horizontal
// adjustment to the player's MotionInterpreter. It must never
// mutate visible Yaw directly; doing so leaves ACE's authoritative
// heading stale and makes targeted attacks skip their server turn.
_mouseLook = new AcDream.UI.Abstractions.Input.MouseLookState(
applyHorizontalAdjustment: adjustment =>
{
_playerController?.SubmitMouseTurnAdjustment(
adjustment,
CaptureMovementInput());
});
// Phase K.2 — auto-enter player mode after EnterWorld
// succeeds. Predicates close over GameWindow state; the
// entry callback flips into player mode via the same code
@ -975,12 +955,12 @@ public sealed class GameWindow : IDisposable
if (_playerMode && _cameraController.IsChaseMode && _chaseCamera is not null)
{
float sens = _sensChase;
if (_mouseLook is not null && _mouseLook.Active)
if (_gameplayInputFrame?.MouseLookActive == true)
{
// DirectInput may deliver several device records before
// one retail GetInput pass. Accumulate raw motion here;
// the update loop filters the aggregate exactly once.
_mouseLook.QueueDelta(dx, dy);
_gameplayInputFrame.QueueRawMouseDelta(dx, dy);
}
else if (_rmbHeld)
{
@ -1269,6 +1249,7 @@ public sealed class GameWindow : IDisposable
// it fires, so the chase camera doesn't snap on top of
// the fly camera mid-inspection.
_debugVm.ToggleFlyMode = ToggleFlyOrChase;
_combatFeedback.ViewModel = _debugVm;
_debugPanel = new AcDream.UI.Abstractions.Panels.Debug.DebugPanel(_debugVm);
_panelHost.Register(_debugPanel);
@ -1439,6 +1420,7 @@ public sealed class GameWindow : IDisposable
_panelHost = null;
_vitalsVm = null;
_vitalsPanel = null;
_combatFeedback.ViewModel = null;
_debugVm = null;
_debugPanel = null;
_chatPanel = null;
@ -1626,14 +1608,7 @@ public sealed class GameWindow : IDisposable
// semantics even when the retail renderer is disabled.
_combatAttackController = new AcDream.App.Combat.CombatAttackController(
Combat,
CanStartLiveCombatAttack,
SendLiveCombatAttack,
prepareAttackRequest: PreparePlayerForAttackRequest,
sendCancelAttack: () => LiveSession?.SendCancelAttack(),
isDualWield: () => _playerController?.Motion.InterpretedState.CurrentStyle
== AcDream.Core.Combat.CombatInputPlanner.DualWieldCombatStyle,
playerReadyForAttack: IsPlayerReadyForCombatAttack,
autoRepeatAttack: () => _persistedGameplay.AutoRepeatAttack);
_combatAttackOperations);
_combatTargetController = new AcDream.App.Combat.CombatTargetController(
Combat,
_selection,
@ -1702,6 +1677,7 @@ public sealed class GameWindow : IDisposable
{
_vitalsVm ??= new AcDream.UI.Abstractions.Panels.Vitals.VitalsVM(Combat, LocalPlayer);
_uiHost = new AcDream.App.UI.UiHost(_gl, shadersDir, _debugFont);
_retainedInputCapture.Root = _uiHost.Root;
_uiHost.Root.UiLocked = _persistedGameplay.LockUI;
var cursorFeedbackController = new AcDream.App.UI.CursorFeedbackController(
_itemInteractionController,
@ -1732,7 +1708,7 @@ public sealed class GameWindow : IDisposable
accountName: () => LiveSession?.Characters?.AccountName
?? _options.LiveUser
?? string.Empty,
stopCompletely: PreparePlayerForAttackRequest,
stopCompletely: _combatAttackOperations.PrepareAttackRequest,
sendUntargeted: spellId => LiveSession?.SendCastUntargetedSpell(spellId),
sendTargeted: (target, spellId) => LiveSession?.SendCastTargetedSpell(target, spellId),
displayMessage: text => Chat.OnSystemMessage(text, 0x1Au),
@ -2631,7 +2607,7 @@ public sealed class GameWindow : IDisposable
() => LiveSession,
PublishLocalPhysicsTimestamps,
AimTeleportDestination,
DumpMovementTruthServerEcho);
_movementTruthDiagnostics);
_liveEntityLiveness = new AcDream.App.World.LiveEntityLivenessController(
_liveEntities,
() => _playerServerGuid,
@ -2652,6 +2628,41 @@ public sealed class GameWindow : IDisposable
// gated behind ACDREAM_LIVE=1 so the default run path is unchanged.
_liveWorldOrigin.SetPlaceholder(centerX, centerY);
_liveSessionController = new AcDream.App.Net.LiveSessionController();
_combatAttackOperations.Bind(
new AcDream.App.Combat.LiveCombatAttackOperations(
Combat,
new AcDream.App.Combat.CombatAttackTargetSource(
_selection,
_liveEntities!,
Objects,
_localPlayerIdentity),
_gameplaySettings,
_playerControllerSlot,
_localPlayerOutbound,
_liveSessionController,
_liveSessionController,
_combatFeedback));
AcDream.App.Input.MouseLookController? mouseLookController =
_mouseSource is not null && _mouseLookCursor is not null
? new AcDream.App.Input.MouseLookController(
_mouseSource,
_pointerPosition,
_localPlayerMode,
_playerControllerSlot,
_cameraController!,
_chaseCameraInput,
_movementInput,
_localPlayerOutbound,
_liveSessionController,
_mouseLookCursor,
new AcDream.App.Input.EnvironmentInputMonotonicClock())
: null;
_gameplayInputFrame = new AcDream.App.Input.GameplayInputFrameController(
_inputDispatcher,
_movementInput,
mouseLookController,
new AcDream.App.Input.CombatAttackInputFrameAdapter(
_combatAttackController!));
_streamingFrame = new AcDream.App.Streaming.StreamingFrameController(
_options.LiveMode,
_localPlayerMode,
@ -2801,12 +2812,7 @@ public sealed class GameWindow : IDisposable
});
private void ResetSessionMouseCapture()
{
bool wasActive = _mouseLook?.Active == true;
_mouseLook?.Release();
if (wasActive || _mouseLookSavedCursorMode.HasValue)
RestoreCursorAfterMouseLook();
}
=> _gameplayInputFrame?.ResetSession();
private void ResetSessionPlayerPresentation()
{
@ -2822,10 +2828,7 @@ public sealed class GameWindow : IDisposable
_playerHost = null;
_chaseCamera = null;
_retailChaseCamera = null;
_playerMouseDeltaX = 0f;
_rmbHeld = false;
_autoRunActive = false;
_lastMovementTruthOutbound = null;
_movementTruthDiagnostics.ResetSession();
_localPlayerShadow.Clear();
_spawnClaimRangeMemo = null;
}
@ -3583,7 +3586,7 @@ public sealed class GameWindow : IDisposable
|| !_teleportTransit.CanBegin(teleportSequence))
return;
EndMouseLookAndRestoreCursor();
_gameplayInputFrame?.EndMouseLook();
// A fresh sequence is a new logical transit. Discard the prior
// destination and presentation before this sequence can observe them;
// its destination PositionUpdate may arrive on a later network tick.
@ -3856,49 +3859,7 @@ public sealed class GameWindow : IDisposable
// Input callbacks feed the current object tick. Packets already read
// by Core.Net remain queued until the retail object/physics phase has
// consumed that input and published its final pose.
_inputDispatcher?.Tick();
// Phase K.2 — mouse-look is an input source for this object's
// movement tick, so sample it before the retail CPhysics/network
// barrier just like held keyboard actions.
if (_mouseLook is not null)
{
bool wantCaptureMouse = IsUiCapturingMouse();
if (wantCaptureMouse != _lastWantCaptureMouse)
{
if (wantCaptureMouse && _mouseLook.Active)
EndMouseLookAndRestoreCursor();
_mouseLook.OnWantCaptureMouseChanged(wantCaptureMouse);
_lastWantCaptureMouse = wantCaptureMouse;
}
float nowSeconds = (float)(Environment.TickCount64 / 1000.0);
if (_mouseLook.TryTakeRawSample(nowSeconds, out float rawX, out float rawY))
{
// GetInput synthesizes (0,0) only after >0.2 s idle.
// MouseLookHandler stops drift before filtering that sample;
// the filter tail may then start a smaller Rotate again.
if (rawX == 0f && rawY == 0f)
_playerController?.StopMouseDrift(CaptureMovementInput());
(float filteredX, float filteredY) =
AcDream.Core.Rendering.CameraDiagnostics.UseRetailChaseCamera
&& _retailChaseCamera is not null
? _retailChaseCamera.FilterMouseDelta(
rawX,
rawY,
weight: 0.5f,
nowSec: nowSeconds)
: (rawX, rawY);
_mouseLook.ApplyDelta(filteredX, _sensChase);
if (_retailChaseCamera is not null)
_retailChaseCamera.AdjustPitch(filteredY * 0.003f * _sensChase);
else
_chaseCamera?.AdjustPitch(filteredY * 0.003f * _sensChase);
}
}
_combatAttackController?.Tick();
_gameplayInputFrame!.Tick(frameTiming);
// Drain pending live-session traffic AFTER streaming so any incoming
// CreateObject events find their landblock already loaded in
@ -4043,10 +4004,7 @@ public sealed class GameWindow : IDisposable
// 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).
// The _playerMouseDeltaX field is preserved as plumbing for the
// future MMB-mouse-look behavior coming back in K.2.
if (_inputDispatcher is null) return;
_playerMouseDeltaX = 0f; // defensive: ensure no leakage even if some path writes it
// Retail-style held-key offset integration. Only active when
// retail chase is selected; legacy camera ignores these.
@ -4069,7 +4027,7 @@ public sealed class GameWindow : IDisposable
// walk speed.
// * Q = AUTORUN TOGGLE: pressing Q latches forward-running
// until Q is pressed again. Handled in OnInputAction; here
// we just OR _autoRunActive into the Forward flag.
// DispatcherMovementInputSource owns the autorun latch.
// * Mouse never drives character yaw (K.1b regression-prevention).
if (!_localPlayerFrame.TryGetPresentationAfterNetwork(out var playerFrame))
return;
@ -4128,82 +4086,12 @@ public sealed class GameWindow : IDisposable
&& _inputDispatcher is not null
&& !(DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard);
private void DumpMovementTruthOutbound(
string kind,
uint sequence,
AcDream.App.Input.MovementResult result,
System.Numerics.Vector3 wirePosition,
uint wireCellId,
byte contactByte)
{
if (!DumpMoveTruthEnabled) return;
var velocity = _playerController?.BodyVelocity ?? System.Numerics.Vector3.Zero;
_lastMovementTruthOutbound = new MovementTruthOutbound(
kind,
sequence,
System.DateTime.UtcNow,
result.Position,
result.CellId,
wirePosition,
wireCellId,
result.IsOnGround,
contactByte,
velocity);
Console.WriteLine(System.FormattableString.Invariant($"move-truth OUT kind={kind} seq={sequence} local={Fmt(result.Position)} localCell=0x{result.CellId:X8} wire={Fmt(wirePosition)} wireCell=0x{wireCellId:X8} grounded={result.IsOnGround} contact={contactByte} vel={Fmt(velocity)} f={FmtCmd(result.ForwardCommand)} s={FmtCmd(result.SidestepCommand)} t={FmtCmd(result.TurnCommand)}"));
}
private void DumpMovementTruthServerEcho(
AcDream.Core.Net.WorldSession.EntityPositionUpdate update,
System.Numerics.Vector3 serverWorldPosition)
{
if (!DumpMoveTruthEnabled || update.Guid != _playerServerGuid) return;
var now = System.DateTime.UtcNow;
var localPosition = _playerController?.Position;
var localCellId = _playerController?.CellId;
var deltaLocal = localPosition.HasValue
? serverWorldPosition - localPosition.Value
: (System.Numerics.Vector3?)null;
string localText = localPosition.HasValue ? Fmt(localPosition.Value) : "-";
string localCellText = localCellId.HasValue
? System.FormattableString.Invariant($"0x{localCellId.Value:X8}")
: "-";
string deltaLocalText = deltaLocal.HasValue ? Fmt(deltaLocal.Value) : "-";
string deltaLocalLen = deltaLocal.HasValue
? System.FormattableString.Invariant($"{deltaLocal.Value.Length():F3}")
: "-";
string lastText = "-";
if (_lastMovementTruthOutbound is { } last)
{
var deltaOut = serverWorldPosition - last.LocalWorldPosition;
var ageMs = (now - last.TimeUtc).TotalMilliseconds;
lastText = System.FormattableString.Invariant($"{last.Kind}:{last.Sequence} ageMs={ageMs:F0} outGrounded={last.IsOnGround} outContact={last.ContactByte} outCell=0x{last.WireCellId:X8} deltaOut={Fmt(deltaOut)} distOut={deltaOut.Length():F3}");
}
string state = _playerController?.State.ToString() ?? "-";
string velocityText = update.Velocity.HasValue ? Fmt(update.Velocity.Value) : "-";
Console.WriteLine(System.FormattableString.Invariant($"move-truth ECHO guid=0x{update.Guid:X8} server={Fmt(serverWorldPosition)} serverCell=0x{update.Position.LandblockId:X8} local={localText} localCell={localCellText} deltaLocal={deltaLocalText} distLocal={deltaLocalLen} serverVel={velocityText} state={state} lastOut={lastText}"));
}
private static string Fmt(System.Numerics.Vector3 v) =>
System.FormattableString.Invariant($"({v.X:F3},{v.Y:F3},{v.Z:F3})");
private static string FmtCmd(uint? command) =>
command.HasValue
? System.FormattableString.Invariant($"0x{command.Value:X8}")
: "-";
private void OnCameraModeChanged(bool _modeBool)
{
if (_mouseLook?.Active == true
if (_gameplayInputFrame?.MouseLookActive == true
&& _cameraController?.IsChaseMode != true)
{
EndMouseLookAndRestoreCursor();
_gameplayInputFrame?.EndMouseLook();
}
if (_input is null) return;
@ -4218,8 +4106,7 @@ public sealed class GameWindow : IDisposable
// the only correct gate. Cursor visible by default in chase /
// orbit modes; Raw cursor only in fly mode (continuous
// look-and-fly affordance). Mouse-look (raw mode) when MMB is
// held is handled separately by HideCursorForMouseLook /
// RestoreCursorAfterMouseLook.
// held is handled separately by the gameplay input owner.
bool needsRawCursor = _cameraController?.IsFlyMode == true;
mouse.Cursor.CursorMode = needsRawCursor ? CursorMode.Raw : CursorMode.Normal;
_capturedMouse = needsRawCursor ? mouse : null;
@ -6805,8 +6692,13 @@ public sealed class GameWindow : IDisposable
= AcDream.UI.Abstractions.Panels.Settings.DisplaySettings.Default;
private AcDream.UI.Abstractions.Panels.Settings.AudioSettings _persistedAudio
= AcDream.UI.Abstractions.Panels.Settings.AudioSettings.Default;
private readonly AcDream.App.Combat.GameplaySettingsState
_gameplaySettings = new();
private AcDream.UI.Abstractions.Panels.Settings.GameplaySettings _persistedGameplay
= AcDream.UI.Abstractions.Panels.Settings.GameplaySettings.Default;
{
get => _gameplaySettings.Value;
set => _gameplaySettings.Value = value;
}
private AcDream.UI.Abstractions.Panels.Settings.ChatSettings _persistedChat
= AcDream.UI.Abstractions.Panels.Settings.ChatSettings.Default;
private AcDream.UI.Abstractions.Panels.Settings.CharacterSettings _persistedCharacter
@ -7111,61 +7003,14 @@ public sealed class GameWindow : IDisposable
// chords also fire Press on key-down + Release on key-up; we
// ignore the in-between Hold ticks here (the mouse-move handler
// checks _rmbHeld each frame anyway).
if (action == AcDream.UI.Abstractions.Input.InputAction.AcdreamRmbOrbitHold)
{
if (activation == AcDream.UI.Abstractions.Input.ActivationType.Press)
_rmbHeld = _playerMode && _cameraController?.IsChaseMode == true;
else if (activation == AcDream.UI.Abstractions.Input.ActivationType.Release)
_rmbHeld = false;
if (_gameplayInputFrame?.HandlePointerAction(action, activation) == true)
return;
}
// Phase K.2 — MMB-hold instant mouse-look. Press hides the
// cursor + activates yaw drive; release restores. WantCapture
// edge handling lives in OnUpdate; only Press needs to read it
// for the initial gate (defense in depth — the dispatcher
// already filters on WantCaptureMouse in OnMouseDown).
if (action == AcDream.UI.Abstractions.Input.InputAction.CameraInstantMouseLook)
{
if (_mouseLook is null) return;
if (activation == AcDream.UI.Abstractions.Input.ActivationType.Press)
{
if (!_playerMode
|| _cameraController?.IsChaseMode != true
|| _playerController is not { State: AcDream.App.Input.PlayerState.InWorld })
return;
bool wcm = IsUiCapturingMouse();
float nowSeconds = (float)(Environment.TickCount64 / 1000.0);
_mouseLook.Press(_lastMouseX, _lastMouseY, wcm, nowSeconds);
if (_mouseLook.Active)
{
if (_playerController is not { } controller
|| !controller.BeginMouseLook(CaptureMovementInput()))
{
// Keep capture ownership atomic with the movement
// transition. A portal/state change between the outer
// gate and this call must not leave a hidden cursor
// with no active movement owner.
_mouseLook.Release();
return;
}
// ToggleMouseLook calls SendMovementEvent at the input
// boundary. This also preserves down+up events that occur
// before the next physics update.
TrySendPlayerMovementEvent(
controller.CaptureMovementResult(mouseLookEvent: false));
HideCursorForMouseLook();
}
}
else if (activation == AcDream.UI.Abstractions.Input.ActivationType.Release)
{
EndMouseLookAndRestoreCursor();
}
return;
}
// ScrollUp / ScrollDown — emit by InputDispatcher.OnScroll on every
// wheel tick. Press is the only activation type for wheel.
if (action == AcDream.UI.Abstractions.Input.InputAction.ScrollUp
@ -7180,12 +7025,10 @@ public sealed class GameWindow : IDisposable
// combat before the movement command enters CommandInterpreter. This
// notification does not consume the input; the movement owner below
// still receives it normally.
_combatAttackController?.HandleMovementInput(action, activation);
// Retail attack actions consume their full transition stream: key-down
// starts the power build and key-up commits it. Handle them before the
// generic Press-only gate below.
if (_combatAttackController?.HandleInputAction(action, activation) == true)
if (_gameplayInputFrame?.HandleCombatAction(action, activation) == true)
return;
// Every other action fires on Press only (no Release / Hold side-
@ -7212,22 +7055,8 @@ public sealed class GameWindow : IDisposable
// — retail-faithful: any deliberate movement input wins. (Pressing
// Forward AGAIN does NOT cancel — retail's autorun stays active
// even when you press W; the two stack.)
if (action == AcDream.UI.Abstractions.Input.InputAction.MovementRunLock)
{
_autoRunActive = !_autoRunActive;
if (_gameplayInputFrame?.HandlePressedMovementAction(action) == true)
return;
}
if (_autoRunActive
&& (action == AcDream.UI.Abstractions.Input.InputAction.MovementBackup
|| action == AcDream.UI.Abstractions.Input.InputAction.MovementStop
|| action == AcDream.UI.Abstractions.Input.InputAction.MovementStrafeLeft
|| action == AcDream.UI.Abstractions.Input.InputAction.MovementStrafeRight))
{
_autoRunActive = false;
// Fall through — these actions still need their normal handling
// (e.g. Stop is currently a no-op in the switch, but keeping the
// fall-through means future logic fires).
}
switch (action)
{
@ -7313,7 +7142,7 @@ public sealed class GameWindow : IDisposable
_cameraController.ToggleFly(); // exit fly, release cursor
else if (_playerMode)
{
EndMouseLookAndRestoreCursor();
_gameplayInputFrame?.EndMouseLook();
_playerMode = false;
_cameraController?.ExitChaseMode();
_playerController = null;
@ -7347,97 +7176,10 @@ public sealed class GameWindow : IDisposable
_debugVm?.AddToast(text);
}
private bool IsPlayerReadyForCombatAttack()
{
if (_playerController is null)
return false;
var motion = _playerController.Motion.InterpretedState;
return AcDream.Core.Combat.CombatInputPlanner.PlayerInReadyPositionForAttack(
Combat.CurrentMode,
motion.CurrentStyle,
motion.ForwardCommand);
}
private bool CanStartLiveCombatAttack()
{
if (_liveSessionController?.IsInWorld != true)
return false;
if (!AcDream.Core.Combat.CombatInputPlanner.SupportsTargetedAttack(Combat.CurrentMode))
{
_debugVm?.AddToast("Enter melee or missile combat first");
Console.WriteLine("combat: attack ignored; not in melee/missile combat mode");
return false;
}
uint? target = GetSelectedOrClosestCombatTarget();
if (target is null)
{
_debugVm?.AddToast("No monster target");
Console.WriteLine("combat: attack ignored; no creature target found");
return false;
}
return true;
}
private bool SendLiveCombatAttack(
AcDream.Core.Combat.AttackHeight height,
float power)
{
if (!CanStartLiveCombatAttack())
return false;
AcDream.Core.Net.WorldSession? session = LiveSession;
if (session is null)
return false;
uint target = _selection.SelectedObjectId!.Value;
power = Math.Clamp(power, 0f, 1f);
if (Combat.CurrentMode == AcDream.Core.Combat.CombatMode.Missile)
{
session.SendMissileAttack(target, height, power);
Console.WriteLine($"combat: missile attack target=0x{target:X8} height={height} accuracy={power:F2}");
}
else
{
session.SendMeleeAttack(target, height, power);
Console.WriteLine($"combat: melee attack target=0x{target:X8} height={height} power={power:F2}");
}
return true;
}
/// <summary>
/// Retail <c>ClientCombatSystem::StartAttackRequest</c> (0x0056C040)
/// calls <c>MaybeStopCompletely</c>, whose successful path sends the
/// stopped movement state synchronously. Doing this in the host preserves
/// wire ordering: ACE sees the final player heading before the later
/// targeted attack request.
/// </summary>
private void PreparePlayerForAttackRequest()
{
if (_playerController is not { } controller
|| !controller.PrepareForAttackRequest())
return;
TrySendPlayerMovementEvent(
controller.CaptureMovementResult(mouseLookEvent: false));
}
internal static AcDream.Core.Physics.RawMotionState BuildOutboundRawMotionState(
AcDream.App.Input.MovementResult result) =>
AcDream.App.Input.LocalPlayerOutboundController.BuildRawMotionState(result);
private bool TrySendPlayerMovementEvent(AcDream.App.Input.MovementResult result)
=> _localPlayerOutbound.TrySendMovement(
LiveSession,
_playerController,
result);
private uint? GetSelectedOrClosestCombatTarget()
=> _selectionInteractions?.GetSelectedOrClosestCombatTarget(
_persistedGameplay.AutoTarget);
/// <summary>
/// Resolves retail's combat-camera target. ClientCombatSystem::
/// UpdateTargetTracking (0x0056A950) enables CameraSet::TrackTarget only
@ -7531,12 +7273,11 @@ public sealed class GameWindow : IDisposable
}
else
{
EndMouseLookAndRestoreCursor();
_gameplayInputFrame?.EndMouseLook();
_cameraController?.ExitChaseMode();
_playerController = null;
_chaseCamera = null;
_retailChaseCamera = null;
_playerMouseDeltaX = 0f;
}
}
@ -8009,11 +7750,6 @@ public sealed class GameWindow : IDisposable
Aspect = _window!.Size.X / (float)_window.Size.Y,
CollisionProbe = new AcDream.App.Rendering.PhysicsCameraCollisionProbe(_physicsEngine),
};
// K.1b: _playerMouseDeltaX is no longer consumed by
// MovementInput, but we still reset it here so any stale
// accumulated value from a previous session doesn't leak
// into a future code path that re-enables mouse-yaw.
_playerMouseDeltaX = 0f;
_cameraController?.EnterChaseMode(_chaseCamera, _retailChaseCamera);
// K-fix1 (2026-04-26): latch the "we have entered chase at least
// once" flag so the live-mode pre-login render gate stops
@ -8024,103 +7760,6 @@ public sealed class GameWindow : IDisposable
return true;
}
/// <summary>
/// Both presentation stacks participate in the same gameplay-input gate.
/// A retained retail panel capturing the pointer must suspend instant
/// mouse-look just as an ImGui developer panel does.
/// </summary>
private bool IsUiCapturingMouse()
=> (DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse)
|| (_uiHost?.Root.WantsMouse ?? false);
private AcDream.App.Input.MovementInput CaptureMovementInput()
{
if (_inputDispatcher is null)
return default;
bool walking = _inputDispatcher.IsActionHeld(
AcDream.UI.Abstractions.Input.InputAction.MovementWalkMode);
bool forward = _inputDispatcher.IsActionHeld(
AcDream.UI.Abstractions.Input.InputAction.MovementForward);
return new AcDream.App.Input.MovementInput(
Forward: forward || _autoRunActive,
Backward: _inputDispatcher.IsActionHeld(
AcDream.UI.Abstractions.Input.InputAction.MovementBackup),
StrafeLeft: _inputDispatcher.IsActionHeld(
AcDream.UI.Abstractions.Input.InputAction.MovementStrafeLeft),
StrafeRight: _inputDispatcher.IsActionHeld(
AcDream.UI.Abstractions.Input.InputAction.MovementStrafeRight),
TurnLeft: _inputDispatcher.IsActionHeld(
AcDream.UI.Abstractions.Input.InputAction.MovementTurnLeft),
TurnRight: _inputDispatcher.IsActionHeld(
AcDream.UI.Abstractions.Input.InputAction.MovementTurnRight),
Run: !walking,
Jump: _inputDispatcher.IsActionHeld(
AcDream.UI.Abstractions.Input.InputAction.MovementJump));
}
/// <summary>
/// Single idempotent teardown for every mouse-look exit path. Retail sends
/// the stopped movement state synchronously; doing so before nulling the
/// player controller preserves ACE's final authoritative heading.
/// </summary>
private void EndMouseLookAndRestoreCursor()
{
bool stateWasActive = _mouseLook?.Active == true;
_mouseLook?.Release();
if (_playerController is { } controller
&& controller.EndMouseLook(CaptureMovementInput()))
{
TrySendPlayerMovementEvent(
controller.CaptureMovementResult(mouseLookEvent: false));
}
if (stateWasActive || _mouseLookSavedCursorMode.HasValue)
RestoreCursorAfterMouseLook();
}
/// <summary>
/// Phase K.2: hide the system cursor while MMB instant mouse-look is
/// held. Saves the previous CursorMode so <see cref="RestoreCursorAfterMouseLook"/>
/// can put it back exactly. Skips when no mouse / no input — tests
/// and headless runs stay clean.
/// </summary>
private void HideCursorForMouseLook()
{
if (_input is null) return;
var mouse = _input.Mice.FirstOrDefault();
if (mouse is null) return;
// Save previous mode (Normal in orbit, Raw in chase/fly) so the
// exact pre-hold mode is restored on release.
_mouseLookSavedCursorMode = mouse.Cursor.CursorMode;
mouse.Cursor.CursorMode = CursorMode.Hidden;
}
/// <summary>
/// Phase K.2: restore the saved cursor mode after MMB instant
/// mouse-look ends. Called from the Release branch and from the
/// WantCaptureMouse-edge suspend path so the cursor never gets
/// stuck hidden.
/// </summary>
private void RestoreCursorAfterMouseLook()
{
if (_input is null) return;
var mouse = _input.Mice.FirstOrDefault();
if (mouse is null) return;
if (_mouseLookSavedCursorMode is { } saved)
{
mouse.Cursor.CursorMode = saved;
_mouseLookSavedCursorMode = null;
}
else
{
// Defense in depth: never observed the saved value, fall
// back to Normal so the user always gets a visible cursor.
mouse.Cursor.CursorMode = CursorMode.Normal;
}
}
/// <summary>
/// K.1b: F8/F9 sensitivity adjust extracted into a helper. Multiplies
/// the currently-active mode's sensitivity (chase / fly / orbit) by the
@ -8389,11 +8028,12 @@ public sealed class GameWindow : IDisposable
]),
new ResourceShutdownStage("session dependents",
[
new("mouse capture", EndMouseLookAndRestoreCursor),
new("mouse capture", () => _gameplayInputFrame?.EndMouseLook()),
new("retail UI", () =>
{
_retailUiRuntime?.Dispose();
_retailUiRuntime = null;
_retainedInputCapture.Root = null;
_uiHost = null;
}),
new("combat target", () =>
@ -8540,7 +8180,7 @@ public sealed class GameWindow : IDisposable
private void OnFocusChanged(bool focused)
{
if (!focused)
EndMouseLookAndRestoreCursor();
_gameplayInputFrame?.EndMouseLook();
}
public void Dispose()

View file

@ -220,9 +220,9 @@ public sealed class InputDispatcher
if (action == InputAction.None) return false;
// While a text field owns the keyboard ("write mode"), held game actions read as
// released: typing "swd" must not move the character. This is the polling-path twin
// of the WantCaptureKeyboard gate on Fired actions. NOTE: this suppresses KEY-driven
// movement only — latched state that isn't a key (e.g. autorun, ORed into Forward at
// the call site) keeps driving the character, so chat doesn't cancel autorun.
// of the WantCaptureKeyboard gate on Fired actions. This suppresses physical keys;
// the App movement owner separately pauses all movement for devtools capture while
// allowing retained-chat capture to preserve the retail autorun latch.
if (_mouse.WantCaptureKeyboard) return false;
if (_automationHeldActions.Contains(action)) return true;
foreach (var b in _bindings.ForAction(action))