acdream/src/AcDream.App/Input/MouseLookController.cs
Erik 972c7ab3b8 fix(input): focus loss no longer faults on an unpublished movement controller (#356)
Losing window focus calls CameraPointerInputController.HandleFocusChanged
-> MouseLookController.EndForLifecycle -> PlayerMovementController
.EndMouseLook, and EnsurePublishedForRuntimeOperation throws when the
controller exists but is not yet published (mid-login) or already retired
(post-logout). A focus callback can land in either window, so a simple
alt-tab during the login stream took the whole process down with an
unhandled InvalidOperationException. Hit live during the Campaign A
listening-session launches.

EndAndRestoreCursor already guarded 'no controller'; publication state is
the finer-grained form of the same condition, so the guard is completed
with the new CanExecuteLiveMovement predicate (the exact lifecycle set
EnsurePublishedForRuntimeOperation accepts) rather than wrapping the call
in a catch. Cursor restore still runs unconditionally - presentation is
always safe. Published-controller behaviour is unchanged.

Also files issue #357: the login placement stall this session exposed
(reveal ready=True, player Place edge never executes, world never opens).
That one is NOT fixed here - full evidence chain, wire capture, and probe
output are in the issue. It is a placement-domain bug and blocks the
Campaign A listening gate.

Suite: green except the known load-dependent measurement flake
(RuntimeCollisionReportingStateTests allocation pin), which passes in
isolation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 12:02:38 +02:00

274 lines
9.1 KiB
C#

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 interface IChaseCameraSource
{
ChaseCamera? Legacy { get; }
RetailChaseCamera? Retail { get; }
}
internal sealed class ChaseCameraInputState : IChaseCameraSource
{
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 IRuntimeLocalPlayerControllerSource _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,
IRuntimeLocalPlayerControllerSource 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();
// The null check alone is not enough: a controller can also be present
// but UNPUBLISHED (mid-login) or retired (post-logout), and a focus-loss
// callback can land in either window. EndMouseLook faults on both, so a
// simple alt-tab during login used to take the process down. Cursor
// restore below still runs — that is presentation and always safe.
PlayerMovementController? controller = _playerController.Controller;
if (controller is { CanExecuteLiveMovement: true }
&& 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());
}