feat(input): #23 Phase K.1c - retail-faithful keymap cutover + JSON persistence (muscle memory change)
Single bisectable commit where the user-visible keyboard layout flips from acdream-current (W/S/A/D/Z/X) to canonical AC retail (W/X/A/D/Z/C). The InputDispatcher abstraction landed in K.1a, existing handlers cut over in K.1b, and now KeyBindings.RetailDefaults() returns the byte-precise retail preset matching docs/research/named-retail/retail-default.keymap.txt. Movement (matches AC1 muscle memory): - W/Up = MovementForward (run by default) - X/Down = MovementBackup - A/Left = MovementTurnLeft - D/Right = MovementTurnRight - Z = MovementStrafeLeft - C = MovementStrafeRight - Alt+A / Alt+Left = MovementStrafeLeft (Alt-flips-turn) - Alt+D / Alt+Right = MovementStrafeRight - LShift (Hold) = MovementWalkMode (default = run; held = walk) - Q = MovementRunLock (autorun toggle) - S = MovementStop (sets Ready stance / idle) - Space = MovementJump (hold to charge) - Y = Ready, G = Sitting, H = Crouch, B = Sleeping (postures) Selection / targeting (18 bindings on punctuation cluster): - F = SelectionPickUp, T = SelectionSplitStack, P = PreviousSelection - Backspace/Minus/Equals = closest/prev/next CompassItem - Backslash/[/] = closest/prev/next Item - Apostrophe/L/Semicolon = closest/prev/next Monster - Home = LastAttacker - Slash/Comma/Period = closest/prev/next Player - N/M = prev/next Fellow - E = SelectionExamine - R = UseSelected UI: - F1 = ToggleHelp; Shift+Ctrl+F1 = TogglePluginManager - F3 = Allegiance, F4 = Fellowship, F5 = Spellbook, F6 = SpellComponents - F8 = Attributes, F9 = Skills, F10 = World, F11 = Options (lights up the Settings panel in K.3), F12 = Inventory - Alt+1/2/3/4 = ToggleFloatingChatWindow1/2/3/4 - Esc = EscapeKey, Shift+Esc = LOGOUT - Numpad * = CaptureScreenshot Hotbar / spellbook: - 1-9 = UseQuickSlot_1..9 (hotbar) AND UseSpellSlot_1..9 (in MagicCombat scope - dormant until Phase L) - Ctrl+1-9 = UseQuickSlot_1..9 (duplicate) - Alt+5-9 = UseQuickSlot_14..18 (second bar) - 0 / Ctrl+0 = CreateShortcut Chat: - Tab = ToggleChatEntry (focus chat input; subscriber stub-TODO in K.2) - Return = EnterChatMode (send) Combat (mode-dependent, dormant - Phase L lights up): - Grave (`) = CombatToggleCombat - Insert/PgUp/Delete/End/PgDn = melee power+attack-level OR missile accuracy+aim-level OR magic spell-tab nav + cast (resolved by scope at runtime once CombatState.CurrentMode lands). - Ctrl+Insert/PgUp/Delete/PgDn = first/last spell tab + first/last spell Emotes: U = Cry, I = Laugh, J = Wave, O = Cheer, K = PointState Camera (numpad cluster + F2): - F2 / Numpad/ = CameraActivateAlternateMode - Numpad 4/6/8/2 = rotate left/right/up/down - Numpad - / + = move toward / away - Numpad 0 = ViewDefault, Numpad . = FirstPerson - Numpad 5 = LookDown, Numpad Enter = MapMode Scroll: - Mouse wheel handled by dispatcher OnScroll path - Ctrl+Up / Ctrl+Down = ScrollUp / ScrollDown Acdream debug actions relocated from F-keys to Ctrl+F-keys to avoid retail conflicts: - Ctrl+F1 = AcdreamToggleDebugPanel - Ctrl+F2 = AcdreamToggleCollisionWires - Ctrl+F3 = AcdreamDumpNearby - Ctrl+F7 = AcdreamCycleTimeOfDay - Ctrl+F8 / Ctrl+F9 = AcdreamSensitivityDown / Up - Ctrl+F10 = AcdreamCycleWeather AcdreamToggleFlyMode + AcdreamTogglePlayerMode have NO keyboard binding in retail-default. K.2 adds a DebugPanel button for fly toggle and auto-enter player mode at login. Total: 149 bindings. JSON load/save: - KeyBindings.LoadOrDefault(path): merge-over-defaults migration. Missing actions get default bindings; unknown actions in user file are skipped (preserves user customizations across action enum additions). Corrupt file warns + returns RetailDefaults without overwriting (don't blow away user's file silently). - KeyBindings.SaveToFile(path): writes with schema version=1, alpha- sorted action names, alpha-sorted modifier keys for stable diffs. - KeyBindings.DefaultPath() = %LOCALAPPDATA%/acdream/keybinds.json. GameWindow startup: - Replaces KeyBindings.AcdreamCurrentDefaults() call with KeyBindings.LoadOrDefault(KeyBindings.DefaultPath()) via a small LoadStartupKeyBindings() helper. - Logs "keybinds: loaded N bindings from <path>" so launch.log shows the source of truth at session start. Three deviations from plan: 1. LoadStartupKeyBindings() helper instead of inline initializer (field initializer can't call methods directly). 2. ToggleChatEntry subscriber is a no-op stub with TODO K.2 comment (ChatPanel doesn't expose FocusInput() yet; will add in K.2). 3. AcdreamRmbOrbitHold removed from RetailDefaults() to avoid double-binding RMB (SelectRight + RmbOrbitHold on the same chord would fire both subscribers). Chase-camera orbit will be replaced by MMB-hold mouse-look in K.2 - retail's CameraInstantMouseLook. 28 new tests: - KeyBindingsRetailTests: 19 cases pinning every retail mapping (W/X movement, Z/C strafe, Tab=ToggleChatEntry, Shift+Esc=LOGOUT, Shift+Ctrl+F1=TogglePluginManager, MovementWalkMode=Hold, Acdream debug on Ctrl+F*, hotbar number-row variants, etc). - KeyBindingsJsonTests: 9 cases (round-trip; missing file → defaults; corrupt → defaults + no-overwrite; merge-over-defaults; legacy version=0 parsing; Hold-activation preservation; unknown- action skip; DefaultPath shape). Solution total: 1162 green (243 Core.Net + 254 UI + 665 Core), 0 warnings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
256e9624bd
commit
da189103b8
5 changed files with 847 additions and 43 deletions
|
|
@ -412,8 +412,20 @@ public sealed class GameWindow : IDisposable
|
||||||
private AcDream.App.Input.SilkKeyboardSource? _kbSource;
|
private AcDream.App.Input.SilkKeyboardSource? _kbSource;
|
||||||
private AcDream.App.Input.SilkMouseSource? _mouseSource;
|
private AcDream.App.Input.SilkMouseSource? _mouseSource;
|
||||||
private AcDream.UI.Abstractions.Input.InputDispatcher? _inputDispatcher;
|
private AcDream.UI.Abstractions.Input.InputDispatcher? _inputDispatcher;
|
||||||
private readonly AcDream.UI.Abstractions.Input.KeyBindings _keyBindings =
|
// K.1c: load user-customized bindings from %LOCALAPPDATA%\acdream\keybinds.json,
|
||||||
AcDream.UI.Abstractions.Input.KeyBindings.RetailDefaults();
|
// 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
|
// Phase 4.7: optional live connection to an ACE server. Enabled only when
|
||||||
// ACDREAM_LIVE=1 is in the environment — fully backward compatible with
|
// ACDREAM_LIVE=1 is in the environment — fully backward compatible with
|
||||||
|
|
@ -5018,6 +5030,15 @@ public sealed class GameWindow : IDisposable
|
||||||
TogglePlayerMode();
|
TogglePlayerMode();
|
||||||
break;
|
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:
|
case AcDream.UI.Abstractions.Input.InputAction.EscapeKey:
|
||||||
if (_cameraController?.IsFlyMode == true)
|
if (_cameraController?.IsFlyMode == true)
|
||||||
_cameraController.ToggleFly(); // exit fly, release cursor
|
_cameraController.ToggleFly(); // exit fly, release cursor
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,8 @@
|
||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text.Json;
|
||||||
using Silk.NET.Input;
|
using Silk.NET.Input;
|
||||||
|
|
||||||
namespace AcDream.UI.Abstractions.Input;
|
namespace AcDream.UI.Abstractions.Input;
|
||||||
|
|
@ -11,15 +15,19 @@ namespace AcDream.UI.Abstractions.Input;
|
||||||
/// without removing the default.
|
/// without removing the default.
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <para>
|
||||||
/// JSON IO + the real <see cref="RetailDefaults"/> preset land in K.1c.
|
/// K.1c: <see cref="RetailDefaults"/> now returns the full retail-faithful
|
||||||
/// For K.1a, <see cref="RetailDefaults"/> proxies
|
/// preset (byte-precise to <c>retail-default.keymap.txt</c>);
|
||||||
/// <see cref="AcdreamCurrentDefaults"/> so the bindings table matches
|
/// <see cref="LoadOrDefault"/> + <see cref="SaveToFile"/> persist the
|
||||||
/// today's hard-coded chords — no behavior change during the K.1a/K.1b
|
/// table to JSON under <see cref="DefaultPath"/> with version-based
|
||||||
/// cutover.
|
/// migration. <see cref="AcdreamCurrentDefaults"/> is preserved as a
|
||||||
|
/// reference for tests pinning the older WASD-only behavior, but is no
|
||||||
|
/// longer the GameWindow startup source.
|
||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class KeyBindings
|
public sealed class KeyBindings
|
||||||
{
|
{
|
||||||
|
private const int CurrentSchemaVersion = 1;
|
||||||
|
|
||||||
private readonly List<Binding> _bindings = new();
|
private readonly List<Binding> _bindings = new();
|
||||||
|
|
||||||
/// <summary>All bindings in insertion order.</summary>
|
/// <summary>All bindings in insertion order.</summary>
|
||||||
|
|
@ -59,26 +67,22 @@ public sealed class KeyBindings
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// K.1a stub: returns CURRENT acdream binds (W=fwd, S=back, A/D=turn,
|
/// Acdream-current keymap (W=fwd, S=back, A/D=turn, Z/X=strafe,
|
||||||
/// Z/X=strafe, Shift=run, Tab=toggle player↔fly mode, F1=DebugPanel,
|
/// Shift=run, Tab=toggle player↔fly mode, F1=DebugPanel, F2=collision
|
||||||
/// F2=collision wires, F3=dump, F7=cycle time, F8/F9=sensitivity,
|
/// wires, F3=dump, F7=cycle time, F8/F9=sensitivity, F10=cycle
|
||||||
/// F10=cycle weather, F=fly toggle, Space=jump). NOT the retail
|
/// weather, F=fly toggle, Space=jump). Preserved as a regression
|
||||||
/// preset — that lands in K.1c. This stub keeps behavior unchanged
|
/// anchor for tests that pin the older modifier-blind WASD layout —
|
||||||
/// during K.1a/K.1b cutover.
|
/// NOT the GameWindow startup source after K.1c.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static KeyBindings AcdreamCurrentDefaults()
|
public static KeyBindings AcdreamCurrentDefaults()
|
||||||
{
|
{
|
||||||
var b = new KeyBindings();
|
var b = new KeyBindings();
|
||||||
|
|
||||||
// Movement (current acdream — wrong for retail but unchanged for K.1a/K.1b).
|
// Movement (current acdream — wrong for retail but preserved for
|
||||||
//
|
// legacy tests). Each movement key gets BOTH a bare-mods chord
|
||||||
// Each movement key gets BOTH a bare-mods chord and a Shift-mods chord
|
// and a Shift-mods chord so the per-frame IsActionHeld query
|
||||||
// so the per-frame IsActionHeld query stays true while the user holds
|
// stays true while the user holds Shift (the acdream-current
|
||||||
// Shift (the acdream-current run-modifier). Before K.1b, movement
|
// run-modifier).
|
||||||
// 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.
|
|
||||||
b.Add(new(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward));
|
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.W, ModifierMask.Shift), InputAction.MovementForward));
|
||||||
b.Add(new(new KeyChord(Key.S, ModifierMask.None), InputAction.MovementBackup));
|
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.Z, ModifierMask.Shift), InputAction.MovementStrafeLeft));
|
||||||
b.Add(new(new KeyChord(Key.X, ModifierMask.None), InputAction.MovementStrafeRight));
|
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.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.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.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.None), InputAction.MovementJump));
|
||||||
b.Add(new(new KeyChord(Key.Space, ModifierMask.Shift), 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.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.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.F1, ModifierMask.None), InputAction.AcdreamToggleDebugPanel));
|
||||||
b.Add(new(new KeyChord(Key.F2, ModifierMask.None), InputAction.AcdreamToggleCollisionWires));
|
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.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.Tab, ModifierMask.None), InputAction.AcdreamTogglePlayerMode));
|
||||||
b.Add(new(new KeyChord(Key.Escape, ModifierMask.None), InputAction.EscapeKey));
|
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(
|
b.Add(new(
|
||||||
new KeyChord(InputDispatcher.MouseButtonToKey(Silk.NET.Input.MouseButton.Right), ModifierMask.None, Device: 1),
|
new KeyChord(InputDispatcher.MouseButtonToKey(Silk.NET.Input.MouseButton.Right), ModifierMask.None, Device: 1),
|
||||||
InputAction.AcdreamRmbOrbitHold,
|
InputAction.AcdreamRmbOrbitHold,
|
||||||
|
|
@ -130,10 +123,361 @@ public sealed class KeyBindings
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// K.1c will replace this with the retail-faithful preset built from
|
/// Canonical AC retail-default keymap, byte-precise to
|
||||||
/// <c>docs/research/named-retail/retail-default.keymap.txt</c>.
|
/// <c>docs/research/named-retail/retail-default.keymap.txt</c> (the
|
||||||
/// For K.1a, returns the acdream-current map so behavior is unchanged.
|
/// user's Throne of Destiny <c>test.keymap</c>). All bindings
|
||||||
|
/// categorized by the <c>Bindings [...]</c> 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.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
// TODO K.1c: replace with full retail preset + JSON LoadOrDefault.
|
public static KeyBindings RetailDefaults()
|
||||||
public static KeyBindings RetailDefaults() => AcdreamCurrentDefaults();
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Load bindings from <paramref name="path"/>. If the file doesn't
|
||||||
|
/// exist, returns <see cref="RetailDefaults"/>. 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).
|
||||||
|
/// </summary>
|
||||||
|
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<InputAction>(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>(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<ActivationType>(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<InputAction>())
|
||||||
|
{
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persist this binding set to <paramref name="path"/> as JSON.
|
||||||
|
/// Format: <c>{ "version": N, "actions": { "ActionName": [ {key,mod,activation,device}... ] } }</c>.
|
||||||
|
/// Sorted keys for deterministic diffs.
|
||||||
|
/// </summary>
|
||||||
|
public void SaveToFile(string path)
|
||||||
|
{
|
||||||
|
var dir = Path.GetDirectoryName(path);
|
||||||
|
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
|
||||||
|
|
||||||
|
var actions = new SortedDictionary<string, List<object>>(StringComparer.Ordinal);
|
||||||
|
foreach (var binding in _bindings)
|
||||||
|
{
|
||||||
|
if (!actions.TryGetValue(binding.Action.ToString(), out var list))
|
||||||
|
{
|
||||||
|
list = new List<object>();
|
||||||
|
actions[binding.Action.ToString()] = list;
|
||||||
|
}
|
||||||
|
var entry = new SortedDictionary<string, object>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["key"] = binding.Chord.Key.ToString(),
|
||||||
|
};
|
||||||
|
if (binding.Chord.Modifiers != ModifierMask.None)
|
||||||
|
entry["mod"] = binding.Chord.Modifiers.ToString();
|
||||||
|
if (binding.Chord.Device != 0)
|
||||||
|
entry["device"] = (int)binding.Chord.Device;
|
||||||
|
if (binding.Activation != ActivationType.Press)
|
||||||
|
entry["activation"] = binding.Activation.ToString();
|
||||||
|
list.Add(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
var root = new SortedDictionary<string, object>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["version"] = CurrentSchemaVersion,
|
||||||
|
["actions"] = actions,
|
||||||
|
};
|
||||||
|
var json = JsonSerializer.Serialize(root, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
File.WriteAllText(path, json);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ModifierMask ParseModifiers(JsonElement bindingEl)
|
||||||
|
{
|
||||||
|
if (!bindingEl.TryGetProperty("mod", out var modEl)) return ModifierMask.None;
|
||||||
|
if (modEl.ValueKind != JsonValueKind.String) return ModifierMask.None;
|
||||||
|
var modString = modEl.GetString();
|
||||||
|
if (string.IsNullOrEmpty(modString)) return ModifierMask.None;
|
||||||
|
var result = ModifierMask.None;
|
||||||
|
foreach (var part in modString.Split(
|
||||||
|
new[] { '|', ',', ' ' },
|
||||||
|
StringSplitOptions.RemoveEmptyEntries))
|
||||||
|
{
|
||||||
|
if (Enum.TryParse<ModifierMask>(part, ignoreCase: true, out var single))
|
||||||
|
result |= single;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Default path: <c>%LOCALAPPDATA%\acdream\keybinds.json</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static string DefaultPath() => Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"acdream",
|
||||||
|
"keybinds.json");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,234 @@
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using AcDream.UI.Abstractions.Input;
|
||||||
|
using Silk.NET.Input;
|
||||||
|
|
||||||
|
namespace AcDream.UI.Abstractions.Tests.Input;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// K.1c: <see cref="KeyBindings.LoadOrDefault"/> /
|
||||||
|
/// <see cref="KeyBindings.SaveToFile"/> round-trip + version migration.
|
||||||
|
/// Pins schema-stable behavior so future K.1d / Phase L panel-binding
|
||||||
|
/// edits don't accidentally break existing user keymap files.
|
||||||
|
/// </summary>
|
||||||
|
public class KeyBindingsJsonTests
|
||||||
|
{
|
||||||
|
private static string TempFile(string suffix = ".json")
|
||||||
|
{
|
||||||
|
var f = Path.Combine(Path.GetTempPath(),
|
||||||
|
$"acdream_kb_test_{System.Guid.NewGuid():N}{suffix}");
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadOrDefault_missing_file_returns_RetailDefaults()
|
||||||
|
{
|
||||||
|
var path = TempFile();
|
||||||
|
// No file written.
|
||||||
|
var loaded = KeyBindings.LoadOrDefault(path);
|
||||||
|
var defaults = KeyBindings.RetailDefaults();
|
||||||
|
Assert.Equal(defaults.All.Count, loaded.All.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Roundtrip_preserves_every_binding()
|
||||||
|
{
|
||||||
|
var path = TempFile();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var original = KeyBindings.RetailDefaults();
|
||||||
|
original.SaveToFile(path);
|
||||||
|
|
||||||
|
var loaded = KeyBindings.LoadOrDefault(path);
|
||||||
|
|
||||||
|
Assert.Equal(original.All.Count, loaded.All.Count);
|
||||||
|
// Every binding from the original must round-trip with structural
|
||||||
|
// equality (chord + action + activation).
|
||||||
|
foreach (var b in original.All)
|
||||||
|
{
|
||||||
|
Assert.Contains(loaded.All, x =>
|
||||||
|
x.Chord == b.Chord
|
||||||
|
&& x.Action == b.Action
|
||||||
|
&& x.Activation == b.Activation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (File.Exists(path)) File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadOrDefault_corrupt_file_returns_RetailDefaults()
|
||||||
|
{
|
||||||
|
var path = TempFile();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllText(path, "{ this is not valid JSON :: garbage");
|
||||||
|
|
||||||
|
var loaded = KeyBindings.LoadOrDefault(path);
|
||||||
|
var defaults = KeyBindings.RetailDefaults();
|
||||||
|
Assert.Equal(defaults.All.Count, loaded.All.Count);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (File.Exists(path)) File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadOrDefault_corrupt_file_does_NOT_overwrite_user_file()
|
||||||
|
{
|
||||||
|
var path = TempFile();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const string corrupt = "{ this is not valid JSON :: garbage";
|
||||||
|
File.WriteAllText(path, corrupt);
|
||||||
|
|
||||||
|
_ = KeyBindings.LoadOrDefault(path);
|
||||||
|
|
||||||
|
// The corrupt file must remain untouched on disk so the user
|
||||||
|
// can recover or hand-edit. We never silently blow it away.
|
||||||
|
Assert.Equal(corrupt, File.ReadAllText(path));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (File.Exists(path)) File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Custom_binding_for_one_action_keeps_defaults_for_others()
|
||||||
|
{
|
||||||
|
var path = TempFile();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// User customizes ONE action — replace MovementForward with Q.
|
||||||
|
var custom = new KeyBindings();
|
||||||
|
custom.Add(new(new KeyChord(Key.Q, ModifierMask.None), InputAction.MovementForward));
|
||||||
|
custom.SaveToFile(path);
|
||||||
|
|
||||||
|
var loaded = KeyBindings.LoadOrDefault(path);
|
||||||
|
|
||||||
|
// Custom: MovementForward → Q (only — the user's file
|
||||||
|
// overrode all defaults for that action).
|
||||||
|
var fwd = loaded.ForAction(InputAction.MovementForward).ToList();
|
||||||
|
Assert.Single(fwd);
|
||||||
|
Assert.Equal(new KeyChord(Key.Q, ModifierMask.None), fwd[0].Chord);
|
||||||
|
|
||||||
|
// Default-merged: MovementBackup still has its retail bindings
|
||||||
|
// because the user file didn't customize it.
|
||||||
|
var back = loaded.ForAction(InputAction.MovementBackup).ToList();
|
||||||
|
Assert.Contains(back, x => x.Chord == new KeyChord(Key.X, ModifierMask.None));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (File.Exists(path)) File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadOrDefault_handles_version_zero_legacy_file()
|
||||||
|
{
|
||||||
|
// A pretend "old schema" file with version=0 + just one action.
|
||||||
|
// Should still parse — unknown fields are ignored, missing
|
||||||
|
// actions get default-merged.
|
||||||
|
var path = TempFile();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const string legacyJson = """
|
||||||
|
{
|
||||||
|
"version": 0,
|
||||||
|
"actions": {
|
||||||
|
"MovementForward": [
|
||||||
|
{ "key": "W", "mod": "None" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
File.WriteAllText(path, legacyJson);
|
||||||
|
|
||||||
|
var loaded = KeyBindings.LoadOrDefault(path);
|
||||||
|
|
||||||
|
// The custom binding survives.
|
||||||
|
var fwd = loaded.ForAction(InputAction.MovementForward).ToList();
|
||||||
|
Assert.Single(fwd);
|
||||||
|
|
||||||
|
// Default-merged: the user file didn't define MovementBackup
|
||||||
|
// so it gets the retail default chords.
|
||||||
|
var back = loaded.ForAction(InputAction.MovementBackup).ToList();
|
||||||
|
Assert.NotEmpty(back);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (File.Exists(path)) File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadOrDefault_preserves_Hold_activation()
|
||||||
|
{
|
||||||
|
var path = TempFile();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var original = new KeyBindings();
|
||||||
|
original.Add(new(
|
||||||
|
new KeyChord(Key.ShiftLeft, ModifierMask.None),
|
||||||
|
InputAction.MovementWalkMode,
|
||||||
|
ActivationType.Hold));
|
||||||
|
original.SaveToFile(path);
|
||||||
|
|
||||||
|
var loaded = KeyBindings.LoadOrDefault(path);
|
||||||
|
var binds = loaded.ForAction(InputAction.MovementWalkMode).ToList();
|
||||||
|
// The user binding is preserved with Hold activation.
|
||||||
|
Assert.Contains(binds, x =>
|
||||||
|
x.Chord.Key == Key.ShiftLeft
|
||||||
|
&& x.Activation == ActivationType.Hold);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (File.Exists(path)) File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LoadOrDefault_unknown_action_name_skipped_silently()
|
||||||
|
{
|
||||||
|
var path = TempFile();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const string json = """
|
||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"actions": {
|
||||||
|
"NotARealActionName": [
|
||||||
|
{ "key": "W", "mod": "None" }
|
||||||
|
],
|
||||||
|
"MovementForward": [
|
||||||
|
{ "key": "Q", "mod": "None" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
File.WriteAllText(path, json);
|
||||||
|
|
||||||
|
var loaded = KeyBindings.LoadOrDefault(path);
|
||||||
|
// The known action loaded; unknown action skipped without
|
||||||
|
// throwing.
|
||||||
|
var fwd = loaded.ForAction(InputAction.MovementForward).ToList();
|
||||||
|
Assert.Contains(fwd, x => x.Chord.Key == Key.Q);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (File.Exists(path)) File.Delete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DefaultPath_lives_under_LocalAppData_acdream()
|
||||||
|
{
|
||||||
|
var path = KeyBindings.DefaultPath();
|
||||||
|
Assert.Contains("acdream", path);
|
||||||
|
Assert.EndsWith("keybinds.json", path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,200 @@
|
||||||
|
using System.Linq;
|
||||||
|
using AcDream.UI.Abstractions.Input;
|
||||||
|
using Silk.NET.Input;
|
||||||
|
|
||||||
|
namespace AcDream.UI.Abstractions.Tests.Input;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// K.1c: <see cref="KeyBindings.RetailDefaults"/> now returns the full
|
||||||
|
/// retail-faithful preset, byte-precise to
|
||||||
|
/// <c>docs/research/named-retail/retail-default.keymap.txt</c>. These
|
||||||
|
/// tests pin the canonical mappings the user-visible keyboard layout
|
||||||
|
/// shifts to (W/X movement instead of W/S, Z/C strafe instead of Z/X,
|
||||||
|
/// Tab → ToggleChatEntry instead of fly-mode toggle, etc).
|
||||||
|
/// </summary>
|
||||||
|
public class KeyBindingsRetailTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void MovementForward_bound_to_W_and_Up()
|
||||||
|
{
|
||||||
|
var b = KeyBindings.RetailDefaults();
|
||||||
|
var binds = b.ForAction(InputAction.MovementForward).ToList();
|
||||||
|
Assert.Contains(binds, x => x.Chord == new KeyChord(Key.W, ModifierMask.None));
|
||||||
|
Assert.Contains(binds, x => x.Chord == new KeyChord(Key.Up, ModifierMask.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MovementBackup_bound_to_X_and_Down_NOT_S()
|
||||||
|
{
|
||||||
|
var b = KeyBindings.RetailDefaults();
|
||||||
|
var binds = b.ForAction(InputAction.MovementBackup).ToList();
|
||||||
|
Assert.Contains(binds, x => x.Chord == new KeyChord(Key.X, ModifierMask.None));
|
||||||
|
Assert.Contains(binds, x => x.Chord == new KeyChord(Key.Down, ModifierMask.None));
|
||||||
|
// Retail: S is NOT MovementBackup. S → MovementStop.
|
||||||
|
Assert.DoesNotContain(binds, x => x.Chord.Key == Key.S);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void S_key_is_MovementStop_in_retail()
|
||||||
|
{
|
||||||
|
var b = KeyBindings.RetailDefaults();
|
||||||
|
var stop = b.Find(new KeyChord(Key.S, ModifierMask.None), ActivationType.Press);
|
||||||
|
Assert.NotNull(stop);
|
||||||
|
Assert.Equal(InputAction.MovementStop, stop!.Value.Action);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MovementStrafeLeft_bound_to_Z_and_AltA_and_AltLeft()
|
||||||
|
{
|
||||||
|
var b = KeyBindings.RetailDefaults();
|
||||||
|
var binds = b.ForAction(InputAction.MovementStrafeLeft).ToList();
|
||||||
|
Assert.Contains(binds, x => x.Chord == new KeyChord(Key.Z, ModifierMask.None));
|
||||||
|
Assert.Contains(binds, x => x.Chord == new KeyChord(Key.A, ModifierMask.Alt));
|
||||||
|
Assert.Contains(binds, x => x.Chord == new KeyChord(Key.Left, ModifierMask.Alt));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MovementStrafeRight_bound_to_C_and_AltD_and_AltRight()
|
||||||
|
{
|
||||||
|
var b = KeyBindings.RetailDefaults();
|
||||||
|
var binds = b.ForAction(InputAction.MovementStrafeRight).ToList();
|
||||||
|
Assert.Contains(binds, x => x.Chord == new KeyChord(Key.C, ModifierMask.None));
|
||||||
|
Assert.Contains(binds, x => x.Chord == new KeyChord(Key.D, ModifierMask.Alt));
|
||||||
|
Assert.Contains(binds, x => x.Chord == new KeyChord(Key.Right, ModifierMask.Alt));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Sleeping_bound_to_B()
|
||||||
|
{
|
||||||
|
var b = KeyBindings.RetailDefaults();
|
||||||
|
Assert.Contains(b.ForAction(InputAction.Sleeping),
|
||||||
|
x => x.Chord == new KeyChord(Key.B, ModifierMask.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MovementWalkMode_uses_Hold_activation()
|
||||||
|
{
|
||||||
|
var b = KeyBindings.RetailDefaults();
|
||||||
|
var binds = b.ForAction(InputAction.MovementWalkMode).ToList();
|
||||||
|
Assert.NotEmpty(binds);
|
||||||
|
Assert.All(binds, x => Assert.Equal(ActivationType.Hold, x.Activation));
|
||||||
|
Assert.Contains(binds, x => x.Chord.Key == Key.ShiftLeft);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToggleChatEntry_bound_to_Tab_NOT_fly_toggle()
|
||||||
|
{
|
||||||
|
var b = KeyBindings.RetailDefaults();
|
||||||
|
var hit = b.Find(new KeyChord(Key.Tab, ModifierMask.None), ActivationType.Press);
|
||||||
|
Assert.NotNull(hit);
|
||||||
|
Assert.Equal(InputAction.ToggleChatEntry, hit!.Value.Action);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LOGOUT_is_Shift_Escape()
|
||||||
|
{
|
||||||
|
var b = KeyBindings.RetailDefaults();
|
||||||
|
var hit = b.Find(new KeyChord(Key.Escape, ModifierMask.Shift), ActivationType.Press);
|
||||||
|
Assert.NotNull(hit);
|
||||||
|
Assert.Equal(InputAction.LOGOUT, hit!.Value.Action);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void EscapeKey_is_bare_Escape()
|
||||||
|
{
|
||||||
|
var b = KeyBindings.RetailDefaults();
|
||||||
|
var hit = b.Find(new KeyChord(Key.Escape, ModifierMask.None), ActivationType.Press);
|
||||||
|
Assert.NotNull(hit);
|
||||||
|
Assert.Equal(InputAction.EscapeKey, hit!.Value.Action);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UseQuickSlot_5_bound_to_Number5_with_and_without_Ctrl()
|
||||||
|
{
|
||||||
|
var b = KeyBindings.RetailDefaults();
|
||||||
|
var binds = b.ForAction(InputAction.UseQuickSlot_5).ToList();
|
||||||
|
Assert.Contains(binds, x => x.Chord == new KeyChord(Key.Number5, ModifierMask.None));
|
||||||
|
Assert.Contains(binds, x => x.Chord == new KeyChord(Key.Number5, ModifierMask.Ctrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UseQuickSlot_18_bound_to_Alt_Number9()
|
||||||
|
{
|
||||||
|
var b = KeyBindings.RetailDefaults();
|
||||||
|
var binds = b.ForAction(InputAction.UseQuickSlot_18).ToList();
|
||||||
|
Assert.Contains(binds, x => x.Chord == new KeyChord(Key.Number9, ModifierMask.Alt));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void TogglePluginManager_is_Shift_Ctrl_F1()
|
||||||
|
{
|
||||||
|
var b = KeyBindings.RetailDefaults();
|
||||||
|
var binds = b.ForAction(InputAction.TogglePluginManager).ToList();
|
||||||
|
Assert.Contains(binds, x =>
|
||||||
|
x.Chord.Key == Key.F1
|
||||||
|
&& x.Chord.Modifiers.HasFlag(ModifierMask.Shift)
|
||||||
|
&& x.Chord.Modifiers.HasFlag(ModifierMask.Ctrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Acdream_debug_actions_relocated_to_Ctrl_F_keys()
|
||||||
|
{
|
||||||
|
var b = KeyBindings.RetailDefaults();
|
||||||
|
Assert.Contains(b.ForAction(InputAction.AcdreamToggleDebugPanel),
|
||||||
|
x => x.Chord == new KeyChord(Key.F1, ModifierMask.Ctrl));
|
||||||
|
Assert.Contains(b.ForAction(InputAction.AcdreamToggleCollisionWires),
|
||||||
|
x => x.Chord == new KeyChord(Key.F2, ModifierMask.Ctrl));
|
||||||
|
Assert.Contains(b.ForAction(InputAction.AcdreamDumpNearby),
|
||||||
|
x => x.Chord == new KeyChord(Key.F3, ModifierMask.Ctrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToggleHelp_is_bare_F1_NOT_acdream_debug()
|
||||||
|
{
|
||||||
|
var b = KeyBindings.RetailDefaults();
|
||||||
|
var hit = b.Find(new KeyChord(Key.F1, ModifierMask.None), ActivationType.Press);
|
||||||
|
Assert.NotNull(hit);
|
||||||
|
Assert.Equal(InputAction.ToggleHelp, hit!.Value.Action);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Total_binding_count_is_at_least_80()
|
||||||
|
{
|
||||||
|
var b = KeyBindings.RetailDefaults();
|
||||||
|
Assert.True(b.All.Count >= 80,
|
||||||
|
$"Expected at least 80 bindings, got {b.All.Count}");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Cry_emote_bound_to_U()
|
||||||
|
{
|
||||||
|
var b = KeyBindings.RetailDefaults();
|
||||||
|
Assert.Contains(b.ForAction(InputAction.Cry),
|
||||||
|
x => x.Chord == new KeyChord(Key.U, ModifierMask.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CombatToggleCombat_bound_to_GraveAccent()
|
||||||
|
{
|
||||||
|
var b = KeyBindings.RetailDefaults();
|
||||||
|
Assert.Contains(b.ForAction(InputAction.CombatToggleCombat),
|
||||||
|
x => x.Chord == new KeyChord(Key.GraveAccent, ModifierMask.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CameraActivateAlternateMode_bound_to_F2_and_NumpadDivide()
|
||||||
|
{
|
||||||
|
var b = KeyBindings.RetailDefaults();
|
||||||
|
var binds = b.ForAction(InputAction.CameraActivateAlternateMode).ToList();
|
||||||
|
Assert.Contains(binds, x => x.Chord == new KeyChord(Key.F2, ModifierMask.None));
|
||||||
|
Assert.Contains(binds, x => x.Chord == new KeyChord(Key.KeypadDivide, ModifierMask.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ScrollUp_bound_to_Ctrl_Up()
|
||||||
|
{
|
||||||
|
var b = KeyBindings.RetailDefaults();
|
||||||
|
Assert.Contains(b.ForAction(InputAction.ScrollUp),
|
||||||
|
x => x.Chord == new KeyChord(Key.Up, ModifierMask.Ctrl));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -97,13 +97,18 @@ public class KeyBindingsTests
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void RetailDefaults_proxies_AcdreamCurrentDefaults_in_K1a()
|
public void RetailDefaults_diverges_from_AcdreamCurrentDefaults_in_K1c()
|
||||||
{
|
{
|
||||||
// K.1a stub — RetailDefaults() returns the acdream-current binds so
|
// K.1c flips RetailDefaults() to the retail-faithful preset.
|
||||||
// K.1b cutover doesn't change behavior. K.1c flips this to the
|
// The acdream-current map remains accessible (for tests pinning
|
||||||
// retail preset.
|
// the older WASD-only behavior), but RetailDefaults is now the
|
||||||
|
// canonical startup source. The two MUST differ — at minimum
|
||||||
|
// RetailDefaults binds X to Backup (retail) where AcdreamCurrent
|
||||||
|
// bound X to StrafeRight, and RetailDefaults binds Tab to
|
||||||
|
// ToggleChatEntry where AcdreamCurrent bound Tab to
|
||||||
|
// AcdreamTogglePlayerMode.
|
||||||
var retail = KeyBindings.RetailDefaults();
|
var retail = KeyBindings.RetailDefaults();
|
||||||
var current = KeyBindings.AcdreamCurrentDefaults();
|
var current = KeyBindings.AcdreamCurrentDefaults();
|
||||||
Assert.Equal(current.All.Count, retail.All.Count);
|
Assert.NotEqual(current.All.Count, retail.All.Count);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue