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

@ -20,9 +20,8 @@ namespace AcDream.App.Tests.UI.Layout;
///
/// <para>
/// Reworked at the 2026-08-11 combined review (M1/M2/M3/S1/S4): the seam now
/// carries <see cref="Binding"/> (chord + activation + scope), the M2 fix means
/// only ONE camera InputMap context maps live, and conflicts open a real confirm
/// dialog instead of auto-reassigning silently.
/// carries <see cref="Binding"/> (chord + activation + scope). Campaign KB gives
/// both camera contexts distinct live identities and removes the store-only tier.
/// </para>
/// </summary>
public sealed class KeyboardConfigControllerTests
@ -55,7 +54,16 @@ public sealed class KeyboardConfigControllerTests
uint inputMapId, uint actionId, RetailActionClass cls,
uint labelHash = 0, uint tooltipHash = 0,
params RetailKeyChord[] defaults) =>
new(inputMapId, actionId, cls, labelHash, tooltipHash, defaults);
new(
inputMapId,
actionId,
cls,
labelHash == 0 ? 0xDE000000u | (actionId & 0x00FFFFFFu) : labelHash,
tooltipHash,
defaults);
private static string? ResolveSyntheticString(uint _, uint hash) =>
(hash & 0xFF000000u) == 0xDE000000u ? $"Action {hash & 0x00FFFFFFu:X}" : null;
private sealed class FakeBindings
{
@ -72,6 +80,9 @@ public sealed class KeyboardConfigControllerTests
public List<uint> InstructionCloses { get; } = new();
public uint NextInstructionContext { get; set; } = 7u;
public bool WireInstructions { get; set; }
public string CurrentKeymapFilename { get; set; } = "acdream.keymap";
public Action? PendingLoadCompleted { get; private set; }
public Action? PendingSaveCompleted { get; private set; }
public void Capture(KeyChord? chord)
{
@ -103,8 +114,26 @@ public sealed class KeyboardConfigControllerTests
BeginCapture: cb => PendingCapture = cb,
Save: () => SaveCalls++,
Toggle: () => ToggleCalls++,
DisplaySystemMessage: msg => Messages.Add(msg),
NonBindableRefusalText: "cannot overwrite",
ResolveTemplate: (key, variables) => key switch
{
"ID_ActionKeyMap_ButtonLabel" => variables[DatStringResolver.ComputeHash("LABEL")],
"ID_ActionKeyMap_TT_ExistingBinding" =>
$"({variables[DatStringResolver.ComputeHash("VALUE")]}) existing binding",
"ID_ActionKeyMap_TT_NewBinding" => "new binding",
"ID_ActionKeyMap_NonUserBindableBinding" =>
$"cannot overwrite {variables[DatStringResolver.ComputeHash("KEY")]}",
"ID_ActionKeyMap_OverwriteExistingBinding" =>
$"overwrite {variables[DatStringResolver.ComputeHash("KEY")]} "
+ variables[DatStringResolver.ComputeHash("ACTION")],
"ID_ActionKeyMap_Binding" =>
$"{variables[DatStringResolver.ComputeHash("ACTION")]} "
+ $"({variables[DatStringResolver.ComputeHash("KEY")]})",
"ID_ActionKeyMap_OverwriteExistingBindings" =>
$"overwrite {variables[DatStringResolver.ComputeHash("KEY")]}\n"
+ variables[DatStringResolver.ComputeHash("BINDINGS")],
_ => null,
},
ShowMessage: msg => Messages.Add(msg),
ConfirmOverwrite: (message, onResult) => PendingConfirm = (message, onResult),
OpenCaptureInstructions: WireInstructions
? label =>
@ -114,11 +143,23 @@ public sealed class KeyboardConfigControllerTests
}
: null,
CloseCaptureInstructions: context => InstructionCloses.Add(context));
public KeyboardConfigController.Bindings ToProfileBindings() =>
ToBindings() with
{
CurrentKeymapFilename = () => this.CurrentKeymapFilename,
OpenLoadKeymap = completed => PendingLoadCompleted = completed,
OpenSaveKeymap = completed => PendingSaveCompleted = completed,
};
}
private static readonly KeyChord ChordW = new(Silk.NET.Input.Key.W, ModifierMask.None);
private static readonly KeyChord ChordUp = new(Silk.NET.Input.Key.Up, ModifierMask.None);
private static readonly KeyChord ChordA = new(Silk.NET.Input.Key.A, ModifierMask.None);
private static readonly KeyChord LeftMouse = new(
InputDispatcher.MouseButtonToKey(Silk.NET.Input.MouseButton.Left),
ModifierMask.None,
Device: 1);
[Fact]
public void Bind_Succeeds_AndBuildsOneRowPerSnapshotRow()
@ -133,7 +174,7 @@ public sealed class KeyboardConfigControllerTests
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
var fake = new FakeBindings();
KeyboardConfigController? controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings());
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings());
Assert.NotNull(controller);
Assert.Equal(3, controller!.Rows.Count);
@ -158,7 +199,7 @@ public sealed class KeyboardConfigControllerTests
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
var fake = new FakeBindings();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
Assert.NotNull(controller);
UiTabPanel tabHost = Assert.IsType<UiTabPanel>(layout.FindElement(0x1000049Bu));
@ -188,28 +229,28 @@ public sealed class KeyboardConfigControllerTests
}
[Fact]
public void Bind_MapsKnownActionsAndLeavesUnknownOnesUnmapped()
public void Bind_MapsEveryRetailActionRow()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x4, 0x29, RetailActionClass.Movement), // -> MovementForward
Row(0x10000006, 0x100000A0, RetailActionClass.Emote), // "Bow Deep" -> no InputAction
Row(0x10000006, 0x100000A0, RetailActionClass.Emote), // -> EmoteBowDeep
});
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
var fake = new FakeBindings();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView forward = controller.Rows.Single(r => r.ActionId == 0x29u);
Assert.Equal(InputAction.MovementForward, forward.MappedAction);
KeyboardConfigController.RowView bowDeep = controller.Rows.Single(r => r.ActionId == 0x100000A0u);
Assert.Null(bowDeep.MappedAction);
Assert.Equal(InputAction.EmoteBowDeep, bowDeep.MappedAction);
}
[Fact]
public void Bind_SeedsRowFromLiveBindings_MappedAndUnmapped()
public void Bind_SeedsEveryRowFromLiveBindings()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
@ -223,11 +264,14 @@ public sealed class KeyboardConfigControllerTests
new(ChordW, InputAction.MovementForward),
new(ChordUp, InputAction.MovementForward),
};
fake.Unmapped[(0x10000006u, 0x100000A0u)] = new List<KeyChord> { ChordA };
fake.Mapped[InputAction.EmoteBowDeep] = new List<Binding>
{
new(ChordA, InputAction.EmoteBowDeep),
};
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView forward = controller.Rows.Single(r => r.ActionId == 0x29u);
Assert.Equal(new[] { ChordW, ChordUp }, forward.Model.Current);
@ -243,7 +287,7 @@ public sealed class KeyboardConfigControllerTests
var fake = new FakeBindings();
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
Assert.NotEmpty(row.KeyButtons);
@ -257,8 +301,8 @@ public sealed class KeyboardConfigControllerTests
Assert.Equal(InputAction.MovementForward, written.Action);
Binding onlyBinding = Assert.Single(written.Value);
Assert.Equal(ChordW, onlyBinding.Chord);
// No live binding existed at build time — falls back to the Binding
// record's own defaults (Press/Game), same as before M1.
// No live binding existed at build time — falls back to the retail
// action identity's activation/scope metadata.
Assert.Equal(ActivationType.Press, onlyBinding.Activation);
Assert.Equal(InputScope.Game, onlyBinding.Scope);
Assert.Equal("W", row.KeyButtons[0].Label);
@ -272,7 +316,7 @@ public sealed class KeyboardConfigControllerTests
fake.Mapped[InputAction.MovementForward] = new List<Binding> { new(ChordW, InputAction.MovementForward) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
row.KeyButtons[0].OnClick!.Invoke();
@ -293,7 +337,7 @@ public sealed class KeyboardConfigControllerTests
var fake = new FakeBindings { WireInstructions = true, NextInstructionContext = 42u };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
row.KeyButtons[0].OnClick!.Invoke();
@ -314,7 +358,7 @@ public sealed class KeyboardConfigControllerTests
var fake = new FakeBindings { WireInstructions = true, NextInstructionContext = 9u };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
row.KeyButtons[0].OnClick!.Invoke();
@ -331,7 +375,7 @@ public sealed class KeyboardConfigControllerTests
var fake = new FakeBindings { WireInstructions = true, NextInstructionContext = 0u };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
row.KeyButtons[0].OnClick!.Invoke();
@ -354,7 +398,7 @@ public sealed class KeyboardConfigControllerTests
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
var requests = new List<(uint LayoutId, uint ElementId)>();
KeyboardConfigController? controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings(),
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings(),
resolveTemplateFont: (layoutId, elementId) =>
{
requests.Add((layoutId, elementId));
@ -382,7 +426,7 @@ public sealed class KeyboardConfigControllerTests
};
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
Assert.Equal(2, row.Model.Current.Count);
@ -400,7 +444,7 @@ public sealed class KeyboardConfigControllerTests
var fake = new FakeBindings();
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
Assert.Empty(row.Model.Current);
@ -411,17 +455,17 @@ public sealed class KeyboardConfigControllerTests
Assert.Empty(fake.MappedSets);
}
/// <summary>S4 (2026-08-11 review): clicking "Mapping 3" (slot index 2) on a
/// row with NO existing bindings must land the captured chord on display
/// index 2, not collapse it onto index 0.</summary>
/// <summary>Retail SetBinding clamps a requested slot past the dense
/// current-list tail to Count. Mapping 3 on an empty row therefore appends
/// at Mapping 1.</summary>
[Fact]
public void KeyButtonClick_OnSparseRow_ThirdSlotLandsOnThirdButton()
public void KeyButtonClick_PastDenseTail_AppendsAtFirstAvailableButton()
{
var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x29, RetailActionClass.Movement) });
var fake = new FakeBindings();
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
Assert.Equal(3, row.KeyButtons.Count);
@ -430,12 +474,10 @@ public sealed class KeyboardConfigControllerTests
row.KeyButtons[2].OnClick!.Invoke(); // "Mapping 3"
fake.Capture(ChordW);
Assert.Null(row.KeyButtons[0].Label);
Assert.Equal("W", row.KeyButtons[0].Label);
Assert.Null(row.KeyButtons[1].Label);
Assert.Equal("W", row.KeyButtons[2].Label);
Assert.Null(row.KeyButtons[2].Label);
// The write to the live seam only ever carries the REAL chord — no
// default(KeyChord) padding leaks into the persisted Binding list.
(InputAction Action, IReadOnlyList<Binding> Value) written = Assert.Single(fake.MappedSets);
Binding onlyBinding = Assert.Single(written.Value);
Assert.Equal(ChordW, onlyBinding.Chord);
@ -453,7 +495,7 @@ public sealed class KeyboardConfigControllerTests
fake.Mapped[InputAction.MovementBackup] = new List<Binding> { new(ChordA, InputAction.MovementBackup) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView forward = controller.Rows.Single(r => r.ActionId == 0x29u);
KeyboardConfigController.RowView backup = controller.Rows.Single(r => r.ActionId == 0x2Au);
@ -463,6 +505,7 @@ public sealed class KeyboardConfigControllerTests
// M3: nothing is applied yet — a confirm dialog is pending.
Assert.NotNull(fake.PendingConfirm);
Assert.Equal("overwrite A Action 2A", fake.PendingConfirm?.Message);
Assert.DoesNotContain(ChordA, forward.Model.Current);
Assert.Contains(ChordA, backup.Model.Current);
Assert.Empty(fake.Messages);
@ -473,6 +516,153 @@ public sealed class KeyboardConfigControllerTests
Assert.DoesNotContain(ChordA, backup.Model.Current);
}
[Fact]
public void Capture_BareShiftConflictWithRetailWalkMode_AlwaysPrompts()
{
var shift = new KeyChord(
Silk.NET.Input.Key.ShiftLeft,
ModifierMask.None);
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x4, 0x29, RetailActionClass.Movement), // Move forward
Row(0x4, 0x32, RetailActionClass.Movement), // Toggle walk/run
});
var fake = new FakeBindings();
fake.Mapped[InputAction.MovementWalkMode] =
[new Binding(
shift,
InputAction.MovementWalkMode,
ActivationType.Hold,
InputScope.Game)];
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout,
snapshot,
MakeTemplateResolver(),
ResolveSyntheticString,
fake.ToBindings())!;
KeyboardConfigController.RowView forward = controller.Rows.Single(
row => row.ActionId == 0x29u);
forward.KeyButtons[0].OnClick!.Invoke();
fake.Capture(shift);
Assert.NotNull(fake.PendingConfirm);
Assert.DoesNotContain(shift, forward.Model.Current);
Assert.Equal(
[shift],
controller.Rows.Single(row => row.ActionId == 0x32u).Model.Current);
}
[Fact]
public void Capture_ConflictWithMultipleRows_UsesRetailPluralBindingList()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x4, 0x29, RetailActionClass.Movement),
Row(0x4, 0x2A, RetailActionClass.Movement),
Row(0x4, 0x2B, RetailActionClass.Movement),
});
var fake = new FakeBindings();
fake.Mapped[InputAction.MovementBackup] =
new List<Binding> { new(ChordA, InputAction.MovementBackup) };
fake.Mapped[InputAction.MovementStop] =
new List<Binding> { new(ChordA, InputAction.MovementStop) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
controller.Rows.Single(row => row.ActionId == 0x29u).KeyButtons[0].OnClick!.Invoke();
fake.Capture(ChordA);
Assert.Equal(
"overwrite A\nAction 2A (A)\nAction 2B (A)",
fake.PendingConfirm?.Message);
}
[Fact]
public void Refresh_UsesRetailExistingAndNewBindingTooltipTemplates()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x4, 0x29, RetailActionClass.Movement),
});
var fake = new FakeBindings();
fake.Mapped[InputAction.MovementForward] =
new List<Binding> { new(ChordW, InputAction.MovementForward) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = Assert.Single(controller.Rows);
Assert.Equal("(W) existing binding", row.KeyButtons[0].TooltipText);
Assert.Equal("new binding", row.KeyButtons[1].TooltipText);
Assert.Equal("new binding", row.KeyButtons[2].TooltipText);
row.KeyButtons[0].OnRightClick!.Invoke();
Assert.Equal("new binding", row.KeyButtons[0].TooltipText);
}
[Fact]
public void Capture_ChordAlreadyInAnotherSlotOfSameRow_IsRetailNoOp()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x4, 0x29, RetailActionClass.Movement),
});
var fake = new FakeBindings();
fake.Mapped[InputAction.MovementForward] = new List<Binding>
{
new(ChordW, InputAction.MovementForward),
new(ChordUp, InputAction.MovementForward),
};
// Prove retail's same-row early return happens before the
// non-user-bindable conflict check too.
fake.Mapped[InputAction.AcdreamToggleAudioMute] = new List<Binding>
{
new(ChordW, InputAction.AcdreamToggleAudioMute),
};
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = Assert.Single(controller.Rows);
row.KeyButtons[2].OnClick!.Invoke();
fake.Capture(ChordW);
Assert.Equal(new[] { ChordW, ChordUp }, row.Model.Current);
Assert.Null(fake.PendingConfirm);
Assert.Empty(fake.Messages);
Assert.Empty(fake.MappedSets);
}
[Fact]
public void Capture_LeftOrRightMouseButton_RemainsArmedUntilSupportedInput()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x4, 0x29, RetailActionClass.Movement),
});
var fake = new FakeBindings { WireInstructions = true };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = Assert.Single(controller.Rows);
row.KeyButtons[0].OnClick!.Invoke();
fake.Capture(LeftMouse);
Assert.NotNull(fake.PendingCapture);
Assert.Empty(fake.InstructionCloses);
Assert.Empty(row.Model.Current);
fake.Capture(ChordW);
Assert.Null(fake.PendingCapture);
Assert.Equal(new[] { ChordW }, row.Model.Current);
Assert.Equal(new[] { 7u }, fake.InstructionCloses);
}
[Fact]
public void Capture_ConflictWithAnotherRow_DeclineLeavesBothRowsUnchanged()
{
@ -486,7 +676,7 @@ public sealed class KeyboardConfigControllerTests
fake.Mapped[InputAction.MovementBackup] = new List<Binding> { new(ChordA, InputAction.MovementBackup) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView forward = controller.Rows.Single(r => r.ActionId == 0x29u);
KeyboardConfigController.RowView backup = controller.Rows.Single(r => r.ActionId == 0x2Au);
@ -500,7 +690,71 @@ public sealed class KeyboardConfigControllerTests
}
[Fact]
public void Capture_ConflictWithNonBindableAcdreamAction_RefusesWithoutADialog()
public void Capture_SharedChordAcrossNonConflictingCombatContexts_KeepsBothBindings()
{
const uint meleeMap = 0x10000003u;
const uint missileMap = 0x10000004u;
var conflicts = new Dictionary<uint, IReadOnlySet<uint>>
{
[meleeMap] = new HashSet<uint> { meleeMap },
[missileMap] = new HashSet<uint> { missileMap },
};
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(meleeMap, 0x1000005Du, RetailActionClass.Combat),
Row(missileMap, 0x100000F1u, RetailActionClass.Combat),
}, conflicts);
var fake = new FakeBindings();
fake.Mapped[InputAction.CombatAimLow] =
new List<Binding> { new(ChordA, InputAction.CombatAimLow, Scope: InputScope.MissileCombat) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView melee = controller.Rows.Single(
row => row.InputMapId == meleeMap);
KeyboardConfigController.RowView missile = controller.Rows.Single(
row => row.InputMapId == missileMap);
melee.KeyButtons[0].OnClick!.Invoke();
fake.Capture(ChordA);
Assert.Null(fake.PendingConfirm);
Assert.Contains(ChordA, melee.Model.Current);
Assert.Contains(ChordA, missile.Model.Current);
}
[Fact]
public void Capture_SharedChordAcrossDatConflictingContexts_StillPrompts()
{
const uint movementMap = 0x4u;
const uint uiMap = 0x10000009u;
var conflicts = new Dictionary<uint, IReadOnlySet<uint>>
{
[movementMap] = new HashSet<uint> { movementMap, uiMap },
};
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(movementMap, 0x29u, RetailActionClass.Movement),
Row(uiMap, 0x10000019u, RetailActionClass.Ui),
}, conflicts);
var fake = new FakeBindings();
fake.Mapped[InputAction.ToggleInventoryPanel] =
new List<Binding> { new(ChordA, InputAction.ToggleInventoryPanel) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView movement = controller.Rows.Single(
row => row.InputMapId == movementMap);
movement.KeyButtons[0].OnClick!.Invoke();
fake.Capture(ChordA);
Assert.NotNull(fake.PendingConfirm);
Assert.DoesNotContain(ChordA, movement.Model.Current);
}
[Fact]
public void Capture_ConflictWithNonBindableAcdreamAction_ShowsRetailMessageDialog()
{
var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x29, RetailActionClass.Movement) });
var fake = new FakeBindings();
@ -510,16 +764,17 @@ public sealed class KeyboardConfigControllerTests
new List<Binding> { new(muteChord, InputAction.AcdreamToggleAudioMute) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView forward = controller.Rows.Single();
forward.KeyButtons[0].OnClick!.Invoke();
fake.Capture(muteChord);
// S1: refused outright, no confirm dialog offered.
// S1: refused outright, with the distinct retail message dialog and no
// overwrite-confirmation dialog.
Assert.Null(fake.PendingConfirm);
Assert.DoesNotContain(muteChord, forward.Model.Current);
Assert.Contains("cannot overwrite", fake.Messages);
Assert.Contains(fake.Messages, message => message.Contains("cannot overwrite"));
Assert.Equal(muteChord, Assert.Single(fake.Mapped[InputAction.AcdreamToggleAudioMute]).Chord);
}
@ -540,14 +795,14 @@ public sealed class KeyboardConfigControllerTests
new List<Binding> { new(sharedChord, InputAction.AcdreamToggleAudioMute) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView forward = controller.Rows.Single(r => r.ActionId == 0x29u);
forward.KeyButtons[0].OnClick!.Invoke();
fake.Capture(sharedChord);
Assert.Null(fake.PendingConfirm);
Assert.Contains("cannot overwrite", fake.Messages);
Assert.Contains(fake.Messages, message => message.Contains("cannot overwrite"));
Assert.DoesNotContain(sharedChord, forward.Model.Current);
}
@ -558,7 +813,7 @@ public sealed class KeyboardConfigControllerTests
var fake = new FakeBindings();
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
row.KeyButtons[0].OnClick!.Invoke();
@ -573,6 +828,102 @@ public sealed class KeyboardConfigControllerTests
Assert.Equal(1, fake.ToggleCalls);
}
[Fact]
public void LoadFile_ReplacesRowsAndRevertBaseline_AndRefreshesFilename()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x4, 0x29, RetailActionClass.Movement),
});
var fake = new FakeBindings();
fake.Mapped[InputAction.MovementForward] =
[new Binding(ChordW, InputAction.MovementForward)];
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString,
fake.ToProfileBindings())!;
((UiButton)layout.FindElement(0x10000027u)!).OnClick!.Invoke();
Assert.NotNull(fake.PendingLoadCompleted);
fake.Mapped[InputAction.MovementForward] =
[new Binding(ChordUp, InputAction.MovementForward)];
fake.CurrentKeymapFilename = "friends.keymap";
fake.PendingLoadCompleted!();
ActionKeyMapOptionRow row = controller.Rows.Single().Model;
Assert.Equal(new[] { ChordUp }, row.Current);
Assert.Equal(new[] { ChordUp }, row.Saved);
Assert.False(row.Changed);
UiText filename = (UiText)layout.FindElement(0x10000028u)!;
Assert.Equal("friends.keymap", Assert.Single(filename.LinesProvider()).Text);
}
[Fact]
public void SaveAs_RefreshesActiveFilenameOnlyAfterSuccessfulCallback()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x4, 0x29, RetailActionClass.Movement),
});
var fake = new FakeBindings();
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
_ = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString,
fake.ToProfileBindings());
UiText filename = (UiText)layout.FindElement(0x10000028u)!;
((UiButton)layout.FindElement(0x10000029u)!).OnClick!.Invoke();
Assert.NotNull(fake.PendingSaveCompleted);
Assert.Equal("acdream.keymap", Assert.Single(filename.LinesProvider()).Text);
fake.CurrentKeymapFilename = "alternate.keymap";
fake.PendingSaveCompleted!();
Assert.Equal("alternate.keymap", Assert.Single(filename.LinesProvider()).Text);
}
[Fact]
public void RevertButton_IsEnabledExactlyWhileWorkingMapDiffersFromSavedMap()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x4, 0x29, RetailActionClass.Movement),
});
var fake = new FakeBindings();
fake.Mapped[InputAction.MovementForward] =
new List<Binding> { new(ChordW, InputAction.MovementForward) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
UiButton revert = (UiButton)layout.FindElement(0x1000002Bu)!;
// gmKeyboardUI::OnOptionChanged @ 0x004DA890: Ghosted while clean.
Assert.False(revert.Enabled);
KeyboardConfigController.RowView row = controller.Rows.Single();
row.KeyButtons[0].OnClick!.Invoke();
fake.Capture(ChordUp);
Assert.True(controller.Page.Changed);
Assert.True(revert.Enabled);
revert.OnClick!.Invoke();
Assert.Equal(new[] { ChordW }, row.Model.Current);
Assert.False(controller.Page.Changed);
Assert.False(revert.Enabled);
// Defaults is also a live uncommitted edit when the DAT default does
// not equal the saved user map, and therefore re-enables Revert.
UiButton defaults = (UiButton)layout.FindElement(0x1000002Au)!;
defaults.OnClick!.Invoke();
Assert.True(controller.Page.Changed);
Assert.True(revert.Enabled);
UiButton ok = (UiButton)layout.FindElement(0x1000002Cu)!;
ok.OnClick!.Invoke();
Assert.False(controller.Page.Changed);
Assert.False(revert.Enabled);
}
[Fact]
public void CancelButton_RevertsUncommittedEditAndToggles()
{
@ -581,7 +932,7 @@ public sealed class KeyboardConfigControllerTests
fake.Mapped[InputAction.MovementForward] = new List<Binding> { new(ChordW, InputAction.MovementForward) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
row.KeyButtons[0].OnClick!.Invoke();
@ -607,7 +958,7 @@ public sealed class KeyboardConfigControllerTests
fake.Mapped[InputAction.MovementForward] = new List<Binding> { new(ChordUp, InputAction.MovementForward) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
Assert.Equal(new[] { ChordUp }, row.Model.Current);
@ -639,7 +990,7 @@ public sealed class KeyboardConfigControllerTests
};
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
UiButton defaultsButton = (UiButton)layout.FindElement(0x1000002Au)!;
defaultsButton.OnClick!.Invoke();
@ -665,7 +1016,7 @@ public sealed class KeyboardConfigControllerTests
};
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
row.KeyButtons[0].OnClick!.Invoke();
@ -695,20 +1046,24 @@ public sealed class KeyboardConfigControllerTests
Row(0x6, 0x35, RetailActionClass.Camera, defaults: new[] { new RetailKeyChord(0xCB, 0, 0, 3) }),
});
var fake = new FakeBindings();
fake.Mapped[InputAction.CameraRotateLeft] =
[new Binding(ChordA, InputAction.CameraRotateLeft)];
fake.Mapped[InputAction.CameraAlternateRotateLeft] =
[new Binding(
ChordUp,
InputAction.CameraAlternateRotateLeft,
Scope: InputScope.Camera)];
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView ctx5 = controller.Rows.Single(r => r.InputMapId == 0x5u);
KeyboardConfigController.RowView ctx6 = controller.Rows.Single(r => r.InputMapId == 0x6u);
Assert.Equal(InputAction.CameraRotateLeft, ctx5.MappedAction);
Assert.Null(ctx6.MappedAction); // unmapped — no live dual-binding infrastructure (M2)
Assert.Equal(InputAction.CameraAlternateRotateLeft, ctx6.MappedAction);
// Round-2 SHOULD-FIX: an unmapped row with no persisted chords now
// DISPLAYS its DAT defaults (retail shows the arrow keys; blank read
// as "unbound"). Display-only — storage stays untouched until the
// user edits THIS row.
// Both rows display their independent live bindings.
Assert.NotEmpty(ctx6.Model.Current);
var ctx6InitialDisplay = ctx6.Model.Current.ToArray();
@ -717,13 +1072,15 @@ public sealed class KeyboardConfigControllerTests
fake.Capture(ChordW);
Assert.Contains(ChordW, ctx5.Model.Current);
Assert.Equal(ctx6InitialDisplay, ctx6.Model.Current); // unchanged by ctx5's edit
Assert.Empty(fake.Unmapped); // ctx6's STORE untouched — display seeding writes nothing
Assert.Empty(fake.Unmapped);
ctx6.KeyButtons[0].OnClick!.Invoke();
fake.Capture(ChordA);
Assert.Contains(ChordA, ctx6.Model.Current);
Assert.Contains(ChordW, ctx5.Model.Current); // ctx5 unaffected by ctx6's edit
Assert.True(fake.Unmapped.ContainsKey((0x6u, 0x35u)));
Assert.Contains(fake.MappedSets, write =>
write.Action == InputAction.CameraAlternateRotateLeft
&& write.Value.Any(binding => binding.Chord == ChordA));
}
[Fact]
@ -756,30 +1113,26 @@ public sealed class KeyboardConfigControllerTests
+ $"and (0x{mapId:X}, 0x{actionId:X}) — aliasing reintroduces the M2 twin-row clobber.");
seen[action] = (mapId, actionId);
}
Assert.True(seen.Count > 100, $"sanity: only {seen.Count} mapped actions seen");
Assert.Equal(306, seen.Count);
}
// ── AD-78 caption dimming (user-directed, 2026-08-11, gate 2) ───────────
[Fact]
public void UnmappedRows_DimTheirCaption_MappedRowsStayWhite()
public void EveryRetailRowCaptionIsEnabled()
{
// AP-203's store-only set: a row whose RetailActionIdentityTable
// lookup fails (MappedAction null -- mostly Emotes/CharacterSettings)
// never reaches the InputDispatcher, so its caption dims. Wiring a
// future mapping for "Bow Deep" (or any other unmapped row) means
// this assertion flips from StoreOnlyCaptionColor to Vector4.One --
// a conscious edit, not a silent pass.
// Campaign KB removes AP-203's store-only set. Every DAT row now has
// a live dispatcher identity and uses the enabled retail caption color.
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x4, 0x29, RetailActionClass.Movement), // -> MovementForward (mapped)
Row(0x10000006, 0x100000A0, RetailActionClass.Emote), // "Bow Deep" -> unmapped
Row(0x10000006, 0x100000A0, RetailActionClass.Emote), // -> EmoteBowDeep
});
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
var fake = new FakeBindings();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView forward = controller.Rows.Single(r => r.ActionId == 0x29u);
Assert.NotNull(forward.MappedAction);
@ -787,9 +1140,9 @@ public sealed class KeyboardConfigControllerTests
Assert.Equal(Vector4.One, forwardCaption.DefaultColor);
KeyboardConfigController.RowView bowDeep = controller.Rows.Single(r => r.ActionId == 0x100000A0u);
Assert.Null(bowDeep.MappedAction);
Assert.Equal(InputAction.EmoteBowDeep, bowDeep.MappedAction);
UiText bowDeepCaption = RowCaption(bowDeep);
Assert.Equal(UiRenderContext.StoreOnlyCaptionColor, bowDeepCaption.DefaultColor);
Assert.Equal(Vector4.One, bowDeepCaption.DefaultColor);
}
/// <summary>The row's synthesized caption (composed beside the authored key