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

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