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;
///
/// 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.
///
///
/// 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.
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];
}
///
/// 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 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));
b.Add(new(new KeyChord(Key.S, ModifierMask.Shift), InputAction.MovementBackup));
b.Add(new(new KeyChord(Key.A, ModifierMask.None), InputAction.MovementTurnLeft));
b.Add(new(new KeyChord(Key.A, ModifierMask.Shift), InputAction.MovementTurnLeft));
b.Add(new(new KeyChord(Key.D, ModifierMask.None), InputAction.MovementTurnRight));
b.Add(new(new KeyChord(Key.D, ModifierMask.Shift), InputAction.MovementTurnRight));
b.Add(new(new KeyChord(Key.Z, ModifierMask.None), InputAction.MovementStrafeLeft));
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));
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));
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));
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));
b.Add(new(new KeyChord(Key.Escape, ModifierMask.None), InputAction.EscapeKey));
b.Add(new(
new KeyChord(InputDispatcher.MouseButtonToKey(Silk.NET.Input.MouseButton.Right), ModifierMask.None, Device: 1),
InputAction.AcdreamRmbOrbitHold,
ActivationType.Hold));
return b;
}
///
/// 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.
///
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. K-fix1 (2026-04-26): the chord
// modifier MUST be Shift, not None — when LShift/RShift is the
// primary key the OS keyboard reports CurrentModifiers=Shift
// alongside the key-down. Bind both left + right shift to match.
// This is the same pattern AcdreamCurrentDefaults uses for its
// Shift→RunLock binding (see lines 98-99 above).
b.Add(new(new KeyChord(Key.ShiftLeft, ModifierMask.Shift), InputAction.MovementWalkMode, ActivationType.Hold));
b.Add(new(new KeyChord(Key.ShiftRight, ModifierMask.Shift), 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. AcdreamTogglePlayerMode has NO keyboard binding
// in retail-default (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));
// K-fix2 (2026-04-26): free-fly toggle keyboard shortcut.
// Retail leaves Ctrl+Shift+F unbound (retail F = SelectionPickUp,
// Ctrl+F = unused) so this is non-conflicting. Also discoverable
// via View → Camera in the ImGui MainMenuBar and the
// "Toggle Free-Fly Mode" button in the Debug panel.
b.Add(new(
new KeyChord(Key.F, ModifierMask.Ctrl | ModifierMask.Shift),
InputAction.AcdreamToggleFlyMode));
// K-fix1 (2026-04-26): RMB-hold camera orbit. Coexists with the
// SelectRight Press binding above — Press fires on click,
// AcdreamRmbOrbitHold fires on hold/release transitions so the
// chase camera can free-orbit while the user drags the mouse.
// Without this, RMB-orbit silently broke when K.1c flipped the
// default keymap from AcdreamCurrentDefaults to RetailDefaults.
b.Add(new(
new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Right), ModifierMask.None, Device: 1),
InputAction.AcdreamRmbOrbitHold,
ActivationType.Hold));
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