feat(input): #21 Phase K.1a - input architecture skeleton (parallel to existing handlers)

Introduces the abstraction without changing user-visible behavior.
Existing keyboard/mouse handlers in GameWindow continue working
unchanged. The new InputDispatcher runs alongside, fires
InputAction events, and a diagnostic Console.WriteLine subscriber
proves the path is observable. K.1b cuts the existing handlers
over; K.1c flips bindings to retail.

New types in src/AcDream.UI.Abstractions/Input/:
- InputAction enum (~110 actions, doc-grouped by retail keymap
  category: MovementCommands, ItemSelectionCommands, UICommands,
  QuickslotCommands, Chat, Combat, Emotes, Camera, Scroll, Mouse
  selection, plus Acdream-specific debug actions for the existing
  F-key behaviors)
- KeyChord record struct (Silk.NET.Input.Key + ModifierMask + Device)
- ModifierMask [Flags] enum matching retail keymap bit values
  (Shift=0x01, Ctrl=0x02, Alt=0x04, Win=0x08)
- ActivationType enum (Press, Release, Hold, DoubleClick, Analog)
- Binding record (chord -> action -> activation)
- InputScope enum with stack semantics (Always at bottom, Game on
  top during normal play; Chat / EditField / Dialog / MeleeCombat /
  MissileCombat / MagicCombat / Camera push as transient overlays)
- KeyBindings collection class with Find / ForAction / Add / Remove.
  AcdreamCurrentDefaults() factory matches today's hardcoded binds
  (W/S/A/D/Z/X movement, Shift run, F-key debug surface) so K.1a
  doesn't change behavior. RetailDefaults() is K.1c's job; for now
  it returns the same map.
- IKeyboardSource / IMouseSource - test-fakeable interfaces wrapping
  Silk.NET. Both surface WantCaptureMouse / WantCaptureKeyboard
  flags so the dispatcher can gate per ImGui state.
- InputDispatcher: multicast event Fired<InputAction, ActivationType>;
  scope stack with PushScope/PopScope/ActiveScope; per-frame Tick()
  fires Hold-type bindings for currently-held chords; mouse buttons
  encoded as KeyChord with Device=1.

New adapters in src/AcDream.App/Input/:
- SilkKeyboardSource - Silk.NET IKeyboard wrapper, tracks held state
- SilkMouseSource - Silk.NET IMouse wrapper, proxies ImGui WantCapture
  flags for both keyboard and mouse

GameWindow.cs:
- Constructs adapters + dispatcher in OnLoad
- Subscribes to dispatcher.Fired with diagnostic Console.WriteLine
  ("[input] {action} {activation}") so the path is observable in
  launch.log without touching any actual game state
- Calls _inputDispatcher.Tick() per frame in OnUpdate
- Existing IsKeyPressed and event handlers unchanged

Memory crib at memory/project_input_pipeline.md describes the five
layers (Silk events -> Source interfaces -> Dispatcher -> Action
events -> Subscribers) with file paths + scope semantics + the K.1c
retail-defaults plan. Indexed in MEMORY.md.

Two deviations from plan, both documented:
1. InputDispatcher placed in UI.Abstractions/Input/ rather than
   App/Input/ - it has no Silk dependencies (uses only the test-
   fakeable interfaces) and the test fakes live in
   UI.Abstractions.Tests. Mirrors LiveCommandBus precedent. Silk
   adapters + GameWindow wiring stay in App.
2. WantCaptureKeyboard moved to IMouseSource alongside WantCaptureMouse
   (the dispatcher needs both at the same point).

34 new tests covering KeyChord equality, ModifierMask flags,
KeyBindings lookup, dispatcher chord matching with modifier
mismatch rejection, Hold-type Press/Release transitions, Tick()
firing held bindings, scope stack push/pop with mismatched-pop
throwing, WantCapture* gating.

Solution total: 1118 green (243 Core.Net + 215 UI + 660 Core),
0 warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-25 23:17:41 +02:00
parent 4717a5b6f7
commit 84512d3c64
20 changed files with 1426 additions and 0 deletions

View file

@ -0,0 +1,46 @@
using System;
using AcDream.UI.Abstractions.Input;
using Silk.NET.Input;
namespace AcDream.App.Input;
/// <summary>
/// Bridges a Silk.NET <see cref="IKeyboard"/> to the test-fakeable
/// <see cref="IKeyboardSource"/> interface used by
/// <see cref="InputDispatcher"/>. K.1a wiring: GameWindow constructs
/// one of these wrapping the first keyboard from
/// <c>IInputContext.Keyboards</c>; the dispatcher fans events out to
/// subscribers without ever touching Silk types directly.
/// </summary>
public sealed class SilkKeyboardSource : IKeyboardSource
{
private readonly IKeyboard _keyboard;
public event Action<Key, ModifierMask>? KeyDown;
public event Action<Key, ModifierMask>? KeyUp;
public SilkKeyboardSource(IKeyboard keyboard)
{
_keyboard = keyboard ?? throw new ArgumentNullException(nameof(keyboard));
_keyboard.KeyDown += (_, key, _) => KeyDown?.Invoke(key, ReadModifiers());
_keyboard.KeyUp += (_, key, _) => KeyUp?.Invoke(key, ReadModifiers());
}
public bool IsHeld(Key key) => _keyboard.IsKeyPressed(key);
public ModifierMask CurrentModifiers => ReadModifiers();
private ModifierMask ReadModifiers()
{
ModifierMask m = ModifierMask.None;
if (_keyboard.IsKeyPressed(Key.ShiftLeft) || _keyboard.IsKeyPressed(Key.ShiftRight))
m |= ModifierMask.Shift;
if (_keyboard.IsKeyPressed(Key.ControlLeft) || _keyboard.IsKeyPressed(Key.ControlRight))
m |= ModifierMask.Ctrl;
if (_keyboard.IsKeyPressed(Key.AltLeft) || _keyboard.IsKeyPressed(Key.AltRight))
m |= ModifierMask.Alt;
if (_keyboard.IsKeyPressed(Key.SuperLeft) || _keyboard.IsKeyPressed(Key.SuperRight))
m |= ModifierMask.Win;
return m;
}
}

View file

@ -0,0 +1,76 @@
using System;
using AcDream.UI.Abstractions.Input;
using Silk.NET.Input;
namespace AcDream.App.Input;
/// <summary>
/// Bridges a Silk.NET <see cref="IMouse"/> + the active ImGui IO
/// (for <see cref="WantCaptureMouse"/> / <see cref="WantCaptureKeyboard"/>)
/// to the test-fakeable <see cref="IMouseSource"/> used by
/// <see cref="InputDispatcher"/>.
///
/// <para>
/// We don't link Hexa.NET.ImGui or ImGuiNET directly here — the
/// constructor takes two delegates so the App.Rendering layer can
/// proxy <c>ImGui.GetIO().WantCaptureMouse</c> via whichever ImGui
/// package is currently active without leaking the type onto the
/// abstraction interface.
/// </para>
/// </summary>
public sealed class SilkMouseSource : IMouseSource
{
private readonly IMouse _mouse;
private readonly Func<bool> _wantCaptureMouse;
private readonly Func<bool> _wantCaptureKeyboard;
private float _lastX;
private float _lastY;
private bool _haveLastPos;
public event Action<MouseButton, ModifierMask>? MouseDown;
public event Action<MouseButton, ModifierMask>? MouseUp;
public event Action<float, float>? MouseMove;
public event Action<float>? Scroll;
/// <summary>Caller-supplied probe for the current modifier mask. Reused
/// from the keyboard source so mouse events carry consistent modifier
/// state.</summary>
public Func<ModifierMask> ModifierProbe { get; set; } = () => ModifierMask.None;
public SilkMouseSource(
IMouse mouse,
Func<bool> wantCaptureMouse,
Func<bool> wantCaptureKeyboard)
{
_mouse = mouse ?? throw new ArgumentNullException(nameof(mouse));
_wantCaptureMouse = wantCaptureMouse ?? throw new ArgumentNullException(nameof(wantCaptureMouse));
_wantCaptureKeyboard = wantCaptureKeyboard ?? throw new ArgumentNullException(nameof(wantCaptureKeyboard));
_mouse.MouseDown += (_, btn) => MouseDown?.Invoke(btn, ModifierProbe());
_mouse.MouseUp += (_, btn) => MouseUp?.Invoke(btn, ModifierProbe());
_mouse.MouseMove += (_, pos) =>
{
float dx, dy;
if (_haveLastPos)
{
dx = pos.X - _lastX;
dy = pos.Y - _lastY;
}
else
{
dx = 0f;
dy = 0f;
_haveLastPos = true;
}
_lastX = pos.X;
_lastY = pos.Y;
MouseMove?.Invoke(dx, dy);
};
_mouse.Scroll += (_, scroll) => Scroll?.Invoke(scroll.Y);
}
public bool IsHeld(MouseButton button) => _mouse.IsButtonPressed(button);
public bool WantCaptureMouse => _wantCaptureMouse();
public bool WantCaptureKeyboard => _wantCaptureKeyboard();
}

