diff --git a/src/AcDream.App/Input/SilkKeyboardSource.cs b/src/AcDream.App/Input/SilkKeyboardSource.cs
new file mode 100644
index 0000000..65c1d1d
--- /dev/null
+++ b/src/AcDream.App/Input/SilkKeyboardSource.cs
@@ -0,0 +1,46 @@
+using System;
+using AcDream.UI.Abstractions.Input;
+using Silk.NET.Input;
+
+namespace AcDream.App.Input;
+
+///
+/// Bridges a Silk.NET to the test-fakeable
+/// interface used by
+/// . K.1a wiring: GameWindow constructs
+/// one of these wrapping the first keyboard from
+/// IInputContext.Keyboards; the dispatcher fans events out to
+/// subscribers without ever touching Silk types directly.
+///
+public sealed class SilkKeyboardSource : IKeyboardSource
+{
+ private readonly IKeyboard _keyboard;
+
+ public event Action? KeyDown;
+ public event Action? 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;
+ }
+}
diff --git a/src/AcDream.App/Input/SilkMouseSource.cs b/src/AcDream.App/Input/SilkMouseSource.cs
new file mode 100644
index 0000000..3669189
--- /dev/null
+++ b/src/AcDream.App/Input/SilkMouseSource.cs
@@ -0,0 +1,76 @@
+using System;
+using AcDream.UI.Abstractions.Input;
+using Silk.NET.Input;
+
+namespace AcDream.App.Input;
+
+///
+/// Bridges a Silk.NET + the active ImGui IO
+/// (for / )
+/// to the test-fakeable used by
+/// .
+///
+///
+/// We don't link Hexa.NET.ImGui or ImGuiNET directly here — the
+/// constructor takes two delegates so the App.Rendering layer can
+/// proxy ImGui.GetIO().WantCaptureMouse via whichever ImGui
+/// package is currently active without leaking the type onto the
+/// abstraction interface.
+///
+///
+public sealed class SilkMouseSource : IMouseSource
+{
+ private readonly IMouse _mouse;
+ private readonly Func _wantCaptureMouse;
+ private readonly Func _wantCaptureKeyboard;
+ private float _lastX;
+ private float _lastY;
+ private bool _haveLastPos;
+
+ public event Action? MouseDown;
+ public event Action? MouseUp;
+ public event Action? MouseMove;
+ public event Action? Scroll;
+
+ /// Caller-supplied probe for the current modifier mask. Reused
+ /// from the keyboard source so mouse events carry consistent modifier
+ /// state.
+ public Func ModifierProbe { get; set; } = () => ModifierMask.None;
+
+ public SilkMouseSource(
+ IMouse mouse,
+ Func wantCaptureMouse,
+ Func 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();
+}
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 77fe759..7aae2cd 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -396,6 +396,16 @@ public sealed class GameWindow : IDisposable
// the orbited position (no snap back).
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
// ACDREAM_LIVE=1 is in the environment — fully backward compatible with
// 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.Enable(EnableCap.DepthTest);
@@ -3783,6 +3816,11 @@ public sealed class GameWindow : IDisposable
// are in the kernel buffer. Fires EntitySpawned events synchronously.
_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;
var kb = _input.Keyboards[0];
diff --git a/src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj b/src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj
index 0fc070c..2f4ca83 100644
--- a/src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj
+++ b/src/AcDream.UI.Abstractions/AcDream.UI.Abstractions.csproj
@@ -6,6 +6,9 @@
latest
true
+
+
+
diff --git a/src/AcDream.UI.Abstractions/Input/ActivationType.cs b/src/AcDream.UI.Abstractions/Input/ActivationType.cs
new file mode 100644
index 0000000..14f40da
--- /dev/null
+++ b/src/AcDream.UI.Abstractions/Input/ActivationType.cs
@@ -0,0 +1,27 @@
+namespace AcDream.UI.Abstractions.Input;
+
+///
+/// When (in the lifecycle of a chord) the bound
+/// 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 for the
+/// CameraInstantMouseLook MMB-hold behavior and
+/// for future axis bindings.
+///
+public enum ActivationType
+{
+ /// Fire on key-down. Default for most actions.
+ Press,
+ /// Fire on key-up. Used for paired walk-mode toggles.
+ Release,
+ /// Fire continuously while the chord is held — the
+ /// dispatcher emits a on key-down and a
+ /// on key-up, then ticks
+ /// in between via Tick().
+ Hold,
+ /// Mouse-button double-click within retail's chord window.
+ DoubleClick,
+ /// Mouse axis or other analog input. Reserved for future
+ /// rebindable mouse-look — K.1a does not emit this.
+ Analog,
+}
diff --git a/src/AcDream.UI.Abstractions/Input/Binding.cs b/src/AcDream.UI.Abstractions/Input/Binding.cs
new file mode 100644
index 0000000..7f49740
--- /dev/null
+++ b/src/AcDream.UI.Abstractions/Input/Binding.cs
@@ -0,0 +1,16 @@
+namespace AcDream.UI.Abstractions.Input;
+
+///
+/// Single chord → action binding. Multiple bindings may resolve to the
+/// same action (e.g. retail's W and Up-arrow both → MovementForward);
+/// enumerates them.
+///
+/// The keyboard or mouse chord that triggers the action.
+/// The high-level action emitted on the dispatcher's
+/// Fired event when the chord matches in the active scope.
+/// When the action fires — defaults to
+/// (key-down).
+public readonly record struct Binding(
+ KeyChord Chord,
+ InputAction Action,
+ ActivationType Activation = ActivationType.Press);
diff --git a/src/AcDream.UI.Abstractions/Input/IKeyboardSource.cs b/src/AcDream.UI.Abstractions/Input/IKeyboardSource.cs
new file mode 100644
index 0000000..e179039
--- /dev/null
+++ b/src/AcDream.UI.Abstractions/Input/IKeyboardSource.cs
@@ -0,0 +1,38 @@
+using System;
+using Silk.NET.Input;
+
+namespace AcDream.UI.Abstractions.Input;
+
+///
+/// Test-fakeable wrapper over a keyboard event source. The production
+/// adapter lives in src/AcDream.App/Input/SilkKeyboardSource.cs
+/// and bridges Silk.NET's to this interface;
+/// tests use a hand-rolled FakeKeyboardSource that lets the
+/// test drive / events on
+/// demand.
+///
+///
+/// The interface deliberately omits any Silk.NET-specific types other
+/// than (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 (single source of UI-capture truth)
+/// rather than split across both surfaces.
+///
+///
+public interface IKeyboardSource
+{
+ /// Fires on every transition from up → down for any key.
+ event Action? KeyDown;
+
+ /// Fires on every transition from down → up for any key.
+ event Action? KeyUp;
+
+ /// True iff the given key is currently pressed.
+ bool IsHeld(Key key);
+
+ /// 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.
+ ModifierMask CurrentModifiers { get; }
+}
diff --git a/src/AcDream.UI.Abstractions/Input/IMouseSource.cs b/src/AcDream.UI.Abstractions/Input/IMouseSource.cs
new file mode 100644
index 0000000..f4f9251
--- /dev/null
+++ b/src/AcDream.UI.Abstractions/Input/IMouseSource.cs
@@ -0,0 +1,49 @@
+using System;
+using Silk.NET.Input;
+
+namespace AcDream.UI.Abstractions.Input;
+
+///
+/// Test-fakeable wrapper over a mouse event source. The production
+/// adapter lives in src/AcDream.App/Input/SilkMouseSource.cs
+/// and bridges Silk.NET's to this interface;
+/// tests use a FakeMouseSource in
+/// tests/AcDream.UI.Abstractions.Tests/Input/.
+///
+///
+/// and
+/// 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 IMouseSource rather than being
+/// split across both surfaces because the dispatcher uses a single
+/// reference to gate both branches.
+///
+///
+public interface IMouseSource
+{
+ /// Fires on every transition from up → down for any button.
+ event Action? MouseDown;
+
+ /// Fires on every transition from down → up for any button.
+ event Action? MouseUp;
+
+ /// Fires on every cursor delta. Args are (dx, dy) in pixels
+ /// since the previous fire.
+ event Action? MouseMove;
+
+ /// Fires on every wheel-tick. Positive = up, negative = down.
+ event Action? Scroll;
+
+ /// True iff the given mouse button is currently pressed.
+ bool IsHeld(MouseButton button);
+
+ /// 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.
+ bool WantCaptureMouse { get; }
+
+ /// 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.
+ bool WantCaptureKeyboard { get; }
+}
diff --git a/src/AcDream.UI.Abstractions/Input/InputAction.cs b/src/AcDream.UI.Abstractions/Input/InputAction.cs
new file mode 100644
index 0000000..6afd512
--- /dev/null
+++ b/src/AcDream.UI.Abstractions/Input/InputAction.cs
@@ -0,0 +1,259 @@
+namespace AcDream.UI.Abstractions.Input;
+
+///
+/// High-level input actions. Each name matches an entry in the retail
+/// acclient.keymap "Bindings" header (see
+/// docs/research/named-retail/retail-default.keymap.txt). The
+/// Acdream* entries at the bottom are extensions for current
+/// debug bindings that have no retail equivalent.
+///
+///
+/// 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 (UseQuickSlot_*,
+/// Combat*, UseSpellSlot_*) have NO subscribers in K.1 —
+/// the chord fires the event but nothing acts on it. Phase L panels
+/// will subscribe.
+///
+///
+public enum InputAction
+{
+ /// Sentinel — no action. Reserved for "unbound".
+ None = 0,
+
+ // ── MovementCommands ──────────────────────────────────
+ /// Move forward (run by default; walk if active).
+ MovementForward,
+ /// Move backward.
+ MovementBackup,
+ /// Turn the character to the left.
+ MovementTurnLeft,
+ /// Turn the character to the right.
+ MovementTurnRight,
+ /// Strafe (sidestep) to the left.
+ MovementStrafeLeft,
+ /// Strafe (sidestep) to the right.
+ MovementStrafeRight,
+ /// Hold-modifier — toggles forward motion to walk while held (retail Shift).
+ MovementWalkMode,
+ /// Toggle autorun on/off (retail Q).
+ MovementRunLock,
+ /// Charged jump — hold to power, release to launch.
+ MovementJump,
+ /// Cancel current motion / return to ready stance.
+ MovementStop,
+ /// Ready posture / sheathe-unsheathe (retail Y).
+ Ready,
+ /// Sit posture (retail G).
+ Sitting,
+ /// Crouch posture (retail H).
+ Crouch,
+ /// Sleep posture (retail B).
+ Sleeping,
+
+ // ── ItemSelectionCommands ─────────────────────────────
+ /// Pick up the selected item.
+ SelectionPickUp,
+ /// Split a stack of the selected item.
+ SelectionSplitStack,
+ /// Cycle back to the previous selection.
+ SelectionPreviousSelection,
+ /// Closest compass-tracked item.
+ SelectionClosestCompassItem,
+ /// Previous compass-tracked item.
+ SelectionPreviousCompassItem,
+ /// Next compass-tracked item.
+ SelectionNextCompassItem,
+ /// Closest item in radius.
+ SelectionClosestItem,
+ /// Previous item in radius.
+ SelectionPreviousItem,
+ /// Next item in radius.
+ SelectionNextItem,
+ /// Closest monster.
+ SelectionClosestMonster,
+ /// Previous monster.
+ SelectionPreviousMonster,
+ /// Next monster.
+ SelectionNextMonster,
+ /// Most-recent attacker (for retaliation).
+ SelectionLastAttacker,
+ /// Closest player.
+ SelectionClosestPlayer,
+ /// Previous player.
+ SelectionPreviousPlayer,
+ /// Next player.
+ SelectionNextPlayer,
+ /// Previous fellow.
+ SelectionPreviousFellow,
+ /// Next fellow.
+ SelectionNextFellow,
+ /// Examine (Appraise) the current selection.
+ SelectionExamine,
+
+ // ── UICommands ────────────────────────────────────────
+ /// Use the selected item / interact (retail R).
+ UseSelected,
+ /// Cancel the topmost UI / clear selection / open log-out menu.
+ EscapeKey,
+ /// Log out of the game (retail Shift+Esc).
+ LOGOUT,
+ /// Toggle the help / control reference panel (retail F1).
+ ToggleHelp,
+ /// Toggle the plugin manager panel (retail F1+Shift+Ctrl).
+ TogglePluginManager,
+ /// Toggle the allegiance panel (retail F3).
+ ToggleAllegiancePanel,
+ /// Toggle the fellowship panel (retail F4).
+ ToggleFellowshipPanel,
+ /// Toggle the spellbook panel (retail F5).
+ ToggleSpellbookPanel,
+ /// Toggle the spell-components panel (retail F6).
+ ToggleSpellComponentsPanel,
+ /// Toggle the attributes panel (retail F8).
+ ToggleAttributesPanel,
+ /// Toggle the skills panel (retail F9).
+ ToggleSkillsPanel,
+ /// Toggle the world / map panel (retail F10).
+ ToggleWorldPanel,
+ /// Toggle the options / settings panel (retail F11) — opens
+ /// our SettingsPanel in K.3.
+ ToggleOptionsPanel,
+ /// Toggle the inventory panel (retail F12).
+ ToggleInventoryPanel,
+ /// Toggle floating chat window 1 (retail Alt+1).
+ ToggleFloatingChatWindow1,
+ /// Toggle floating chat window 2.
+ ToggleFloatingChatWindow2,
+ /// Toggle floating chat window 3.
+ ToggleFloatingChatWindow3,
+ /// Toggle floating chat window 4.
+ ToggleFloatingChatWindow4,
+ /// Capture a screenshot (retail PrintScreen).
+ 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,
+ /// Drop-target shortcut creation (retail 0 / drag-drop).
+ CreateShortcut,
+
+ // ── Chat ──────────────────────────────────────────────
+ /// Focus the chat input field (retail Tab).
+ ToggleChatEntry,
+ /// Send the current chat-input contents (retail Return).
+ EnterChatMode,
+
+ // ── Combat ────────────────────────────────────────────
+ /// Toggle combat-stance on/off (retail Grave / `).
+ 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 ────────────────────────────────────────────
+ /// Cry emote (retail U).
+ Cry,
+ /// Laugh emote (retail I).
+ Laugh,
+ /// Cheer emote — celebratory jump (retail O).
+ Cheer,
+ /// Wave emote (retail J).
+ Wave,
+ /// Point emote (retail K).
+ PointState,
+
+ // ── Camera ────────────────────────────────────────────
+ /// Toggle alternate camera mode (retail F2 / Numpad-/).
+ CameraActivateAlternateMode,
+ /// Hold-MMB instant mouse-look — hardcoded behavior in K.2.
+ CameraInstantMouseLook,
+ CameraRotateLeft,
+ CameraRotateRight,
+ CameraRotateUp,
+ CameraRotateDown,
+ CameraMoveToward,
+ CameraMoveAway,
+ CameraViewDefault,
+ CameraViewFirstPerson,
+ CameraViewLookDown,
+ CameraViewMapMode,
+
+ // ── Scroll ────────────────────────────────────────────
+ /// Scroll up — wheel up or Ctrl+Up.
+ ScrollUp,
+ /// Scroll down — wheel down or Ctrl+Down.
+ ScrollDown,
+
+ // ── Mouse selection ───────────────────────────────────
+ /// Single left-click select.
+ SelectLeft,
+ /// Single right-click select.
+ SelectRight,
+ /// Single middle-click select.
+ SelectMid,
+ /// Double left-click select.
+ SelectDblLeft,
+ /// Double right-click select.
+ SelectDblRight,
+ /// Double middle-click select.
+ SelectDblMid,
+
+ // ── Acdream debug actions (existing F-key behavior) ──
+ /// F1 toggles entire DebugPanel visibility (acdream-only).
+ AcdreamToggleDebugPanel,
+ /// F2 toggles collision wireframes.
+ AcdreamToggleCollisionWires,
+ /// F3 dumps player + nearby entities to console.
+ AcdreamDumpNearby,
+ /// F7 cycles time of day.
+ AcdreamCycleTimeOfDay,
+ /// F8 decreases mouse sensitivity.
+ AcdreamSensitivityDown,
+ /// F9 increases mouse sensitivity.
+ AcdreamSensitivityUp,
+ /// F10 cycles weather.
+ AcdreamCycleWeather,
+ /// F (existing) toggles between fly camera and orbit/chase mode.
+ AcdreamToggleFlyMode,
+ /// Tab — currently toggles fly↔player mode (will be reassigned to ToggleChatEntry in K.1c).
+ AcdreamTogglePlayerMode,
+}
diff --git a/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs
new file mode 100644
index 0000000..d955da4
--- /dev/null
+++ b/src/AcDream.UI.Abstractions/Input/InputDispatcher.cs
@@ -0,0 +1,211 @@
+using System;
+using System.Collections.Generic;
+using Silk.NET.Input;
+
+namespace AcDream.UI.Abstractions.Input;
+
+///
+/// Multicast event dispatcher: subscribes to an
+/// + , looks up
+/// the active in , and
+/// fires 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.
+///
+///
+/// Why not 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.
+///
+///
+///
+/// K.1a wiring: GameWindow constructs a dispatcher alongside the
+/// existing IsKeyPressed + event-handler paths. Nothing
+/// subscribes to 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.
+///
+///
+public sealed class InputDispatcher
+{
+ private readonly IKeyboardSource _keyboard;
+ private readonly IMouseSource _mouse;
+ private readonly KeyBindings _bindings;
+ private readonly Stack _scopes = new();
+ private readonly HashSet _heldHoldChords = new();
+
+ /// Fires every time a binding matches a press / release / hold tick.
+ /// Multicast — every subscriber gets every event in subscription order.
+ public event Action? 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;
+ }
+
+ /// Topmost scope on the stack — what the dispatcher looks up first.
+ public InputScope ActiveScope => _scopes.Peek();
+
+ /// Push a scope onto the active stack. Top wins.
+ public void PushScope(InputScope scope) => _scopes.Push(scope);
+
+ /// Pop the topmost scope. is the
+ /// scope the caller believes is on top; mismatch throws to catch
+ /// unbalanced push/pop early.
+ public void PopScope(InputScope expected)
+ {
+ if (_scopes.Peek() != expected)
+ throw new InvalidOperationException(
+ $"PopScope expected {expected} but top is {_scopes.Peek()}");
+ _scopes.Pop();
+ }
+
+ ///
+ /// Per-frame tick. Re-fires
+ /// 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").
+ ///
+ 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();
+ 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();
+ 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;
+ }
+
+ ///
+ /// Encode a Silk.NET as a
+ /// 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
+ /// Device=1 on the resulting .
+ ///
+ 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),
+ };
+}
diff --git a/src/AcDream.UI.Abstractions/Input/InputScope.cs b/src/AcDream.UI.Abstractions/Input/InputScope.cs
new file mode 100644
index 0000000..857f381
--- /dev/null
+++ b/src/AcDream.UI.Abstractions/Input/InputScope.cs
@@ -0,0 +1,44 @@
+namespace AcDream.UI.Abstractions.Input;
+
+///
+/// Categorical buckets the InputDispatcher 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.
+/// sits at the bottom of the stack and catches global chords like
+/// Esc / F1 that should fire regardless of focus.
+///
+///
+/// K.1a defines the enum but only pushes +
+/// by default. Combat scopes light up in Phase L
+/// when CombatState.CurrentMode tracking lands.
+///
+///
+public enum InputScope
+{
+ /// Bottom of the stack — Esc, F1, F11 etc fire here so they
+ /// work no matter what else is focused.
+ Always,
+ /// Game world — movement, camera, targeting, hotbar.
+ Game,
+ /// Chat input has focus — scope is suspended
+ /// (W goes to the text field, not the player).
+ Chat,
+ /// A generic non-chat text-edit field (rebind capture, name
+ /// entry, etc.) has focus.
+ EditField,
+ /// A modal dialog is open and capturing input.
+ Dialog,
+ /// Combat with melee weapon equipped — Insert/PgUp/Delete/End/PgDn
+ /// remap to power + attack-level. Dormant until Phase L.
+ MeleeCombat,
+ /// Combat with missile weapon equipped — Insert/PgUp/Delete/End/PgDn
+ /// remap to accuracy + aim-level. Dormant until Phase L.
+ MissileCombat,
+ /// Magic mode — 1-9 cast UseSpellSlot; Insert/PgUp etc.
+ /// page through spell tabs. Dormant until Phase L.
+ MagicCombat,
+ /// Camera alternate mode (F2 / Numpad-/) — arrow keys rotate
+ /// the camera instead of the character.
+ Camera,
+}
diff --git a/src/AcDream.UI.Abstractions/Input/KeyBindings.cs b/src/AcDream.UI.Abstractions/Input/KeyBindings.cs
new file mode 100644
index 0000000..527b9f5
--- /dev/null
+++ b/src/AcDream.UI.Abstractions/Input/KeyBindings.cs
@@ -0,0 +1,104 @@
+using System.Collections.Generic;
+using Silk.NET.Input;
+
+namespace AcDream.UI.Abstractions.Input;
+
+///
+/// Mutable collection of 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.
+///
+///
+/// JSON IO + the real preset land in K.1c.
+/// For K.1a, proxies
+/// so the bindings table matches
+/// today's hard-coded chords — no behavior change during the K.1a/K.1b
+/// cutover.
+///
+///
+public sealed class KeyBindings
+{
+ private readonly List _bindings = new();
+
+ /// All bindings in insertion order.
+ public IReadOnlyList All => _bindings;
+
+ /// Append a binding. Duplicates are allowed; first match wins.
+ public void Add(Binding b) => _bindings.Add(b);
+
+ /// Remove the first occurrence of the given binding (structural equality).
+ public bool Remove(Binding b) => _bindings.Remove(b);
+
+ /// Drop every binding.
+ public void Clear() => _bindings.Clear();
+
+ ///
+ /// First binding (in insertion order) whose chord matches AND whose
+ /// activation type matches the requested phase. Null if none.
+ ///
+ 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;
+ }
+
+ ///
+ /// All bindings for a given action (one action can have multiple keys
+ /// — retail's W and Up-arrow both → MovementForward).
+ ///
+ public IEnumerable ForAction(InputAction action)
+ {
+ for (int i = 0; i < _bindings.Count; i++)
+ if (_bindings[i].Action == action) yield return _bindings[i];
+ }
+
+ ///
+ /// 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.
+ ///
+ 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;
+ }
+
+ ///
+ /// K.1c will replace this with the retail-faithful preset built from
+ /// docs/research/named-retail/retail-default.keymap.txt.
+ /// For K.1a, returns the acdream-current map so behavior is unchanged.
+ ///
+ // TODO K.1c: replace with full retail preset + JSON LoadOrDefault.
+ public static KeyBindings RetailDefaults() => AcdreamCurrentDefaults();
+}
diff --git a/src/AcDream.UI.Abstractions/Input/KeyChord.cs b/src/AcDream.UI.Abstractions/Input/KeyChord.cs
new file mode 100644
index 0000000..e2aed26
--- /dev/null
+++ b/src/AcDream.UI.Abstractions/Input/KeyChord.cs
@@ -0,0 +1,19 @@
+using Silk.NET.Input;
+
+namespace AcDream.UI.Abstractions.Input;
+
+///
+/// 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 Ctrl+A does not match Shift+Ctrl+A
+/// or bare A.
+///
+/// The primary trigger key. Encoded as a Silk.NET
+/// for keyboard chords; for mouse chords the key is reinterpreted via
+/// conventions and
+/// is set to 1.
+/// Bitmask of held modifier keys.
+/// for bare key chords.
+/// Retail keymap convention: 0 = keyboard, 1 = mouse,
+/// 2+ = future devices. Defaults to 0 (keyboard).
+public readonly record struct KeyChord(Key Key, ModifierMask Modifiers, byte Device = 0);
diff --git a/src/AcDream.UI.Abstractions/Input/ModifierMask.cs b/src/AcDream.UI.Abstractions/Input/ModifierMask.cs
new file mode 100644
index 0000000..55831d0
--- /dev/null
+++ b/src/AcDream.UI.Abstractions/Input/ModifierMask.cs
@@ -0,0 +1,25 @@
+using System;
+
+namespace AcDream.UI.Abstractions.Input;
+
+///
+/// Bitmask of modifier keys held alongside a primary chord key. Bit values
+/// are taken from the retail acclient.keymap "Metakeys" table:
+/// LSHIFT=1, LCTRL=2, LALT=4, LWIN=8. Same numeric layout the
+/// retail keymap text format writes — keeping it compatible lets us load
+/// future user keymap exports byte-for-byte.
+///
+[Flags]
+public enum ModifierMask : uint
+{
+ /// No modifier held — bare key.
+ None = 0,
+ /// Shift (left or right) — retail keymap bit 1.
+ Shift = 0x01,
+ /// Control (left or right) — retail keymap bit 2.
+ Ctrl = 0x02,
+ /// Alt / Menu (left or right) — retail keymap bit 3.
+ Alt = 0x04,
+ /// Windows / GUI key — retail keymap bit 4.
+ Win = 0x08,
+}
diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/FakeKeyboardSource.cs b/tests/AcDream.UI.Abstractions.Tests/Input/FakeKeyboardSource.cs
new file mode 100644
index 0000000..4b59296
--- /dev/null
+++ b/tests/AcDream.UI.Abstractions.Tests/Input/FakeKeyboardSource.cs
@@ -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;
+
+///
+/// Hand-rolled in-memory for unit tests.
+/// Tests drive the surface by calling /
+/// ; the dispatcher under test reacts via the
+/// usual / events.
+///
+internal sealed class FakeKeyboardSource : IKeyboardSource
+{
+ public event Action? KeyDown;
+ public event Action? KeyUp;
+
+ private readonly HashSet _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);
+ }
+}
diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/FakeMouseSource.cs b/tests/AcDream.UI.Abstractions.Tests/Input/FakeMouseSource.cs
new file mode 100644
index 0000000..f88a9b2
--- /dev/null
+++ b/tests/AcDream.UI.Abstractions.Tests/Input/FakeMouseSource.cs
@@ -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;
+
+///
+/// Hand-rolled in-memory for unit tests.
+/// Tests drive button + cursor + wheel events; tests can also flip
+/// /
+/// to simulate ImGui-focus behavior.
+///
+internal sealed class FakeMouseSource : IMouseSource
+{
+ public event Action? MouseDown;
+ public event Action? MouseUp;
+ public event Action? MouseMove;
+ public event Action? Scroll;
+
+ private readonly HashSet _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);
+}
diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherTests.cs
new file mode 100644
index 0000000..bedb5a5
--- /dev/null
+++ b/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherTests.cs
@@ -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(() => 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);
+ }
+}
diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsTests.cs
new file mode 100644
index 0000000..4faffa4
--- /dev/null
+++ b/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsTests.cs
@@ -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);
+ }
+}
diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/KeyChordTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/KeyChordTests.cs
new file mode 100644
index 0000000..9ffcf0c
--- /dev/null
+++ b/tests/AcDream.UI.Abstractions.Tests/Input/KeyChordTests.cs
@@ -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);
+ }
+}
diff --git a/tests/AcDream.UI.Abstractions.Tests/Input/ModifierMaskTests.cs b/tests/AcDream.UI.Abstractions.Tests/Input/ModifierMaskTests.cs
new file mode 100644
index 0000000..43bdc29
--- /dev/null
+++ b/tests/AcDream.UI.Abstractions.Tests/Input/ModifierMaskTests.cs
@@ -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);
+ }
+}