fix(client): restore retail interaction parity
All checks were successful
CI / linux-portable (push) Successful in 3m27s
CI / windows-gate (push) Successful in 6m42s
CI / release (push) Successful in 2m12s

Harden keyboard and camera routing, inventory and vendor interactions, chat/emotes, relog portal flow, and paperdoll rendering. Add retail research, connected gate coverage, and release-gate validation.
This commit is contained in:
Erik 2026-08-26 20:45:11 +02:00
parent 0c699240e0
commit f6fe0f2a4f
151 changed files with 10162 additions and 1211 deletions

View file

@ -8,10 +8,9 @@ namespace AcDream.UI.Abstractions.Input;
/// debug bindings that have no retail equivalent.
///
/// <para>
/// K.1a defined the enum and K.1c flipped the bindings table to the full
/// retail preset. Runtime controllers subscribe by subsystem; actions whose
/// owning panel has not landed yet (for example <c>UseSpellSlot_*</c>) may
/// intentionally remain undispatched.
/// The installed Sept-2013 ActionMap's 306 user-bindable rows each have one
/// distinct enum identity and one live subsystem consumer. Low, non-bindable
/// MasterInputMap commands remain separate infrastructure actions.
/// </para>
/// </summary>
public enum InputAction
@ -92,7 +91,10 @@ public enum InputAction
// ── UICommands ────────────────────────────────────────
/// <summary>Use the selected item / interact (retail R).</summary>
UseSelected,
/// <summary>Cancel the topmost UI / clear selection / open log-out menu.</summary>
/// <summary>
/// Retail Escape priority: cancel focused UI/targeting/movement, clear
/// selection, then toggle the Gameplay Options page.
/// </summary>
EscapeKey,
/// <summary>Log out of the game (retail Shift+Esc).</summary>
LOGOUT,
@ -169,7 +171,7 @@ public enum InputAction
// ── Combat ────────────────────────────────────────────
/// <summary>Toggle combat-stance on/off (retail Grave / `).</summary>
CombatToggleCombat,
// Mode-dependent (dormant in K — Phase L lights them up)
// Mode-dependent retail combat actions.
CombatDecreaseAttackPower,
CombatIncreaseAttackPower,
CombatLowAttack,
@ -267,7 +269,8 @@ public enum InputAction
AcdreamToggleAudioMute,
/// <summary>F (existing) toggles between fly camera and orbit/chase mode.</summary>
AcdreamToggleFlyMode,
/// <summary>Tab — currently toggles fly↔player mode (will be reassigned to ToggleChatEntry in K.1c).</summary>
/// <summary>Legacy acdream player-mode toggle. Intentionally unbound;
/// retail Tab is <see cref="InputAction.ToggleChatEntry"/>.</summary>
AcdreamTogglePlayerMode,
/// <summary>Hold-RMB chase-camera orbit (debug-only, not user-rebindable).
/// Camera orbits around the player while held; never drives character yaw.</summary>
@ -285,4 +288,201 @@ public enum InputAction
CameraRaise,
/// <summary>Camera lower (held key, integrates Pitch= adjSpeed·dt·0.02). Default unbound.</summary>
CameraLower,
// ── Remaining Sept-2013 retail ActionMap identities ────────────
// Appended after every pre-existing member so persisted numeric enum values
// remain stable. These are distinct even where retail reuses the same
// Action id in another InputMap (notably CameraAlternateControls).
CameraAlternateMoveToward,
CameraAlternateMoveAway,
CameraAlternateRotateLeft,
CameraAlternateRotateRight,
CameraAlternateRotateUp,
CameraAlternateRotateDown,
CameraAlternateViewDefault,
CameraAlternateViewFirstPerson,
CameraAlternateViewLookDown,
CameraAlternateViewMapMode,
UseSpellSlot_10,
UseSpellSlot_11,
UseSpellSlot_12,
EmoteAfkState,
EmoteAkimbo,
EmoteAToyotState,
EmoteAkimboState,
EmoteAtEaseState,
EmoteBeckon,
EmoteBeSeeingYou,
EmoteBlowKiss,
EmoteBowDeep,
EmoteBowDeepState,
EmoteClapHands,
EmoteClapHandsState,
EmoteCringe,
EmoteCrossArmsState,
EmoteCurtseyState,
EmoteDrudgeDance,
EmoteDrudgeDanceState,
EmoteHaveASeat,
EmoteHaveASeatState,
EmoteHeartyLaugh,
EmoteHelper,
EmoteKneel,
EmoteKneelState,
EmoteKnock,
EmoteLeanState,
EmoteMeditateState,
EmoteMimeDrinking,
EmoteMimeEating,
EmoteMock,
EmoteNod,
EmoteNudgeLeft,
EmoteNudgeRight,
EmotePlead,
EmotePleadState,
EmotePoint,
EmotePointDown,
EmotePointDownState,
EmotePointLeft,
EmotePointLeftState,
EmotePointRight,
EmotePointRightState,
EmotePossumState,
EmotePray,
EmotePrayState,
EmoteReadState,
EmoteSalute,
EmoteSaluteState,
EmoteScanHorizon,
EmoteScratchHead,
EmoteScratchHeadState,
EmoteShakeFist,
EmoteShakeFistState,
EmoteShakeHead,
EmoteShiver,
EmoteShiverState,
EmoteShoo,
EmoteShrug,
EmoteSitState,
EmoteSitBackState,
EmoteSitCrossleggedState,
EmoteSlouch,
EmoteSlouchState,
EmoteSmackHead,
EmoteSnowAngelState,
EmoteSpit,
EmoteSurrender,
EmoteSurrenderState,
EmoteTalkToTheHandState,
EmoteTapFoot,
EmoteTapFootState,
EmoteTeapot,
EmoteThinkerState,
EmoteWarmHands,
EmoteWaveState,
EmoteWaveLow,
EmoteWaveHigh,
EmoteWinded,
EmoteWindedState,
EmoteWoah,
EmoteWoahState,
EmoteYawnAndStretch,
EmoteYmca,
SelectionSelf,
SelectionPlaceInInventory,
SelectionUseClosestUnopenedCorpse,
SelectionUseNextUnopenedCorpse,
SelectionGiveToTarget,
SelectionDrop,
SelectionPlaceInMainPack,
SelectionClosestUnopenedCorpse,
SelectionNextUnopenedCorpse,
ToggleAbuseReportingPanel,
ToggleCharacterInfoPanel,
TogglePositiveMagicPanel,
ToggleNegativeMagicPanel,
ToggleLinkStatusPanel,
ToggleUrgentAssistancePanel,
ToggleVitaePanel,
ToggleSocialPanel,
ToggleSpellManagementPanel,
ToggleCharacterDetailPanel,
ToggleMapPage,
ToggleHousePage,
ToggleGameplayOptionsPage,
ToggleCharacterSettingsPage,
ToggleConfigurationPage,
ToggleCompass,
ToggleKeyboardConfiguration,
ToggleFriendsPage,
ToggleCharacterTitlesPage,
ToggleQuestDetailPanel,
ToggleQuestJournalPage,
ToggleJournalPageList,
ToggleContractsPage,
ChatMonarchReply,
ChatPatronReply,
ChatReply,
ChatStartCommand,
ChatTellToSelected,
UseQuickSlot_10,
UseQuickSlot_11,
UseQuickSlot_12,
UseQuickSlot_13,
ToggleCharacterOptionAutoRepeatAttack,
ToggleCharacterOptionIgnoreAllegianceRequests,
ToggleCharacterOptionIgnoreFellowshipRequests,
ToggleCharacterOptionIgnoreTradeRequests,
ToggleCharacterOptionPersistentAtDay,
ToggleCharacterOptionAllowGive,
ToggleCharacterOptionViewCombatTarget,
ToggleCharacterOptionShowTooltips,
ToggleCharacterOptionUseDeception,
ToggleCharacterOptionToggleRun,
ToggleCharacterOptionStayInChatMode,
ToggleCharacterOptionAdvancedCombatUi,
ToggleCharacterOptionAutoTarget,
ToggleCharacterOptionVividTargetingIndicator,
ToggleCharacterOptionFellowshipShareXp,
ToggleCharacterOptionAcceptLootPermits,
ToggleCharacterOptionFellowshipShareLoot,
ToggleCharacterOptionFellowshipAutoAcceptRequests,
ToggleCharacterOptionCoordinatesOnRadar,
ToggleCharacterOptionSpellDuration,
ToggleCharacterOptionDisableHouseRestrictionEffects,
ToggleCharacterOptionDragItemOnPlayerOpensSecureTrade,
ToggleCharacterOptionDisplayAllegianceLogonNotifications,
ToggleCharacterOptionUseChargeAttack,
ToggleCharacterOptionUseCraftSuccessDialog,
ToggleCharacterOptionListenToAllegianceChat,
ToggleCharacterOptionDisplayDateOfBirth,
ToggleCharacterOptionDisplayAge,
ToggleCharacterOptionDisplayChessRank,
ToggleCharacterOptionDisplayFishingSkill,
ToggleCharacterOptionDisplayNumberDeaths,
ToggleCharacterOptionDisplayTimeStamps,
ToggleCharacterOptionSalvageMultiple,
ToggleCharacterOptionListenToGeneralChat,
ToggleCharacterOptionListenToTradeChat,
ToggleCharacterOptionListenToLfgChat,
ToggleCharacterOptionListenToRoleplayChat,
ToggleCharacterOptionDisplayNumberCharacterTitles,
ToggleCharacterOptionMainPackPreferred,
ToggleCharacterOptionLeadMissileTargets,
ToggleCharacterOptionUseFastMissiles,
ToggleCharacterOptionFilterLanguage,
ToggleCharacterOptionConfirmVolatileRareUse,
ToggleCharacterOptionListenToSocietyChat,
ToggleCharacterOptionShowHelm,
ToggleCharacterOptionDisableDistanceFog,
ToggleCharacterOptionShowCloak,
ToggleCharacterOptionSideBySideVitals,
}

View file