View file

@ -396,6 +396,16 @@ public sealed class GameWindow : IDisposable
// the orbited position (no snap back). // the orbited position (no snap back).
private bool _rmbHeld; private bool _rmbHeld;
// Phase K.1a — input architecture skeleton. Lives ALONGSIDE the
// existing IsKeyPressed + KeyDown handlers; nothing subscribes to
// dispatcher.Fired except a diagnostic logger. K.1b cuts the legacy
// paths over.
private AcDream.App.Input.SilkKeyboardSource? _kbSource;
private AcDream.App.Input.SilkMouseSource? _mouseSource;
private AcDream.UI.Abstractions.Input.InputDispatcher? _inputDispatcher;
private readonly AcDream.UI.Abstractions.Input.KeyBindings _keyBindings =
AcDream.UI.Abstractions.Input.KeyBindings.RetailDefaults();
// Phase 4.7: optional live connection to an ACE server. Enabled only when // Phase 4.7: optional live connection to an ACE server. Enabled only when
// ACDREAM_LIVE=1 is in the environment — fully backward compatible with // ACDREAM_LIVE=1 is in the environment — fully backward compatible with
// the offline rendering pipeline. // the offline rendering pipeline.
@ -801,6 +811,29 @@ public sealed class GameWindow : IDisposable
}; };
} }
// Phase K.1a — input dispatcher skeleton wired ALONGSIDE the
// existing IsKeyPressed + KeyDown handlers above. The dispatcher
// observes the same Silk.NET keyboard/mouse and fires high-level
// InputAction events; in K.1a nothing actually drives behavior
// off the dispatcher except a diagnostic Console.WriteLine, so
// this is a no-op for the player. K.1b cuts the legacy paths
// over to subscribe to dispatcher.Fired.
var firstKb = _input.Keyboards.FirstOrDefault();
var firstMouse = _input.Mice.FirstOrDefault();
if (firstKb is not null && firstMouse is not null)
{
_kbSource = new AcDream.App.Input.SilkKeyboardSource(firstKb);
_mouseSource = new AcDream.App.Input.SilkMouseSource(
firstMouse,
wantCaptureMouse: () => DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureMouse,
wantCaptureKeyboard: () => DevToolsEnabled && ImGuiNET.ImGui.GetIO().WantCaptureKeyboard);
_mouseSource.ModifierProbe = () => _kbSource.CurrentModifiers;
_inputDispatcher = new AcDream.UI.Abstractions.Input.InputDispatcher(
_kbSource, _mouseSource, _keyBindings);
_inputDispatcher.Fired += (action, activation) =>
Console.WriteLine($"[input] {action} {activation}");
}
_gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f); _gl.ClearColor(0.05f, 0.10f, 0.18f, 1.0f);
_gl.Enable(EnableCap.DepthTest); _gl.Enable(EnableCap.DepthTest);
@ -3783,6 +3816,11 @@ public sealed class GameWindow : IDisposable
// are in the kernel buffer. Fires EntitySpawned events synchronously. // are in the kernel buffer. Fires EntitySpawned events synchronously.
_liveSession?.Tick(); _liveSession?.Tick();
// Phase K.1a — tick the input dispatcher so Hold-type bindings
// re-fire while their chord is held. K.1b adds the subscribers
// that actually consume the events.
_inputDispatcher?.Tick();
if (_cameraController is null || _input is null) return; if (_cameraController is null || _input is null) return;
var kb = _input.Keyboards[0]; var kb = _input.Keyboards[0];

View file

@ -6,6 +6,9 @@
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors> <TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<PackageReference Include="Silk.NET.Input" Version="2.23.0" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" /> <ProjectReference Include="..\AcDream.Core\AcDream.Core.csproj" />
</ItemGroup> </ItemGroup>

View file

@ -0,0 +1,27 @@
namespace AcDream.UI.Abstractions.Input;
/// <summary>
/// When (in the lifecycle of a chord) the bound <see cref="InputAction"/>
/// fires. Modeled on the retail keymap's "activation type" sixth-field —
/// the retail format uses it primarily for mouse double-click detection,
/// but we also surface <see cref="Hold"/> for the
/// <c>CameraInstantMouseLook</c> MMB-hold behavior and <see cref="Analog"/>
/// for future axis bindings.
/// </summary>
public enum ActivationType
{
/// <summary>Fire on key-down. Default for most actions.</summary>
Press,
/// <summary>Fire on key-up. Used for paired walk-mode toggles.</summary>
Release,
/// <summary>Fire continuously while the chord is held — the
/// dispatcher emits a <see cref="Press"/> on key-down and a
/// <see cref="Release"/> on key-up, then <see cref="Hold"/> ticks
/// in between via <c>Tick()</c>.</summary>
Hold,
/// <summary>Mouse-button double-click within retail's chord window.</summary>
DoubleClick,
/// <summary>Mouse axis or other analog input. Reserved for future
/// rebindable mouse-look — K.1a does not emit this.</summary>
Analog,
}

View file

@ -0,0 +1,16 @@
namespace AcDream.UI.Abstractions.Input;
/// <summary>
/// Single chord → action binding. Multiple bindings may resolve to the
/// same action (e.g. retail's W and Up-arrow both → <c>MovementForward</c>);
/// <see cref="KeyBindings.ForAction"/> enumerates them.
/// </summary>
/// <param name="Chord">The keyboard or mouse chord that triggers the action.</param>
/// <param name="Action">The high-level action emitted on the dispatcher's
/// <c>Fired</c> event when the chord matches in the active scope.</param>
/// <param name="Activation">When the action fires — defaults to
/// <see cref="ActivationType.Press"/> (key-down).</param>
public readonly record struct Binding(
KeyChord Chord,
InputAction Action,
ActivationType Activation = ActivationType.Press);

View file

@ -0,0 +1,38 @@
using System;
using Silk.NET.Input;
namespace AcDream.UI.Abstractions.Input;
/// <summary>
/// Test-fakeable wrapper over a keyboard event source. The production
/// adapter lives in <c>src/AcDream.App/Input/SilkKeyboardSource.cs</c>
/// and bridges Silk.NET's <see cref="IKeyboard"/> to this interface;
/// tests use a hand-rolled <c>FakeKeyboardSource</c> that lets the
/// test drive <see cref="KeyDown"/> / <see cref="KeyUp"/> events on
/// demand.
///
/// <para>
/// The interface deliberately omits any Silk.NET-specific types other
/// than <see cref="Key"/> (which is a value-type enum). That lets the
/// dispatcher be unit-testable without spinning a window context.
/// ImGui's WantCaptureKeyboard / WantCaptureMouse flags are exposed
/// on <see cref="IMouseSource"/> (single source of UI-capture truth)
/// rather than split across both surfaces.
/// </para>
/// </summary>
public interface IKeyboardSource
{
/// <summary>Fires on every transition from up → down for any key.</summary>
event Action<Key, ModifierMask>? KeyDown;
/// <summary>Fires on every transition from down → up for any key.</summary>
event Action<Key, ModifierMask>? KeyUp;
/// <summary>True iff the given key is currently pressed.</summary>
bool IsHeld(Key key);
/// <summary>Bitmask of modifier keys currently held. Used by the
/// dispatcher's per-frame Tick() to assemble fresh chords for held
/// keys without re-listening to every event.</summary>
ModifierMask CurrentModifiers { get; }
}

