Two issues from the K-fix1 launch (2026-04-26 user report):
1. Mouse pointer invisible after login.
Root cause: CameraController.EnterChaseMode invokes
ModeChanged?.Invoke(IsChaseMode) — passing TRUE when chase
becomes active. The OnCameraModeChanged handler interpreted
that bool as `isFlyMode`, so chase entry wrongly triggered
the Raw cursor branch (raw = invisible pointer). The bool is
unreliable: ToggleFly passes IsFlyMode, ExitChaseMode passes
IsFlyMode, but EnterChaseMode passes IsChaseMode. Read the
controller state directly inside the handler instead — fly
mode IS the only state that needs Raw, everything else stays
Normal so the user can click panels / future selectables.
2. No way to enter free-fly mode.
The DebugPanel already had a "Toggle Free-Fly Mode" button
wired in K.2, but the user didn't know to look there. Added
two more discovery paths:
- Keyboard shortcut: Ctrl+Shift+F → AcdreamToggleFlyMode
in RetailDefaults() (retail leaves Ctrl+Shift+F unbound;
Ctrl+F is unused too, so this is conflict-free).
- View → Camera submenu in the ImGui MainMenuBar with a
"Enter / Exit Free-Fly Mode" entry whose label flips with
the active state. Shortcut hint shows "Ctrl+Shift+F".
The keyboard handler now also cancels _playerModeAutoEntry on
manual fly toggle (matches the DebugPanel button + new menu
entry — user's choice wins, the chase camera doesn't snap on
top of the fly camera mid-inspection).
Also corrected the View → Debug menu shortcut hint (was "F1",
actual binding is Ctrl+F1 since K.1c).
Tests still 1220 green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
508 lines
31 KiB
C#
508 lines
31 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Mutable collection of <see cref="Binding"/>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.
|
|
///
|
|
/// <para>
|
|
/// K.1c: <see cref="RetailDefaults"/> now returns the full retail-faithful
|
|
/// preset (byte-precise to <c>retail-default.keymap.txt</c>);
|
|
/// <see cref="LoadOrDefault"/> + <see cref="SaveToFile"/> persist the
|
|
/// table to JSON under <see cref="DefaultPath"/> with version-based
|
|
/// 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>
|
|
/// </summary>
|
|
public sealed class KeyBindings
|
|
{
|
|
private const int CurrentSchemaVersion = 1;
|
|
|
|
private readonly List<Binding> _bindings = new();
|
|
|
|
/// <summary>All bindings in insertion order.</summary>
|
|
public IReadOnlyList<Binding> All => _bindings;
|
|
|
|
/// <summary>Append a binding. Duplicates are allowed; first match wins.</summary>
|
|
public void Add(Binding b) => _bindings.Add(b);
|
|
|
|
/// <summary>Remove the first occurrence of the given binding (structural equality).</summary>
|
|
public bool Remove(Binding b) => _bindings.Remove(b);
|
|
|
|
/// <summary>Drop every binding.</summary>
|
|
public void Clear() => _bindings.Clear();
|
|
|
|
/// <summary>
|
|
/// First binding (in insertion order) whose chord matches AND whose
|
|
/// activation type matches the requested phase. Null if none.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// All bindings for a given action (one action can have multiple keys
|
|
/// — retail's W and Up-arrow both → MovementForward).
|
|
/// </summary>
|
|
public IEnumerable<Binding> ForAction(InputAction action)
|
|
{
|
|
for (int i = 0; i < _bindings.Count; i++)
|
|
if (_bindings[i].Action == action) yield return _bindings[i];
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Canonical AC retail-default keymap, byte-precise to
|
|
/// <c>docs/research/named-retail/retail-default.keymap.txt</c> (the
|
|
/// 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>
|
|
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;
|
|
}
|
|
|
|
/// <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");
|
|
}
|