@ -22,12 +22,9 @@ namespace AcDream.UI.Abstractions.Input;
/// </para>
///
/// <para>
/// K.1a wiring: <c>GameWindow</c> constructs a dispatcher alongside the
/// existing <c>IsKeyPressed</c> + event-handler paths. Nothing
/// subscribes to <see cref="Fired"/> yet except a diagnostic console
/// logger — the dispatcher is observable but doesn't drive any
/// behavior. K.1b cuts the existing handlers over to the dispatcher's
/// action stream.
/// The production gameplay router is the sole gameplay subscriber; retained
/// UI, selection, camera, combat, movement, and commands all receive semantic
/// actions through this stream.
/// </para>
/// </summary>
public sealed class InputDispatcher : IDisposable
@ -38,6 +35,7 @@ public sealed class InputDispatcher : IDisposable
private KeyBindings _bindings;
private readonly Stack<InputScope> _scopes = new();
private InputScope? _combatScope;
private bool _cameraAlternateScope;
private readonly HashSet<KeyChord> _heldHoldChords = new();
private readonly HashSet<InputAction> _automationHeldActions = new();
private readonly Dictionary<MouseButton, float> _mouseClickTravel = new();
@ -55,16 +53,28 @@ public sealed class InputDispatcher : IDisposable
private const long DoubleClickThresholdMs = 500;
private const float ClickDragThresholdPixels = 3f;
/// <summary>K.3 modal-rebind hook: when non-null, the next non-modifier
/// chord is reported via this callback INSTEAD of firing actions. Esc
/// cancels (callback receives <c>default(KeyChord)</c>).</summary>
/// <summary>K.3 modal-rebind hook: when non-null, the next complete key or
/// mouse chord is reported via this callback INSTEAD of firing actions.
/// A modifier key is deferred until release so it can be captured alone or
/// used as a prefix. Esc cancels (callback receives
/// <c>default(KeyChord)</c>).</summary>
private Action<KeyChord>? _captureCallback;
private Key? _captureModifierCandidate;
private KeyChord? _currentPhysicalChord;
/// <summary>Fires every time a binding matches a press, release, hold,
/// complete click, or double-click.
/// Multicast — every subscriber gets every event in subscription order.</summary>
public event Action<InputAction, ActivationType>? Fired;
/// <summary>
/// The keyboard chord whose native key-down callback is synchronously
/// publishing <see cref="Fired"/>, or <see langword="null"/> outside that
/// callback. This lets retained UI suppress the raw tail of the same key
/// after a semantic action has just moved keyboard focus.
/// </summary>
public KeyChord? CurrentPhysicalChord => _currentPhysicalChord;
private InputDispatcher(
IKeyboardSource keyboard,
IMouseSource mouse,
@ -158,9 +168,11 @@ public sealed class InputDispatcher : IDisposable
{
Interlocked.Exchange(ref _active, 0);
_captureCallback = null;
_captureModifierCandidate = null;
_heldHoldChords.Clear();
_automationHeldActions.Clear();
_mouseClickTravel.Clear();
_cameraAlternateScope = false;
}
public void Dispose()
@ -226,9 +238,24 @@ public sealed class InputDispatcher : IDisposable
}
/// <summary>Topmost scope on the stack — what the dispatcher looks up first.</summary>
public InputScope ActiveScope => _scopes.Peek() == InputScope.Game && _combatScope is { } combat
? combat
: _scopes.Peek();
public InputScope ActiveScope => _cameraAlternateScope
? InputScope.Camera
: _scopes.Peek() == InputScope.Game && _combatScope is { } combat
? combat
: _scopes.Peek();
/// <summary>
/// Installs retail InputMap 6 while the camera-mode chord (F2 or keypad
/// divide by default) is physically held. Retail registers this map at
/// priority 2000 over the ordinary priority-1000 maps, so its arrow-key
/// bindings shadow movement without replacing the normal scope stack.
/// </summary>
public void SetCameraAlternateScope(bool active)
{
if (_cameraAlternateScope == active) return;
ReleaseHeldHoldBindings();
_cameraAlternateScope = active;
}
/// <summary>Set the mode-dependent combat layer that shadows normal game chords.</summary>
public void SetCombatScope(InputScope? scope)
@ -243,34 +270,72 @@ public sealed class InputDispatcher : IDisposable
private Binding? FindActive(KeyChord chord, ActivationType activation)
{
IReadOnlyList<Binding> bindings = FindActiveBindings(chord, activation);
return bindings.Count == 0 ? null : bindings[0];
}
/// <summary>
/// Returns every binding in the highest-priority active retail InputMap.
/// Retail's shipped map deliberately assigns Alt+1..4 in both UICommands
/// and QuickslotCommands; ICIDM emits both actions because those maps are
/// simultaneously active. A first-match lookup made half of those exact
/// defaults unreachable.
/// </summary>
private IReadOnlyList<Binding> FindActiveBindings(
KeyChord chord,
ActivationType activation)
{
if (_cameraAlternateScope)
{
Binding[] camera = FindInScope(InputScope.Camera, chord, activation);
if (camera.Length != 0)
return camera;
}
foreach (InputScope scope in _scopes)
{
if (scope == InputScope.Game && _combatScope is { } combat
&& _bindings.Find(chord, activation, combat) is { } combatBinding)
return combatBinding;
if (_bindings.Find(chord, activation, scope) is { } binding)
return binding;
if (scope == InputScope.Game && _combatScope is { } combat)
{
Binding[] combatBindings = FindInScope(combat, chord, activation);
if (combatBindings.Length != 0)
return combatBindings;
}
Binding[] bindings = FindInScope(scope, chord, activation);
if (bindings.Length != 0)
return bindings;
}
return null;
return Array.Empty<Binding>();
}
private Binding[] FindInScope(
InputScope scope,
KeyChord chord,
ActivationType activation) =>
_bindings.All
.Where(binding =>
binding.Scope == scope
&& binding.Chord == chord
&& binding.Activation == activation)
.DistinctBy(static binding => binding.Action)
.ToArray();
/// <summary>True iff a <see cref="BeginCapture"/> is in progress.</summary>
public bool IsCapturing => _captureCallback is not null;
/// <summary>
/// Enter modal capture mode. The next non-modifier chord pressed
/// (with whatever modifiers are held at that moment) is reported
/// via <paramref name="onCaptured"/> and the dispatcher does NOT
/// fire normal action events for that chord. Esc cancels —
/// <paramref name="onCaptured"/> receives a sentinel
/// <c>default(KeyChord)</c>. Modifier-only key transitions
/// (Shift / Ctrl / Alt / Win held alone) are NOT captured; only a
/// non-modifier key down completes capture, so the user can dial
/// in modifier combinations before pressing the trigger key.
/// Enter modal capture mode. The next keyboard key or mouse button
/// (with whatever modifiers are held at that moment) is reported via
/// <paramref name="onCaptured"/> and the dispatcher does NOT fire normal
/// actions for that chord. Shift/Ctrl/Alt/Win are deferred until key-up:
/// pressing another key first makes them a prefix; releasing the modifier
/// first captures the modifier-only binding. Esc cancels and reports
/// <c>default(KeyChord)</c>.
/// </summary>
public void BeginCapture(Action<KeyChord> onCaptured)
{
_captureCallback = onCaptured ?? throw new ArgumentNullException(nameof(onCaptured));
_captureModifierCandidate = null;
}
/// <summary>
@ -283,6 +348,7 @@ public sealed class InputDispatcher : IDisposable
var cb = _captureCallback;
if (cb is null) return;
_captureCallback = null;
_captureModifierCandidate = null;
cb(default);
}
@ -440,8 +506,7 @@ public sealed class InputDispatcher : IDisposable
if (_heldHoldChords.Count == 0) return;
var releases = new List<Binding>(_heldHoldChords.Count);
foreach (KeyChord chord in _heldHoldChords)
if (FindActive(chord, ActivationType.Hold) is { } binding)
releases.Add(binding);
releases.AddRange(FindActiveBindings(chord, ActivationType.Hold));
_heldHoldChords.Clear();
foreach (Binding binding in releases)
Fired?.Invoke(binding.Action, ActivationType.Release);
@ -469,9 +534,8 @@ public sealed class InputDispatcher : IDisposable
// chord; never dispatch a stale snapshot entry afterward.
if (!_heldHoldChords.Contains(chord))
continue;
var hold = FindActive(chord, ActivationType.Hold);
if (hold is not null)
Fired?.Invoke(hold.Value.Action, ActivationType.Hold);
foreach (Binding hold in FindActiveBindings(chord, ActivationType.Hold))
Fired?.Invoke(hold.Action, ActivationType.Hold);
}
}
@ -480,50 +544,62 @@ public sealed class InputDispatcher : IDisposable
if (Volatile.Read(ref _active) == 0) return;
// K.3 modal capture (used by Settings panel's "Rebind" UX) takes
// precedence over both WantCaptureKeyboard gating AND normal
// binding lookup. Esc cancels capture; modifier-only keys don't
// complete it (so the user can dial in Shift/Ctrl/Alt before
// pressing the trigger key); every other key completes capture
// with the current modifier state.
// binding lookup. Esc cancels capture. A modifier key is deferred
// until its key-up so it can either become the primary binding by
// itself (retail's walk-mode default) or remain a prefix when the
// user presses a non-modifier key before releasing it.
if (_captureCallback is not null)
{
if (key == Key.Escape)
{
var cb = _captureCallback;
_captureCallback = null;
_captureModifierCandidate = null;
cb(default);
return;
}
if (IsModifierKey(key)) return; // dial more mods, don't complete
if (IsModifierKey(key))
{
_captureModifierCandidate = key;
return;
}
var captured = new KeyChord(key, mods, Device: 0);
var cb2 = _captureCallback;
_captureCallback = null;
_captureModifierCandidate = null;
cb2(captured);
return; // SUPPRESS the action — don't run binding lookup below
}
if (_mouse.WantCaptureKeyboard) return;
var chord = new KeyChord(key, mods, Device: 0);
var press = FindActive(chord, ActivationType.Press);
if (press is not null) Fired?.Invoke(press.Value.Action, ActivationType.Press);
var click = FindActive(chord, ActivationType.Click);
if (click is not null) Fired?.Invoke(click.Value.Action, ActivationType.Click);
var hold = FindActive(chord, ActivationType.Hold);
if (hold is not null)
var chord = KeyboardChord(key, mods);
_currentPhysicalChord = chord;
try
{
// Emit a Press transition so subscribers can latch state, then
// record the chord so Tick() will re-fire Hold every frame.
Fired?.Invoke(hold.Value.Action, ActivationType.Press);
_heldHoldChords.Add(chord);
foreach (Binding press in FindActiveBindings(chord, ActivationType.Press))
Fired?.Invoke(press.Action, ActivationType.Press);
foreach (Binding click in FindActiveBindings(chord, ActivationType.Click))
Fired?.Invoke(click.Action, ActivationType.Click);
IReadOnlyList<Binding> holds = FindActiveBindings(chord, ActivationType.Hold);
if (holds.Count != 0)
{
// Emit a Press transition so subscribers can latch state, then
// record the chord so Tick() will re-fire Hold every frame.
foreach (Binding hold in holds)
Fired?.Invoke(hold.Action, ActivationType.Press);
_heldHoldChords.Add(chord);
}
}
finally
{
_currentPhysicalChord = null;
}
}
/// <summary>True for Shift/Ctrl/Alt/Win left+right variants — keys
/// that don't complete a capture by themselves. The user holds them
/// to dial in modifier combinations before pressing the trigger key.</summary>
/// <summary>True for Shift/Ctrl/Alt/Win left+right variants.</summary>
private static bool IsModifierKey(Key key) => key switch
{
Key.ShiftLeft or Key.ShiftRight => true,
@ -536,13 +612,24 @@ public sealed class InputDispatcher : IDisposable
private void OnKeyUp(Key key, ModifierMask mods)
{
if (Volatile.Read(ref _active) == 0) return;
if (_captureCallback is not null)
{
if (_captureModifierCandidate == key)
{
Action<KeyChord> callback = _captureCallback;
_captureCallback = null;
_captureModifierCandidate = null;
callback(KeyboardChord(key, mods));
}
return;
}
// Release fires regardless of WantCaptureKeyboard so we don't
// strand a Hold subscriber in the "held" state if the UI captured
// mid-press.
var chord = new KeyChord(key, mods, Device: 0);
var chord = KeyboardChord(key, mods);
var release = FindActive(chord, ActivationType.Release);
if (release is not null) Fired?.Invoke(release.Value.Action, ActivationType.Release);
foreach (Binding release in FindActiveBindings(chord, ActivationType.Release))
Fired?.Invoke(release.Action, ActivationType.Release);
// Any matching Hold binding gets a Release transition. Walk the
// tracked set looking for a chord with a matching Key (ignoring
@ -556,8 +643,8 @@ public sealed class InputDispatcher : IDisposable
foreach (var held in toRemove)
{
_heldHoldChords.Remove(held);
var hold = FindActive(held, ActivationType.Hold);
if (hold is not null) Fired?.Invoke(hold.Value.Action, ActivationType.Release);
foreach (Binding hold in FindActiveBindings(held, ActivationType.Hold))
Fired?.Invoke(hold.Action, ActivationType.Release);
}
}
@ -565,16 +652,33 @@ public sealed class InputDispatcher : IDisposable
{
if (Volatile.Read(ref _active) == 0) return;
_mouseClickTravel.Remove(button);
// Retail UIOption_ActionKeyMap captures a QualifiedControl, not merely
// a keyboard scan code. Mouse buttons therefore use the same modal
// capture path and suppress their ordinary action, even while the UI
// owns the pointer for the binding dialog.
if (_captureCallback is not null)
{
var captured = new KeyChord(
MouseButtonToKey(button),
mods,
Device: 1);
Action<KeyChord> callback = _captureCallback;
_captureCallback = null;
_captureModifierCandidate = null;
callback(captured);
return;
}
if (_mouse.WantCaptureMouse) return;
var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1);
var press = FindActive(chord, ActivationType.Press);
if (press is not null) Fired?.Invoke(press.Value.Action, ActivationType.Press);
foreach (Binding press in FindActiveBindings(chord, ActivationType.Press))
Fired?.Invoke(press.Action, ActivationType.Press);
var hold = FindActive(chord, ActivationType.Hold);
if (hold is not null)
IReadOnlyList<Binding> holds = FindActiveBindings(chord, ActivationType.Hold);
if (holds.Count != 0)
{
Fired?.Invoke(hold.Value.Action, ActivationType.Press);
foreach (Binding hold in holds)
Fired?.Invoke(hold.Action, ActivationType.Press);
_heldHoldChords.Add(chord);
}
@ -589,8 +693,8 @@ public sealed class InputDispatcher : IDisposable
if (_lastMouseDownButton == button
&& nowMs - _lastMouseDownTickMs <= DoubleClickThresholdMs)
{
var dbl = FindActive(chord, ActivationType.DoubleClick);
if (dbl is not null) Fired?.Invoke(dbl.Value.Action, ActivationType.DoubleClick);
foreach (Binding dbl in FindActiveBindings(chord, ActivationType.DoubleClick))
Fired?.Invoke(dbl.Action, ActivationType.DoubleClick);
_lastMouseDownButton = null; // consumed; require fresh pair for next
}
else
@ -606,8 +710,8 @@ public sealed class InputDispatcher : IDisposable
var chord = new KeyChord(MouseButtonToKey(button), mods, Device: 1);
bool wasClickCandidate = _mouseClickTravel.Remove(button, out float travel);
var release = FindActive(chord, ActivationType.Release);
if (release is not null) Fired?.Invoke(release.Value.Action, ActivationType.Release);
foreach (Binding release in FindActiveBindings(chord, ActivationType.Release))
Fired?.Invoke(release.Action, ActivationType.Release);
var keyForLookup = MouseButtonToKey(button);
var toRemove = new List<KeyChord>();
@ -619,16 +723,17 @@ public sealed class InputDispatcher : IDisposable
foreach (var held in toRemove)
{
_heldHoldChords.Remove(held);
var hold = FindActive(held, ActivationType.Hold);
if (hold is not null) Fired?.Invoke(hold.Value.Action, ActivationType.Release);
foreach (Binding hold in FindActiveBindings(held, ActivationType.Hold))
Fired?.Invoke(hold.Action, ActivationType.Release);
}
if (wasClickCandidate
&& !_mouse.WantCaptureMouse
&& travel <= ClickDragThresholdPixels
&& FindActive(chord, ActivationType.Click) is { } click)
&& FindActiveBindings(chord, ActivationType.Click) is { Count: > 0 } clicks)
{
Fired?.Invoke(click.Action, ActivationType.Click);
foreach (Binding click in clicks)
Fired?.Invoke(click.Action, ActivationType.Click);
}
}
@ -683,6 +788,25 @@ public sealed class InputDispatcher : IDisposable
_ => (Key)(-1000 - (int)button),
};
/// <summary>
/// Silk includes a modifier key's own bit in the event modifier mask;
/// retail QualifiedControl stores a bare DIK_LSHIFT/LCONTROL/LMENU with
/// metamode zero. Remove only the primary key's self bit so persisted and
/// displayed chords remain byte-faithful while combinations stay exact.
/// </summary>
private static KeyChord KeyboardChord(Key key, ModifierMask modifiers)
{
modifiers &= key switch
{
Key.ShiftLeft or Key.ShiftRight => ~ModifierMask.Shift,
Key.ControlLeft or Key.ControlRight => ~ModifierMask.Ctrl,
Key.AltLeft or Key.AltRight => ~ModifierMask.Alt,
Key.SuperLeft or Key.SuperRight => ~ModifierMask.Win,
_ => ~ModifierMask.None,
};
return new KeyChord(key, modifiers, Device: 0);
}
private List<Exception> DetachSources()
{
var failures = new List<Exception>();

View file

@ -8,11 +8,8 @@ namespace AcDream.UI.Abstractions.Input;
/// sits at the bottom of the stack and catches global chords like
/// Esc / F1 that should fire regardless of focus.
///
/// <para>
/// K.1a defines the enum but only pushes <see cref="Always"/> +
/// <see cref="Game"/> by default. Combat scopes light up in Phase L
/// when <c>CombatState.CurrentMode</c> tracking lands.
/// </para>
/// <para>Combat scope follows the live retail combat mode; modal/edit/chat
/// scopes are pushed above it as their authored surfaces activate.</para>
/// </summary>
public enum InputScope
{
@ -30,13 +27,13 @@ public enum InputScope
/// <summary>A modal dialog is open and capturing input.</summary>
Dialog,
/// <summary>Combat with melee weapon equipped — Insert/PgUp/Delete/End/PgDn
/// remap to power + attack-level. Dormant until Phase L.</summary>
/// remap to power + attack-level.</summary>
MeleeCombat,
/// <summary>Combat with missile weapon equipped — Insert/PgUp/Delete/End/PgDn
/// remap to accuracy + aim-level. Dormant until Phase L.</summary>
/// remap to accuracy + aim-level.</summary>
MissileCombat,
/// <summary>Magic mode — 1-9 cast <c>UseSpellSlot</c>; Insert/PgUp etc.
/// page through spell tabs. Dormant until Phase L.</summary>
/// page through spell tabs.</summary>
MagicCombat,
/// <summary>Camera alternate mode (F2 / Numpad-/) — arrow keys rotate
/// the camera instead of the character.</summary>

View file

@ -10,9 +10,9 @@ 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.
/// Insertion order is preserved. Direct <see cref="Find(KeyChord, ActivationType)"/>
/// queries return the first match; <see cref="InputDispatcher"/> emits every
/// distinct action in the highest-priority active retail input map.
///
/// <para>
/// K.1c: <see cref="RetailDefaults"/> now returns the full retail-faithful
@ -26,7 +26,7 @@ namespace AcDream.UI.Abstractions.Input;
/// </summary>
public sealed class KeyBindings
{
private const int CurrentSchemaVersion = 5;
private const int CurrentSchemaVersion = 7;
private readonly List<Binding> _bindings = new();
@ -162,15 +162,9 @@ public sealed class KeyBindings
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));
// Retail authors exactly bare DIK_LSHIFT. InputDispatcher normalizes
// Silk's self-reported Shift modifier bit at the physical boundary.
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));
@ -180,7 +174,10 @@ public sealed class KeyBindings
b.Add(new(new KeyChord(Key.Space, ModifierMask.None), InputAction.MovementJump));
// ── ItemSelectionCommands ──────────────────────────────
b.Add(new(new KeyChord(Key.F, ModifierMask.None), InputAction.SelectionPickUp));
// Retail action 0x1000002C: F places the selected object in the
// inventory. The old SelectionPickUp alias had no ActionMap row and
// made Configure Keyboard show the F default on the wrong command.
b.Add(new(new KeyChord(Key.F, ModifierMask.None), InputAction.SelectionPlaceInInventory));
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));
@ -223,20 +220,21 @@ public sealed class KeyBindings
b.Add(new(new KeyChord(Key.Escape, ModifierMask.Shift), InputAction.LOGOUT));
// ── QuickslotCommands ──────────────────────────────────
// Retail gmToolbarUI::ListenToGlobalMessage @ 0x004BE4E0 receives
// distinct action-id ranges: bare 1..9 USE slots 0..8, while
// Ctrl+1..9 SELECT those slots. The keymap repeats the display name
// UseQuickSlot_N for both bindings, so our semantic action layer must
// preserve the differing intent explicitly.
// Retail's MasterInputMap binds both bare N and Ctrl+N to the SAME
// UseQuickSlot_N action ids (0x10000042..4A). The separate Select
// Quickslot action ids (0x1000004E..56) have no default chords.
for (int i = 1; i <= 9; i++)
{
var k = (Key)((int)Key.Number0 + i); // Number1..Number9
var useAction = (InputAction)((int)InputAction.UseQuickSlot_1 + i - 1);
var selectAction = (InputAction)((int)InputAction.SelectQuickSlot_1 + i - 1);
b.Add(new(new KeyChord(k, ModifierMask.None), useAction));
b.Add(new(new KeyChord(k, ModifierMask.Ctrl), selectAction));
b.Add(new(new KeyChord(k, ModifierMask.Ctrl), useAction));
}
// Alt+5..9 → UseQuickSlot_14..18.
// Alt+1..4 → slots 10..13; Alt+5..9 → slots 14..18.
b.Add(new(new KeyChord(Key.Number1, ModifierMask.Alt), InputAction.UseQuickSlot_10));
b.Add(new(new KeyChord(Key.Number2, ModifierMask.Alt), InputAction.UseQuickSlot_11));
b.Add(new(new KeyChord(Key.Number3, ModifierMask.Alt), InputAction.UseQuickSlot_12));
b.Add(new(new KeyChord(Key.Number4, ModifierMask.Alt), InputAction.UseQuickSlot_13));
for (int i = 5; i <= 9; i++)
{
var k = (Key)((int)Key.Number0 + i);
@ -250,7 +248,7 @@ public sealed class KeyBindings
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) ──
// ── Combat (mode-dependent retail scopes) ──
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, Scope: InputScope.MeleeCombat));
@ -261,15 +259,13 @@ public sealed class KeyBindings
b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatLowAttack, ActivationType.Hold, InputScope.MeleeCombat));
b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatMediumAttack, ActivationType.Hold, InputScope.MeleeCombat));
b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatHighAttack, ActivationType.Hold, InputScope.MeleeCombat));
// 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.
// Missile + Magic + Spell-tab — same chords; resolved by the live
// combat scope through InputDispatcher's stack lookup.
b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatDecreaseMissileAccuracy, Scope: InputScope.MissileCombat));
b.Add(new(new KeyChord(Key.PageUp, ModifierMask.None), InputAction.CombatIncreaseMissileAccuracy, Scope: InputScope.MissileCombat));
b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatAimLow, Scope: InputScope.MissileCombat));
b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatAimMedium, Scope: InputScope.MissileCombat));
b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatAimHigh, Scope: InputScope.MissileCombat));
b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatAimLow, ActivationType.Hold, InputScope.MissileCombat));
b.Add(new(new KeyChord(Key.End, ModifierMask.None), InputAction.CombatAimMedium, ActivationType.Hold, InputScope.MissileCombat));
b.Add(new(new KeyChord(Key.PageDown, ModifierMask.None), InputAction.CombatAimHigh, ActivationType.Hold, InputScope.MissileCombat));
b.Add(new(new KeyChord(Key.Insert, ModifierMask.None), InputAction.CombatPrevSpellTab, Scope: InputScope.MagicCombat));
b.Add(new(new KeyChord(Key.PageUp, ModifierMask.None), InputAction.CombatNextSpellTab, Scope: InputScope.MagicCombat));
b.Add(new(new KeyChord(Key.Delete, ModifierMask.None), InputAction.CombatPrevSpell, Scope: InputScope.MagicCombat));
@ -294,8 +290,8 @@ public sealed class KeyBindings
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));
b.Add(new(new KeyChord(Key.KeypadDivide, ModifierMask.None), InputAction.CameraActivateAlternateMode, ActivationType.Hold));
b.Add(new(new KeyChord(Key.F2, ModifierMask.None), InputAction.CameraActivateAlternateMode, ActivationType.Hold));
// 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.
@ -304,16 +300,22 @@ public sealed class KeyBindings
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.Keypad4, ModifierMask.None), InputAction.CameraRotateLeft, ActivationType.Hold));
b.Add(new(new KeyChord(Key.Keypad6, ModifierMask.None), InputAction.CameraRotateRight, ActivationType.Hold));
b.Add(new(new KeyChord(Key.Keypad8, ModifierMask.None), InputAction.CameraRotateUp, ActivationType.Hold));
b.Add(new(new KeyChord(Key.Keypad2, ModifierMask.None), InputAction.CameraRotateDown, ActivationType.Hold));
b.Add(new(new KeyChord(Key.KeypadSubtract, ModifierMask.None), InputAction.CameraMoveToward, ActivationType.Hold));
b.Add(new(new KeyChord(Key.KeypadAdd, ModifierMask.None), InputAction.CameraMoveAway, ActivationType.Hold));
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));
// CameraAlternateControls is a separate retail InputMap. Its arrow
// defaults must not alias the movement/camera-primary row identities.
b.Add(new(new KeyChord(Key.Left, ModifierMask.None), InputAction.CameraAlternateRotateLeft, ActivationType.Hold, InputScope.Camera));
b.Add(new(new KeyChord(Key.Right, ModifierMask.None), InputAction.CameraAlternateRotateRight, ActivationType.Hold, InputScope.Camera));
b.Add(new(new KeyChord(Key.Up, ModifierMask.None), InputAction.CameraAlternateRotateUp, ActivationType.Hold, InputScope.Camera));
b.Add(new(new KeyChord(Key.Down, ModifierMask.None), InputAction.CameraAlternateRotateDown, ActivationType.Hold, InputScope.Camera));
// ── Mouse selection ────────────────────────────────────
// Retail keymap: SelectLeft = LMB, SelectRight = RMB, SelectMid = MMB,
@ -413,6 +415,7 @@ public sealed class KeyBindings
var defaults = RetailDefaults();
var loaded = new KeyBindings();
var explicitlyStoredActions = new HashSet<InputAction>();
if (root.TryGetProperty("actions", out var actionsEl)
&& actionsEl.ValueKind == JsonValueKind.Object)
@ -422,6 +425,7 @@ public sealed class KeyBindings
if (!Enum.TryParse<InputAction>(actionProp.Name, out var action))
continue; // unknown action → skip
if (actionProp.Value.ValueKind != JsonValueKind.Array) continue;
explicitlyStoredActions.Add(action);
foreach (var bindingEl in actionProp.Value.EnumerateArray())
{
if (!bindingEl.TryGetProperty("key", out var keyEl)) continue;
@ -444,7 +448,11 @@ public sealed class KeyBindings
device = (byte)dEl.GetInt32();
}
var chord = new KeyChord(silkKey, mods, device);
action = MigrateLegacyQuickSlotIntent(version, action, chord, activation);
action = MigrateQuickSlotIntent(version, action, chord, activation);
// A migrated action is still an explicit user entry.
// Without this, the default-merge pass below appends the
// retail default beside the migrated custom chord.
explicitlyStoredActions.Add(action);
activation = MigrateCombatAttackActivation(version, action, activation);
activation = MigrateSelectRightActivation(version, action, activation);
InputScope scope = defaults.ForAction(action)
@ -468,7 +476,7 @@ public sealed class KeyBindings
// newly-added actions if the user file is older.
foreach (var actionInDefaults in Enum.GetValues<InputAction>())
{
if (!loaded.ForAction(actionInDefaults).Any()
if (!explicitlyStoredActions.Contains(actionInDefaults)
&& defaults.ForAction(actionInDefaults).Any())
{
foreach (var def in defaults.ForAction(actionInDefaults))
@ -496,6 +504,12 @@ public sealed class KeyBindings
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
var actions = new SortedDictionary<string, List<object>>(StringComparer.Ordinal);
// An empty array is meaningful: the player explicitly cleared all
// three GUI slots for this retail action. Earlier schemas omitted the
// property entirely, so the next launch mistook "unbound" for "new
// action missing from an old file" and silently restored its default.
foreach (InputAction action in RetailActionIdentityTable.Map.Values)
actions.TryAdd(action.ToString(), new List<object>());
foreach (var binding in _bindings)
{
if (!actions.TryGetValue(binding.Action.ToString(), out var list))
@ -528,30 +542,31 @@ public sealed class KeyBindings
}
/// <summary>
/// Schema v1 repeated <c>UseQuickSlot_N</c> for bare and Ctrl chords,
/// losing retail's use-vs-select distinction. Migrate only the exact old
/// default Ctrl+matching-number shape; arbitrary user rebindings remain
/// attached to the action the user chose.
/// Schema v2-v5 incorrectly rewrote retail's Ctrl+1..9
/// <c>UseQuickSlot_N</c> defaults to <c>SelectQuickSlot_N</c>. The 2013
/// MasterInputMap and <c>gmToolbarUI::ListenToGlobalMessage</c> both prove
/// Ctrl+N sends the same use action as bare N. Repair only that exact old
/// generated-default shape; arbitrary SelectQuickSlot rebindings remain.
/// </summary>
private static InputAction MigrateLegacyQuickSlotIntent(
private static InputAction MigrateQuickSlotIntent(
int version,
InputAction action,
KeyChord chord,
ActivationType activation)
{
if (version >= 2
if (version >= 6
|| activation != ActivationType.Press
|| chord.Device != 0
|| chord.Modifiers != ModifierMask.Ctrl)
return action;
int offset = (int)action - (int)InputAction.UseQuickSlot_1;
int offset = (int)action - (int)InputAction.SelectQuickSlot_1;
if ((uint)offset >= 9u)
return action;
var expectedKey = (Key)((int)Key.Number1 + offset);
return chord.Key == expectedKey
? (InputAction)((int)InputAction.SelectQuickSlot_1 + offset)
? (InputAction)((int)InputAction.UseQuickSlot_1 + offset)
: action;
}

View file

@ -1,72 +1,138 @@
using System.Collections.Generic;
using System.Linq;
namespace AcDream.UI.Abstractions.Input;
/// <summary>
/// Campaign OP slice OP8: maps a retail DAT ActionMap row — the
/// <c>(InputMap id, Action id)</c> pair <c>AcDream.Core.Input.RetailActionMapRow</c>
/// carries — to acdream's own <see cref="InputAction"/>, when one exists.
/// carries — to acdream's own <see cref="InputAction"/>.
///
/// <para>
/// <b>Why this table exists.</b> The DAT ActionMap singleton (empirically dumped
/// 2026-08-11, see <c>AcDream.Core.Input.RetailActionMap</c>'s class doc) carries 306
/// user-bindable rows. <see cref="InputAction"/> — the enum every OTHER acdream input
/// path (live dispatch, <c>KeyBindings</c>, <c>InputDispatcher</c>) already keys on —
/// has roughly half that many members, because it was authored around "what acdream
/// currently implements" (K.1a/K.1c), not "every action the 2013 client's keymap
/// screen can show." Two categories are the biggest gaps: 82 of the DAT's 87 Emote
/// rows have no acdream animation dispatch yet (only 5 are wired: Cry/Laugh/Cheer/
/// Wave/PointState — exactly the 5 that happen to carry retail default keys), and all
/// 48 CharacterSettings rows are hotkeys for the SAME <c>PlayerOption</c>/
/// <c>CharacterOptions</c> preference bits OP1's <c>CharacterOptionTable</c> and OP4's
/// Character-tab checkboxes already model — wiring "press this key, flip that same
/// server-synced bit" is a real feature (a hotkey-to-option-toggle dispatcher) that
/// does not exist yet anywhere in acdream and is out of scope for this slice (see the
/// OP8 register row).
/// The DAT ActionMap singleton carries exactly 306 user-bindable rows. Campaign KB
/// gives every row one distinct live identity. Identity is the full
/// <c>(InputMapId, ActionId)</c> pair: retail legitimately reuses action ids between
/// CameraControls and CameraAlternateControls, and collapsing those rows would make
/// one GUI rebind silently overwrite the other.
/// </para>
///
/// <para>
/// <b>Every mapping below was verified two ways</b> before being added: (1) the DAT's
/// <b>Every mapping below was verified two ways</b>: (1) the DAT's
/// resolved English label/tooltip unambiguously names the SAME action as the
/// <see cref="InputAction"/> member's own XML doc, AND (2) where the retail default
/// key(s) for that DAT row are non-empty, they match
/// <see cref="KeyBindings.RetailDefaults"/>'s existing chord(s) for the candidate
/// <see cref="InputAction"/> (byte-verified 2026-08-11 against the installed dats —
/// see <c>RetailActionMapReaderTests.LiveDatTests</c> and this slice's
/// <c>RetailActionIdentityRoundTripTests</c>). A DAT row that could not be verified
/// BOTH ways is left OUT of this table on purpose — it renders on the Configure
/// Keyboard screen as a real, bindable, persisted row (see
/// <c>KeyboardConfigController</c>), it just does not yet reach any live acdream
/// consumer. Silently guessing a wrong mapping would misroute a user's rebind to the
/// WRONG gameplay action, which is worse than an honest "not wired yet."
/// </para>
///
/// <para>
/// <b>Known gaps deliberately left unmapped</b> (register row, OP8):
/// Spell Slot 10/11/12 (ctx <c>0x10000005</c>, DAT actions <c>0x6E/0x6F/0x70</c> —
/// <see cref="InputAction"/> only defines <c>UseSpellSlot_1..9</c>); Quickslot
/// 10/11/12/13 (ctx <c>0x1000000C</c>, DAT actions <c>0x1000004B/4C/4D/10000132</c> —
/// <see cref="InputAction"/>'s <c>UseQuickSlot_*</c> family jumps from 9 straight to
/// 14, a pre-existing enum gap this slice did not introduce and does not fix); every
/// CharacterSettings row (ctx <c>0x10000008</c>, all 48); 82 of 87 Emote rows (ctx
/// <c>0x10000006</c>); all 10 CameraAlternateControls rows (ctx <c>0x6</c> — the M2
/// de-alias carve-out, see the mapping table's own comment); and roughly half of the
/// UI-class rows (ctx <c>0x10000007</c>/<c>0x10000009</c> — panels acdream has no
/// toggle for, e.g. Vitae, Link Status, House, Map, Character Info, the
/// positive/negative Magic panels).
/// <see cref="InputAction"/>. The installed-DAT conformance test requires complete,
/// injective 306/306 coverage, so a future DAT drift cannot quietly recreate the
/// former dim/store-only tier.
/// </para>
/// </summary>
public static class RetailActionIdentityTable
{
/// <summary>(InputMap id, Action id) → the acdream <see cref="InputAction"/> that
/// owns live dispatch for it. A DAT row whose key is absent has no acdream
/// consumer yet.</summary>
/// owns live dispatch for it.</summary>
public static readonly IReadOnlyDictionary<(uint InputMapId, uint ActionId), InputAction> Map =
BuildTable();
public static bool TryResolve(uint inputMapId, uint actionId, out InputAction action) =>
Map.TryGetValue((inputMapId, actionId), out action);
/// <summary>Inverse identity used by family routers and conformance checks.</summary>
public static readonly IReadOnlyDictionary<InputAction, (uint InputMapId, uint ActionId)> ReverseMap =
Map.ToDictionary(static pair => pair.Value, static pair => pair.Key);
public static bool TryGetRetailIdentity(
InputAction action,
out (uint InputMapId, uint ActionId) identity) =>
ReverseMap.TryGetValue(action, out identity);
/// <summary>Live dispatch scope implied by the retail InputMap context.</summary>
public static InputScope ScopeForInputMap(uint inputMapId) => inputMapId switch
{
0x00000006u => InputScope.Camera,
0x10000003u => InputScope.MeleeCombat,
0x10000004u => InputScope.MissileCombat,
0x10000005u => InputScope.MagicCombat,
_ => InputScope.Game,
};
/// <summary>
/// Retail action delivery shape for a DAT ActionMap row. Continuous
/// movement/camera actions receive start and stop edges; the remaining
/// rows are one-shot presses unless a concrete retail consumer declares
/// otherwise in <see cref="KeyBindings.RetailDefaults"/>.
/// </summary>
public static ActivationType ActivationFor(uint inputMapId, uint actionId)
{
if (inputMapId == 0x4u && actionId == 0x32u)
return ActivationType.Hold;
if (inputMapId is 0x5u or 0x6u
&& actionId is >= 0x33u and <= 0x38u)
{
return ActivationType.Hold;
}
if (inputMapId == 0x5u && actionId is 0x3Du or 0x3Eu)
return ActivationType.Hold;
// ClientCombatSystem::HandleCombatAction @ 0x0056D600 sends both
// melee 0x5D-0x5F and missile 0xF1-0xF3 through Begin/EndAttackRequest.
if (inputMapId == 0x10000003u
&& actionId is >= 0x1000005Du and <= 0x1000005Fu)
{
return ActivationType.Hold;
}
if (inputMapId == 0x10000004u
&& actionId is >= 0x100000F1u and <= 0x100000F3u)
{
return ActivationType.Hold;
}
return ActivationType.Press;
}
/// <summary>
/// Maps a CharacterSettings hotkey identity to retail's linear
/// PlayerOption id. The five 2013 options absent from ActionMap remain
/// configurable through the Character page, but correctly have no row
/// here.
/// </summary>
public static bool TryGetCharacterOptionId(InputAction action, out uint optionId)
{
optionId = 0u;
if (!TryGetRetailIdentity(action, out var identity)
|| identity.InputMapId != 0x10000008u)
{
return false;
}
optionId = identity.ActionId switch
{
>= 0x10000071u and <= 0x10000074u => identity.ActionId - 0x10000071u,
>= 0x10000076u and <= 0x10000083u => identity.ActionId - 0x10000071u,
>= 0x10000085u and <= 0x10000093u => identity.ActionId - 0x10000071u,
0x1000010Eu => 0x23u,
0x1000010Fu => 0x24u,
0x10000110u => 0x25u,
0x10000112u => 0x26u,
0x1000011Bu => 0x28u,
0x1000011Du => 0x29u,
0x1000011Eu => 0x2Au,
0x1000011Fu => 0x2Bu,
0x10000120u => 0x2Cu,
0x10000123u => 0x2Du,
0x10000125u => 0x2Eu,
0x1000012Au => 0x2Fu,
0x1000012Cu => 0x30u,
0x1000012Fu => 0x32u,
0x1000013Eu => 0x13u,
_ => uint.MaxValue,
};
return optionId != uint.MaxValue;
}
private static Dictionary<(uint, uint), InputAction> BuildTable()
{
var t = new Dictionary<(uint, uint), InputAction>();
@ -89,24 +155,9 @@ public static class RetailActionIdentityTable
M(0x4, 0x10000097, InputAction.Sleeping);
// ── CameraControls (ctx 0x5) — 12/12. ──────────────────────────
// M2 REWORK (2026-08-11 review): CameraControls (ctx 0x5, the
// Numpad-default scheme RetailDefaults() actually carries) and
// CameraAlternateControls (ctx 0x6, the arrow-key alternate scheme
// RetailDefaults() never had — see
// RetailActionIdentityRoundTripTests' now-retired camera allowlist
// entries) were both previously mapped to the SAME InputAction.
// KeyBindings/Binding has no "which scheme" tag, and SetForAction is
// whole-action replacement, so the two rows aliased one live target:
// both showed identical (stale) chords, rebinding one silently wiped
// the other, and a row could conflict with its own twin. Building
// real per-scheme dual-binding storage (or ten new InputAction
// members plus the camera-dispatch code to consume them) is a real
// feature, not a one-line fix, and out of scope for this rework. Only
// ctx 0x5 — the scheme that already has a live, verified
// RetailDefaults() presence — maps here; ctx 0x6 falls through to the
// generic unmapped/store-only path below (AP-203), fully renderable,
// bindable and persisted, honestly carrying no live effect, exactly
// like every other unmapped row.
// The primary and alternate maps deliberately use distinct actions.
// Retail reuses the ten low action ids, but they are separate rows and
// separate rebind targets; collapsing them aliases GUI state.
M(0x5, 0x33, InputAction.CameraMoveToward);
M(0x5, 0x34, InputAction.CameraMoveAway);
M(0x5, 0x35, InputAction.CameraRotateLeft);
@ -120,6 +171,18 @@ public static class RetailActionIdentityTable
M(0x5, 0x3D, InputAction.CameraInstantMouseLook);
M(0x5, 0x3E, InputAction.CameraActivateAlternateMode);
// ── CameraAlternateControls (ctx 0x6) — 10/10. ─────────────
M(0x6, 0x33, InputAction.CameraAlternateMoveToward);
M(0x6, 0x34, InputAction.CameraAlternateMoveAway);
M(0x6, 0x35, InputAction.CameraAlternateRotateLeft);
M(0x6, 0x36, InputAction.CameraAlternateRotateRight);
M(0x6, 0x37, InputAction.CameraAlternateRotateUp);
M(0x6, 0x38, InputAction.CameraAlternateRotateDown);
M(0x6, 0x39, InputAction.CameraAlternateViewDefault);
M(0x6, 0x3A, InputAction.CameraAlternateViewFirstPerson);
M(0x6, 0x3B, InputAction.CameraAlternateViewLookDown);
M(0x6, 0x3C, InputAction.CameraAlternateViewMapMode);
// ── Combat (ctx 0x10000002) — 1/1. ─────────────────────────────
M(0x10000002, 0x1000005A, InputAction.CombatToggleCombat);
@ -137,8 +200,7 @@ public static class RetailActionIdentityTable
M(0x10000004, 0x100000F2, InputAction.CombatAimMedium);
M(0x10000004, 0x100000F3, InputAction.CombatAimHigh);
// ── MagicCombat (ctx 0x10000005) — 18/21 (Spell Slot 10/11/12 have
// no InputAction — register row). ────────────────────────────
// ── MagicCombat (ctx 0x10000005) — 21/21. ──────────────────────
M(0x10000005, 0x10000060, InputAction.CombatCastCurrentSpell);
M(0x10000005, 0x10000061, InputAction.CombatPrevSpell);
M(0x10000005, 0x10000062, InputAction.CombatNextSpell);
@ -153,21 +215,111 @@ public static class RetailActionIdentityTable
M(0x10000005, 0x1000006B, InputAction.UseSpellSlot_7);
M(0x10000005, 0x1000006C, InputAction.UseSpellSlot_8);
M(0x10000005, 0x1000006D, InputAction.UseSpellSlot_9);
// 0x6E/0x6F/0x70 (Spell Slot 10/11/12) — no InputAction. Unmapped.
M(0x10000005, 0x1000006E, InputAction.UseSpellSlot_10);
M(0x10000005, 0x1000006F, InputAction.UseSpellSlot_11);
M(0x10000005, 0x10000070, InputAction.UseSpellSlot_12);
M(0x10000005, 0x10000102, InputAction.CombatFirstSpell);
M(0x10000005, 0x10000103, InputAction.CombatLastSpell);
M(0x10000005, 0x10000104, InputAction.CombatFirstSpellTab);
M(0x10000005, 0x10000105, InputAction.CombatLastSpellTab);
// ── Emotes (ctx 0x10000006) — 5/87 (the only 5 acdream dispatches
// an animation for; also the only 5 with retail default keys). ──
M(0x10000006, 0x100000A2, InputAction.Cheer);
M(0x10000006, 0x100000A7, InputAction.Cry);
M(0x10000006, 0x100000B2, InputAction.Laugh);
M(0x10000006, 0x100000BE, InputAction.PointState);
M(0x10000006, 0x100000E5, InputAction.Wave);
// ── Emotes (ctx 0x10000006) — 87/87. ────────────────────────
InputAction[] emotes =
{
InputAction.EmoteAfkState,
InputAction.EmoteAkimbo,
InputAction.EmoteAToyotState,
InputAction.EmoteAkimboState,
InputAction.EmoteAtEaseState,
InputAction.EmoteBeckon,
InputAction.EmoteBeSeeingYou,
InputAction.EmoteBlowKiss,
InputAction.EmoteBowDeep,
InputAction.EmoteBowDeepState,
InputAction.Cheer,
InputAction.EmoteClapHands,
InputAction.EmoteClapHandsState,
InputAction.EmoteCringe,
InputAction.EmoteCrossArmsState,
InputAction.Cry,
InputAction.EmoteCurtseyState,
InputAction.EmoteDrudgeDance,
InputAction.EmoteDrudgeDanceState,
InputAction.EmoteHaveASeat,
InputAction.EmoteHaveASeatState,
InputAction.EmoteHeartyLaugh,
InputAction.EmoteHelper,
InputAction.EmoteKneel,
InputAction.EmoteKneelState,
InputAction.EmoteKnock,
InputAction.Laugh,
InputAction.EmoteLeanState,
InputAction.EmoteMeditateState,
InputAction.EmoteMimeDrinking,
InputAction.EmoteMimeEating,
InputAction.EmoteMock,
InputAction.EmoteNod,
InputAction.EmoteNudgeLeft,
InputAction.EmoteNudgeRight,
InputAction.EmotePlead,
InputAction.EmotePleadState,
InputAction.EmotePoint,
InputAction.PointState,
InputAction.EmotePointDown,
InputAction.EmotePointDownState,
InputAction.EmotePointLeft,
InputAction.EmotePointLeftState,
InputAction.EmotePointRight,
InputAction.EmotePointRightState,
InputAction.EmotePossumState,
InputAction.EmotePray,
InputAction.EmotePrayState,
InputAction.EmoteReadState,
InputAction.EmoteSalute,
InputAction.EmoteSaluteState,
InputAction.EmoteScanHorizon,
InputAction.EmoteScratchHead,
InputAction.EmoteScratchHeadState,
InputAction.EmoteShakeFist,
InputAction.EmoteShakeFistState,
InputAction.EmoteShakeHead,
InputAction.EmoteShiver,
InputAction.EmoteShiverState,
InputAction.EmoteShoo,
InputAction.EmoteShrug,
InputAction.EmoteSitState,
InputAction.EmoteSitBackState,
InputAction.EmoteSitCrossleggedState,
InputAction.EmoteSlouch,
InputAction.EmoteSlouchState,
InputAction.EmoteSmackHead,
InputAction.EmoteSnowAngelState,
InputAction.EmoteSpit,
InputAction.EmoteSurrender,
InputAction.EmoteSurrenderState,
InputAction.EmoteTalkToTheHandState,
InputAction.EmoteTapFoot,
InputAction.EmoteTapFootState,
InputAction.EmoteTeapot,
InputAction.EmoteThinkerState,
InputAction.EmoteWarmHands,
InputAction.Wave,
InputAction.EmoteWaveState,
InputAction.EmoteWaveLow,
InputAction.EmoteWaveHigh,
InputAction.EmoteWinded,
InputAction.EmoteWindedState,
InputAction.EmoteWoah,
InputAction.EmoteWoahState,
InputAction.EmoteYawnAndStretch,
InputAction.EmoteYmca,
};
for (int i = 0; i < emotes.Length; i++)
M(0x10000006, 0x10000098u + (uint)i, emotes[i]);
// ── ItemSelectionCommands (ctx 0x10000007) — 17/26. ────────────
// ── ItemSelectionCommands (ctx 0x10000007) — 26/26. ────────────
M(0x10000007, 0x1000002A, InputAction.SelectionSelf);
M(0x10000007, 0x1000002C, InputAction.SelectionPlaceInInventory);
M(0x10000007, 0x1000002D, InputAction.SelectionSplitStack);
M(0x10000007, 0x1000002E, InputAction.SelectionPreviousSelection);
M(0x10000007, 0x1000002F, InputAction.SelectionClosestCompassItem);
@ -185,20 +337,44 @@ public static class RetailActionIdentityTable
M(0x10000007, 0x1000003B, InputAction.SelectionNextPlayer);
M(0x10000007, 0x1000003C, InputAction.SelectionPreviousFellow);
M(0x10000007, 0x1000003D, InputAction.SelectionNextFellow);
M(0x10000007, 0x1000003E, InputAction.SelectionUseClosestUnopenedCorpse);
M(0x10000007, 0x1000003F, InputAction.SelectionUseNextUnopenedCorpse);
M(0x10000007, 0x10000040, InputAction.SelectionGiveToTarget);
M(0x10000007, 0x10000041, InputAction.SelectionDrop);
M(0x10000007, 0x1000011C, InputAction.SelectionPlaceInMainPack);
M(0x10000007, 0x10000121, InputAction.SelectionClosestUnopenedCorpse);
M(0x10000007, 0x10000122, InputAction.SelectionNextUnopenedCorpse);
// ── UICommands (ctx 0x10000009) — 22/42. ───────────────────────
// ── UICommands (ctx 0x10000009) — 42/42. ───────────────────────
M(0x10000009, 0x55, InputAction.CaptureScreenshot);
M(0x10000009, 0x7B, InputAction.ToggleHelp);
M(0x10000009, 0x7C, InputAction.TogglePluginManager);
M(0x10000009, 0x10000003, InputAction.ToggleAbuseReportingPanel);
M(0x10000009, 0x10000005, InputAction.ToggleCharacterInfoPanel);
M(0x10000009, 0x10000006, InputAction.TogglePositiveMagicPanel);
M(0x10000009, 0x10000007, InputAction.ToggleNegativeMagicPanel);
M(0x10000009, 0x10000009, InputAction.ToggleLinkStatusPanel);
M(0x10000009, 0x1000000B, InputAction.ToggleUrgentAssistancePanel);
M(0x10000009, 0x1000000C, InputAction.ToggleVitaePanel);
M(0x10000009, 0x1000000D, InputAction.ToggleSocialPanel);
M(0x10000009, 0x1000000E, InputAction.ToggleAllegiancePanel);
M(0x10000009, 0x1000000F, InputAction.ToggleFellowshipPanel);
M(0x10000009, 0x10000010, InputAction.ToggleSpellManagementPanel);
M(0x10000009, 0x10000011, InputAction.ToggleSpellbookPanel);
M(0x10000009, 0x10000012, InputAction.ToggleSpellComponentsPanel);
M(0x10000009, 0x10000013, InputAction.ToggleCharacterDetailPanel);
M(0x10000009, 0x10000014, InputAction.ToggleAttributesPanel);
M(0x10000009, 0x10000015, InputAction.ToggleSkillsPanel);
M(0x10000009, 0x10000016, InputAction.ToggleWorldPanel);
M(0x10000009, 0x10000017, InputAction.ToggleMapPage);
M(0x10000009, 0x10000018, InputAction.ToggleHousePage);
M(0x10000009, 0x1000001A, InputAction.ToggleOptionsPanel);
M(0x10000009, 0x10000019, InputAction.ToggleInventoryPanel);
M(0x10000009, 0x1000001B, InputAction.ToggleGameplayOptionsPage);
M(0x10000009, 0x1000001C, InputAction.ToggleCharacterSettingsPage);
M(0x10000009, 0x1000001D, InputAction.ToggleConfigurationPage);
M(0x10000009, 0x1000001E, InputAction.ToggleCompass);
M(0x10000009, 0x1000001F, InputAction.ToggleKeyboardConfiguration);
M(0x10000009, 0x10000114, InputAction.ToggleFloatingChatWindow1);
M(0x10000009, 0x10000115, InputAction.ToggleFloatingChatWindow2);
M(0x10000009, 0x10000116, InputAction.ToggleFloatingChatWindow3);
@ -206,19 +382,24 @@ public static class RetailActionIdentityTable
M(0x10000009, 0x10000025, InputAction.UseSelected);
M(0x10000009, 0x10000026, InputAction.LOGOUT);
M(0x10000009, 0x1000002B, InputAction.SelectionExamine);
// 0x1000001F ("Show/Hide Keyboard Configuration") deliberately left
// unmapped: it is the retail action that opens THIS screen
// (research doc §4.3/lane A §7 — wired directly by
// KeyboardConfigController's mount, not through InputAction).
// ── ChatCommands (ctx 0x1000000A) — 1/6. ───────────────────────
M(0x10000009, 0x10000118, InputAction.ToggleFriendsPage);
M(0x10000009, 0x1000011A, InputAction.ToggleCharacterTitlesPage);
M(0x10000009, 0x10000127, InputAction.ToggleQuestDetailPanel);
M(0x10000009, 0x10000128, InputAction.ToggleQuestJournalPage);
M(0x10000009, 0x10000129, InputAction.ToggleJournalPageList);
M(0x10000009, 0x1000012E, InputAction.ToggleContractsPage);
// ── ChatCommands (ctx 0x1000000A) — 6/6. ───────────────────────
M(0x1000000A, 0x10000020, InputAction.ChatMonarchReply);
M(0x1000000A, 0x10000021, InputAction.ChatPatronReply);
M(0x1000000A, 0x10000022, InputAction.ChatReply);
M(0x1000000A, 0x10000023, InputAction.EnterChatMode);
M(0x1000000A, 0x10000028, InputAction.ChatStartCommand);
M(0x1000000A, 0x10000119, InputAction.ChatTellToSelected);
// ── ToggleChatEntry (ctx 0x1000000D) — 1/1. ────────────────────
M(0x1000000D, 0x10000024, InputAction.ToggleChatEntry);
// ── QuickslotCommands (ctx 0x1000000C) — 24/28 (Quickslot
// 10/11/12/13 have no InputAction — pre-existing enum gap). ──
// ── QuickslotCommands (ctx 0x1000000C) — 28/28. ────────────────
M(0x1000000C, 0x10000042, InputAction.UseQuickSlot_1);
M(0x1000000C, 0x10000043, InputAction.UseQuickSlot_2);
M(0x1000000C, 0x10000044, InputAction.UseQuickSlot_3);
@ -228,6 +409,9 @@ public static class RetailActionIdentityTable
M(0x1000000C, 0x10000048, InputAction.UseQuickSlot_7);
M(0x1000000C, 0x10000049, InputAction.UseQuickSlot_8);
M(0x1000000C, 0x1000004A, InputAction.UseQuickSlot_9);
M(0x1000000C, 0x1000004B, InputAction.UseQuickSlot_10);
M(0x1000000C, 0x1000004C, InputAction.UseQuickSlot_11);
M(0x1000000C, 0x1000004D, InputAction.UseQuickSlot_12);
M(0x1000000C, 0x1000004E, InputAction.SelectQuickSlot_1);
M(0x1000000C, 0x1000004F, InputAction.SelectQuickSlot_2);
M(0x1000000C, 0x10000050, InputAction.SelectQuickSlot_3);
@ -238,16 +422,62 @@ public static class RetailActionIdentityTable
M(0x1000000C, 0x10000055, InputAction.SelectQuickSlot_8);
M(0x1000000C, 0x10000056, InputAction.SelectQuickSlot_9);
M(0x1000000C, 0x1000010D, InputAction.CreateShortcut);
// 0x10000132 ("Quickslot 13") has no InputAction — same pre-existing
// UseQuickSlot_10..13 enum gap as the bare-numeral block above. Unmapped.
M(0x1000000C, 0x10000132, InputAction.UseQuickSlot_13);
M(0x1000000C, 0x10000133, InputAction.UseQuickSlot_14);
M(0x1000000C, 0x10000134, InputAction.UseQuickSlot_15);
M(0x1000000C, 0x10000135, InputAction.UseQuickSlot_16);
M(0x1000000C, 0x10000136, InputAction.UseQuickSlot_17);
M(0x1000000C, 0x10000137, InputAction.UseQuickSlot_18);
// CharacterSettings (ctx 0x10000008) is intentionally EMPTY here —
// see class doc "Known gaps deliberately left unmapped".
// ── CharacterSettings (ctx 0x10000008) — 48/48. ────────────────
M(0x10000008, 0x10000071, InputAction.ToggleCharacterOptionAutoRepeatAttack);
M(0x10000008, 0x10000072, InputAction.ToggleCharacterOptionIgnoreAllegianceRequests);
M(0x10000008, 0x10000073, InputAction.ToggleCharacterOptionIgnoreFellowshipRequests);
M(0x10000008, 0x10000074, InputAction.ToggleCharacterOptionIgnoreTradeRequests);
M(0x10000008, 0x10000076, InputAction.ToggleCharacterOptionPersistentAtDay);
M(0x10000008, 0x10000077, InputAction.ToggleCharacterOptionAllowGive);
M(0x10000008, 0x10000078, InputAction.ToggleCharacterOptionViewCombatTarget);
M(0x10000008, 0x10000079, InputAction.ToggleCharacterOptionShowTooltips);
M(0x10000008, 0x1000007A, InputAction.ToggleCharacterOptionUseDeception);
M(0x10000008, 0x1000007B, InputAction.ToggleCharacterOptionToggleRun);
M(0x10000008, 0x1000007C, InputAction.ToggleCharacterOptionStayInChatMode);
M(0x10000008, 0x1000007D, InputAction.ToggleCharacterOptionAdvancedCombatUi);
M(0x10000008, 0x1000007E, InputAction.ToggleCharacterOptionAutoTarget);
M(0x10000008, 0x1000007F, InputAction.ToggleCharacterOptionVividTargetingIndicator);
M(0x10000008, 0x10000080, InputAction.ToggleCharacterOptionFellowshipShareXp);
M(0x10000008, 0x10000081, InputAction.ToggleCharacterOptionAcceptLootPermits);
M(0x10000008, 0x10000082, InputAction.ToggleCharacterOptionFellowshipShareLoot);
M(0x10000008, 0x10000083, InputAction.ToggleCharacterOptionFellowshipAutoAcceptRequests);
M(0x10000008, 0x10000085, InputAction.ToggleCharacterOptionCoordinatesOnRadar);
M(0x10000008, 0x10000086, InputAction.ToggleCharacterOptionSpellDuration);
M(0x10000008, 0x10000087, InputAction.ToggleCharacterOptionDisableHouseRestrictionEffects);
M(0x10000008, 0x10000088, InputAction.ToggleCharacterOptionDragItemOnPlayerOpensSecureTrade);
M(0x10000008, 0x10000089, InputAction.ToggleCharacterOptionDisplayAllegianceLogonNotifications);
M(0x10000008, 0x1000008A, InputAction.ToggleCharacterOptionUseChargeAttack);
M(0x10000008, 0x1000008B, InputAction.ToggleCharacterOptionUseCraftSuccessDialog);
M(0x10000008, 0x1000008C, InputAction.ToggleCharacterOptionListenToAllegianceChat);
M(0x10000008, 0x1000008D, InputAction.ToggleCharacterOptionDisplayDateOfBirth);
M(0x10000008, 0x1000008E, InputAction.ToggleCharacterOptionDisplayAge);
M(0x10000008, 0x1000008F, InputAction.ToggleCharacterOptionDisplayChessRank);
M(0x10000008, 0x10000090, InputAction.ToggleCharacterOptionDisplayFishingSkill);
M(0x10000008, 0x10000091, InputAction.ToggleCharacterOptionDisplayNumberDeaths);
M(0x10000008, 0x10000092, InputAction.ToggleCharacterOptionDisplayTimeStamps);
M(0x10000008, 0x10000093, InputAction.ToggleCharacterOptionSalvageMultiple);
M(0x10000008, 0x1000010E, InputAction.ToggleCharacterOptionListenToGeneralChat);
M(0x10000008, 0x1000010F, InputAction.ToggleCharacterOptionListenToTradeChat);
M(0x10000008, 0x10000110, InputAction.ToggleCharacterOptionListenToLfgChat);
M(0x10000008, 0x10000112, InputAction.ToggleCharacterOptionListenToRoleplayChat);
M(0x10000008, 0x1000011B, InputAction.ToggleCharacterOptionDisplayNumberCharacterTitles);
M(0x10000008, 0x1000011D, InputAction.ToggleCharacterOptionMainPackPreferred);
M(0x10000008, 0x1000011E, InputAction.ToggleCharacterOptionLeadMissileTargets);
M(0x10000008, 0x1000011F, InputAction.ToggleCharacterOptionUseFastMissiles);
M(0x10000008, 0x10000120, InputAction.ToggleCharacterOptionFilterLanguage);
M(0x10000008, 0x10000123, InputAction.ToggleCharacterOptionConfirmVolatileRareUse);
M(0x10000008, 0x10000125, InputAction.ToggleCharacterOptionListenToSocietyChat);
M(0x10000008, 0x1000012A, InputAction.ToggleCharacterOptionShowHelm);
M(0x10000008, 0x1000012C, InputAction.ToggleCharacterOptionDisableDistanceFog);
M(0x10000008, 0x1000012F, InputAction.ToggleCharacterOptionShowCloak);
M(0x10000008, 0x1000013E, InputAction.ToggleCharacterOptionSideBySideVitals);
return t;
}

View file

@ -13,17 +13,17 @@ namespace AcDream.UI.Abstractions.Input;
/// <see cref="Key"/> enum.
///
/// <para>
/// The scan-code table covers exactly the 84 distinct DIK codes that appear across
/// the DAT's 306 user-bindable ActionMap rows' default bindings (2026-08-11 probe —
/// see <c>RetailActionMap.cs</c>'s class doc), cross-checked against
/// The scan-code table covers the 84 distinct DIK codes that appear across the
/// DAT's 306 user-bindable ActionMap rows' default bindings (2026-08-11 probe),
/// plus the remaining keyboard controls accepted by retail's plain-text keymap
/// interchange. The default set was cross-checked against
/// <c>tools/dump-keymap/Program.cs</c>'s own <c>Dik(uint)</c> transcription (itself
/// verified against <c>acclient_2013_pseudo_c.txt</c>'s
/// <c>ControlNameMapper::AddKeySemantic</c> calls) and against
/// <see cref="KeyBindings.RetailDefaults"/>'s existing chords, which already encode
/// the same standard US-layout DirectInput scan codes by construction (both were
/// authored from the same <c>retail-default.keymap.txt</c>). Codes outside this set
/// (rare/debug/joystick bindings never seen with a non-empty default in the shipped
/// DAT) intentionally return null rather than guess.
/// authored from the same <c>retail-default.keymap.txt</c>). Unsupported joystick
/// controls intentionally return null rather than guess.
/// </para>
/// </summary>
public static class RetailScanCodeMap
@ -94,6 +94,7 @@ public static class RetailScanCodeMap
0x1A => Key.LeftBracket,
0x1B => Key.RightBracket,
0x1C => Key.Enter,
0x1D => Key.ControlLeft,
0x1E => Key.A,
0x1F => Key.S,
0x20 => Key.D,
@ -120,7 +121,9 @@ public static class RetailScanCodeMap
0x35 => Key.Slash,
0x36 => Key.ShiftRight,
0x37 => Key.KeypadMultiply,
0x38 => Key.AltLeft,
0x39 => Key.Space,
0x3A => Key.CapsLock,
0x3B => Key.F1,
0x3C => Key.F2,
0x3D => Key.F3,
@ -148,9 +151,15 @@ public static class RetailScanCodeMap
0x53 => Key.KeypadDecimal,
0x57 => Key.F11,
0x58 => Key.F12,
0x64 => Key.F13,
0x65 => Key.F14,
0x66 => Key.F15,
0x9C => Key.KeypadEnter,
0x9D => Key.ControlRight,
0xB5 => Key.KeypadDivide,
0xB7 => Key.PrintScreen,
0xB8 => Key.AltRight,
0xC5 => Key.Pause,
0xC7 => Key.Home,
0xC8 => Key.Up,
0xC9 => Key.PageUp,
@ -161,7 +170,163 @@ public static class RetailScanCodeMap
0xD1 => Key.PageDown,
0xD2 => Key.Insert,
0xD3 => Key.Delete,
0xDB => Key.SuperLeft,
0xDC => Key.SuperRight,
0xDD => Key.Menu,
_ => null,
};
}
/// <summary>
/// Retail's plain-text <c>.keymap</c> control semantic to the same device /
/// scan-code pair consumed by <see cref="ToSilkKey"/>. The legacy file
/// uses a few historical aliases (<c>UPARROW</c>, <c>PGUP</c>,
/// <c>NUMPADSTAR</c>, ...), so parsing accepts both those spellings and the
/// canonical DirectInput spellings emitted by <see cref="TryToFileControl"/>.
/// </summary>
public static bool TryFromFileControl(
string control,
out uint scan,
out uint device)
{
scan = 0u;
device = 0u;
if (string.IsNullOrWhiteSpace(control))
return false;
string token = control.Trim().ToUpperInvariant();
if (token.StartsWith("DIMOFS_BUTTON", StringComparison.Ordinal)
&& int.TryParse(token["DIMOFS_BUTTON".Length..], out int button)
&& button is >= 0 and <= 4)
{
scan = (uint)(0x0C + button);
device = 1u;
return true;
}
if (!token.StartsWith("DIK_", StringComparison.Ordinal))
return false;
token = token[4..];
scan = token switch
{
"ESCAPE" => 0x01,
"1" => 0x02, "2" => 0x03, "3" => 0x04, "4" => 0x05,
"5" => 0x06, "6" => 0x07, "7" => 0x08, "8" => 0x09,
"9" => 0x0A, "0" => 0x0B,
"MINUS" => 0x0C, "EQUALS" => 0x0D, "BACK" => 0x0E,
"TAB" => 0x0F,
"Q" => 0x10, "W" => 0x11, "E" => 0x12, "R" => 0x13,
"T" => 0x14, "Y" => 0x15, "U" => 0x16, "I" => 0x17,
"O" => 0x18, "P" => 0x19,
"LBRACKET" => 0x1A, "RBRACKET" => 0x1B, "RETURN" => 0x1C,
"LCONTROL" => 0x1D,
"A" => 0x1E, "S" => 0x1F, "D" => 0x20, "F" => 0x21,
"G" => 0x22, "H" => 0x23, "J" => 0x24, "K" => 0x25,
"L" => 0x26, "SEMICOLON" => 0x27, "APOSTROPHE" => 0x28,
"GRAVE" => 0x29, "LSHIFT" => 0x2A, "BACKSLASH" => 0x2B,
"Z" => 0x2C, "X" => 0x2D, "C" => 0x2E, "V" => 0x2F,
"B" => 0x30, "N" => 0x31, "M" => 0x32,
"COMMA" => 0x33, "PERIOD" => 0x34, "SLASH" => 0x35,
"RSHIFT" => 0x36, "MULTIPLY" or "NUMPADSTAR" => 0x37,
"LMENU" or "LALT" => 0x38, "SPACE" => 0x39, "CAPITAL" => 0x3A,
"F1" => 0x3B, "F2" => 0x3C, "F3" => 0x3D, "F4" => 0x3E,
"F5" => 0x3F, "F6" => 0x40, "F7" => 0x41, "F8" => 0x42,
"F9" => 0x43, "F10" => 0x44, "NUMLOCK" => 0x45,
"SCROLL" => 0x46, "NUMPAD7" => 0x47, "NUMPAD8" => 0x48,
"NUMPAD9" => 0x49, "SUBTRACT" or "NUMPADMINUS" => 0x4A,
"NUMPAD4" => 0x4B, "NUMPAD5" => 0x4C, "NUMPAD6" => 0x4D,
"ADD" or "NUMPADPLUS" => 0x4E, "NUMPAD1" => 0x4F,
"NUMPAD2" => 0x50, "NUMPAD3" => 0x51, "NUMPAD0" => 0x52,
"DECIMAL" or "NUMPADPERIOD" => 0x53,
"F11" => 0x57, "F12" => 0x58, "F13" => 0x64,
"F14" => 0x65, "F15" => 0x66, "NUMPADENTER" => 0x9C,
"RCONTROL" => 0x9D, "DIVIDE" or "NUMPADSLASH" => 0xB5,
"SYSRQ" => 0xB7, "RMENU" or "RALT" => 0xB8,
"PAUSE" => 0xC5, "HOME" => 0xC7,
"UP" or "UPARROW" => 0xC8, "PRIOR" or "PGUP" => 0xC9,
"LEFT" => 0xCB, "RIGHT" or "RIGHTARROW" => 0xCD,
"END" => 0xCF, "DOWN" or "DOWNARROW" => 0xD0,
"NEXT" or "PGDN" => 0xD1, "INSERT" => 0xD2,
"DELETE" => 0xD3, "LWIN" => 0xDB, "RWIN" => 0xDC,
"APPS" => 0xDD,
_ => uint.MaxValue,
};
return scan != uint.MaxValue;
}
/// <summary>Converts an acdream chord to retail's plain-text control
/// semantic. Returns false for controls the 2013 DirectInput keymap cannot
/// represent (for example joystick axes).</summary>
public static bool TryToFileControl(KeyChord chord, out string control)
{
control = string.Empty;
if (chord.Device == 1)
{
int button = (int)chord.Key switch
{
-1001 => 0,
-1002 => 1,
-1003 => 2,
-1004 => 3,
-1005 => 4,
_ => -1,
};
if (button < 0) return false;
control = $"DIMOFS_BUTTON{button}";
return true;
}
if (chord.Device != 0) return false;
for (uint scan = 1; scan <= 0xDD; scan++)
{
if (ToSilkKey(scan, 0) != chord.Key) continue;
control = scan switch
{
0x37 => "DIK_NUMPADSTAR",
0x4A => "DIK_NUMPADMINUS",
0x4E => "DIK_NUMPADPLUS",
0xB5 => "DIK_NUMPADSLASH",
0xC8 => "DIK_UPARROW",
0xC9 => "DIK_PGUP",
0xCD => "DIK_RIGHTARROW",
0xD0 => "DIK_DOWNARROW",
0xD1 => "DIK_PGDN",
_ => FileToken(scan),
};
return control.Length != 0;
}
return false;
}
private static string FileToken(uint scan) => scan switch
{
0x01 => "DIK_ESCAPE",
>= 0x02 and <= 0x0A => $"DIK_{scan - 1}",
0x0B => "DIK_0", 0x0C => "DIK_MINUS", 0x0D => "DIK_EQUALS",
0x0E => "DIK_BACK", 0x0F => "DIK_TAB",
>= 0x10 and <= 0x19 => $"DIK_{"QWERTYUIOP"[(int)(scan - 0x10)]}",
0x1A => "DIK_LBRACKET", 0x1B => "DIK_RBRACKET",
0x1C => "DIK_RETURN", 0x1D => "DIK_LCONTROL",
>= 0x1E and <= 0x26 => $"DIK_{"ASDFGHJKL"[(int)(scan - 0x1E)]}",
0x27 => "DIK_SEMICOLON", 0x28 => "DIK_APOSTROPHE",
0x29 => "DIK_GRAVE", 0x2A => "DIK_LSHIFT", 0x2B => "DIK_BACKSLASH",
>= 0x2C and <= 0x32 => $"DIK_{"ZXCVBNM"[(int)(scan - 0x2C)]}",
0x33 => "DIK_COMMA", 0x34 => "DIK_PERIOD", 0x35 => "DIK_SLASH",
0x36 => "DIK_RSHIFT", 0x38 => "DIK_LMENU", 0x39 => "DIK_SPACE",
0x3A => "DIK_CAPITAL",
>= 0x3B and <= 0x44 => $"DIK_F{scan - 0x3A}",
0x45 => "DIK_NUMLOCK", 0x46 => "DIK_SCROLL",
0x47 => "DIK_NUMPAD7", 0x48 => "DIK_NUMPAD8", 0x49 => "DIK_NUMPAD9",
0x4B => "DIK_NUMPAD4", 0x4C => "DIK_NUMPAD5", 0x4D => "DIK_NUMPAD6",
0x4F => "DIK_NUMPAD1", 0x50 => "DIK_NUMPAD2", 0x51 => "DIK_NUMPAD3",
0x52 => "DIK_NUMPAD0", 0x53 => "DIK_DECIMAL",
0x57 => "DIK_F11", 0x58 => "DIK_F12", 0x64 => "DIK_F13",
0x65 => "DIK_F14", 0x66 => "DIK_F15", 0x9C => "DIK_NUMPADENTER",
0x9D => "DIK_RCONTROL", 0xB7 => "DIK_SYSRQ", 0xB8 => "DIK_RALT",
0xC5 => "DIK_PAUSE", 0xC7 => "DIK_HOME",
0xCB => "DIK_LEFT", 0xCF => "DIK_END", 0xD2 => "DIK_INSERT",
0xD3 => "DIK_DELETE", 0xDB => "DIK_LWIN", 0xDC => "DIK_RWIN",
0xDD => "DIK_APPS",
_ => string.Empty,
};
}

View file

@ -6,15 +6,13 @@ using System.Text.Json;
namespace AcDream.UI.Abstractions.Input;
/// <summary>
/// Campaign OP slice OP8: persisted bindings for DAT ActionMap rows that
/// <c>RetailActionIdentityTable</c> has no <see cref="InputAction"/> for —
/// mostly Emotes and CharacterSettings hotkeys (see that table's class doc for
/// the full accounting). These rows still render, bind, conflict-check, and
/// persist on the Configure Keyboard screen exactly like a mapped row; they
/// just have no live gameplay consumer to dispatch through yet, so they live in
/// their own small store rather than <see cref="KeyBindings"/>'s
/// <see cref="InputAction"/>-keyed schema. Sibling file next to
/// <c>keybinds.json</c> (D4 — no <c>.keymap</c> file interchange).
/// Forward-compatible persisted bindings for an ActionMap row introduced by a
/// future DAT revision. Campaign KB maps every one of the 306 rows in the
/// supported Sept-2013 EoR DAT, so this sibling file has no production entries
/// there; it only keeps an unknown future row visible and round-trippable
/// instead of crashing an older client. The compatibility sibling file stays
/// beside <c>keybinds.json</c>; installed-retail rows use the canonical
/// <c>*.keymap</c> profile instead.
/// </summary>
public sealed class RetailUnmappedKeyBindings
{

View file

@ -59,6 +59,12 @@ public sealed class ChatVM : IDisposable, IChatCommandFeedback
public string? LastOutgoingTellTarget =>
_commandTargets.LastOutgoingTellTarget;
public string? LastMonarchSender =>
_commandTargets.LastMonarchSender;
public string? LastPatronSender =>
_commandTargets.LastPatronSender;
/// <summary>
/// Optional callback exposing the live framerate. Wired by
/// <c>GameWindow</c> at construction so the client-side