View file

@ -0,0 +1,49 @@
using System;
using Silk.NET.Input;
namespace AcDream.UI.Abstractions.Input;
/// <summary>
/// Test-fakeable wrapper over a mouse event source. The production
/// adapter lives in <c>src/AcDream.App/Input/SilkMouseSource.cs</c>
/// and bridges Silk.NET's <see cref="IMouse"/> to this interface;
/// tests use a <c>FakeMouseSource</c> in
/// <c>tests/AcDream.UI.Abstractions.Tests/Input/</c>.
///
/// <para>
/// <see cref="WantCaptureMouse"/> and <see cref="WantCaptureKeyboard"/>
/// are proxied from the active UI backend (ImGui in K.1a) so the
/// dispatcher can suppress firing while a panel is hovered or a text
/// field is focused. They live on <c>IMouseSource</c> rather than being
/// split across both surfaces because the dispatcher uses a single
/// reference to gate both branches.
/// </para>
/// </summary>
public interface IMouseSource
{
/// <summary>Fires on every transition from up → down for any button.</summary>
event Action<MouseButton, ModifierMask>? MouseDown;
/// <summary>Fires on every transition from down → up for any button.</summary>
event Action<MouseButton, ModifierMask>? MouseUp;
/// <summary>Fires on every cursor delta. Args are (dx, dy) in pixels
/// since the previous fire.</summary>
event Action<float, float>? MouseMove;
/// <summary>Fires on every wheel-tick. Positive = up, negative = down.</summary>
event Action<float>? Scroll;
/// <summary>True iff the given mouse button is currently pressed.</summary>
bool IsHeld(MouseButton button);
/// <summary>True when the UI (ImGui) wants the mouse — the dispatcher
/// suppresses MouseDown/MouseUp/MouseMove/Scroll firing in that case
/// so e.g. RMB orbit doesn't trigger while hovering a panel.</summary>
bool WantCaptureMouse { get; }
/// <summary>True when the UI (ImGui) wants the keyboard — the dispatcher
/// suppresses KeyDown firing in that case so typing into a chat field
/// doesn't fire game actions.</summary>
bool WantCaptureKeyboard { get; }
}

View file

