refactor(input): own gameplay action routing

Move the sole semantic action-priority graph, combat and diagnostic commands, and retained-root item-drop lifetime behind focused typed owners. Preserve retail toggle behavior, explicit auto-wield cancellation, shutdown quiescence, and symmetric callback cleanup.

Co-authored-by: Codex <codex@openai.com>
This commit is contained in:
Erik 2026-07-22 12:43:05 +02:00
parent 8b8afeefa3
commit 4eae9b4f5a
25 changed files with 2608 additions and 418 deletions

View file

@ -0,0 +1,214 @@
using AcDream.App.Input;
using AcDream.App.Net;
using AcDream.App.UI;
using AcDream.Core.Combat;
using AcDream.Core.Items;
namespace AcDream.App.Combat;
internal interface ILiveCombatModeAuthority
{
bool IsInWorld { get; }
void SendChangeCombatMode(CombatMode mode);
}
internal sealed class LiveSessionCombatModeAuthority(LiveSessionHost session)
: ILiveCombatModeAuthority
{
private readonly LiveSessionHost _session = session
?? throw new ArgumentNullException(nameof(session));
public bool IsInWorld =>
_session.IsInWorld && _session.CurrentSession is not null;
public void SendChangeCombatMode(CombatMode mode)
{
if (!IsInWorld)
{
throw new InvalidOperationException(
"A combat-mode request requires an active in-world session.");
}
_session.CurrentSession!.SendChangeCombatMode(mode);
}
}
internal interface ICombatEquipmentSource
{
IReadOnlyList<ClientObject> GetOrderedEquipment();
}
internal sealed class LocalPlayerCombatEquipmentSource(
ClientObjectTable objects,
ILocalPlayerIdentitySource identity) : ICombatEquipmentSource
{
private readonly ClientObjectTable _objects = objects
?? throw new ArgumentNullException(nameof(objects));
private readonly ILocalPlayerIdentitySource _identity = identity
?? throw new ArgumentNullException(nameof(identity));
public IReadOnlyList<ClientObject> GetOrderedEquipment() =>
_objects.GetEquippedBy(_identity.ServerGuid);
}
internal interface IExplicitCombatModeIntentSink
{
void NotifyExplicitCombatModeRequest();
}
internal sealed class ItemInteractionCombatModeIntentSink(
ItemInteractionController items) : IExplicitCombatModeIntentSink
{
private readonly ItemInteractionController _items = items
?? throw new ArgumentNullException(nameof(items));
public void NotifyExplicitCombatModeRequest() =>
_items.NotifyExplicitCombatModeRequest();
}
internal interface ILiveCombatModeCommand
{
void Toggle();
}
/// <summary>
/// Focused deferred edge shared by the early-created retained toolbar and the
/// later-created gameplay router. It owns no combat state and forwards to one
/// window-lifetime command owner after session composition is complete.
/// </summary>
internal sealed class LiveCombatModeCommandSlot : ILiveCombatModeCommand
{
private readonly object _gate = new();
private ILiveCombatModeCommand? _target;
private bool _deactivated;
public void Bind(ILiveCombatModeCommand target)
{
ArgumentNullException.ThrowIfNull(target);
lock (_gate)
{
ObjectDisposedException.ThrowIf(_deactivated, this);
if (_target is not null && !ReferenceEquals(_target, target))
{
throw new InvalidOperationException(
"Live combat-mode commands are already bound.");
}
_target = target;
}
}
public void Unbind(ILiveCombatModeCommand target)
{
ArgumentNullException.ThrowIfNull(target);
lock (_gate)
{
if (ReferenceEquals(_target, target))
_target = null;
}
}
public void Deactivate()
{
lock (_gate)
{
_deactivated = true;
_target = null;
}
}
public void Toggle()
{
lock (_gate)
{
if (!_deactivated)
_target?.Toggle();
}
}
}
/// <summary>
/// Owns the input-facing combat-mode command. The choice itself remains the
/// retail port in <see cref="CombatInputPlanner"/>:
/// <c>ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310</c> and
/// <c>ClientCombatSystem::ToggleCombatMode @ 0x0056C8C0</c>. ACE remains the
/// authority; the local state update preserves the accepted immediate toolbar
/// response while the server confirmation travels through the normal route.
/// </summary>
internal sealed class LiveCombatModeCommandController : ILiveCombatModeCommand
{
private readonly ILiveCombatModeAuthority _authority;
private readonly ICombatEquipmentSource _equipment;
private readonly CombatState _combat;
private readonly IExplicitCombatModeIntentSink _itemIntent;
private readonly Action<string> _log;
private readonly Action<string>? _toast;
private readonly Action<string> _systemMessage;
public LiveCombatModeCommandController(
ILiveCombatModeAuthority authority,
ICombatEquipmentSource equipment,
CombatState combat,
IExplicitCombatModeIntentSink itemIntent,
Action<string>? log = null,
Action<string>? toast = null,
Action<string>? systemMessage = null)
{
_authority = authority ?? throw new ArgumentNullException(nameof(authority));
_equipment = equipment ?? throw new ArgumentNullException(nameof(equipment));
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
_itemIntent = itemIntent ?? throw new ArgumentNullException(nameof(itemIntent));
_log = log ?? (_ => { });
_toast = toast;
_systemMessage = systemMessage ?? (_ => { });
}
public void Toggle()
{
if (!_authority.IsInWorld)
return;
// Every explicit user combat request supersedes an in-flight auto-wield
// settlement, including a request retail rejects before SetCombatMode.
_itemIntent.NotifyExplicitCombatModeRequest();
CombatMode currentMode = _combat.CurrentMode;
CombatMode nextMode;
if (currentMode != CombatMode.NonCombat)
{
// ToggleCombatMode @ 0x0056C8C0 immediately selects NonCombat and
// never queries default equipment on this branch.
nextMode = CombatMode.NonCombat;
}
else
{
DefaultCombatModeDecision defaultDecision =
CombatInputPlanner.GetDefaultCombatModeDecision(
_equipment.GetOrderedEquipment());
nextMode = CombatInputPlanner.ToggleMode(
currentMode,
defaultDecision.Mode);
// GetDefaultCombatMode prints this notice before SetCombatMode.
// SetCombatMode then sees NonCombat == NonCombat and does no work.
if (defaultDecision.IncompatibleHeldItem is { } held)
{
string notice =
$"You can't enter combat mode while wielding the {held.GetAppropriateName()}";
_log($"combat: {notice}");
_systemMessage(notice);
return;
}
}
// Preserve the accepted command order: explicit intent was published
// above, then send the request and publish local presentation.
_authority.SendChangeCombatMode(nextMode);
_combat.SetCombatMode(nextMode);
string message = $"Combat mode {nextMode}";
_log($"combat: {message}");
_toast?.Invoke(message);
}
}

View file

@ -0,0 +1,340 @@
using System.Numerics;
using AcDream.App.Input;
using AcDream.App.Rendering;
using AcDream.App.Streaming;
using AcDream.App.World;
using AcDream.Core.Physics;
using AcDream.Core.World;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Diagnostics;
internal interface IRuntimeDiagnosticCommands
{
bool Handle(InputAction action);
void CycleTimeOfDay();
void CycleWeather();
void ToggleCollisionWireframes();
}
/// <summary>
/// Focused deferred edge used by the early-created developer panel and the
/// later-created gameplay router. It owns no diagnostic state and binds one
/// window-lifetime command owner exactly once.
/// </summary>
internal sealed class RuntimeDiagnosticCommandSlot : IRuntimeDiagnosticCommands
{
private readonly object _gate = new();
private IRuntimeDiagnosticCommands? _target;
private bool _deactivated;
public void Bind(IRuntimeDiagnosticCommands target)
{
ArgumentNullException.ThrowIfNull(target);
lock (_gate)
{
ObjectDisposedException.ThrowIf(_deactivated, this);
if (_target is not null && !ReferenceEquals(_target, target))
{
throw new InvalidOperationException(
"Runtime diagnostic commands are already bound.");
}
_target = target;
}
}
public void Unbind(IRuntimeDiagnosticCommands target)
{
ArgumentNullException.ThrowIfNull(target);
lock (_gate)
{
if (ReferenceEquals(_target, target))
_target = null;
}
}
public void Deactivate()
{
lock (_gate)
{
_deactivated = true;
_target = null;
}
}
public bool Handle(InputAction action)
{
lock (_gate)
{
if (!_deactivated && _target is { } target)
return target.Handle(action);
}
return RuntimeDiagnosticCommandController.IsDiagnosticAction(action);
}
public void CycleTimeOfDay()
{
lock (_gate)
{
if (!_deactivated)
_target?.CycleTimeOfDay();
}
}
public void CycleWeather()
{
lock (_gate)
{
if (!_deactivated)
_target?.CycleWeather();
}
}
public void ToggleCollisionWireframes()
{
lock (_gate)
{
if (!_deactivated)
_target?.ToggleCollisionWireframes();
}
}
}
internal interface INearbyWorldDiagnosticSource
{
Vector3 ObserverPosition { get; }
int LiveCenterX { get; }
int LiveCenterY { get; }
int TotalShadowObjects { get; }
IReadOnlyList<WorldEntity> WorldEntities { get; }
IEnumerable<ShadowEntry> ShadowEntries { get; }
}
/// <summary>
/// Read-only view over canonical runtime owners. It does not cache the current
/// player, camera, entity list, origin, or shadow registry.
/// </summary>
internal sealed class RuntimeNearbyWorldDiagnosticSource : INearbyWorldDiagnosticSource
{
private readonly ILocalPlayerModeSource _mode;
private readonly ILocalPlayerControllerSource _player;
private readonly CameraController _camera;
private readonly LiveWorldOriginState _origin;
private readonly GpuWorldState _world;
private readonly PhysicsEngine _physics;
public RuntimeNearbyWorldDiagnosticSource(
ILocalPlayerModeSource mode,
ILocalPlayerControllerSource player,
CameraController camera,
LiveWorldOriginState origin,
GpuWorldState world,
PhysicsEngine physics)
{
_mode = mode ?? throw new ArgumentNullException(nameof(mode));
_player = player ?? throw new ArgumentNullException(nameof(player));
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
_origin = origin ?? throw new ArgumentNullException(nameof(origin));
_world = world ?? throw new ArgumentNullException(nameof(world));
_physics = physics ?? throw new ArgumentNullException(nameof(physics));
}
public Vector3 ObserverPosition
{
get
{
if (_mode.IsPlayerMode && _player.Controller is { } player)
return player.Position;
Matrix4x4.Invert(_camera.Active.View, out Matrix4x4 inverseView);
return new Vector3(
inverseView.M41,
inverseView.M42,
inverseView.M43);
}
}
public int LiveCenterX => _origin.CenterX;
public int LiveCenterY => _origin.CenterY;
public int TotalShadowObjects => _physics.ShadowObjects.TotalRegistered;
public IReadOnlyList<WorldEntity> WorldEntities => _world.Entities;
public IEnumerable<ShadowEntry> ShadowEntries =>
_physics.ShadowObjects.AllEntriesForDebug();
}
internal sealed class NearbyWorldDiagnosticDumper
{
private const float NearbyDistance = 15f;
private readonly INearbyWorldDiagnosticSource _source;
private readonly Action<string> _log;
public NearbyWorldDiagnosticDumper(
INearbyWorldDiagnosticSource source,
Action<string>? log = null)
{
_source = source ?? throw new ArgumentNullException(nameof(source));
_log = log ?? (_ => { });
}
public void Dump()
{
Vector3 position = _source.ObserverPosition;
int centerX = _source.LiveCenterX;
int centerY = _source.LiveCenterY;
int landblockX = centerX + (int)MathF.Floor(position.X / 192f);
int landblockY = centerY + (int)MathF.Floor(position.Y / 192f);
_log(
$"=== F3 DEBUG DUMP ===\n" +
$" player pos=({position.X:F2},{position.Y:F2},{position.Z:F2})\n" +
$" landblock=0x{(uint)((landblockX << 24) | (landblockY << 16) | 0xFFFF):X8} " +
$"local=({position.X - (landblockX - centerX) * 192f:F2}," +
$"{position.Y - (landblockY - centerY) * 192f:F2})\n" +
$" total shadow objects: {_source.TotalShadowObjects}");
List<WorldEntity> visibleNearby = _source.WorldEntities
.Where(entity => HorizontalDistanceSquared(entity.Position, position)
< NearbyDistance * NearbyDistance)
.OrderBy(entity => (entity.Position - position).Length())
.ToList();
_log($" VISIBLE entities within 15m: {visibleNearby.Count}");
foreach (WorldEntity entity in visibleNearby.Take(12))
{
float distance = (entity.Position - position).Length();
_log(
$" VIS id=0x{entity.Id:X8} src=0x{entity.SourceGfxObjOrSetupId:X8} " +
$"pos=({entity.Position.X:F2},{entity.Position.Y:F2},{entity.Position.Z:F2}) " +
$"dist={distance:F2} scale={entity.Scale:F2}");
}
List<(ShadowEntry Entry, float Distance)> shadows = _source.ShadowEntries
.Select(entry =>
{
float dx = entry.Position.X - position.X;
float dy = entry.Position.Y - position.Y;
return (Entry: entry, Distance: MathF.Sqrt(dx * dx + dy * dy));
})
.Where(item => item.Distance < NearbyDistance)
.OrderBy(item => item.Distance)
.ToList();
_log($" SHADOW objects within 15m: {shadows.Count}");
foreach ((ShadowEntry entry, float distance) in shadows.Take(12))
{
_log(
$" SHAD id=0x{entry.EntityId:X8} {entry.CollisionType} " +
$"r={entry.Radius:F2} h={entry.CylHeight:F2} " +
$"pos=({entry.Position.X:F2},{entry.Position.Y:F2},{entry.Position.Z:F2}) " +
$"dist={distance:F2}");
}
}
private static float HorizontalDistanceSquared(Vector3 left, Vector3 right)
{
float dx = left.X - right.X;
float dy = left.Y - right.Y;
return dx * dx + dy * dy;
}
}
/// <summary>
/// Routes developer-only semantic actions while leaving every mutable value in
/// its canonical owner. No diagnostic command owns a second clock, weather,
/// camera-sensitivity, collision, entity, or shadow state.
/// </summary>
internal sealed class RuntimeDiagnosticCommandController : IRuntimeDiagnosticCommands
{
private readonly WorldEnvironmentController _environment;
private readonly WorldSceneDebugState _sceneDebug;
private readonly IPointerSensitivityCommands? _pointer;
private readonly NearbyWorldDiagnosticDumper _nearby;
private readonly Action<string>? _toast;
public RuntimeDiagnosticCommandController(
WorldEnvironmentController environment,
WorldSceneDebugState sceneDebug,
IPointerSensitivityCommands? pointer,
NearbyWorldDiagnosticDumper nearby,
Action<string>? toast = null)
{
_environment = environment ?? throw new ArgumentNullException(nameof(environment));
_sceneDebug = sceneDebug ?? throw new ArgumentNullException(nameof(sceneDebug));
_pointer = pointer;
_nearby = nearby ?? throw new ArgumentNullException(nameof(nearby));
_toast = toast;
}
public bool Handle(InputAction action)
{
switch (action)
{
case InputAction.AcdreamToggleCollisionWires:
ToggleCollisionWireframes();
return true;
case InputAction.AcdreamDumpNearby:
_nearby.Dump();
return true;
case InputAction.AcdreamCycleTimeOfDay:
CycleTimeOfDay();
return true;
case InputAction.AcdreamSensitivityDown:
if (_pointer is not null)
{
string message = _pointer.AdjustSensitivity(1f / 1.2f);
_toast?.Invoke(message);
}
return true;
case InputAction.AcdreamSensitivityUp:
if (_pointer is not null)
{
string message = _pointer.AdjustSensitivity(1.2f);
_toast?.Invoke(message);
}
return true;
case InputAction.AcdreamCycleWeather:
CycleWeather();
return true;
default:
return false;
}
}
public void CycleTimeOfDay()
{
string message = _environment.CycleTimeOfDay();
_toast?.Invoke(message);
}
public void CycleWeather()
{
string message = _environment.CycleWeather();
_toast?.Invoke(message);
}
public void ToggleCollisionWireframes()
{
bool visible = _sceneDebug.ToggleCollisionWireframes();
_toast?.Invoke($"Collision wireframes {(visible ? "ON" : "OFF")}");
}
internal static bool IsDiagnosticAction(InputAction action) => action is
InputAction.AcdreamToggleCollisionWires
or InputAction.AcdreamDumpNearby
or InputAction.AcdreamCycleTimeOfDay
or InputAction.AcdreamSensitivityDown
or InputAction.AcdreamSensitivityUp
or InputAction.AcdreamCycleWeather;
}

