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,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);
}
}