@ -0,0 +1,259 @@
namespace AcDream.UI.Abstractions.Input;
/// <summary>
/// High-level input actions. Each name matches an entry in the retail
/// <c>acclient.keymap</c> "Bindings" header (see
/// <c>docs/research/named-retail/retail-default.keymap.txt</c>). The
/// <c>Acdream*</c> entries at the bottom are extensions for current
/// debug bindings that have no retail equivalent.
///
/// <para>
/// K.1a defines the enum but only the existing acdream-current chords
/// resolve to actions; K.1c flips the bindings table to the full retail
/// preset. Several actions in this enum (<c>UseQuickSlot_*</c>,
/// <c>Combat*</c>, <c>UseSpellSlot_*</c>) have NO subscribers in K.1 —
/// the chord fires the event but nothing acts on it. Phase L panels
/// will subscribe.
/// </para>
/// </summary>
public enum InputAction
{
/// <summary>Sentinel — no action. Reserved for "unbound".</summary>
None = 0,
// ── MovementCommands ──────────────────────────────────
/// <summary>Move forward (run by default; walk if <see cref="MovementWalkMode"/> active).</summary>
MovementForward,
/// <summary>Move backward.</summary>
MovementBackup,
/// <summary>Turn the character to the left.</summary>
MovementTurnLeft,
/// <summary>Turn the character to the right.</summary>
MovementTurnRight,
/// <summary>Strafe (sidestep) to the left.</summary>
MovementStrafeLeft,
/// <summary>Strafe (sidestep) to the right.</summary>
MovementStrafeRight,
/// <summary>Hold-modifier — toggles forward motion to walk while held (retail Shift).</summary>
MovementWalkMode,
/// <summary>Toggle autorun on/off (retail Q).</summary>
MovementRunLock,
/// <summary>Charged jump — hold to power, release to launch.</summary>
MovementJump,
/// <summary>Cancel current motion / return to ready stance.</summary>
MovementStop,
/// <summary>Ready posture / sheathe-unsheathe (retail Y).</summary>
Ready,
/// <summary>Sit posture (retail G).</summary>
Sitting,
/// <summary>Crouch posture (retail H).</summary>
Crouch,
/// <summary>Sleep posture (retail B).</summary>
Sleeping,
// ── ItemSelectionCommands ─────────────────────────────
/// <summary>Pick up the selected item.</summary>
SelectionPickUp,
/// <summary>Split a stack of the selected item.</summary>
SelectionSplitStack,
/// <summary>Cycle back to the previous selection.</summary>
SelectionPreviousSelection,
/// <summary>Closest compass-tracked item.</summary>
SelectionClosestCompassItem,
/// <summary>Previous compass-tracked item.</summary>
SelectionPreviousCompassItem,
/// <summary>Next compass-tracked item.</summary>
SelectionNextCompassItem,
/// <summary>Closest item in radius.</summary>
SelectionClosestItem,
/// <summary>Previous item in radius.</summary>
SelectionPreviousItem,
/// <summary>Next item in radius.</summary>
SelectionNextItem,
/// <summary>Closest monster.</summary>
SelectionClosestMonster,
/// <summary>Previous monster.</summary>
SelectionPreviousMonster,
/// <summary>Next monster.</summary>
SelectionNextMonster,
/// <summary>Most-recent attacker (for retaliation).</summary>
SelectionLastAttacker,
/// <summary>Closest player.</summary>
SelectionClosestPlayer,
/// <summary>Previous player.</summary>
SelectionPreviousPlayer,
/// <summary>Next player.</summary>
SelectionNextPlayer,
/// <summary>Previous fellow.</summary>
SelectionPreviousFellow,
/// <summary>Next fellow.</summary>
SelectionNextFellow,
/// <summary>Examine (Appraise) the current selection.</summary>
SelectionExamine,
// ── UICommands ────────────────────────────────────────
/// <summary>Use the selected item / interact (retail R).</summary>
UseSelected,
/// <summary>Cancel the topmost UI / clear selection / open log-out menu.</summary>
EscapeKey,
/// <summary>Log out of the game (retail Shift+Esc).</summary>
LOGOUT,
/// <summary>Toggle the help / control reference panel (retail F1).</summary>
ToggleHelp,
/// <summary>Toggle the plugin manager panel (retail F1+Shift+Ctrl).</summary>
TogglePluginManager,
/// <summary>Toggle the allegiance panel (retail F3).</summary>
ToggleAllegiancePanel,
/// <summary>Toggle the fellowship panel (retail F4).</summary>
ToggleFellowshipPanel,
/// <summary>Toggle the spellbook panel (retail F5).</summary>
ToggleSpellbookPanel,
/// <summary>Toggle the spell-components panel (retail F6).</summary>
ToggleSpellComponentsPanel,
/// <summary>Toggle the attributes panel (retail F8).</summary>
ToggleAttributesPanel,
/// <summary>Toggle the skills panel (retail F9).</summary>
ToggleSkillsPanel,
/// <summary>Toggle the world / map panel (retail F10).</summary>
ToggleWorldPanel,
/// <summary>Toggle the options / settings panel (retail F11) — opens
/// our SettingsPanel in K.3.</summary>
ToggleOptionsPanel,
/// <summary>Toggle the inventory panel (retail F12).</summary>
ToggleInventoryPanel,
/// <summary>Toggle floating chat window 1 (retail Alt+1).</summary>
ToggleFloatingChatWindow1,
/// <summary>Toggle floating chat window 2.</summary>
ToggleFloatingChatWindow2,
/// <summary>Toggle floating chat window 3.</summary>
ToggleFloatingChatWindow3,
/// <summary>Toggle floating chat window 4.</summary>
ToggleFloatingChatWindow4,
/// <summary>Capture a screenshot (retail PrintScreen).</summary>
CaptureScreenshot,
// ── QuickslotCommands ─────────────────────────────────
UseQuickSlot_1,
UseQuickSlot_2,
UseQuickSlot_3,
UseQuickSlot_4,
UseQuickSlot_5,
UseQuickSlot_6,
UseQuickSlot_7,
UseQuickSlot_8,
UseQuickSlot_9,
UseQuickSlot_14,
UseQuickSlot_15,
UseQuickSlot_16,
UseQuickSlot_17,
UseQuickSlot_18,
/// <summary>Drop-target shortcut creation (retail 0 / drag-drop).</summary>
CreateShortcut,
// ── Chat ──────────────────────────────────────────────
/// <summary>Focus the chat input field (retail Tab).</summary>
ToggleChatEntry,
/// <summary>Send the current chat-input contents (retail Return).</summary>
EnterChatMode,
// ── Combat ────────────────────────────────────────────
/// <summary>Toggle combat-stance on/off (retail Grave / `).</summary>
CombatToggleCombat,
// Mode-dependent (dormant in K — Phase L lights them up)
CombatDecreaseAttackPower,
CombatIncreaseAttackPower,
CombatLowAttack,
CombatMediumAttack,
CombatHighAttack,
CombatDecreaseMissileAccuracy,
CombatIncreaseMissileAccuracy,
CombatAimLow,
CombatAimMedium,
CombatAimHigh,
CombatPrevSpellTab,
CombatNextSpellTab,
CombatPrevSpell,
CombatCastCurrentSpell,
CombatNextSpell,
CombatFirstSpellTab,
CombatLastSpellTab,
CombatFirstSpell,
CombatLastSpell,
UseSpellSlot_1,
UseSpellSlot_2,
UseSpellSlot_3,
UseSpellSlot_4,
UseSpellSlot_5,
UseSpellSlot_6,
UseSpellSlot_7,
UseSpellSlot_8,
UseSpellSlot_9,
// ── Emotes ────────────────────────────────────────────
/// <summary>Cry emote (retail U).</summary>
Cry,
/// <summary>Laugh emote (retail I).</summary>
Laugh,
/// <summary>Cheer emote — celebratory jump (retail O).</summary>
Cheer,
/// <summary>Wave emote (retail J).</summary>
Wave,
/// <summary>Point emote (retail K).</summary>
PointState,
// ── Camera ────────────────────────────────────────────
/// <summary>Toggle alternate camera mode (retail F2 / Numpad-/).</summary>
CameraActivateAlternateMode,
/// <summary>Hold-MMB instant mouse-look — hardcoded behavior in K.2.</summary>
CameraInstantMouseLook,
CameraRotateLeft,
CameraRotateRight,
CameraRotateUp,
CameraRotateDown,
CameraMoveToward,
CameraMoveAway,
CameraViewDefault,
CameraViewFirstPerson,
CameraViewLookDown,
CameraViewMapMode,
// ── Scroll ────────────────────────────────────────────
/// <summary>Scroll up — wheel up or Ctrl+Up.</summary>
ScrollUp,
/// <summary>Scroll down — wheel down or Ctrl+Down.</summary>
ScrollDown,
// ── Mouse selection ───────────────────────────────────
/// <summary>Single left-click select.</summary>
SelectLeft,
/// <summary>Single right-click select.</summary>
SelectRight,
/// <summary>Single middle-click select.</summary>
SelectMid,
/// <summary>Double left-click select.</summary>
SelectDblLeft,
/// <summary>Double right-click select.</summary>
SelectDblRight,
/// <summary>Double middle-click select.</summary>
SelectDblMid,
// ── Acdream debug actions (existing F-key behavior) ──
/// <summary>F1 toggles entire DebugPanel visibility (acdream-only).</summary>
AcdreamToggleDebugPanel,
/// <summary>F2 toggles collision wireframes.</summary>
AcdreamToggleCollisionWires,
/// <summary>F3 dumps player + nearby entities to console.</summary>
AcdreamDumpNearby,
/// <summary>F7 cycles time of day.</summary>
AcdreamCycleTimeOfDay,
/// <summary>F8 decreases mouse sensitivity.</summary>
AcdreamSensitivityDown,
/// <summary>F9 increases mouse sensitivity.</summary>
AcdreamSensitivityUp,
/// <summary>F10 cycles weather.</summary>
AcdreamCycleWeather,
/// <summary>F (existing) toggles between fly camera and orbit/chase mode.</summary>
AcdreamToggleFlyMode,
/// <summary>Tab — currently toggles fly↔player mode (will be reassigned to ToggleChatEntry in K.1c).</summary>
AcdreamTogglePlayerMode,
}

View file

