diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 2450265..e4a2731 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -412,8 +412,20 @@ public sealed class GameWindow : IDisposable
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();
+ // K.1c: load user-customized bindings from %LOCALAPPDATA%\acdream\keybinds.json,
+ // falling back to the retail-faithful defaults if the file is missing
+ // or corrupt. This is THE single source of truth for the keymap at
+ // startup — no other call to RetailDefaults() / AcdreamCurrentDefaults()
+ // should land in the GameWindow construction path.
+ private readonly AcDream.UI.Abstractions.Input.KeyBindings _keyBindings = LoadStartupKeyBindings();
+
+ private static AcDream.UI.Abstractions.Input.KeyBindings LoadStartupKeyBindings()
+ {
+ var path = AcDream.UI.Abstractions.Input.KeyBindings.DefaultPath();
+ var bindings = AcDream.UI.Abstractions.Input.KeyBindings.LoadOrDefault(path);
+ Console.WriteLine($"keybinds: loaded {bindings.All.Count} bindings from {path}");
+ return bindings;
+ }
// Phase 4.7: optional live connection to an ACE server. Enabled only when
// ACDREAM_LIVE=1 is in the environment — fully backward compatible with
@@ -5018,6 +5030,15 @@ public sealed class GameWindow : IDisposable
TogglePlayerMode();
break;
+ case AcDream.UI.Abstractions.Input.InputAction.ToggleChatEntry:
+ // K.1c: Tab in retail focuses the chat input. Phase K.2
+ // wires this to ChatPanel.FocusInput() once the panel grows
+ // an explicit focus method; the current ImGui-backed chat
+ // panel takes focus on click. Press is logged via the
+ // [input] diagnostic above so the cutover is observable.
+ // TODO K.2: call _chatPanel.FocusInput() once available.
+ break;
+
case AcDream.UI.Abstractions.Input.InputAction.EscapeKey:
if (_cameraController?.IsFlyMode == true)
_cameraController.ToggleFly(); // exit fly, release cursor
diff --git a/src/AcDream.UI.Abstractions/Input/KeyBindings.cs b/src/AcDream.UI.Abstractions/Input/KeyBindings.cs
index bb23b41..58ba368 100644
--- a/src/AcDream.UI.Abstractions/Input/KeyBindings.cs
+++ b/src/AcDream.UI.Abstractions/Input/KeyBindings.cs
@@ -1,4 +1,8 @@
+using System;
using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
using Silk.NET.Input;
namespace AcDream.UI.Abstractions.Input;
@@ -11,15 +15,19 @@ namespace AcDream.UI.Abstractions.Input;
/// 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.
+/// K.1c: now returns the full retail-faithful
+/// preset (byte-precise to retail-default.keymap.txt);
+/// + persist the
+/// table to JSON under with version-based
+/// migration. is preserved as a
+/// reference for tests pinning the older WASD-only behavior, but is no
+/// longer the GameWindow startup source.
///
///
public sealed class KeyBindings
{
+ private const int CurrentSchemaVersion = 1;
+
private readonly List _bindings = new();
/// All bindings in insertion order.
@@ -59,26 +67,22 @@ public sealed class KeyBindings
}
///
- /// 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.
+ /// Acdream-current keymap (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). Preserved as a regression
+ /// anchor for tests that pin the older modifier-blind WASD layout —
+ /// NOT the GameWindow startup source after K.1c.
///
public static KeyBindings AcdreamCurrentDefaults()
{
var b = new KeyBindings();
- // Movement (current acdream — wrong for retail but unchanged for K.1a/K.1b).
- //
- // Each movement key gets BOTH a bare-mods chord and a Shift-mods chord
- // so the per-frame IsActionHeld query stays true while the user holds
- // Shift (the acdream-current run-modifier). Before K.1b, movement
- // polled IsKeyPressed(Key.W) directly — modifier-blind. K.1b's
- // dispatcher does strict modifier matching, so we duplicate-bind here
- // to preserve the modifier-blind feel until K.1c reshapes the whole
- // table to retail defaults.
+ // Movement (current acdream — wrong for retail but preserved for
+ // legacy tests). Each movement key gets BOTH a bare-mods chord
+ // and a Shift-mods chord so the per-frame IsActionHeld query
+ // stays true while the user holds Shift (the acdream-current
+ // run-modifier).
b.Add(new(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward));
b.Add(new(new KeyChord(Key.W, ModifierMask.Shift), InputAction.MovementForward));
b.Add(new(new KeyChord(Key.S, ModifierMask.None), InputAction.MovementBackup));
@@ -91,22 +95,14 @@ public sealed class KeyBindings
b.Add(new(new KeyChord(Key.Z, ModifierMask.Shift), InputAction.MovementStrafeLeft));
b.Add(new(new KeyChord(Key.X, ModifierMask.None), InputAction.MovementStrafeRight));
b.Add(new(new KeyChord(Key.X, ModifierMask.Shift), InputAction.MovementStrafeRight));
- // Run-modifier: Shift held triggers run. When ShiftLeft/ShiftRight
- // is held, the keyboard's CurrentModifiers includes Shift — so the
- // chord requires Modifiers=Shift. Both sides resolve to the same
- // action so a held-poll on either side answers true.
b.Add(new(new KeyChord(Key.ShiftLeft, ModifierMask.Shift), InputAction.MovementRunLock, ActivationType.Hold));
b.Add(new(new KeyChord(Key.ShiftRight, ModifierMask.Shift), InputAction.MovementRunLock, ActivationType.Hold));
b.Add(new(new KeyChord(Key.Space, ModifierMask.None), InputAction.MovementJump));
b.Add(new(new KeyChord(Key.Space, ModifierMask.Shift), InputAction.MovementJump));
- // Fly-camera descend — Ctrl held in fly mode lowers the camera.
- // ControlLeft held delivers CurrentModifiers=Ctrl, so chord uses
- // mask=Ctrl. Both Ctrl sides resolve to the same action.
b.Add(new(new KeyChord(Key.ControlLeft, ModifierMask.Ctrl), InputAction.AcdreamFlyDown, ActivationType.Hold));
b.Add(new(new KeyChord(Key.ControlRight, ModifierMask.Ctrl), InputAction.AcdreamFlyDown, ActivationType.Hold));
- // 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));
@@ -118,9 +114,6 @@ public sealed class KeyBindings
b.Add(new(new KeyChord(Key.Tab, ModifierMask.None), InputAction.AcdreamTogglePlayerMode));
b.Add(new(new KeyChord(Key.Escape, ModifierMask.None), InputAction.EscapeKey));
- // K.1b mouse: RMB-hold drives camera-only orbit (never character yaw).
- // Device=1 marks this as a mouse chord; the dispatcher routes
- // _mouse.IsHeld(Right) through this chord for IsActionHeld lookup.
b.Add(new(
new KeyChord(InputDispatcher.MouseButtonToKey(Silk.NET.Input.MouseButton.Right), ModifierMask.None, Device: 1),
InputAction.AcdreamRmbOrbitHold,
@@ -130,10 +123,361 @@ public sealed class KeyBindings
}
///
- /// 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.
+ /// Canonical AC retail-default keymap, byte-precise to
+ /// docs/research/named-retail/retail-default.keymap.txt (the
+ /// user's Throne of Destiny test.keymap). All bindings
+ /// categorized by the Bindings [...] sections of the keymap
+ /// text. Default movement mode = RUN; hold LShift toggles to walk.
+ /// Note: K.1c puts the bindings in the table; the dispatcher /
+ /// MovementInput wiring that interprets MovementWalkMode + the
+ /// "default-run" semantics doesn't change in K.1c — that's K.2.
///
- // TODO K.1c: replace with full retail preset + JSON LoadOrDefault.
- public static KeyBindings RetailDefaults() => AcdreamCurrentDefaults();
+ public static KeyBindings RetailDefaults()
+ {
+ var b = new KeyBindings();
+
+ // ── MovementCommands ───────────────────────────────────
+ b.Add(new(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward));
+ b.Add(new(new KeyChord(Key.Up, ModifierMask.None), InputAction.MovementForward));
+ b.Add(new(new KeyChord(Key.X, ModifierMask.None), InputAction.MovementBackup));
+ b.Add(new(new KeyChord(Key.Down, ModifierMask.None), InputAction.MovementBackup));
+ b.Add(new(new KeyChord(Key.A, ModifierMask.None), InputAction.MovementTurnLeft));
+ b.Add(new(new KeyChord(Key.Left, ModifierMask.None), InputAction.MovementTurnLeft));
+ b.Add(new(new KeyChord(Key.D, ModifierMask.None), InputAction.MovementTurnRight));
+ b.Add(new(new KeyChord(Key.Right, ModifierMask.None), InputAction.MovementTurnRight));
+ b.Add(new(new KeyChord(Key.Z, ModifierMask.None), InputAction.MovementStrafeLeft));
+ b.Add(new(new KeyChord(Key.A, ModifierMask.Alt), InputAction.MovementStrafeLeft));
+ b.Add(new(new KeyChord(Key.Left, ModifierMask.Alt), InputAction.MovementStrafeLeft));
+ b.Add(new(new KeyChord(Key.C, ModifierMask.None), InputAction.MovementStrafeRight));
+ b.Add(new(new KeyChord(Key.D, ModifierMask.Alt), InputAction.MovementStrafeRight));
+ b.Add(new(new KeyChord(Key.Right, ModifierMask.Alt), InputAction.MovementStrafeRight));
+ // Walk-mode modifier — Hold so a subscriber can latch state on
+ // press and unlatch on release.
+ b.Add(new(new KeyChord(Key.ShiftLeft, ModifierMask.None), InputAction.MovementWalkMode, ActivationType.Hold));
+ b.Add(new(new KeyChord(Key.Q, ModifierMask.None), InputAction.MovementRunLock));
+ b.Add(new(new KeyChord(Key.S, ModifierMask.None), InputAction.MovementStop));
+ b.Add(new(new KeyChord(Key.Y, ModifierMask.None), InputAction.Ready));
+ b.Add(new(new KeyChord(Key.G, ModifierMask.None), InputAction.Sitting));
+ b.Add(new(new KeyChord(Key.H, ModifierMask.None), InputAction.Crouch));
+ b.Add(new(new KeyChord(Key.B, ModifierMask.None), InputAction.Sleeping));
+ b.Add(new(new KeyChord(Key.Space, ModifierMask.None), InputAction.MovementJump));
+
+ // ── ItemSelectionCommands ──────────────────────────────
+ b.Add(new(new KeyChord(Key.F, ModifierMask.None), InputAction.SelectionPickUp));
+ b.Add(new(new KeyChord(Key.T, ModifierMask.None), InputAction.SelectionSplitStack));
+ b.Add(new(new KeyChord(Key.P, ModifierMask.None), InputAction.SelectionPreviousSelection));
+ b.Add(new(new KeyChord(Key.Backspace, ModifierMask.None), InputAction.SelectionClosestCompassItem));
+ b.Add(new(new KeyChord(Key.Minus, ModifierMask.None), InputAction.SelectionPreviousCompassItem));
+ b.Add(new(new KeyChord(Key.Equal, ModifierMask.None), InputAction.SelectionNextCompassItem));
+ b.Add(new(new KeyChord(Key.BackSlash, ModifierMask.None), InputAction.SelectionClosestItem));
+ b.Add(new(new KeyChord(Key.LeftBracket, ModifierMask.None), InputAction.SelectionPreviousItem));
+ b.Add(new(new KeyChord(Key.RightBracket, ModifierMask.None), InputAction.SelectionNextItem));
+ b.Add(new(new KeyChord(Key.Apostrophe, ModifierMask.None), InputAction.SelectionClosestMonster));
+ b.Add(new(new KeyChord(Key.L, ModifierMask.None), InputAction.SelectionPreviousMonster));
+ // Silk.NET names this Semicolon (lowercase c), not SemiColon.
+ b.Add(new(new KeyChord(Key.Semicolon, ModifierMask.None), InputAction.SelectionNextMonster));
+ b.Add(new(new KeyChord(Key.Home, ModifierMask.None), InputAction.SelectionLastAttacker));
+ b.Add(new(new KeyChord(Key.Slash, ModifierMask.None), InputAction.SelectionClosestPlayer));
+ b.Add(new(new KeyChord(Key.Comma, ModifierMask.None), InputAction.SelectionPreviousPlayer));
+ b.Add(new(new KeyChord(Key.Period, ModifierMask.None), InputAction.SelectionNextPlayer));
+ b.Add(new(new KeyChord(Key.N, ModifierMask.None), InputAction.SelectionPreviousFellow));
+ b.Add(new(new KeyChord(Key.M, ModifierMask.None), InputAction.SelectionNextFellow));
+
+ // ── UICommands ─────────────────────────────────────────
+ b.Add(new(new KeyChord(Key.E, ModifierMask.None), InputAction.SelectionExamine));
+ b.Add(new(new KeyChord(Key.KeypadMultiply, ModifierMask.None), InputAction.CaptureScreenshot));
+ b.Add(new(new KeyChord(Key.F1, ModifierMask.None), InputAction.ToggleHelp));
+ b.Add(new(new KeyChord(Key.F1, ModifierMask.Shift | ModifierMask.Ctrl), InputAction.TogglePluginManager));
+ b.Add(new(new KeyChord(Key.F3, ModifierMask.None), InputAction.ToggleAllegiancePanel));
+ b.Add(new(new KeyChord(Key.F4, ModifierMask.None), InputAction.ToggleFellowshipPanel));
+ b.Add(new(new KeyChord(Key.F5, ModifierMask.None), InputAction.ToggleSpellbookPanel));
+ b.Add(new(new KeyChord(Key.F6, ModifierMask.None), InputAction.ToggleSpellComponentsPanel));
+ b.Add(new(new KeyChord(Key.F8, ModifierMask.None), InputAction.ToggleAttributesPanel));
+ b.Add(new(new KeyChord(Key.F9, ModifierMask.None), InputAction.ToggleSkillsPanel));
+ b.Add(new(new KeyChord(Key.F10, ModifierMask.None), InputAction.ToggleWorldPanel));
+ b.Add(new(new KeyChord(Key.F11, ModifierMask.None), InputAction.ToggleOptionsPanel));
+ b.Add(new(new KeyChord(Key.F12, ModifierMask.None), InputAction.ToggleInventoryPanel));
+ b.Add(new(new KeyChord(Key.Number1, ModifierMask.Alt), InputAction.ToggleFloatingChatWindow1));
+ b.Add(new(new KeyChord(Key.Number2, ModifierMask.Alt), InputAction.ToggleFloatingChatWindow2));
+ b.Add(new(new KeyChord(Key.Number3, ModifierMask.Alt), InputAction.ToggleFloatingChatWindow3));
+ b.Add(new(new KeyChord(Key.Number4, ModifierMask.Alt), InputAction.ToggleFloatingChatWindow4));
+ b.Add(new(new KeyChord(Key.R, ModifierMask.None), InputAction.UseSelected));
+ b.Add(new(new KeyChord(Key.Escape, ModifierMask.None), InputAction.EscapeKey));
+ b.Add(new(new KeyChord(Key.Escape, ModifierMask.Shift), InputAction.LOGOUT));
+
+ // ── QuickslotCommands ──────────────────────────────────
+ // Number1..9 → UseQuickSlot_1..9 with bare-mod and Ctrl-mod chords.
+ for (int i = 1; i <= 9; i++)
+ {
+ var k = (Key)((int)Key.Number0 + i); // Number1..Number9
+ var action = (InputAction)((int)InputAction.UseQuickSlot_1 + i - 1);
+ b.Add(new(new KeyChord(k, ModifierMask.None), action));
+ b.Add(new(new KeyChord(k, ModifierMask.Ctrl), action));
+ }
+ // Alt+5..9 → UseQuickSlot_14..18.
+ for (int i = 5; i <= 9; i++)
+ {
+ var k = (Key)((int)Key.Number0 + i);
+ var action = (InputAction)((int)InputAction.UseQuickSlot_14 + i - 5);
+ b.Add(new(new KeyChord(k, ModifierMask.Alt), action));
+ }
+ b.Add(new(new KeyChord(Key.Number0, ModifierMask.None), InputAction.CreateShortcut));
+ b.Add(new(new KeyChord(Key.Number0, ModifierMask.Ctrl), InputAction.CreateShortcut));
+
+ // ── Chat ────────────────────────────────────────────────
+ b.Add(new(new KeyChord(Key.Tab, ModifierMask.None), InputAction.ToggleChatEntry));
+ b.Add(new(new KeyChord(Key.Enter, ModifierMask.None), InputAction.EnterChatMode));
+
+ // ── Combat (mode-dependent — dormant in K, lights up in Phase L) ──
+ b.Add(new(new KeyChord(Key.GraveAccent, ModifierMask.None), InputAction.CombatToggleCombat));
+ // Melee mode (active when MeleeCombat scope pushed).
+ b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatDecreaseAttackPower));
+ b.Add(new(new KeyChord(Key.PageUp, ModifierMask.None), InputAction.CombatIncreaseAttackPower));
+ b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatLowAttack));
+ b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatMediumAttack));
+ b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatHighAttack));
+ // Missile + Magic + Spell-tab — same chords; resolved by scope at
+ // runtime per InputDispatcher's stack lookup. Add the bindings;
+ // subscribers arrive in Phase L when CombatState.CurrentMode is
+ // wired.
+ b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatDecreaseMissileAccuracy));
+ b.Add(new(new KeyChord(Key.PageUp, ModifierMask.None), InputAction.CombatIncreaseMissileAccuracy));
+ b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatAimLow));
+ b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatAimMedium));
+ b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatAimHigh));
+ b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatPrevSpellTab));
+ b.Add(new(new KeyChord(Key.PageUp, ModifierMask.None), InputAction.CombatNextSpellTab));
+ b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatPrevSpell));
+ b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatCastCurrentSpell));
+ b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatNextSpell));
+ b.Add(new(new KeyChord(Key.Insert, ModifierMask.Ctrl), InputAction.CombatFirstSpellTab));
+ b.Add(new(new KeyChord(Key.PageUp, ModifierMask.Ctrl), InputAction.CombatLastSpellTab));
+ b.Add(new(new KeyChord(Key.Delete, ModifierMask.Ctrl), InputAction.CombatFirstSpell));
+ b.Add(new(new KeyChord(Key.PageDown, ModifierMask.Ctrl), InputAction.CombatLastSpell));
+ for (int i = 1; i <= 9; i++)
+ {
+ var k = (Key)((int)Key.Number0 + i);
+ var action = (InputAction)((int)InputAction.UseSpellSlot_1 + i - 1);
+ b.Add(new(new KeyChord(k, ModifierMask.None), action));
+ }
+
+ // ── Emotes ──────────────────────────────────────────────
+ b.Add(new(new KeyChord(Key.U, ModifierMask.None), InputAction.Cry));
+ b.Add(new(new KeyChord(Key.I, ModifierMask.None), InputAction.Laugh));
+ b.Add(new(new KeyChord(Key.J, ModifierMask.None), InputAction.Wave));
+ b.Add(new(new KeyChord(Key.O, ModifierMask.None), InputAction.Cheer));
+ b.Add(new(new KeyChord(Key.K, ModifierMask.None), InputAction.PointState));
+
+ // ── Camera ─────────────────────────────────────────────
+ b.Add(new(new KeyChord(Key.KeypadDivide, ModifierMask.None), InputAction.CameraActivateAlternateMode));
+ b.Add(new(new KeyChord(Key.F2, ModifierMask.None), InputAction.CameraActivateAlternateMode));
+ // CameraInstantMouseLook (MMB hold) — encoded as a mouse chord
+ // via the K.1a Device=1 convention. K.2 lights up the actual
+ // camera+yaw drive logic.
+ b.Add(new(
+ new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Middle), ModifierMask.None, Device: 1),
+ InputAction.CameraInstantMouseLook,
+ ActivationType.Hold));
+ // Numpad cluster.
+ b.Add(new(new KeyChord(Key.Keypad4, ModifierMask.None), InputAction.CameraRotateLeft));
+ b.Add(new(new KeyChord(Key.Keypad6, ModifierMask.None), InputAction.CameraRotateRight));
+ b.Add(new(new KeyChord(Key.Keypad8, ModifierMask.None), InputAction.CameraRotateUp));
+ b.Add(new(new KeyChord(Key.Keypad2, ModifierMask.None), InputAction.CameraRotateDown));
+ b.Add(new(new KeyChord(Key.KeypadSubtract, ModifierMask.None), InputAction.CameraMoveToward));
+ b.Add(new(new KeyChord(Key.KeypadAdd, ModifierMask.None), InputAction.CameraMoveAway));
+ b.Add(new(new KeyChord(Key.Keypad0, ModifierMask.None), InputAction.CameraViewDefault));
+ b.Add(new(new KeyChord(Key.KeypadDecimal, ModifierMask.None), InputAction.CameraViewFirstPerson));
+ b.Add(new(new KeyChord(Key.Keypad5, ModifierMask.None), InputAction.CameraViewLookDown));
+ b.Add(new(new KeyChord(Key.KeypadEnter, ModifierMask.None), InputAction.CameraViewMapMode));
+
+ // ── Mouse selection ────────────────────────────────────
+ // Retail keymap: SelectLeft = LMB, SelectRight = RMB, SelectMid = MMB,
+ // and the doubles fire on MouseDblClick. Encoded as Device=1 chords;
+ // dispatcher resolves the mouse button via MouseButtonToKey.
+ b.Add(new(
+ new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Left), ModifierMask.None, Device: 1),
+ InputAction.SelectLeft));
+ b.Add(new(
+ new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Right), ModifierMask.None, Device: 1),
+ InputAction.SelectRight));
+ b.Add(new(
+ new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Middle), ModifierMask.None, Device: 1),
+ InputAction.SelectMid));
+ b.Add(new(
+ new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Left), ModifierMask.None, Device: 1),
+ InputAction.SelectDblLeft, ActivationType.DoubleClick));
+ b.Add(new(
+ new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Right), ModifierMask.None, Device: 1),
+ InputAction.SelectDblRight, ActivationType.DoubleClick));
+ b.Add(new(
+ new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Middle), ModifierMask.None, Device: 1),
+ InputAction.SelectDblMid, ActivationType.DoubleClick));
+
+ // ── Scrollable ─────────────────────────────────────────
+ // Mouse wheel → ScrollUp/Down handled by dispatcher's OnScroll path.
+ b.Add(new(new KeyChord(Key.Up, ModifierMask.Ctrl), InputAction.ScrollUp));
+ b.Add(new(new KeyChord(Key.Down, ModifierMask.Ctrl), InputAction.ScrollDown));
+
+ // ── Acdream debug actions: relocated to Ctrl+F* to avoid retail
+ // conflicts. AcdreamToggleFlyMode + AcdreamTogglePlayerMode have
+ // NO keyboard binding in retail-default; K.2 adds a DebugPanel
+ // button for free-fly toggle and player-mode is auto-entered at
+ // login. AcdreamRmbOrbitHold is intentionally absent — retail
+ // binds RMB to SelectRight; the chase-camera orbit lives behind
+ // a debug-mode flag in K.2.
+ b.Add(new(new KeyChord(Key.F1, ModifierMask.Ctrl), InputAction.AcdreamToggleDebugPanel));
+ b.Add(new(new KeyChord(Key.F2, ModifierMask.Ctrl), InputAction.AcdreamToggleCollisionWires));
+ b.Add(new(new KeyChord(Key.F3, ModifierMask.Ctrl), InputAction.AcdreamDumpNearby));
+ b.Add(new(new KeyChord(Key.F7, ModifierMask.Ctrl), InputAction.AcdreamCycleTimeOfDay));
+ b.Add(new(new KeyChord(Key.F8, ModifierMask.Ctrl), InputAction.AcdreamSensitivityDown));
+ b.Add(new(new KeyChord(Key.F9, ModifierMask.Ctrl), InputAction.AcdreamSensitivityUp));
+ b.Add(new(new KeyChord(Key.F10, ModifierMask.Ctrl), InputAction.AcdreamCycleWeather));
+
+ return b;
+ }
+
+ ///
+ /// Load bindings from . If the file doesn't
+ /// exist, returns . If the file exists
+ /// but is missing actions present in the current default set, those
+ /// missing actions get the default binding (merge-over-defaults).
+ /// On corrupt file: warns + returns RetailDefaults without writing
+ /// (don't blow away the user's file silently).
+ ///
+ public static KeyBindings LoadOrDefault(string path)
+ {
+ if (!File.Exists(path)) return RetailDefaults();
+ try
+ {
+ using var stream = File.OpenRead(path);
+ var doc = JsonDocument.Parse(stream);
+ var root = doc.RootElement;
+ // version is currently advisory — read so future migrations
+ // can branch on it; the field's presence is non-fatal.
+ _ = root.TryGetProperty("version", out var vEl) ? vEl.GetInt32() : 0;
+
+ var defaults = RetailDefaults();
+ var loaded = new KeyBindings();
+
+ if (root.TryGetProperty("actions", out var actionsEl)
+ && actionsEl.ValueKind == JsonValueKind.Object)
+ {
+ foreach (var actionProp in actionsEl.EnumerateObject())
+ {
+ if (!Enum.TryParse(actionProp.Name, out var action))
+ continue; // unknown action → skip
+ if (actionProp.Value.ValueKind != JsonValueKind.Array) continue;
+ foreach (var bindingEl in actionProp.Value.EnumerateArray())
+ {
+ if (!bindingEl.TryGetProperty("key", out var keyEl)) continue;
+ var key = keyEl.GetString();
+ if (key is null) continue;
+ if (!Enum.TryParse(key, out var silkKey))
+ continue; // unknown key → skip
+ var mods = ParseModifiers(bindingEl);
+ var activation = ActivationType.Press;
+ if (bindingEl.TryGetProperty("activation", out var actEl)
+ && actEl.ValueKind == JsonValueKind.String
+ && Enum.TryParse(actEl.GetString(), out var parsedAct))
+ {
+ activation = parsedAct;
+ }
+ byte device = 0;
+ if (bindingEl.TryGetProperty("device", out var dEl)
+ && dEl.ValueKind == JsonValueKind.Number)
+ {
+ device = (byte)dEl.GetInt32();
+ }
+ loaded.Add(new(new KeyChord(silkKey, mods, device), action, activation));
+ }
+ }
+ }
+
+ // Merge: any action that has bindings in defaults but NOT in
+ // loaded picks up the defaults. Preserves user customizations
+ // for actions they DID rebind, while not silently losing
+ // newly-added actions if the user file is older.
+ foreach (var actionInDefaults in Enum.GetValues())
+ {
+ if (!loaded.ForAction(actionInDefaults).Any()
+ && defaults.ForAction(actionInDefaults).Any())
+ {
+ foreach (var def in defaults.ForAction(actionInDefaults))
+ loaded.Add(def);
+ }
+ }
+
+ return loaded;
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"keybinds: failed to load {path}: {ex.Message} — using retail defaults");
+ return RetailDefaults();
+ }
+ }
+
+ ///
+ /// Persist this binding set to as JSON.
+ /// Format: { "version": N, "actions": { "ActionName": [ {key,mod,activation,device}... ] } }.
+ /// Sorted keys for deterministic diffs.
+ ///
+ public void SaveToFile(string path)
+ {
+ var dir = Path.GetDirectoryName(path);
+ if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
+
+ var actions = new SortedDictionary>(StringComparer.Ordinal);
+ foreach (var binding in _bindings)
+ {
+ if (!actions.TryGetValue(binding.Action.ToString(), out var list))
+ {
+ list = new List