View file

@ -74,13 +74,19 @@ internal sealed class SilkPointerCursorModeTarget(IMouse mouse)
}
}
internal interface IPointerSensitivityCommands
{
string AdjustSensitivity(float factor);
}
/// <summary>
/// Owns raw pointer subscriptions and the camera-facing pointer policy that
/// previously lived in GameWindow. Physical event removal is retriable; logical
/// deactivation is immediate, so a copied or stubborn Silk delegate cannot
/// re-enter gameplay while shutdown is closing the live session.
/// </summary>
internal sealed class CameraPointerInputController : IDisposable
internal sealed class CameraPointerInputController
: IDisposable, IPointerSensitivityCommands
{
private readonly IReadOnlyList<IRawPointerSurface> _surfaces;
private readonly IPointerCursorModeTarget _cursor;

View file

@ -0,0 +1,316 @@
using AcDream.App.Interaction;
using AcDream.App.Rendering;
using AcDream.App.UI;
using AcDream.Core.Combat;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Input;
internal interface IGameplayInputActionSurface
{
void AddFired(Action<InputAction, ActivationType> callback);
void RemoveFired(Action<InputAction, ActivationType> callback);
void SetCombatScope(InputScope? scope);
}
internal sealed class DispatcherGameplayInputActionSurface(InputDispatcher dispatcher)
: IGameplayInputActionSurface
{
private readonly InputDispatcher _dispatcher = dispatcher
?? throw new ArgumentNullException(nameof(dispatcher));
public void AddFired(Action<InputAction, ActivationType> callback) =>
_dispatcher.Fired += callback;
public void RemoveFired(Action<InputAction, ActivationType> callback) =>
_dispatcher.Fired -= callback;
public void SetCombatScope(InputScope? scope) =>
_dispatcher.SetCombatScope(scope);
}
internal interface ICombatModeEventSurface
{
CombatMode CurrentMode { get; }
void AddChanged(Action<CombatMode> callback);
void RemoveChanged(Action<CombatMode> callback);
}
internal sealed class CombatStateModeEventSurface(CombatState combat)
: ICombatModeEventSurface
{
private readonly CombatState _combat = combat
?? throw new ArgumentNullException(nameof(combat));
public CombatMode CurrentMode => _combat.CurrentMode;
public void AddChanged(Action<CombatMode> callback) =>
_combat.CombatModeChanged += callback;
public void RemoveChanged(Action<CombatMode> callback) =>
_combat.CombatModeChanged -= callback;
}
internal interface IGameplayInputPriorityTargets
{
bool HandlePointerAction(InputAction action, ActivationType activation);
void HandleScroll(InputAction action);
bool HandleCombatAction(InputAction action, ActivationType activation);
bool HandleRetainedUiAction(InputAction action);
bool HandleSelectionAction(InputAction action);
bool HandlePressedMovementAction(InputAction action);
void HandleCommand(InputAction action);
}
/// <summary>
/// Typed adapter over canonical gameplay owners. Optional presentation owners
/// remain no-ops when their stack is disabled; no state is mirrored here.
/// </summary>
internal sealed class RuntimeGameplayInputPriorityTargets
: IGameplayInputPriorityTargets
{
private readonly GameplayInputFrameController _frame;
private readonly CameraPointerInputController _pointer;
private readonly RetailUiRuntime? _retainedUi;
private readonly SelectionInteractionController? _selection;
private readonly IGameplayInputCommandTarget _commands;
public RuntimeGameplayInputPriorityTargets(
GameplayInputFrameController frame,
CameraPointerInputController pointer,
RetailUiRuntime? retainedUi,
SelectionInteractionController? selection,
IGameplayInputCommandTarget commands)
{
_frame = frame ?? throw new ArgumentNullException(nameof(frame));
_pointer = pointer ?? throw new ArgumentNullException(nameof(pointer));
_retainedUi = retainedUi;
_selection = selection;
_commands = commands ?? throw new ArgumentNullException(nameof(commands));
}
public bool HandlePointerAction(InputAction action, ActivationType activation) =>
_frame.HandlePointerAction(action, activation);
public void HandleScroll(InputAction action) =>
_pointer.HandleScroll(action);
public bool HandleCombatAction(InputAction action, ActivationType activation) =>
_frame.HandleCombatAction(action, activation);
public bool HandleRetainedUiAction(InputAction action) =>
_retainedUi?.HandleInputAction(action) == true;
public bool HandleSelectionAction(InputAction action) =>
_selection?.HandleInputAction(action) == true;
public bool HandlePressedMovementAction(InputAction action) =>
_frame.HandlePressedMovementAction(action);
public void HandleCommand(InputAction action) =>
_commands.Handle(action);
}
/// <summary>
/// Sole gameplay subscriber to <see cref="InputDispatcher.Fired"/>. The route
/// preserves the accepted priority exactly: pointer, scroll, combat,
/// Press/DoubleClick gate, retained UI, selection, pressed movement, commands.
/// Subscription ownership is reversible and callbacks become inert before the
/// live-session shutdown barrier.
/// </summary>
internal sealed class GameplayInputActionRouter : IDisposable
{
private readonly IGameplayInputActionSurface _actions;
private readonly ICombatModeEventSurface _combat;
private readonly IGameplayInputPriorityTargets _targets;
private readonly HostQuiescenceGate _quiescence;
private readonly Action<string> _log;
private readonly Action<InputAction, ActivationType> _fired;
private readonly Action<CombatMode> _combatModeChanged;
private readonly bool[] _attached = new bool[2];
private ResourceShutdownTransaction? _detach;
private bool _attachStarted;
private int _disposeRequested;
private int _active;
public GameplayInputActionRouter(
IGameplayInputActionSurface actions,
ICombatModeEventSurface combat,
IGameplayInputPriorityTargets targets,
HostQuiescenceGate quiescence,
Action<string>? log = null)
{
_actions = actions ?? throw new ArgumentNullException(nameof(actions));
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
_targets = targets ?? throw new ArgumentNullException(nameof(targets));
_quiescence = quiescence ?? throw new ArgumentNullException(nameof(quiescence));
_log = log ?? (_ => { });
_fired = OnFired;
_combatModeChanged = OnCombatModeChanged;
}
public static GameplayInputActionRouter Create(
InputDispatcher dispatcher,
CombatState combat,
IGameplayInputPriorityTargets targets,
HostQuiescenceGate quiescence,
Action<string>? log = null) =>
new(
new DispatcherGameplayInputActionSurface(dispatcher),
new CombatStateModeEventSurface(combat),
targets,
quiescence,
log);
public bool IsDisposalComplete =>
_attached.All(static attached => !attached);
public void Attach()
{
ObjectDisposedException.ThrowIf(
Volatile.Read(ref _disposeRequested) != 0,
this);
if (_attachStarted)
{
throw new InvalidOperationException(
"Gameplay action attachment has already started.");
}
_attachStarted = true;
try
{
// Mark each edge before invoking its custom accessor. An accessor
// may perform the add and then throw; rollback must still remove it.
_attached[0] = true;
_actions.AddFired(_fired);
_attached[1] = true;
_combat.AddChanged(_combatModeChanged);
Volatile.Write(ref _active, 1);
SetCombatScope(_combat.CurrentMode);
}
catch (Exception attachError)
{
Deactivate();
try
{
EnsureDetachTransaction().CompleteOrThrow();
}
catch (Exception rollbackError)
{
throw new AggregateException(
"Gameplay action registration and rollback both failed.",
new InvalidOperationException(
"Gameplay action registration failed.", attachError),
rollbackError);
}
throw new InvalidOperationException(
"Gameplay action registration failed and was rolled back.",
attachError);
}
}
public void Deactivate() => Interlocked.Exchange(ref _active, 0);
public void Dispose()
{
Interlocked.Exchange(ref _disposeRequested, 1);
Deactivate();
EnsureDetachTransaction().CompleteOrThrow();
}
private void OnFired(InputAction action, ActivationType activation) =>
_quiescence.Invoke(() =>
{
if (Volatile.Read(ref _active) != 0)
Route(action, activation);
});
private void OnCombatModeChanged(CombatMode mode) =>
_quiescence.Invoke(() =>
{
if (Volatile.Read(ref _active) != 0)
SetCombatScope(mode);
});
private void Route(InputAction action, ActivationType activation)
{
_log($"[input] {action} {activation}");
if (_targets.HandlePointerAction(action, activation))
return;
if (action is InputAction.ScrollUp or InputAction.ScrollDown)
{
if (activation == ActivationType.Press)
_targets.HandleScroll(action);
return;
}
if (_targets.HandleCombatAction(action, activation))
return;
if (activation is not ActivationType.Press
and not ActivationType.DoubleClick)
{
return;
}
if (_targets.HandleRetainedUiAction(action))
return;
if (_targets.HandleSelectionAction(action))
return;
if (_targets.HandlePressedMovementAction(action))
return;
_targets.HandleCommand(action);
}
private void SetCombatScope(CombatMode mode) =>
_actions.SetCombatScope(mode switch
{
CombatMode.Melee => InputScope.MeleeCombat,
CombatMode.Missile => InputScope.MissileCombat,
CombatMode.Magic => InputScope.MagicCombat,
_ => null,
});
private ResourceShutdownTransaction EnsureDetachTransaction() =>
_detach ??= new ResourceShutdownTransaction(
new ResourceShutdownStage("gameplay action callbacks",
[
new("combat mode", () => Remove(1)),
new("dispatcher fired", () => Remove(0)),
]));
private void Remove(int index)
{
if (!_attached[index])
return;
switch (index)
{
case 0:
_actions.RemoveFired(_fired);
break;
case 1:
_combat.RemoveChanged(_combatModeChanged);
break;
default:
throw new ArgumentOutOfRangeException(nameof(index));
}
_attached[index] = false;
}
}

View file

@ -0,0 +1,210 @@
using AcDream.App.Combat;
using AcDream.App.Diagnostics;
using AcDream.App.Rendering;
using AcDream.App.UI;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Input;
internal interface IRetainedGameplayWindowCommands
{
void ToggleInventory();
}
internal sealed class RetainedGameplayWindowCommands(RetailUiRuntime? runtime)
: IRetainedGameplayWindowCommands
{
private readonly RetailUiRuntime? _runtime = runtime;
public void ToggleInventory() =>
_runtime?.ToggleWindow(WindowNames.Inventory);
}
internal interface IDevToolsGameplayCommands
{
void ToggleDebugPanel();
void FocusChatInput();
void ToggleSettingsPanel();
}
internal sealed class DevToolsGameplayCommands(DevToolsFramePresenter? presenter)
: IDevToolsGameplayCommands
{
private readonly DevToolsFramePresenter? _presenter = presenter;
public void ToggleDebugPanel() => _presenter?.ToggleDebugPanel();
public void FocusChatInput() => _presenter?.FocusChatInput();
public void ToggleSettingsPanel() => _presenter?.ToggleSettingsPanel();
}
internal interface IPlayerModeGameplayCommands
{
bool IsPlayerMode { get; }
void ToggleFlyOrChase();
void TogglePlayerMode();
void ExitPlayerMode();
}
internal sealed class PlayerModeGameplayCommands(
ILocalPlayerModeSource mode,
PlayerModeController controller) : IPlayerModeGameplayCommands
{
private readonly ILocalPlayerModeSource _mode = mode
?? throw new ArgumentNullException(nameof(mode));
private readonly PlayerModeController _controller = controller
?? throw new ArgumentNullException(nameof(controller));
public bool IsPlayerMode => _mode.IsPlayerMode;
public void ToggleFlyOrChase() => _controller.ToggleFlyOrChase();
public void TogglePlayerMode() => _controller.Toggle();
public void ExitPlayerMode() => _controller.Exit();
}
internal interface IItemTargetModeCommands
{
bool IsAnyTargetModeActive { get; }
void CancelTargetMode();
}
internal sealed class ItemTargetModeCommands(ItemInteractionController items)
: IItemTargetModeCommands
{
private readonly ItemInteractionController _items = items
?? throw new ArgumentNullException(nameof(items));
public bool IsAnyTargetModeActive => _items.IsAnyTargetModeActive;
public void CancelTargetMode() => _items.CancelTargetMode();
}
internal interface IGameplayCameraModeCommands
{
bool IsFlyMode { get; }
void ExitFlyMode();
}
internal sealed class GameplayCameraModeCommands(CameraController camera)
: IGameplayCameraModeCommands
{
private readonly CameraController _camera = camera
?? throw new ArgumentNullException(nameof(camera));
public bool IsFlyMode => _camera.IsFlyMode;
public void ExitFlyMode() => _camera.ToggleFly();
}
internal interface IGameplayWindowCommands
{
void Close();
}
internal sealed class GameplayWindowCommands(Action close) : IGameplayWindowCommands
{
private readonly Action _close = close
?? throw new ArgumentNullException(nameof(close));
public void Close() => _close();
}
internal interface IGameplayInputCommandTarget
{
bool Handle(InputAction action);
}
/// <summary>
/// Owns the final command tier of the frozen gameplay-action priority graph.
/// It coordinates canonical owners but stores no UI, camera, player, combat,
/// item-target, or diagnostic state of its own.
/// </summary>
internal sealed class GameplayInputCommandController : IGameplayInputCommandTarget
{
private readonly IRetainedGameplayWindowCommands _retained;
private readonly IDevToolsGameplayCommands _devTools;
private readonly IRuntimeDiagnosticCommands _diagnostics;
private readonly IPlayerModeGameplayCommands _playerMode;
private readonly IItemTargetModeCommands _targetMode;
private readonly IGameplayCameraModeCommands _camera;
private readonly ILiveCombatModeCommand _combat;
private readonly IGameplayWindowCommands _window;
public GameplayInputCommandController(
IRetainedGameplayWindowCommands retained,
IDevToolsGameplayCommands devTools,
IRuntimeDiagnosticCommands diagnostics,
IPlayerModeGameplayCommands playerMode,
IItemTargetModeCommands targetMode,
IGameplayCameraModeCommands camera,
ILiveCombatModeCommand combat,
IGameplayWindowCommands window)
{
_retained = retained ?? throw new ArgumentNullException(nameof(retained));
_devTools = devTools ?? throw new ArgumentNullException(nameof(devTools));
_diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
_playerMode = playerMode ?? throw new ArgumentNullException(nameof(playerMode));
_targetMode = targetMode ?? throw new ArgumentNullException(nameof(targetMode));
_camera = camera ?? throw new ArgumentNullException(nameof(camera));
_combat = combat ?? throw new ArgumentNullException(nameof(combat));
_window = window ?? throw new ArgumentNullException(nameof(window));
}
public bool Handle(InputAction action)
{
if (_diagnostics.Handle(action))
return true;
switch (action)
{
case InputAction.ToggleInventoryPanel:
_retained.ToggleInventory();
return true;
case InputAction.AcdreamToggleDebugPanel:
_devTools.ToggleDebugPanel();
return true;
case InputAction.AcdreamToggleFlyMode:
_playerMode.ToggleFlyOrChase();
return true;
case InputAction.AcdreamTogglePlayerMode:
_playerMode.TogglePlayerMode();
return true;
case InputAction.ToggleChatEntry:
_devTools.FocusChatInput();
return true;
case InputAction.ToggleOptionsPanel:
_devTools.ToggleSettingsPanel();
return true;
case InputAction.CombatToggleCombat:
_combat.Toggle();
return true;
case InputAction.EscapeKey:
HandleEscape();
return true;
default:
return false;
}
}
private void HandleEscape()
{
if (_targetMode.IsAnyTargetModeActive)
_targetMode.CancelTargetMode();
else if (_camera.IsFlyMode)
_camera.ExitFlyMode();
else if (_playerMode.IsPlayerMode)
_playerMode.ExitPlayerMode();
else
_window.Close();
}
}

View file

@ -0,0 +1,139 @@
using AcDream.App.Rendering;
using AcDream.App.UI;
namespace AcDream.App.Input;
internal interface IRetainedUiDragReleaseSurface
{
void AddReleasedOutside(Action<object, int, int> callback);
void RemoveReleasedOutside(Action<object, int, int> callback);
}
internal sealed class UiRootDragReleaseSurface(UiRoot root)
: IRetainedUiDragReleaseSurface
{
private readonly UiRoot _root = root
?? throw new ArgumentNullException(nameof(root));
public void AddReleasedOutside(Action<object, int, int> callback) =>
_root.DragReleasedOutsideUi += callback;
public void RemoveReleasedOutside(Action<object, int, int> callback) =>
_root.DragReleasedOutsideUi -= callback;
}
/// <summary>
/// Owns the retained-root gameplay edge used when an item drag is released in
/// the world. Logical deactivation precedes live-session teardown and physical
/// removal remains retriable if a custom event accessor fails.
/// </summary>
internal sealed class RetainedUiGameplayBinding : IDisposable
{
private readonly IRetainedUiDragReleaseSurface _surface;
private readonly Action<ItemDragPayload, int, int> _placeDraggedItem;
private readonly HostQuiescenceGate _quiescence;
private readonly Action<object, int, int> _releasedOutside;
private ResourceShutdownTransaction? _detach;
private bool _attached;
private bool _attachStarted;
private int _disposeRequested;
private int _active;
public RetainedUiGameplayBinding(
IRetainedUiDragReleaseSurface surface,
Action<ItemDragPayload, int, int> placeDraggedItem,
HostQuiescenceGate quiescence)
{
_surface = surface ?? throw new ArgumentNullException(nameof(surface));
_placeDraggedItem = placeDraggedItem
?? throw new ArgumentNullException(nameof(placeDraggedItem));
_quiescence = quiescence
?? throw new ArgumentNullException(nameof(quiescence));
_releasedOutside = OnReleasedOutside;
}
public static RetainedUiGameplayBinding Create(
UiRoot root,
Action<ItemDragPayload, int, int> placeDraggedItem,
HostQuiescenceGate quiescence) =>
new(new UiRootDragReleaseSurface(root), placeDraggedItem, quiescence);
public bool IsDisposalComplete => !_attached;
public void Attach()
{
ObjectDisposedException.ThrowIf(
Volatile.Read(ref _disposeRequested) != 0,
this);
if (_attachStarted)
{
throw new InvalidOperationException(
"Retained gameplay attachment has already started.");
}
_attachStarted = true;
try
{
// Publish before the custom accessor: it may add and then throw.
_attached = true;
_surface.AddReleasedOutside(_releasedOutside);
Volatile.Write(ref _active, 1);
}
catch (Exception attachError)
{
Deactivate();
try
{
EnsureDetachTransaction().CompleteOrThrow();
}
catch (Exception rollbackError)
{
throw new AggregateException(
"Retained gameplay registration and rollback both failed.",
new InvalidOperationException(
"Retained gameplay registration failed.", attachError),
rollbackError);
}
throw new InvalidOperationException(
"Retained gameplay registration failed and was rolled back.",
attachError);
}
}
public void Deactivate() => Interlocked.Exchange(ref _active, 0);
public void Dispose()
{
Interlocked.Exchange(ref _disposeRequested, 1);
Deactivate();
EnsureDetachTransaction().CompleteOrThrow();
}
private void OnReleasedOutside(object payload, int x, int y) =>
_quiescence.Invoke(() =>
{
if (Volatile.Read(ref _active) != 0
&& payload is ItemDragPayload item)
{
_placeDraggedItem(item, x, y);
}
});
private ResourceShutdownTransaction EnsureDetachTransaction() =>
_detach ??= new ResourceShutdownTransaction(
new ResourceShutdownStage("retained gameplay callbacks",
[
new("drag released outside UI", Remove),
]));
private void Remove()
{
if (!_attached)
return;
_surface.RemoveReleasedOutside(_releasedOutside);
_attached = false;
}
}

View file

@ -447,12 +447,9 @@ public sealed class GameWindow : IDisposable
// the bool here.
private AcDream.App.Input.PlayerModeAutoEntry? _playerModeAutoEntry;
// 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
// kb.KeyDown switch + mouse.MouseDown/MouseUp handlers are GONE; only
// mouse.MouseMove survives as a direct handler because mouse-delta is
// axis input, not chord input.
// Phase K.1b / Slice 8 F — one semantic input path. Transitional actions
// flow through GameplayInputActionRouter; held movement is polled through
// InputDispatcher. Raw axis motion belongs to CameraPointerInputController.
private AcDream.App.Input.SilkKeyboardSource? _kbSource;
private AcDream.App.Input.SilkMouseSource? _mouseSource;
private AcDream.UI.Abstractions.Input.InputDispatcher? _inputDispatcher;
@ -462,6 +459,12 @@ public sealed class GameWindow : IDisposable
private readonly AcDream.App.Input.DispatcherCameraInputSource _cameraInput = new();
private AcDream.App.Input.IMouseLookCursor? _mouseLookCursor;
private AcDream.App.Input.GameplayInputFrameController? _gameplayInputFrame;
private AcDream.App.Input.GameplayInputActionRouter? _gameplayInputActions;
private AcDream.App.Input.RetainedUiGameplayBinding? _retainedUiGameplayBinding;
private readonly AcDream.App.Diagnostics.RuntimeDiagnosticCommandSlot
_runtimeDiagnosticCommands = new();
private readonly AcDream.App.Combat.LiveCombatModeCommandSlot
_liveCombatModeCommands = new();
// 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
@ -693,10 +696,6 @@ public sealed class GameWindow : IDisposable
_inputDispatcher.Attach();
_movementInput.Bind(_inputDispatcher);
_cameraInput.Bind(_inputDispatcher);
_inputDispatcher.Fired += OnInputAction;
Combat.CombatModeChanged += SetInputCombatScope;
SetInputCombatScope(Combat.CurrentMode);
}
_gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f);
@ -960,9 +959,12 @@ public sealed class GameWindow : IDisposable
getFrameMs: () =>
(float)(_renderFrameDiagnostics?.Snapshot.FrameMilliseconds ?? 16.7),
combat: Combat);
_debugVm.CycleTimeOfDay = CycleTimeOfDay;
_debugVm.CycleWeather = CycleWeather;
_debugVm.ToggleCollisionWires = ToggleCollisionWires;
_debugVm.CycleTimeOfDay =
_runtimeDiagnosticCommands.CycleTimeOfDay;
_debugVm.CycleWeather =
_runtimeDiagnosticCommands.CycleWeather;
_debugVm.ToggleCollisionWires =
_runtimeDiagnosticCommands.ToggleCollisionWireframes;
// Phase K.2: free-fly toggle button — same routine the
// legacy F-key alias hits. Cancels the one-shot
// auto-entry if the user opts out of player mode before
@ -1406,8 +1408,6 @@ public sealed class GameWindow : IDisposable
displayMessage: text => Chat.OnSystemMessage(text, 0x1Au),
incrementBusy: () => _itemInteractionController.IncrementBusyCount(),
canSend: () => _liveSessionHost?.IsInWorld == true);
_uiHost.Root.DragReleasedOutsideUi += OnUiDragReleasedOutside;
// Feed Silk input to the UiRoot tree so windows drag / close / select.
// UiRoot consumes UI events; the game InputDispatcher (subscribed to the
// same devices) is gated off via WantCaptureMouse/Keyboard above when the
@ -1577,7 +1577,7 @@ public sealed class GameWindow : IDisposable
UseItemByGuid,
Combat,
ItemMana,
ToggleLiveCombatMode,
_liveCombatModeCommands.Toggle,
_itemInteractionController,
entry => LiveSession?.SendAddShortcut(entry),
index => LiveSession?.SendRemoveShortcut(index),
@ -2007,6 +2007,17 @@ public sealed class GameWindow : IDisposable
text => _debugVm?.AddToast(text),
_playerApproachCompletions);
}
if (_uiHost is { } retainedHost
&& _selectionInteractions is { } selectionInteractions)
{
_retainedUiGameplayBinding =
AcDream.App.Input.RetainedUiGameplayBinding.Create(
retainedHost.Root,
(item, x, y) =>
selectionInteractions.PlaceDraggedItem(item, x, y),
_hostQuiescence);
_retainedUiGameplayBinding.Attach();
}
// A.5 T22.5: apply A2C gate from quality preset.
_wbDrawDispatcher.AlphaToCoverage = _resolvedQuality.AlphaToCoverage;
@ -2813,6 +2824,81 @@ public sealed class GameWindow : IDisposable
_playerModeAutoEntry),
cameraFrame);
_liveSessionHost = CreateLiveSessionHost();
AcDream.UI.Abstractions.Panels.Debug.DebugVM? debugVm = _debugVm;
Action<string>? debugToast = debugVm is null
? null
: message => debugVm.AddToast(message);
AcDream.Core.Chat.ChatLog chat = Chat;
var combatCommand =
new AcDream.App.Combat.LiveCombatModeCommandController(
new AcDream.App.Combat.LiveSessionCombatModeAuthority(
_liveSessionHost),
new AcDream.App.Combat.LocalPlayerCombatEquipmentSource(
Objects,
_localPlayerIdentity),
Combat,
new AcDream.App.Combat.ItemInteractionCombatModeIntentSink(
_itemInteractionController!),
Console.WriteLine,
debugToast,
text => chat.OnSystemMessage(text, 0x1Au));
_liveCombatModeCommands.Bind(combatCommand);
var nearbyDiagnostics =
new AcDream.App.Diagnostics.NearbyWorldDiagnosticDumper(
new AcDream.App.Diagnostics.RuntimeNearbyWorldDiagnosticSource(
_localPlayerMode,
_playerControllerSlot,
_cameraController!,
_liveWorldOrigin,
_worldState,
_physicsEngine),
Console.WriteLine);
var runtimeDiagnostics =
new AcDream.App.Diagnostics.RuntimeDiagnosticCommandController(
_worldEnvironment,
_worldSceneDebugState,
_cameraPointerInput,
nearbyDiagnostics,
debugToast);
_runtimeDiagnosticCommands.Bind(runtimeDiagnostics);
if (_inputDispatcher is { } dispatcher)
{
var pointer = _cameraPointerInput
?? throw new InvalidOperationException(
"Gameplay action routing requires the composed pointer owner.");
var commands = new AcDream.App.Input.GameplayInputCommandController(
new AcDream.App.Input.RetainedGameplayWindowCommands(
_retailUiRuntime),
new AcDream.App.Input.DevToolsGameplayCommands(
_devToolsFramePresenter),
runtimeDiagnostics,
new AcDream.App.Input.PlayerModeGameplayCommands(
_localPlayerMode,
_playerModeController),
new AcDream.App.Input.ItemTargetModeCommands(
_itemInteractionController!),
new AcDream.App.Input.GameplayCameraModeCommands(
_cameraController!),
combatCommand,
new AcDream.App.Input.GameplayWindowCommands(_window!.Close));
var targets = new AcDream.App.Input.RuntimeGameplayInputPriorityTargets(
_gameplayInputFrame!,
pointer,
_retailUiRuntime,
_selectionInteractions,
commands);
_gameplayInputActions = AcDream.App.Input.GameplayInputActionRouter.Create(
dispatcher,
Combat,
targets,
_hostQuiescence,
Console.WriteLine);
_gameplayInputActions.Attach();
}
AcDream.App.Net.LiveSessionStartResult liveStart =
_liveSessionHost.Start(_options);
switch (liveStart.Status)
@ -3241,36 +3327,6 @@ public sealed class GameWindow : IDisposable
private bool GetDebugPlayerOnGround() =>
_playerMode && _playerController is not null && !_playerController.IsAirborne;
/// <summary>
/// Cycle the time-of-day debug override. Same body as the old F7
/// keybind handler; called by both the keybind AND the DebugPanel
/// "Cycle time of day" button via DebugVM.CycleTimeOfDay.
/// </summary>
private void CycleTimeOfDay()
{
string message = _worldEnvironment.CycleTimeOfDay();
_debugVm?.AddToast(message);
}
/// <summary>
/// Cycle the weather kind. Same body as the old F10 keybind handler.
/// </summary>
private void CycleWeather()
{
string message = _worldEnvironment.CycleWeather();
_debugVm?.AddToast(message);
}
/// <summary>
/// Toggle the collision-wires debug renderer. Same body as the old
/// F2 keybind handler.
/// </summary>
private void ToggleCollisionWires()
{
bool visible = _worldSceneDebugState.ToggleCollisionWireframes();
_debugVm?.AddToast($"Collision wireframes {(visible ? "ON" : "OFF")}");
}
// Phase K.3 settings state remains runtime-owned; the presenter owns the
// optional SettingsPanel and its visibility/input operations.
private AcDream.UI.Abstractions.Panels.Settings.SettingsVM? _settingsVm;
@ -3282,11 +3338,6 @@ public sealed class GameWindow : IDisposable
private AcDream.UI.Abstractions.Panels.Settings.SettingsStore? _settingsStore;
private string _activeToonKey = "default";
private bool ToggleRetailWindow(string name)
{
return _retailUiRuntime?.ToggleWindow(name) ?? false;
}
private void SyncToolbarWindowButtons()
{
_retailUiRuntime?.SyncToolbarWindowButtons();
@ -3369,12 +3420,6 @@ public sealed class GameWindow : IDisposable
}
}
private void OnUiDragReleasedOutside(object payload, int x, int y)
{
if (payload is AcDream.App.UI.ItemDragPayload itemPayload)
_selectionInteractions?.PlaceDraggedItem(itemPayload, x, y);
}
// L.0 follow-up: persisted-settings cache populated by
// LoadAndApplyPersistedSettings (runs unconditionally in OnLoad,
// not gated on DevToolsEnabled). The Settings PANEL construction
@ -3563,202 +3608,6 @@ public sealed class GameWindow : IDisposable
&& width > 0
&& height > 0;
}
// ── K.1b: dispatcher action handler ──────────────────────────────────
//
// SINGLE place where every game-side keyboard/mouse-button reaction
// lives. The legacy direct kb.KeyDown switch + mouse.MouseDown/MouseUp
// handlers are gone; everything now flows through InputDispatcher.Fired
// → here. New behaviors register a new InputAction in the enum + a
// case in this switch + a binding in KeyBindings.
/// <summary>
/// K.1b — multicast subscriber on <see cref="InputDispatcher.Fired"/>.
/// Handles every game-side reaction to a keyboard/mouse-button chord.
/// Per-frame held-state polling (movement WASD/Shift/Space) lives in
/// <see cref="OnUpdate"/> via <see cref="InputDispatcher.IsActionHeld"/>;
/// this method handles transitional Press/Release events only.
/// </summary>
private void SetInputCombatScope(AcDream.Core.Combat.CombatMode mode)
{
_inputDispatcher?.SetCombatScope(mode switch
{
AcDream.Core.Combat.CombatMode.Melee => AcDream.UI.Abstractions.Input.InputScope.MeleeCombat,
AcDream.Core.Combat.CombatMode.Missile => AcDream.UI.Abstractions.Input.InputScope.MissileCombat,
AcDream.Core.Combat.CombatMode.Magic => AcDream.UI.Abstractions.Input.InputScope.MagicCombat,
_ => null,
});
}
private void OnInputAction(
AcDream.UI.Abstractions.Input.InputAction action,
AcDream.UI.Abstractions.Input.ActivationType activation)
{
// Diagnostic — kept from K.1a; helpful for K.1c verification.
Console.WriteLine($"[input] {action} {activation}");
// RMB orbit and instant mouse-look remain the first accepted input
// transition. Their state is owned by the gameplay/pointer owners.
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).
// 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
|| action == AcDream.UI.Abstractions.Input.InputAction.ScrollDown)
{
if (activation != AcDream.UI.Abstractions.Input.ActivationType.Press) return;
HandleScrollAction(action);
return;
}
// ACCmdInterp::HandleNewForwardMovement (0x0058B1F0) aborts automatic
// combat before the movement command enters CommandInterpreter. This
// notification does not consume the input; the movement owner below
// still receives it normally.
// 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 (_gameplayInputFrame?.HandleCombatAction(action, activation) == true)
return;
// Every other action fires on Press only (no Release / Hold side-
// effects in the K.1b set). Filter out non-Press activations early
// so subscribers that have Release-mode bindings don't accidentally
// re-fire. B.4b exception: DoubleClick must pass through so
// SelectDblLeft / SelectDblRight / SelectDblMid can reach the switch.
if (activation != AcDream.UI.Abstractions.Input.ActivationType.Press
&& activation != AcDream.UI.Abstractions.Input.ActivationType.DoubleClick) return;
// Wave 4.1: one retained-UI delegation seam. The runtime routes only
// toolbar-owned semantic actions; every other action continues through
// the game switch below. Keyboard capture is already enforced at the
// InputDispatcher source, matching retail's stack-entry focus gate.
if (_retailUiRuntime?.HandleInputAction(action) == true)
return;
if (_selectionInteractions?.HandleInputAction(action) == true)
return;
// K-fix1 (2026-04-26): Q is autorun TOGGLE, not hold-to-run. Press
// Q to start, press Q again to stop. Pressing Backup / Stop /
// StrafeLeft / StrafeRight while autorun is active also cancels it
// — 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 (_gameplayInputFrame?.HandlePressedMovementAction(action) == true)
return;
switch (action)
{
case AcDream.UI.Abstractions.Input.InputAction.ToggleInventoryPanel:
// Retail F12 (rebindable). Gated upstream by WantsKeyboard, so it
// does not fire while the chat input holds focus. Null _uiHost =
// retail UI off (ACDREAM_RETAIL_UI unset) → no-op.
ToggleRetailWindow(AcDream.App.UI.WindowNames.Inventory);
break;
case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleDebugPanel:
_devToolsFramePresenter?.ToggleDebugPanel();
break;
case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleCollisionWires:
ToggleCollisionWires();
break;
case AcDream.UI.Abstractions.Input.InputAction.AcdreamDumpNearby:
DumpPlayerAndNearbyEntities();
break;
case AcDream.UI.Abstractions.Input.InputAction.AcdreamCycleTimeOfDay:
CycleTimeOfDay();
break;
case AcDream.UI.Abstractions.Input.InputAction.AcdreamSensitivityDown:
AdjustActiveSensitivity(1f / 1.2f);
break;
case AcDream.UI.Abstractions.Input.InputAction.AcdreamSensitivityUp:
AdjustActiveSensitivity(1.2f);
break;
case AcDream.UI.Abstractions.Input.InputAction.AcdreamCycleWeather:
CycleWeather();
break;
case AcDream.UI.Abstractions.Input.InputAction.AcdreamToggleFlyMode:
// K-fix3 (2026-04-26): proper round-trip when player has
// an active chase camera. ToggleFly() only swaps
// Fly↔Orbit, so a user who flew out of player mode used
// to land in Holtburg-orbit on toggle-back. With a chase
// camera available, prefer Fly→Chase / Chase→Fly so the
// user round-trips back to the same player view.
_playerModeController?.ToggleFlyOrChase();
break;
case AcDream.UI.Abstractions.Input.InputAction.AcdreamTogglePlayerMode:
_playerModeController?.Toggle();
break;
case AcDream.UI.Abstractions.Input.InputAction.ToggleChatEntry:
// K.2: Tab focuses the chat input. ChatPanel.FocusInput()
// sets a one-shot flag that emits SetKeyboardFocusHere on
// the next render. No-op when the devtools presenter is absent
// (offline / non-devtools build) — the dispatcher still
// logs the action via the [input] diagnostic above so the
// path is observable in either case.
_devToolsFramePresenter?.FocusChatInput();
break;
case AcDream.UI.Abstractions.Input.InputAction.ToggleOptionsPanel:
// K.3: F11 toggles the Settings panel. Null-safe vs.
// devtools-off / panel-not-registered — the [input] log
// line above still records the press regardless.
_devToolsFramePresenter?.ToggleSettingsPanel();
break;
case AcDream.UI.Abstractions.Input.InputAction.CombatToggleCombat:
ToggleLiveCombatMode();
break;
case AcDream.UI.Abstractions.Input.InputAction.EscapeKey:
if (_itemInteractionController?.IsAnyTargetModeActive == true)
_itemInteractionController.CancelTargetMode();
else if (_cameraController?.IsFlyMode == true)
_cameraController.ToggleFly(); // exit fly, release cursor
else if (_playerMode)
_playerModeController?.Exit();
else
_window!.Close();
break;
}
}
private void ToggleLiveCombatMode()
{
AcDream.Core.Net.WorldSession? session = LiveSession;
if (_liveSessionHost?.IsInWorld != true || session is null)
return;
IReadOnlyList<AcDream.Core.Items.ClientObject> orderedEquipment =
Objects.GetEquippedBy(_playerServerGuid);
var defaultMode = AcDream.Core.Combat.CombatInputPlanner
.GetDefaultCombatMode(orderedEquipment);
var nextMode = AcDream.Core.Combat.CombatInputPlanner.ToggleMode(
Combat.CurrentMode,
defaultMode);
_itemInteractionController?.NotifyExplicitCombatModeRequest();
session.SendChangeCombatMode(nextMode);
Combat.SetCombatMode(nextMode);
string text = $"Combat mode {nextMode}";
Console.WriteLine($"combat: {text}");
_debugVm?.AddToast(text);
}
/// <summary>
/// Item-target-mode world pick at the current cursor. The renderer supplies the
/// exact visible CPhysicsPart equivalents and RetailWorldPicker performs
@ -3843,87 +3692,6 @@ public sealed class GameWindow : IDisposable
return unhydratable;
}
/// <summary>
/// K.1b: F8/F9 sensitivity adjust extracted into a helper. Multiplies
/// the currently-active mode's sensitivity (chase / fly / orbit) by the
/// given factor and clamps to [0.005, 3.0].
/// </summary>
private void AdjustActiveSensitivity(float factor)
{
if (_cameraPointerInput is not { } pointer)
return;
string message = pointer.AdjustSensitivity(factor);
_debugVm?.AddToast(message);
}
/// <summary>
/// K.1b: F3 dump handler extracted into a method. Same body as the
/// previous in-line F3 branch — prints the player's position +
/// nearby visible entities + nearby shadow physics objects.
/// </summary>
private void DumpPlayerAndNearbyEntities()
{
System.Numerics.Vector3 pos;
if (_playerMode && _playerController is not null)
pos = _playerController.Position;
else
{
System.Numerics.Matrix4x4.Invert(_cameraController!.Active.View, out var iv);
pos = new System.Numerics.Vector3(iv.M41, iv.M42, iv.M43);
}
int lbX = _liveCenterX + (int)MathF.Floor(pos.X / 192f);
int lbY = _liveCenterY + (int)MathF.Floor(pos.Y / 192f);
Console.WriteLine(
$"=== F3 DEBUG DUMP ===\n" +
$" player pos=({pos.X:F2},{pos.Y:F2},{pos.Z:F2})\n" +
$" landblock=0x{(uint)((lbX << 24) | (lbY << 16) | 0xFFFF):X8} local=({pos.X - (lbX - _liveCenterX) * 192f:F2},{pos.Y - (lbY - _liveCenterY) * 192f:F2})\n" +
$" total shadow objects: {_physicsEngine.ShadowObjects.TotalRegistered}");
var visibleNearby = new List<AcDream.Core.World.WorldEntity>();
foreach (var e in _worldState.Entities)
{
float dx = e.Position.X - pos.X;
float dy = e.Position.Y - pos.Y;
if (dx * dx + dy * dy < 15f * 15f) visibleNearby.Add(e);
}
Console.WriteLine($" VISIBLE entities within 15m: {visibleNearby.Count}");
foreach (var e in visibleNearby.OrderBy(e => (e.Position - pos).Length()).Take(12))
{
float d = (e.Position - pos).Length();
Console.WriteLine(
$" VIS id=0x{e.Id:X8} src=0x{e.SourceGfxObjOrSetupId:X8} " +
$"pos=({e.Position.X:F2},{e.Position.Y:F2},{e.Position.Z:F2}) dist={d:F2} scale={e.Scale:F2}");
}
var sorted = new List<(AcDream.Core.Physics.ShadowEntry obj, float dist)>();
foreach (var o in _physicsEngine.ShadowObjects.AllEntriesForDebug())
{
float dx = o.Position.X - pos.X;
float dy = o.Position.Y - pos.Y;
float d = MathF.Sqrt(dx * dx + dy * dy);
if (d < 15f) sorted.Add((o, d));
}
sorted.Sort((a, b) => a.dist.CompareTo(b.dist));
Console.WriteLine($" SHADOW objects within 15m: {sorted.Count}");
foreach (var (o, d) in sorted.Take(12))
{
Console.WriteLine(
$" SHAD id=0x{o.EntityId:X8} {o.CollisionType} r={o.Radius:F2} h={o.CylHeight:F2} " +
$"pos=({o.Position.X:F2},{o.Position.Y:F2},{o.Position.Z:F2}) dist={d:F2}");
}
}
/// <summary>
/// K.1b: ScrollUp / ScrollDown action handler. Adjusts whichever
/// camera distance is current — chase camera distance in player mode,
/// orbit camera distance otherwise. Fly mode ignores scroll. Magnitude
/// is fixed-step (the previous proportional scroll.Y was lost when we
/// moved scroll into the dispatcher, but the discrete step matches
/// retail wheel feel).
/// </summary>
private void HandleScrollAction(AcDream.UI.Abstractions.Input.InputAction action)
=> _cameraPointerInput?.HandleScroll(action);
private void OnClosing()
{
_hostQuiescence.StopAccepting();
@ -3956,6 +3724,10 @@ public sealed class GameWindow : IDisposable
// are already inert.
new ResourceShutdownStage("input callback deactivation",
[
new("combat command slot", _liveCombatModeCommands.Deactivate),
new("diagnostic command slot", _runtimeDiagnosticCommands.Deactivate),
new("retained gameplay", () => _retainedUiGameplayBinding?.Deactivate()),
new("gameplay actions", () => _gameplayInputActions?.Deactivate()),
new("camera pointer", () => _cameraPointerInput?.Deactivate()),
new("dispatcher", () => _inputDispatcher?.Deactivate()),
new("mouse source", () => _mouseSource?.Deactivate()),
@ -3991,6 +3763,28 @@ public sealed class GameWindow : IDisposable
]),
new ResourceShutdownStage("input callback detach",
[
new("retained gameplay", () =>
{
AcDream.App.Input.RetainedUiGameplayBinding? binding =
_retainedUiGameplayBinding;
if (binding is null) return;
binding.Dispose();
if (!binding.IsDisposalComplete)
throw new InvalidOperationException(
"Retained gameplay callback removal remains pending.");
_retainedUiGameplayBinding = null;
}),
new("gameplay actions", () =>
{
AcDream.App.Input.GameplayInputActionRouter? actions =
_gameplayInputActions;
if (actions is null) return;
actions.Dispose();
if (!actions.IsDisposalComplete)
throw new InvalidOperationException(
"Gameplay action callback removal remains pending.");
_gameplayInputActions = null;
}),
new("retained UI input", () => _uiHost?.DeactivateInput()),
new("developer tools input", () => _devToolsInputContext?.Dispose()),
new("camera pointer", () =>
@ -4003,14 +3797,11 @@ public sealed class GameWindow : IDisposable
throw new InvalidOperationException(
"Camera pointer callback removal remains pending.");
}),
new("combat input subscription", () =>
Combat.CombatModeChanged -= SetInputCombatScope),
new("dispatcher", () =>
{
AcDream.UI.Abstractions.Input.InputDispatcher? dispatcher =
_inputDispatcher;
if (dispatcher is null) return;
dispatcher.Fired -= OnInputAction;
_movementInput.Unbind(dispatcher);
_cameraInput.Unbind(dispatcher);
dispatcher.Dispose();

View file

@ -36,6 +36,14 @@ public enum CombatAttackAction
High,
}
/// <summary>
/// Retail's default-mode lookup returns the selected mode and, on the one
/// rejected held-item branch, the object used to format its user notice.
/// </summary>
public readonly record struct DefaultCombatModeDecision(
CombatMode Mode,
ClientObject? IncompatibleHeldItem);
/// <summary>
/// Retail input-facing combat decisions. The heavyweight parts of the combat
/// system remain server authoritative; this helper only maps UI intent to the
@ -92,6 +100,14 @@ public static class CombatInputPlanner
/// object intersecting the requested location mask.
/// </summary>
public static CombatMode GetDefaultCombatMode(
IReadOnlyList<ClientObject> orderedPlayerContents) =>
GetDefaultCombatModeDecision(orderedPlayerContents).Mode;
/// <summary>
/// Complete result of retail <c>GetDefaultCombatMode</c>, including the
/// incompatible Held object used by its exact failure notice.
/// </summary>
public static DefaultCombatModeDecision GetDefaultCombatModeDecision(
IReadOnlyList<ClientObject> orderedPlayerContents)
{
ArgumentNullException.ThrowIfNull(orderedPlayerContents);
@ -102,20 +118,22 @@ public static class CombatInputPlanner
{
// Retail COMBAT_USE_MISSILE = 2. Every other combat-use value in
// this primary weapon slot selects melee.
return weapon.CombatUse == 2
? CombatMode.Missile
: CombatMode.Melee;
return new(
weapon.CombatUse == 2
? CombatMode.Missile
: CombatMode.Melee,
IncompatibleHeldItem: null);
}
ClientObject? held = GetObjectAtLocation(
orderedPlayerContents, EquipMask.Held);
if (held is null)
return CombatMode.Melee;
return new(CombatMode.Melee, IncompatibleHeldItem: null);
// The decomp's byte-1 sign test is ITEM_TYPE bit 15 (Caster).
return (held.Type & ItemType.Caster) != 0
? CombatMode.Magic
: CombatMode.NonCombat;
? new(CombatMode.Magic, IncompatibleHeldItem: null)
: new(CombatMode.NonCombat, held);
}
private static ClientObject? GetObjectAtLocation(
@ -136,12 +154,13 @@ public static class CombatInputPlanner
CombatMode currentMode,
CombatMode defaultCombatMode = CombatMode.Melee)
{
if ((currentMode & CombatMode.CombatCombat) != 0)
// ClientCombatSystem::ToggleCombatMode @ 0x0056C8C0 compares the
// current value directly with NonCombat. It does not coerce a
// rejected/default NonCombat result to unarmed Melee.
if (currentMode != CombatMode.NonCombat)
return CombatMode.NonCombat;
return (defaultCombatMode & CombatMode.CombatCombat) != 0
? defaultCombatMode
: CombatMode.Melee;
return defaultCombatMode;
}
public static bool SupportsTargetedAttack(CombatMode mode) =>