@ -0,0 +1,211 @@
using System;
using System.Collections.Generic;
using Silk.NET.Input;
namespace AcDream.UI.Abstractions.Input;
/// <summary>
/// Multicast event dispatcher: subscribes to an
/// <see cref="IKeyboardSource"/> + <see cref="IMouseSource"/>, looks up
/// the active <see cref="Binding"/> in <see cref="KeyBindings"/>, and
/// fires <see cref="Fired"/> for every match. Owns the runtime scope
/// stack — top of stack wins; when no binding matches the topmost
/// scope, lookup falls through to the scope below.
///
/// <para>
/// <b>Why not <c>LiveCommandBus</c>?</b> <see cref="LiveCommandBus"/>
/// is single-handler-per-type by design (one canonical recipient per
/// command). Input is fundamentally multicast: a single key press may
/// drive movement, animation, audio, and a panel toggle simultaneously.
/// The two are complementary — input fans out to many subscribers, who
/// then issue commands through the bus to one canonical recipient.
/// </para>
///
/// <para>
/// K.1a wiring: <c>GameWindow</c> constructs a dispatcher alongside the
/// existing <c>IsKeyPressed</c> + event-handler paths. Nothing
/// subscribes to <see cref="Fired"/> yet except a diagnostic console
/// logger — the dispatcher is observable but doesn't drive any
/// behavior. K.1b cuts the existing handlers over to the dispatcher's
/// action stream.
/// </para>
/// </summary>
public sealed class InputDispatcher
{
private readonly IKeyboardSource _keyboard;
private readonly IMouseSource _mouse;
private readonly KeyBindings _bindings;
private readonly Stack<InputScope> _scopes = new();
private readonly HashSet<KeyChord> _heldHoldChords = new();
/// <summary>Fires every time a binding matches a press / release / hold tick.
/// Multicast — every subscriber gets every event in subscription order.</summary>
public event Action<InputAction, ActivationType>? Fired;
public InputDispatcher(IKeyboardSource keyboard, IMouseSource mouse, KeyBindings bindings)
{
_keyboard = keyboard ?? throw new ArgumentNullException(nameof(keyboard));
_mouse = mouse ?? throw new ArgumentNullException(nameof(mouse));
_bindings = bindings ?? throw new ArgumentNullException(nameof(bindings));
_scopes.Push(InputScope.Always); // bottom of the stack
_scopes.Push(InputScope.Game); // default top for normal play
_keyboard.KeyDown += OnKeyDown;
_keyboard.KeyUp += OnKeyUp;
_mouse.MouseDown += OnMouseDown;
_mouse.MouseUp += OnMouseUp;
_mouse.Scroll += OnScroll;
}
/// <summary>Topmost scope on the stack — what the dispatcher looks up first.</summary>
public InputScope ActiveScope => _scopes.Peek();
/// <summary>Push a scope onto the active stack. Top wins.</summary>
public void PushScope(InputScope scope) => _scopes.Push(scope);
/// <summary>Pop the topmost scope. <paramref name="expected"/> is the
/// scope the caller believes is on top; mismatch throws to catch
/// unbalanced push/pop early.</summary>
public void PopScope(InputScope expected)
{
if (_scopes.Peek() != expected)
throw new InvalidOperationException(
$"PopScope expected {expected} but top is {_scopes.Peek()}");
_scopes.Pop();
}
/// <summary>
/// Per-frame tick. Re-fires <see cref="ActivationType.Hold"/>
/// activations for every chord that's currently held. Call once per
/// game-update tick (NOT once per render frame — Hold semantics are
/// "fire while held," not "fire every render").
/// </summary>
public void Tick()
{
if (_mouse.WantCaptureKeyboard) return;
// Snapshot to avoid issues if a subscriber mutates _heldHoldChords.
if (_heldHoldChords.Count == 0) return;
var snapshot = new KeyChord[_heldHoldChords.Count];
_heldHoldChords.CopyTo(snapshot);
for (int i = 0; i < snapshot.Length; i++)
{
var chord = snapshot[i];
var hold = _bindings.Find(chord, ActivationType.Hold);
if (hold is not null)
Fired?.Invoke(hold.Value.Action, ActivationType.Hold);
}
}
private void OnKeyDown(Key key, ModifierMask mods)
{
if (_mouse.WantCaptureKeyboard) return;
var chord = new KeyChord(key, mods, Device: 0);
var press = _bindings.Find(chord, ActivationType.Press);
if (press is not null) Fired?.Invoke(press.Value.Action, ActivationType.Press);
var hold = _bindings.Find(chord, ActivationType.Hold);
if (hold is not null)
{
// Emit a Press transition so subscribers can latch state, then
// record the chord so Tick() will re-fire Hold every frame.
Fired?.Invoke(hold.Value.Action, ActivationType.Press);
_heldHoldChords.Add(chord);
}
}
private void OnKeyUp(Key key, ModifierMask mods)
{
// Release fires regardless of WantCaptureKeyboard so we don't
// strand a Hold subscriber in the "held" state if the UI captured
// mid-press.
var chord = new KeyChord(key, mods, Device: 0);
var release = _bindings.Find(chord, ActivationType.Release);
if (release is not null) Fired?.Invoke(release.Value.Action, ActivationType.Release);
// Any matching Hold binding gets a Release transition. Walk the
// tracked set looking for a chord with a matching Key (ignoring
// modifiers — modifiers may have been released first).
var toRemove = new List<KeyChord>();
foreach (var held in _heldHoldChords)
{
if (held.Key == key && held.Device == 0)
toRemove.Add(held);
}
foreach (var held in toRemove)
{
_heldHoldChords.Remove(held);
var hold = _bindings.Find(held, ActivationType.Hold);
if (hold is not null) Fired?.Invoke(hold.Value.Action, ActivationType.Release);
}
}
private void OnMouseDown(MouseButton button, ModifierMask mods)
{
if (_mouse.WantCaptureMouse) return;
var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1);
var press = _bindings.Find(chord, ActivationType.Press);
if (press is not null) Fired?.Invoke(press.Value.Action, ActivationType.Press);
var hold = _bindings.Find(chord, ActivationType.Hold);
if (hold is not null)
{
Fired?.Invoke(hold.Value.Action, ActivationType.Press);
_heldHoldChords.Add(chord);
}
}
private void OnMouseUp(MouseButton button, ModifierMask mods)
{
var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1);
var release = _bindings.Find(chord, ActivationType.Release);
if (release is not null) Fired?.Invoke(release.Value.Action, ActivationType.Release);
var keyForLookup = MouseButtonToKey(button);
var toRemove = new List<KeyChord>();
foreach (var held in _heldHoldChords)
{
if (held.Key == keyForLookup && held.Device == 1)
toRemove.Add(held);
}
foreach (var held in toRemove)
{
_heldHoldChords.Remove(held);
var hold = _bindings.Find(held, ActivationType.Hold);
if (hold is not null) Fired?.Invoke(hold.Value.Action, ActivationType.Release);
}
}
private void OnScroll(float delta)
{
if (_mouse.WantCaptureMouse) return;
// Wheel ticks emit ScrollUp / ScrollDown actions if either chord
// is bound. We don't go through KeyBindings.Find here — wheel is
// a fixed mapping for now (rebindable in K.1c).
// Empty in K.1a — no subscribers; the action is observable via
// direct subscription if a future caller wants it.
_ = delta;
}
/// <summary>
/// Encode a Silk.NET <see cref="MouseButton"/> as a <see cref="Key"/>
/// value for chord lookup. Reuses the high values of the Key enum
/// that Silk.NET doesn't assign meaningful keyboard codes to. This
/// is an internal-only convention; the dispatcher always sets
/// <c>Device=1</c> on the resulting <see cref="KeyChord"/>.
/// </summary>
public static Key MouseButtonToKey(MouseButton button) => button switch
{
MouseButton.Left => (Key)(-1001),
MouseButton.Right => (Key)(-1002),
MouseButton.Middle => (Key)(-1003),
MouseButton.Button4 => (Key)(-1004),
MouseButton.Button5 => (Key)(-1005),
_ => (Key)(-1000 - (int)button),
};
}

View file

@ -0,0 +1,44 @@
namespace AcDream.UI.Abstractions.Input;
/// <summary>
/// Categorical buckets the <c>InputDispatcher</c> uses to gate which
/// bindings are active. Managed as a stack at runtime — the topmost
/// scope wins; if a chord doesn't match in the topmost scope the
/// dispatcher falls through to the next one down. <see cref="Always"/>
/// sits at the bottom of the stack and catches global chords like
/// Esc / F1 that should fire regardless of focus.
///
/// <para>
/// K.1a defines the enum but only pushes <see cref="Always"/> +
/// <see cref="Game"/> by default. Combat scopes light up in Phase L
/// when <c>CombatState.CurrentMode</c> tracking lands.
/// </para>
/// </summary>
public enum InputScope
{
/// <summary>Bottom of the stack — Esc, F1, F11 etc fire here so they
/// work no matter what else is focused.</summary>
Always,
/// <summary>Game world — movement, camera, targeting, hotbar.</summary>
Game,
/// <summary>Chat input has focus — <see cref="Game"/> scope is suspended
/// (W goes to the text field, not the player).</summary>
Chat,
/// <summary>A generic non-chat text-edit field (rebind capture, name
/// entry, etc.) has focus.</summary>
EditField,
/// <summary>A modal dialog is open and capturing input.</summary>
Dialog,
/// <summary>Combat with melee weapon equipped — Insert/PgUp/Delete/End/PgDn
/// remap to power + attack-level. Dormant until Phase L.</summary>
MeleeCombat,
/// <summary>Combat with missile weapon equipped — Insert/PgUp/Delete/End/PgDn
/// remap to accuracy + aim-level. Dormant until Phase L.</summary>
MissileCombat,
/// <summary>Magic mode — 1-9 cast <c>UseSpellSlot</c>; Insert/PgUp etc.
/// page through spell tabs. Dormant until Phase L.</summary>
MagicCombat,
/// <summary>Camera alternate mode (F2 / Numpad-/) — arrow keys rotate
/// the camera instead of the character.</summary>
Camera,
}

View file

@ -0,0 +1,104 @@
using System.Collections.Generic;
using Silk.NET.Input;
namespace AcDream.UI.Abstractions.Input;
/// <summary>
/// Mutable collection of <see cref="Binding"/>s. Owns lookup by chord
/// (for the dispatcher) and lookup by action (for the Settings UI).
/// Insertion-order preserved — first-match-wins on lookup, so a user
/// can add a custom binding ahead of a default and have it take effect
/// without removing the default.
///
/// <para>
/// JSON IO + the real <see cref="RetailDefaults"/> preset land in K.1c.
/// For K.1a, <see cref="RetailDefaults"/> proxies
/// <see cref="AcdreamCurrentDefaults"/> so the bindings table matches
/// today's hard-coded chords — no behavior change during the K.1a/K.1b
/// cutover.
/// </para>
/// </summary>
public sealed class KeyBindings
{
private readonly List<Binding> _bindings = new();
/// <summary>All bindings in insertion order.</summary>
public IReadOnlyList<Binding> All => _bindings;
/// <summary>Append a binding. Duplicates are allowed; first match wins.</summary>
public void Add(Binding b) => _bindings.Add(b);
/// <summary>Remove the first occurrence of the given binding (structural equality).</summary>
public bool Remove(Binding b) => _bindings.Remove(b);
/// <summary>Drop every binding.</summary>
public void Clear() => _bindings.Clear();
/// <summary>
/// First binding (in insertion order) whose chord matches AND whose
/// activation type matches the requested phase. Null if none.
/// </summary>
public Binding? Find(KeyChord chord, ActivationType activation)
{
for (int i = 0; i < _bindings.Count; i++)
{
var b = _bindings[i];
if (b.Chord == chord && b.Activation == activation) return b;
}
return null;
}
/// <summary>
/// All bindings for a given action (one action can have multiple keys
/// — retail's W and Up-arrow both → MovementForward).
/// </summary>
public IEnumerable<Binding> ForAction(InputAction action)
{
for (int i = 0; i < _bindings.Count; i++)
if (_bindings[i].Action == action) yield return _bindings[i];
}
/// <summary>
/// K.1a stub: returns CURRENT acdream binds (W=fwd, S=back, A/D=turn,
/// Z/X=strafe, Shift=run, Tab=toggle player↔fly mode, F1=DebugPanel,
/// F2=collision wires, F3=dump, F7=cycle time, F8/F9=sensitivity,
/// F10=cycle weather, F=fly toggle, Space=jump). NOT the retail
/// preset — that lands in K.1c. This stub keeps behavior unchanged
/// during K.1a/K.1b cutover.
/// </summary>
public static KeyBindings AcdreamCurrentDefaults()
{
var b = new KeyBindings();
// Movement (current acdream — wrong for retail but unchanged for K.1a)
b.Add(new(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward));
b.Add(new(new KeyChord(Key.S, ModifierMask.None), InputAction.MovementBackup));
b.Add(new(new KeyChord(Key.A, ModifierMask.None), InputAction.MovementTurnLeft));
b.Add(new(new KeyChord(Key.D, ModifierMask.None), InputAction.MovementTurnRight));
b.Add(new(new KeyChord(Key.Z, ModifierMask.None), InputAction.MovementStrafeLeft));
b.Add(new(new KeyChord(Key.X, ModifierMask.None), InputAction.MovementStrafeRight));
b.Add(new(new KeyChord(Key.ShiftLeft, ModifierMask.None), InputAction.MovementRunLock, ActivationType.Hold));
b.Add(new(new KeyChord(Key.Space, ModifierMask.None), InputAction.MovementJump));
// Acdream debug binds
b.Add(new(new KeyChord(Key.F1, ModifierMask.None), InputAction.AcdreamToggleDebugPanel));
b.Add(new(new KeyChord(Key.F2, ModifierMask.None), InputAction.AcdreamToggleCollisionWires));
b.Add(new(new KeyChord(Key.F3, ModifierMask.None), InputAction.AcdreamDumpNearby));
b.Add(new(new KeyChord(Key.F7, ModifierMask.None), InputAction.AcdreamCycleTimeOfDay));
b.Add(new(new KeyChord(Key.F8, ModifierMask.None), InputAction.AcdreamSensitivityDown));
b.Add(new(new KeyChord(Key.F9, ModifierMask.None), InputAction.AcdreamSensitivityUp));
b.Add(new(new KeyChord(Key.F10, ModifierMask.None), InputAction.AcdreamCycleWeather));
b.Add(new(new KeyChord(Key.F, ModifierMask.None), InputAction.AcdreamToggleFlyMode));
b.Add(new(new KeyChord(Key.Tab, ModifierMask.None), InputAction.AcdreamTogglePlayerMode));
return b;
}
/// <summary>
/// K.1c will replace this with the retail-faithful preset built from
/// <c>docs/research/named-retail/retail-default.keymap.txt</c>.
/// For K.1a, returns the acdream-current map so behavior is unchanged.
/// </summary>
// TODO K.1c: replace with full retail preset + JSON LoadOrDefault.
public static KeyBindings RetailDefaults() => AcdreamCurrentDefaults();
}

View file

@ -0,0 +1,19 @@
using Silk.NET.Input;
namespace AcDream.UI.Abstractions.Input;
/// <summary>
/// A single key (keyboard or mouse) plus its modifier bitmask. Equality
/// is structural: two chords match iff key, modifier mask, and device
/// index all match — so <c>Ctrl+A</c> does not match <c>Shift+Ctrl+A</c>
/// or bare <c>A</c>.
/// </summary>
/// <param name="Key">The primary trigger key. Encoded as a Silk.NET <see cref="Key"/>
/// for keyboard chords; for mouse chords the key is reinterpreted via
/// <see cref="MouseChord"/> conventions and <paramref name="Device"/>
/// is set to 1.</param>
/// <param name="Modifiers">Bitmask of held modifier keys. <see cref="ModifierMask.None"/>
/// for bare key chords.</param>
/// <param name="Device">Retail keymap convention: 0 = keyboard, 1 = mouse,
/// 2+ = future devices. Defaults to 0 (keyboard).</param>
public readonly record struct KeyChord(Key Key, ModifierMask Modifiers, byte Device = 0);

View file

@ -0,0 +1,25 @@
using System;
namespace AcDream.UI.Abstractions.Input;
/// <summary>
/// Bitmask of modifier keys held alongside a primary chord key. Bit values
/// are taken from the retail <c>acclient.keymap</c> "Metakeys" table:
/// <c>LSHIFT=1, LCTRL=2, LALT=4, LWIN=8</c>. Same numeric layout the
/// retail keymap text format writes — keeping it compatible lets us load
/// future user keymap exports byte-for-byte.
/// </summary>
[Flags]
public enum ModifierMask : uint
{
/// <summary>No modifier held — bare key.</summary>
None = 0,
/// <summary>Shift (left or right) — retail keymap bit 1.</summary>
Shift = 0x01,
/// <summary>Control (left or right) — retail keymap bit 2.</summary>
Ctrl = 0x02,
/// <summary>Alt / Menu (left or right) — retail keymap bit 3.</summary>
Alt = 0x04,
/// <summary>Windows / GUI key — retail keymap bit 4.</summary>
Win = 0x08,
}

View file

@ -0,0 +1,38 @@
using System;
using System.Collections.Generic;
using AcDream.UI.Abstractions.Input;
using Silk.NET.Input;
namespace AcDream.UI.Abstractions.Tests.Input;
/// <summary>
/// Hand-rolled in-memory <see cref="IKeyboardSource"/> for unit tests.
/// Tests drive the surface by calling <see cref="EmitKeyDown"/> /
/// <see cref="EmitKeyUp"/>; the dispatcher under test reacts via the
/// usual <see cref="KeyDown"/> / <see cref="KeyUp"/> events.
/// </summary>
internal sealed class FakeKeyboardSource : IKeyboardSource
{
public event Action<Key, ModifierMask>? KeyDown;
public event Action<Key, ModifierMask>? KeyUp;
private readonly HashSet<Key> _held = new();
public ModifierMask CurrentModifiers { get; set; } = ModifierMask.None;
public bool IsHeld(Key key) => _held.Contains(key);
public void EmitKeyDown(Key key, ModifierMask mods)
{
CurrentModifiers = mods;
_held.Add(key);
KeyDown?.Invoke(key, mods);
}
public void EmitKeyUp(Key key, ModifierMask mods)
{
CurrentModifiers = mods;
_held.Remove(key);
KeyUp?.Invoke(key, mods);
}
}

View file

@ -0,0 +1,42 @@
using System;
using System.Collections.Generic;
using AcDream.UI.Abstractions.Input;
using Silk.NET.Input;
namespace AcDream.UI.Abstractions.Tests.Input;
/// <summary>
/// Hand-rolled in-memory <see cref="IMouseSource"/> for unit tests.
/// Tests drive button + cursor + wheel events; tests can also flip
/// <see cref="WantCaptureMouse"/> / <see cref="WantCaptureKeyboard"/>
/// to simulate ImGui-focus behavior.
/// </summary>
internal sealed class FakeMouseSource : IMouseSource
{
public event Action<MouseButton, ModifierMask>? MouseDown;
public event Action<MouseButton, ModifierMask>? MouseUp;
public event Action<float, float>? MouseMove;
public event Action<float>? Scroll;
private readonly HashSet<MouseButton> _held = new();
public bool IsHeld(MouseButton button) => _held.Contains(button);
public bool WantCaptureMouse { get; set; }
public bool WantCaptureKeyboard { get; set; }
public void EmitMouseDown(MouseButton button, ModifierMask mods)
{
_held.Add(button);
MouseDown?.Invoke(button, mods);
}
public void EmitMouseUp(MouseButton button, ModifierMask mods)
{
_held.Remove(button);
MouseUp?.Invoke(button, mods);
}
public void EmitMouseMove(float dx, float dy) => MouseMove?.Invoke(dx, dy);
public void EmitScroll(float delta) => Scroll?.Invoke(delta);
}

View file

@ -0,0 +1,189 @@
using System;
using System.Collections.Generic;
using AcDream.UI.Abstractions.Input;
using Silk.NET.Input;
namespace AcDream.UI.Abstractions.Tests.Input;
public class InputDispatcherTests
{
private static (InputDispatcher dispatcher, FakeKeyboardSource kb, FakeMouseSource mouse, KeyBindings bindings, List<(InputAction, ActivationType)> fired)
Build()
{
var kb = new FakeKeyboardSource();
var mouse = new FakeMouseSource();
var bindings = new KeyBindings();
var dispatcher = new InputDispatcher(kb, mouse, bindings);
var fired = new List<(InputAction, ActivationType)>();
dispatcher.Fired += (a, t) => fired.Add((a, t));
return (dispatcher, kb, mouse, bindings, fired);
}
[Fact]
public void KeyDown_with_matching_chord_fires_press_action()
{
var (_, kb, _, bindings, fired) = Build();
bindings.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward));
kb.EmitKeyDown(Key.W, ModifierMask.None);
Assert.Single(fired);
Assert.Equal((InputAction.MovementForward, ActivationType.Press), fired[0]);
}
[Fact]
public void KeyDown_with_mismatched_modifier_does_not_fire()
{
var (_, kb, _, bindings, fired) = Build();
bindings.Add(new Binding(new KeyChord(Key.A, ModifierMask.Ctrl), InputAction.SelectionExamine));
// Press A without Ctrl — no fire.
kb.EmitKeyDown(Key.A, ModifierMask.None);
Assert.Empty(fired);
// Press A with Shift+Ctrl — also no fire (extra modifier).
kb.EmitKeyDown(Key.A, ModifierMask.Shift | ModifierMask.Ctrl);
Assert.Empty(fired);
// Exactly Ctrl+A fires.
kb.EmitKeyDown(Key.A, ModifierMask.Ctrl);
Assert.Single(fired);
}
[Fact]
public void WantCaptureKeyboard_suppresses_KeyDown_events()
{
var (_, kb, mouse, bindings, fired) = Build();
bindings.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward));
mouse.WantCaptureKeyboard = true;
kb.EmitKeyDown(Key.W, ModifierMask.None);
Assert.Empty(fired);
}
[Fact]
public void WantCaptureMouse_suppresses_MouseDown_events()
{
var (_, _, mouse, bindings, fired) = Build();
var leftClick = new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Left), ModifierMask.None, Device: 1);
bindings.Add(new Binding(leftClick, InputAction.SelectLeft));
mouse.WantCaptureMouse = true;
mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None);
Assert.Empty(fired);
}
[Fact]
public void Default_active_scope_is_Game()
{
var (dispatcher, _, _, _, _) = Build();
Assert.Equal(InputScope.Game, dispatcher.ActiveScope);
}
[Fact]
public void PushScope_changes_ActiveScope()
{
var (dispatcher, _, _, _, _) = Build();
dispatcher.PushScope(InputScope.Chat);
Assert.Equal(InputScope.Chat, dispatcher.ActiveScope);
}
[Fact]
public void PopScope_with_mismatched_expected_throws()
{
var (dispatcher, _, _, _, _) = Build();
dispatcher.PushScope(InputScope.Chat);
Assert.Throws<InvalidOperationException>(() => dispatcher.PopScope(InputScope.Dialog));
}
[Fact]
public void PopScope_restores_previous()
{
var (dispatcher, _, _, _, _) = Build();
dispatcher.PushScope(InputScope.Chat);
dispatcher.PopScope(InputScope.Chat);
Assert.Equal(InputScope.Game, dispatcher.ActiveScope);
}
[Fact]
public void Hold_binding_fires_Press_on_KeyDown_and_Release_on_KeyUp()
{
var (_, kb, _, bindings, fired) = Build();
bindings.Add(new Binding(
new KeyChord(Key.ShiftLeft, ModifierMask.None),
InputAction.MovementRunLock,
ActivationType.Hold));
kb.EmitKeyDown(Key.ShiftLeft, ModifierMask.None);
Assert.Contains((InputAction.MovementRunLock, ActivationType.Press), fired);
kb.EmitKeyUp(Key.ShiftLeft, ModifierMask.None);
Assert.Contains((InputAction.MovementRunLock, ActivationType.Release), fired);
}
[Fact]
public void Tick_fires_Hold_for_currently_held_chords()
{
var (dispatcher, kb, _, bindings, fired) = Build();
bindings.Add(new Binding(
new KeyChord(Key.ShiftLeft, ModifierMask.None),
InputAction.MovementRunLock,
ActivationType.Hold));
kb.EmitKeyDown(Key.ShiftLeft, ModifierMask.None);
fired.Clear(); // discard the initial Press transition
dispatcher.Tick();
dispatcher.Tick();
// Two Hold ticks while held.
Assert.Equal(2, fired.Count);
Assert.All(fired, e => Assert.Equal((InputAction.MovementRunLock, ActivationType.Hold), e));
kb.EmitKeyUp(Key.ShiftLeft, ModifierMask.None);
fired.Clear();
dispatcher.Tick();
Assert.Empty(fired); // no longer held
}
[Fact]
public void Release_binding_fires_only_on_KeyUp()
{
var (_, kb, _, bindings, fired) = Build();
bindings.Add(new Binding(
new KeyChord(Key.W, ModifierMask.None),
InputAction.MovementStop,
ActivationType.Release));
kb.EmitKeyDown(Key.W, ModifierMask.None);
Assert.Empty(fired);
kb.EmitKeyUp(Key.W, ModifierMask.None);
Assert.Single(fired);
Assert.Equal((InputAction.MovementStop, ActivationType.Release), fired[0]);
}
[Fact]
public void MouseDown_with_matching_chord_fires_action()
{
var (_, _, mouse, bindings, fired) = Build();
var leftClick = new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Left), ModifierMask.None, Device: 1);
bindings.Add(new Binding(leftClick, InputAction.SelectLeft));
mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None);
Assert.Single(fired);
Assert.Equal((InputAction.SelectLeft, ActivationType.Press), fired[0]);
}
[Fact]
public void Tick_no_op_when_no_chords_held()
{
var (dispatcher, _, _, _, fired) = Build();
dispatcher.Tick();
Assert.Empty(fired);
}
}

View file

@ -0,0 +1,106 @@
using System.Linq;
using AcDream.UI.Abstractions.Input;
using Silk.NET.Input;
namespace AcDream.UI.Abstractions.Tests.Input;
public class KeyBindingsTests
{
[Fact]
public void Add_appends_to_All()
{
var b = new KeyBindings();
var binding = new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward);
b.Add(binding);
Assert.Single(b.All);
Assert.Equal(binding, b.All[0]);
}
[Fact]
public void Remove_drops_binding()
{
var b = new KeyBindings();
var binding = new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward);
b.Add(binding);
Assert.True(b.Remove(binding));
Assert.Empty(b.All);
}
[Fact]
public void Clear_empties_all()
{
var b = new KeyBindings();
b.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward));
b.Add(new Binding(new KeyChord(Key.S, ModifierMask.None), InputAction.MovementBackup));
b.Clear();
Assert.Empty(b.All);
}
[Fact]
public void Find_returns_matching_binding_by_chord_and_activation()
{
var b = new KeyBindings();
var press = new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward, ActivationType.Press);
var release = new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward, ActivationType.Release);
b.Add(press);
b.Add(release);
Assert.Equal(press, b.Find(new KeyChord(Key.W, ModifierMask.None), ActivationType.Press));
Assert.Equal(release, b.Find(new KeyChord(Key.W, ModifierMask.None), ActivationType.Release));
}
[Fact]
public void Find_returns_null_when_no_match()
{
var b = new KeyBindings();
b.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward));
Assert.Null(b.Find(new KeyChord(Key.S, ModifierMask.None), ActivationType.Press));
}
[Fact]
public void ForAction_returns_all_chords_bound_to_action()
{
var b = new KeyBindings();
// Retail-style: W and Up both → MovementForward.
b.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward));
b.Add(new Binding(new KeyChord(Key.Up, ModifierMask.None), InputAction.MovementForward));
b.Add(new Binding(new KeyChord(Key.S, ModifierMask.None), InputAction.MovementBackup));
var forward = b.ForAction(InputAction.MovementForward).ToList();
Assert.Equal(2, forward.Count);
Assert.Contains(forward, x => x.Chord.Key == Key.W);
Assert.Contains(forward, x => x.Chord.Key == Key.Up);
}
[Fact]
public void AcdreamCurrentDefaults_includes_WASD_movement()
{
var b = KeyBindings.AcdreamCurrentDefaults();
Assert.NotNull(b.Find(new KeyChord(Key.W, ModifierMask.None), ActivationType.Press));
Assert.NotNull(b.Find(new KeyChord(Key.S, ModifierMask.None), ActivationType.Press));
Assert.NotNull(b.Find(new KeyChord(Key.A, ModifierMask.None), ActivationType.Press));
Assert.NotNull(b.Find(new KeyChord(Key.D, ModifierMask.None), ActivationType.Press));
Assert.NotNull(b.Find(new KeyChord(Key.Z, ModifierMask.None), ActivationType.Press));
Assert.NotNull(b.Find(new KeyChord(Key.X, ModifierMask.None), ActivationType.Press));
}
[Fact]
public void AcdreamCurrentDefaults_binds_shift_as_hold_for_run()
{
var b = KeyBindings.AcdreamCurrentDefaults();
var hold = b.Find(new KeyChord(Key.ShiftLeft, ModifierMask.None), ActivationType.Hold);
Assert.NotNull(hold);
Assert.Equal(InputAction.MovementRunLock, hold!.Value.Action);
}
[Fact]
public void RetailDefaults_proxies_AcdreamCurrentDefaults_in_K1a()
{
// K.1a stub — RetailDefaults() returns the acdream-current binds so
// K.1b cutover doesn't change behavior. K.1c flips this to the
// retail preset.
var retail = KeyBindings.RetailDefaults();
var current = KeyBindings.AcdreamCurrentDefaults();
Assert.Equal(current.All.Count, retail.All.Count);
}
}

View file

@ -0,0 +1,54 @@
using AcDream.UI.Abstractions.Input;
using Silk.NET.Input;
namespace AcDream.UI.Abstractions.Tests.Input;
public class KeyChordTests
{
[Fact]
public void Same_key_no_modifier_equal()
{
var a = new KeyChord(Key.W, ModifierMask.None);
var b = new KeyChord(Key.W, ModifierMask.None);
Assert.Equal(a, b);
}
[Fact]
public void CtrlA_does_not_match_bare_A()
{
var ctrlA = new KeyChord(Key.A, ModifierMask.Ctrl);
var bareA = new KeyChord(Key.A, ModifierMask.None);
Assert.NotEqual(ctrlA, bareA);
}
[Fact]
public void CtrlA_does_not_match_ShiftCtrlA()
{
var ctrlA = new KeyChord(Key.A, ModifierMask.Ctrl);
var shiftCtrlA = new KeyChord(Key.A, ModifierMask.Shift | ModifierMask.Ctrl);
Assert.NotEqual(ctrlA, shiftCtrlA);
}
[Fact]
public void Different_keys_with_same_modifier_distinct()
{
Assert.NotEqual(
new KeyChord(Key.A, ModifierMask.Shift),
new KeyChord(Key.B, ModifierMask.Shift));
}
[Fact]
public void Default_device_is_zero_keyboard()
{
var c = new KeyChord(Key.W, ModifierMask.None);
Assert.Equal(0, c.Device);
}
[Fact]
public void Different_device_with_same_key_distinct()
{
var keyboard = new KeyChord(Key.A, ModifierMask.None, Device: 0);
var mouse = new KeyChord(Key.A, ModifierMask.None, Device: 1);
Assert.NotEqual(keyboard, mouse);
}
}

View file

@ -0,0 +1,42 @@
using AcDream.UI.Abstractions.Input;
namespace AcDream.UI.Abstractions.Tests.Input;
public class ModifierMaskTests
{
[Fact]
public void None_is_zero()
{
Assert.Equal((uint)0, (uint)ModifierMask.None);
}
[Fact]
public void Bit_values_match_retail_keymap_metakey_table()
{
// Retail's "Metakeys" table: LSHIFT=1, LCTRL=2, LALT=4, LWIN=8.
// The retail keymap text format writes these as bit-flags so a
// user editing acclient.keymap by hand sees the same values.
Assert.Equal((uint)0x01, (uint)ModifierMask.Shift);
Assert.Equal((uint)0x02, (uint)ModifierMask.Ctrl);
Assert.Equal((uint)0x04, (uint)ModifierMask.Alt);
Assert.Equal((uint)0x08, (uint)ModifierMask.Win);
}
[Fact]
public void Or_combines_bits()
{
var combo = ModifierMask.Shift | ModifierMask.Ctrl;
Assert.True(combo.HasFlag(ModifierMask.Shift));
Assert.True(combo.HasFlag(ModifierMask.Ctrl));
Assert.False(combo.HasFlag(ModifierMask.Alt));
}
[Fact]
public void Equality_distinguishes_distinct_masks()
{
Assert.NotEqual(ModifierMask.Shift, ModifierMask.Ctrl);
Assert.NotEqual(ModifierMask.None, ModifierMask.Shift);
Assert.Equal(ModifierMask.Shift | ModifierMask.Ctrl,
ModifierMask.Ctrl | ModifierMask.Shift);
}
}