fix(ui): OP8 rework — activation/scope preservation, camera-row de-alias, conflict-confirm dialog

Fixes the three MUST-FIX findings from the 2026-08-11 combined dual-lens
review of commit b4edee97 (docs/research/2026-08-11-op8-review.md).

M1 — SetForAction destroyed ActivationType/InputScope on every write,
collapsing walk-mode's Hold, the three combat-scoped bindings, and
CameraInstantMouseLook's mouse chord the instant a row (including
Defaults, which touches all ~140 mapped rows at once) wrote back.
Widened the Bindings seam to carry the full Binding (chord + activation
+ scope), not a bare chord: KeyboardConfigController captures each
row's live Activation/Scope ONCE at build time (every multi-chord
action in KeyBindings.RetailDefaults() shares one pair across all its
bindings) and reapplies it on every write — rebind, Cancel/Revert, and
Defaults (which restores DAT-sourced KEYS only, never touches the
pair). New tests pin this across both Defaults and Cancel for a
Hold+MeleeCombat-scoped action.

M2 — InputMap 0x5 (CameraControls) and 0x6 (CameraAlternateControls)
aliased one InputAction each: both rows read/wrote the same live target,
so they showed identical stale chords, a rebind of one silently wiped
the other, and a row could conflict with its own twin. Building real
per-scheme dual-binding storage (or new InputAction members plus the
camera-dispatch code to consume them) is a feature, not a one-line fix.
Chose the third option: only ctx 0x5 — the scheme RetailDefaults()
actually has live support for — maps to InputAction; ctx 0x6 falls
through to the existing unmapped/store-only path (AP-203), fully
rendered, bindable, and persisted, honestly carrying no live effect.
This also retired 10 stale allowlist entries in the DAT-vs-
RetailDefaults() round-trip test: with the alias gone, ctx 0x5 alone
matches RetailDefaults() exactly for all twelve Camera actions.

M3 — the auto-reassign-on-conflict path was wired silent in production
(NotifyReassigned: _ => "") though the contract asked for a prompt and
retail confirms before overwriting (OpenOverwriteBindingDialog). Wired
a real confirm dialog through RetailDialogFactory.MakeConfirmation —
the same seam GameplayConfirmationController already uses — read
lazily since DialogFactory mounts after MountKeyboardConfig in
Initialize()'s order. Only reassigns on accept; decline leaves every
row untouched. AP-204 (which recorded the narrowing) is RETIRED; the
still-true OK/Cancel left-click-vs-right-click-release note moves to a
code comment (zero observable difference, doesn't warrant a register
row). Reverted the gate script's step 9 from documenting the silent
shape back to the real confirm-prompt behavior.

SHOULD-FIX addressed as one-liners in files already touched:
- S1: non-user-bindable conflicts are now checked BEFORE any row
  conflict (retail's own order), and ALL conflicting rows are collected
  (N-way), not just the first match.
- S3: Save wraps the file-write pair in the same try/catch
  RuntimeKeyBindingTarget.Apply already uses for keybinds.json.
- S4: assigning "Mapping 3" on a row with no existing bindings now
  lands on display index 2, not index 0 — ReplaceSlotValue trims only
  TRAILING empty slots instead of stripping every default(KeyChord).
  Right-click on an already-empty slot is now a no-op instead of
  shifting later bindings.
- S6: UiButton.OnRightClick returns false (unhandled, bubbles to
  parent) when no handler is set, disabled or not — matching the
  pre-existing behavior the class doc already claimed.

Left for a future pass (not one-liners): S2 (ActionMap.ConflictingMaps
is still unread — the conflict scan treats all 306 rows as one flat
universe instead of respecting the DAT's own legitimately-shared-key
table) and S5 (the ~330 DAT layout imports still run eagerly at mount
instead of lazily on first open).

19 KeyboardConfigControllerTests (was 12): +2 activation/scope
preservation (Defaults, Cancel), +1 camera de-alias, +2 confirm-dialog
accept/decline, +1 non-bindable-takes-priority-over-row-conflict, +1
sparse-row third-slot placement. Full solution suite 13,154 passed / 4
skipped / 0 failed (this round's baseline 13,147/4/0, zero regressions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-11 09:53:10 +02:00
parent b4edee970f
commit b1968ce980
8 changed files with 521 additions and 163 deletions

File diff suppressed because one or more lines are too long

View file

@ -910,13 +910,16 @@ line calling it INERT no longer applies.
8. **Still on Movement, click "Move Backward"'s first key button** 8. **Still on Movement, click "Move Backward"'s first key button**
(currently "X"), then press **W** — the SAME key you just confirmed (currently "X"), then press **W** — the SAME key you just confirmed
is bound to "Move Forward." is bound to "Move Forward."
9. **Expect**: "Move Backward" takes W, and "Move Forward" silently 9. **Expect**: a confirmation dialog opens (retail's own
loses its W slot (down to just "U" from step 5) — the SAME outcome `OpenOverwriteBindingDialog`, ported through the same
you'd get after confirming retail's own "reassign?" dialog, except `RetailDialogFactory` confirm mechanism the game's other Yes/No
this port applies it immediately without asking first (AP-204, prompts already use) naming "Move Forward" as the row that currently
register row — a deliberate scope narrowing for this slice; retail holds the key and asking whether to reassign it to "Move Backward."
shows a confirm dialog before reassigning). No error, no crash, both **Click Yes**: "Move Backward" takes W, and "Move Forward" loses its
rows' key-button labels update to reflect the swap. W slot (down to just "U" from step 5); both rows' key-button labels
update to reflect the swap. Repeat steps 8-9 once more but **click
No** this time: neither row should change at all — the capture is
simply abandoned, exactly like Escape.
### Non-bindable refusal ### Non-bindable refusal
@ -989,9 +992,9 @@ line calling it INERT no longer applies.
explicitly guards against — a regression here would mean one page's explicitly guards against — a regression here would mean one page's
rows leaked into another, or the six reused element ids resolved to rows leaked into another, or the six reused element ids resolved to
the wrong page's instance). the wrong page's instance).
- Whether the silent auto-reassign (step 9) or the refusal message's - Whether the confirm dialog's wording (step 9) or the refusal
odd phrasing (step 10) feels wrong enough in practice to warrant message's odd phrasing (step 10, the literal DAT string) reads
building the real confirm-dialog integration AP-204 defers. awkwardly enough in practice to warrant a follow-up polish pass.
- Any row whose Emote/CharacterSettings binding visibly DOES something - Any row whose Emote/CharacterSettings binding visibly DOES something
in-game despite AP-203 saying it shouldn't (would mean acdream grew a in-game despite AP-203 saying it shouldn't (would mean acdream grew a
consumer for it since this table was written, and the identity table consumer for it since this table was written, and the identity table

View file

@ -81,21 +81,44 @@ namespace AcDream.App.UI.Layout;
/// </para> /// </para>
/// ///
/// <para> /// <para>
/// <b>Conflicts (research doc §5.4).</b> Retail's conflict model is N-way and /// <b>Activation/Scope preservation (M1, 2026-08-11 review).</b> A mapped row's
/// cross-input-map, with a DISTINCT refusal for a chord already bound to a /// <see cref="Bindings.CurrentForAction"/> read returns the FULL live
/// non-user-bindable action. This port scans every OTHER row on this screen /// <see cref="Binding"/> list, not bare chords — a single acdream
/// (the full user-bindable universe, since every DAT-sourced row is inherently /// <see cref="InputAction"/> consistently carries one <see cref="ActivationType"/>/
/// user-bindable — <see cref="RetailActionMapReader"/> already filtered out the /// <see cref="InputScope"/> pair across every one of its bindings (verified
/// non-bindable ones) PLUS the live <see cref="KeyBindings"/> table for chords /// against every multi-chord action in <c>KeyBindings.RetailDefaults()</c>:
/// bound to an acdream-only action with no <see cref="RetailActionIdentityTable"/> /// walk-mode's Hold, the three melee/missile/magic combat scopes, ...), so this
/// row at all (Ctrl+M mute, the debug F-keys, ...) — those are this port's /// row captures that pair ONCE at build time (from the first live binding, or
/// "non-user-bindable" analogue (there is no retail row to reassign them from) and /// <see cref="ActivationType.Press"/>/<see cref="InputScope.Game"/> if the action
/// refuse via <see cref="Bindings.NonBindableRefusalText"/> exactly like retail's /// starts wholly unbound) and reapplies it to every chord this row ever writes —
/// distinct <c>OpenCantOverwriteBindingDialog</c>. A genuine cross-row conflict /// on a live rebind, on Cancel/Revert (<c>RestoreSavedValue</c>), and on Defaults
/// (register row — narrowed from retail's modal confirm-before-reassign) auto- /// (<c>RestoreDefaultValue</c>, which restores DAT-sourced KEYS only; Activation/
/// reassigns (erases the losing row's slot, applies the new one) and reports the /// Scope are retail-side properties of the ACTION, not of which physical key
/// outcome via <see cref="Bindings.NotifyReassigned"/> rather than blocking on a /// triggers it, so Defaults must never touch them). Before this fix,
/// confirm dialog this slice does not build. /// <c>SetForAction</c> reconstructed every <see cref="Binding"/> with the
/// constructor's bare defaults (<see cref="ActivationType.Press"/>/
/// <see cref="InputScope.Game"/>), so a single click of Defaults collapsed the
/// Hold/scope of every one of the ~140 mapped actions in one shot — walk-mode
/// stopped unlatching, melee/missile/magic combat holds stopped repeating, and
/// scope precedence broke for every chord shared across those three scopes by
/// design (Insert/Delete/End/PageUp/PageDown).
/// </para>
///
/// <para>
/// <b>Conflicts (research doc §5.4, reworked per M3/S1, 2026-08-11 review).</b>
/// Retail's conflict model is N-way and cross-input-map, with a DISTINCT refusal
/// for a chord already bound to a non-user-bindable action, checked BEFORE any
/// user-bindable conflict is even considered (retail refuses outright the instant
/// ANY conflicting target is non-user-bindable). This port's non-user-bindable
/// analogue is a chord already bound to an acdream-only action with no
/// <see cref="RetailActionIdentityTable"/> row at all (Ctrl+M mute, the debug
/// F-keys, ...) — refused via <see cref="Bindings.NonBindableRefusalText"/>
/// exactly like retail's distinct <c>OpenCantOverwriteBindingDialog</c>, with no
/// dialog (a hard stop, matching the DAT-verified refusal string). A genuine
/// cross-row conflict collects EVERY conflicting row (not just the first) and
/// opens a real confirm dialog through <see cref="Bindings.ConfirmOverwrite"/> —
/// retail's <c>OpenOverwriteBindingDialog(&amp;conflicts)</c> — BEFORE reassigning;
/// only on accept are the losing rows' slots erased and the new chord applied.
/// </para> /// </para>
/// </summary> /// </summary>
public sealed class KeyboardConfigController public sealed class KeyboardConfigController
@ -152,10 +175,12 @@ public sealed class KeyboardConfigController
/// <summary>The live read/write/capture seam this screen writes bindings /// <summary>The live read/write/capture seam this screen writes bindings
/// through — mirrors every other Campaign OP page controller's /// through — mirrors every other Campaign OP page controller's
/// <c>Bindings</c> shape (a plain delegate record, no DAT/InputDispatcher /// <c>Bindings</c> shape (a plain delegate record, no DAT/InputDispatcher
/// dependency baked into the controller itself).</summary> /// dependency baked into the controller itself). <see cref="CurrentForAction"/>/
/// <see cref="SetForAction"/> carry the FULL <see cref="Binding"/> (chord +
/// activation + scope), not a bare chord — M1's fix (see class doc).</summary>
public sealed record Bindings( public sealed record Bindings(
Func<InputAction, IReadOnlyList<KeyChord>> CurrentForAction, Func<InputAction, IReadOnlyList<Binding>> CurrentForAction,
Action<InputAction, IReadOnlyList<KeyChord>> SetForAction, Action<InputAction, IReadOnlyList<Binding>> SetForAction,
Func<(uint InputMapId, uint ActionId), IReadOnlyList<KeyChord>> CurrentForUnmapped, Func<(uint InputMapId, uint ActionId), IReadOnlyList<KeyChord>> CurrentForUnmapped,
Action<(uint InputMapId, uint ActionId), IReadOnlyList<KeyChord>> SetForUnmapped, Action<(uint InputMapId, uint ActionId), IReadOnlyList<KeyChord>> SetForUnmapped,
Action<Action<KeyChord?>> BeginCapture, Action<Action<KeyChord?>> BeginCapture,
@ -163,7 +188,11 @@ public sealed class KeyboardConfigController
Action Toggle, Action Toggle,
Action<string> DisplaySystemMessage, Action<string> DisplaySystemMessage,
string NonBindableRefusalText, string NonBindableRefusalText,
Func<string, string> NotifyReassigned); // M3 (2026-08-11 review): retail's OpenOverwriteBindingDialog — confirm
// BEFORE reassigning a chord already bound to another row on this screen.
// message is pre-composed (real row labels, no invented retail text);
// the callback receives the user's Yes(true)/No(false) choice.
Action<string, Action<bool>> ConfirmOverwrite);
public OptionPage Page { get; } = new(); public OptionPage Page { get; } = new();
public IReadOnlyList<RowView> Rows => _rows; public IReadOnlyList<RowView> Rows => _rows;
@ -325,17 +354,35 @@ public sealed class KeyboardConfigController
bool mapped = RetailActionIdentityTable.TryResolve(row.InputMapId, row.ActionId, out InputAction action); bool mapped = RetailActionIdentityTable.TryResolve(row.InputMapId, row.ActionId, out InputAction action);
InputAction? mappedAction = mapped ? action : null; InputAction? mappedAction = mapped ? action : null;
IReadOnlyList<KeyChord> initial = mapped // M1: capture this row's live Activation/Scope ONCE, from the first
// existing binding for the action (every multi-chord action in
// KeyBindings.RetailDefaults() shares one Activation/Scope pair across
// all its bindings — see class doc). Falls back to the Binding record's
// own defaults (Press/Game) only when the action starts wholly unbound.
IReadOnlyList<Binding> liveBindings = mapped
? bindings.CurrentForAction(action) ? bindings.CurrentForAction(action)
: Array.Empty<Binding>();
(ActivationType Activation, InputScope Scope) template = liveBindings.Count > 0
? (liveBindings[0].Activation, liveBindings[0].Scope)
: (ActivationType.Press, InputScope.Game);
IReadOnlyList<KeyChord> initial = mapped
? liveBindings.Select(b => b.Chord).ToArray()
: bindings.CurrentForUnmapped((row.InputMapId, row.ActionId)); : bindings.CurrentForUnmapped((row.InputMapId, row.ActionId));
IReadOnlyList<KeyChord> defaults = DatDefaultsToChords(row.DefaultBindings); IReadOnlyList<KeyChord> defaults = DatDefaultsToChords(row.DefaultBindings);
var model = new ActionKeyMapOptionRow(initial, defaults, apply: value => var model = new ActionKeyMapOptionRow(initial, defaults, apply: value =>
{ {
// Interior/padding default(KeyChord) entries (S4 — sparse-slot
// display, see ReplaceSlotValue) are never real bindings; filter
// them out at the write boundary, not at storage time.
IReadOnlyList<KeyChord> real = value.Where(c => c != default).ToArray();
if (mapped) if (mapped)
bindings.SetForAction(action, value); bindings.SetForAction(
action,
real.Select(c => new Binding(c, action, template.Activation, template.Scope)).ToArray());
else else
bindings.SetForUnmapped((row.InputMapId, row.ActionId), value); bindings.SetForUnmapped((row.InputMapId, row.ActionId), real);
}); });
Page.Register(model); Page.Register(model);
@ -369,7 +416,10 @@ public sealed class KeyboardConfigController
{ {
IReadOnlyList<KeyChord> current = view.Model.Current; IReadOnlyList<KeyChord> current = view.Model.Current;
for (int i = 0; i < view.KeyButtons.Count; i++) for (int i = 0; i < view.KeyButtons.Count; i++)
view.KeyButtons[i].Label = i < current.Count ? DescribeChord(current[i]) : null; {
bool bound = i < current.Count && current[i] != default;
view.KeyButtons[i].Label = bound ? DescribeChord(current[i]) : null;
}
} }
private static string DescribeChord(KeyChord chord) private static string DescribeChord(KeyChord chord)
@ -384,83 +434,114 @@ public sealed class KeyboardConfigController
{ {
if (captured is not { } chord) return; // Escape — retail cancels silently. if (captured is not { } chord) return; // Escape — retail cancels silently.
switch (FindConflict(chord, exclude: view)) (ConflictOutcome outcome, List<RowView> conflictRows) = FindConflicts(chord, exclude: view);
switch (outcome)
{ {
case ConflictKind.None: case ConflictOutcome.NonBindable:
break; // S1 / retail order: checked BEFORE any row conflict is even
case ConflictKind.Row: // considered — retail refuses outright the instant ANY
// A real cross-row conflict — auto-reassign (register row: retail // conflicting target is non-user-bindable. This port's
// confirms first via OpenOverwriteBindingDialog; this port narrows // analogue: a chord already bound to an acdream-only action
// to reassign-then-notify rather than a blocking modal). // with no DAT row at all (Ctrl+M mute, the debug F-keys, ...) —
RowView conflictRow = _lastConflictRow!; // OpenCantOverwriteBindingDialog's ported refusal, no dialog.
ReplaceSlotValue(conflictRow, RemoveChord(conflictRow.Model.Current, chord));
RefreshRowButtons(conflictRow);
bindings.DisplaySystemMessage(bindings.NotifyReassigned(conflictRow.Label ?? "?"));
break;
case ConflictKind.NonBindable:
// Bound to an acdream-only action with no DAT row at all (Ctrl+M
// mute, the debug F-keys, ...) — this port's analogue of retail's
// distinct "can't overwrite" refusal (OpenCantOverwriteBindingDialog).
bindings.DisplaySystemMessage(bindings.NonBindableRefusalText); bindings.DisplaySystemMessage(bindings.NonBindableRefusalText);
return; return;
}
List<KeyChord> updated = new(view.Model.Current); case ConflictOutcome.Rows:
while (updated.Count <= slot) updated.Add(default); // M3: retail's OpenOverwriteBindingDialog — confirm BEFORE
updated[slot] = chord; // reassigning (N-way: every conflicting row is named, not just
ReplaceSlotValue(view, updated); // the first). Only on accept do the losing rows lose the slot.
RefreshRowButtons(view); string names = string.Join(", ", conflictRows.Select(r => r.Label ?? "?"));
string message =
$"'{DescribeChord(chord)}' is already bound to {names}. "
+ $"Reassign it to '{view.Label}'?";
bindings.ConfirmOverwrite(message, accepted =>
{
if (!accepted) return;
foreach (RowView conflictRow in conflictRows)
{
ReplaceSlotValue(conflictRow, RemoveChord(conflictRow.Model.Current, chord));
RefreshRowButtons(conflictRow);
}
ApplySlot(view, slot, chord);
});
return;
case ConflictOutcome.None:
ApplySlot(view, slot, chord);
return;
}
}); });
} }
private static void ApplySlot(RowView view, int slot, KeyChord chord)
{
List<KeyChord> updated = new(view.Model.Current);
while (updated.Count <= slot) updated.Add(default);
updated[slot] = chord;
ReplaceSlotValue(view, updated);
RefreshRowButtons(view);
}
private void EraseSlot(RowView view, int slot) private void EraseSlot(RowView view, int slot)
{ {
if (slot >= view.Model.Current.Count) return; if (slot >= view.Model.Current.Count) return;
if (view.Model.Current[slot] == default) return; // nothing bound in this display slot
var updated = new List<KeyChord>(view.Model.Current); var updated = new List<KeyChord>(view.Model.Current);
updated.RemoveAt(slot); updated.RemoveAt(slot);
ReplaceSlotValue(view, updated); ReplaceSlotValue(view, updated);
RefreshRowButtons(view); RefreshRowButtons(view);
} }
private static void ReplaceSlotValue(RowView view, IReadOnlyList<KeyChord> value) => private static void ReplaceSlotValue(RowView view, IReadOnlyList<KeyChord> value)
view.Model.SetCurrentValue(value.Where(c => c != default).ToArray()); {
// S4 (2026-08-11 review): only trim TRAILING empty slots. Retail's
// SetBinding(qc, slot) writes the SPECIFIC slot the user clicked — a row
// with no bindings whose "Mapping 3" button is set must keep the chord at
// display index 2, not collapse it onto index 0. Interior default(KeyChord)
// entries only ever come from ApplySlot's own padding, so trimming just the
// tail keeps RefreshRowButtons' positional read correct without inventing a
// nullable-chord storage type.
int lastReal = -1;
for (int i = 0; i < value.Count; i++)
if (value[i] != default) lastReal = i;
view.Model.SetCurrentValue(lastReal < 0 ? Array.Empty<KeyChord>() : value.Take(lastReal + 1).ToArray());
}
private static IReadOnlyList<KeyChord> RemoveChord(IReadOnlyList<KeyChord> from, KeyChord chord) => private static IReadOnlyList<KeyChord> RemoveChord(IReadOnlyList<KeyChord> from, KeyChord chord) =>
from.Where(c => c != chord).ToArray(); from.Where(c => c != chord).ToArray();
private enum ConflictKind { None, Row, NonBindable } private enum ConflictOutcome { None, NonBindable, Rows }
// Set by FindConflict just before returning ConflictKind.Row — avoids a
// second lookup pass at the call site. Single-threaded (UI thread only).
private RowView? _lastConflictRow;
/// <summary> /// <summary>
/// Retail's N-way, cross-input-map conflict scan (research doc §5.4: /// Retail's N-way, cross-input-map conflict scan (research doc §5.4:
/// <c>ICIDM::FindConflictingInputMaps</c>/<c>FindConflictingControls</c>), /// <c>ICIDM::FindConflictingInputMaps</c>/<c>FindConflictingControls</c>),
/// scoped to this screen's own universe: every OTHER row's current chord set /// scoped to this screen's own universe: the non-user-bindable check runs
/// FIRST (S1 — retail's own order), then EVERY OTHER row's current chord set
/// (covers BOTH mapped and unmapped rows — a chord already claimed by an /// (covers BOTH mapped and unmapped rows — a chord already claimed by an
/// unmapped row is just as real a conflict as one claimed by a mapped one), /// unmapped row is just as real a conflict as one claimed by a mapped one) is
/// then the live <see cref="KeyBindings"/> table for an acdream-only action /// collected in full, not just the first match.
/// this screen has no row for at all.
/// </summary> /// </summary>
private ConflictKind FindConflict(KeyChord chord, RowView exclude) private (ConflictOutcome Outcome, List<RowView> Rows) FindConflicts(KeyChord chord, RowView exclude)
{ {
_lastConflictRow = null; if (_bindings is not null)
{
foreach (InputAction candidate in Enum.GetValues<InputAction>())
{
if (RetailActionIdentityTable.Map.Values.Contains(candidate)) continue;
if (_bindings.CurrentForAction(candidate).Any(b => b.Chord == chord))
return (ConflictOutcome.NonBindable, new List<RowView>());
}
}
var rows = new List<RowView>();
foreach (RowView other in _rows) foreach (RowView other in _rows)
{ {
if (ReferenceEquals(other, exclude)) continue; if (ReferenceEquals(other, exclude)) continue;
if (!other.Model.Current.Contains(chord)) continue; if (other.Model.Current.Contains(chord))
_lastConflictRow = other; rows.Add(other);
return ConflictKind.Row;
} }
if (_bindings is null) return ConflictKind.None; return rows.Count > 0 ? (ConflictOutcome.Rows, rows) : (ConflictOutcome.None, rows);
foreach (InputAction candidate in Enum.GetValues<InputAction>())
{
if (RetailActionIdentityTable.Map.Values.Contains(candidate)) continue;
if (_bindings.CurrentForAction(candidate).Contains(chord))
return ConflictKind.NonBindable;
}
return ConflictKind.None;
} }
private static void WireScreenButtons( private static void WireScreenButtons(
@ -493,8 +574,9 @@ public sealed class KeyboardConfigController
// OK — right-click release in retail (idMessage 0x19); ported as a plain // OK — right-click release in retail (idMessage 0x19); ported as a plain
// left-click here, matching every other Campaign OP button (the asymmetry // left-click here, matching every other Campaign OP button (the asymmetry
// is authored-input-only, not a behavior a user would notice — register // is authored-input-only — no user-visible affordance differs, since
// row if reviewed otherwise). // retail's own right-click-release on just this pair of buttons carries
// no distinguishing visual cue either).
if (layout.FindElement(OkButtonId) is UiButton okButton) if (layout.FindElement(OkButtonId) is UiButton okButton)
okButton.OnClick = () => okButton.OnClick = () =>
{ {

View file

@ -2331,13 +2331,17 @@ public sealed class RetailUiRuntime : IDisposable
}, },
resolveString: (tableId, stringId) => strings.Resolve(tableId, stringId), resolveString: (tableId, stringId) => strings.Resolve(tableId, stringId),
new Layout.KeyboardConfigController.Bindings( new Layout.KeyboardConfigController.Bindings(
CurrentForAction: action => dispatcher.Bindings.ForAction(action) // M1 (2026-08-11 review): read/write the FULL live Binding
.Select(b => b.Chord).ToArray(), // (chord + activation + scope), not a bare chord — SetForAction
SetForAction: (action, chords) => // used to reconstruct every Binding with the constructor's bare
// defaults (Press/Game), collapsing walk-mode's Hold and every
// combat-scoped binding's scope the instant a row wrote back.
CurrentForAction: action => dispatcher.Bindings.ForAction(action).ToArray(),
SetForAction: (action, newBindings) =>
{ {
KeyBindings updated = CloneWithout(dispatcher.Bindings, action); KeyBindings updated = CloneWithout(dispatcher.Bindings, action);
foreach (KeyChord chord in chords) foreach (Binding b in newBindings)
updated.Add(new Binding(chord, action)); updated.Add(b);
dispatcher.SetBindings(updated); dispatcher.SetBindings(updated);
}, },
CurrentForUnmapped: key => unmapped.Get(key.InputMapId, key.ActionId), CurrentForUnmapped: key => unmapped.Get(key.InputMapId, key.ActionId),
@ -2346,8 +2350,20 @@ public sealed class RetailUiRuntime : IDisposable
chord => onResult(chord == default ? null : chord)), chord => onResult(chord == default ? null : chord)),
Save: () => Save: () =>
{ {
dispatcher.Bindings.SaveToFile(keyboard.KeyBindingsFilePath); // S3 (2026-08-11 review): match the existing keybinds.json
unmapped.SaveToFile(unmappedPath); // writer's own discipline (RuntimeKeyBindingTarget.Apply) —
// an IO failure is reported, not thrown out of UiButton.OnClick
// into the input/render loop, and does not roll back the
// already-accepted live binding.
try
{
dispatcher.Bindings.SaveToFile(keyboard.KeyBindingsFilePath);
unmapped.SaveToFile(unmappedPath);
}
catch (Exception failure)
{
Console.WriteLine($"keyboard config: save failed: {failure.Message}");
}
}, },
Toggle: () => ToggleWindow(WindowNames.KeyboardConfig), Toggle: () => ToggleWindow(WindowNames.KeyboardConfig),
DisplaySystemMessage: text => DisplaySystemMessage: text =>
@ -2355,13 +2371,20 @@ public sealed class RetailUiRuntime : IDisposable
if (!string.IsNullOrEmpty(text)) _bindings.Options.DisplaySystemMessage(text); if (!string.IsNullOrEmpty(text)) _bindings.Options.DisplaySystemMessage(text);
}, },
NonBindableRefusalText: refusalText ?? string.Empty, NonBindableRefusalText: refusalText ?? string.Empty,
// No retail string exists for "binding reassigned" — retail's // M3 (2026-08-11 review): retail's OpenOverwriteBindingDialog —
// own flow only shows the confirm-before-reassign dialog // confirm through the SAME RetailDialogFactory/MakeConfirmation
// (research doc §5.4's OpenOverwriteBindingDialog), never a // seam GameplayConfirmationController already uses, before
// post-reassign notice. This port's auto-reassign (register // reassigning. DialogFactory is mounted AFTER MountKeyboardConfig
// row) stays silent rather than inventing English for a // in Initialize()'s order, so this reads the property lazily
// message retail never had. // (Initialize() has always finished by the time a user can
NotifyReassigned: _ => string.Empty)); // actually open this screen and trigger a capture).
ConfirmOverwrite: (message, onResult) =>
{
if (DialogFactory is null) { onResult(false); return; }
DialogFactory.MakeConfirmation(
message,
data => onResult(data.GetBoolean(RetailDialogProperty.ConfirmationResult)));
}));
if (controller is null) if (controller is null)
{ {

View file

@ -528,9 +528,19 @@ public sealed class UiButton : UiElement, IUiGlobalTimeListener, IUiDatStateful
OnClickAt?.Invoke(e.Data1, e.Data2); OnClickAt?.Invoke(e.Data1, e.Data2);
return OnClick is not null || OnClickAt is not null; return OnClick is not null || OnClickAt is not null;
case UiEventType.RightClick: case UiEventType.RightClick:
// S6 (2026-08-11 review): unlike Click (whose swallow-when-
// disabled is pre-existing, harmless-by-construction behavior
// every button already had), RightClick was UNHANDLED before
// this class grew OnRightClick — it fell through to `default:
// return false` and bubbled to the parent. Preserve that for
// every button with no handler, disabled or not, so this
// addition is genuinely a no-op for every pre-existing button
// (matching this property's own doc comment) and only changes
// behavior for the ones that opt in.
if (OnRightClick is null) return false;
if (!Enabled) return true; if (!Enabled) return true;
OnRightClick?.Invoke(); OnRightClick.Invoke();
return OnRightClick is not null; return true;
case UiEventType.DragEnter: case UiEventType.DragEnter:
_itemDragAcceptance = e.Payload is ItemDragPayload payload _itemDragAcceptance = e.Payload is ItemDragPayload payload
? OnItemDragOver?.Invoke(payload) ?? ItemDragAcceptance.None ? OnItemDragOver?.Invoke(payload) ?? ItemDragAcceptance.None

View file

@ -49,9 +49,11 @@ namespace AcDream.UI.Abstractions.Input;
/// <see cref="InputAction"/>'s <c>UseQuickSlot_*</c> family jumps from 9 straight to /// <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 /// 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 /// CharacterSettings row (ctx <c>0x10000008</c>, all 48); 82 of 87 Emote rows (ctx
/// <c>0x10000006</c>); and roughly half of the UI-class rows (ctx /// <c>0x10000006</c>); all 10 CameraAlternateControls rows (ctx <c>0x6</c> — the M2
/// <c>0x10000007</c>/<c>0x10000009</c> — panels acdream has no toggle for, e.g. Vitae, /// de-alias carve-out, see the mapping table's own comment); and roughly half of the
/// Link Status, House, Map, Character Info, the positive/negative Magic panels). /// 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).
/// </para> /// </para>
/// </summary> /// </summary>
public static class RetailActionIdentityTable public static class RetailActionIdentityTable
@ -86,24 +88,35 @@ public static class RetailActionIdentityTable
M(0x4, 0x10000096, InputAction.Sitting); M(0x4, 0x10000096, InputAction.Sitting);
M(0x4, 0x10000097, InputAction.Sleeping); M(0x4, 0x10000097, InputAction.Sleeping);
// ── CameraControls (ctx 0x5) + CameraAlternateControls (ctx 0x6) — // ── CameraControls (ctx 0x5) — 12/12. ──────────────────────────
// 12 distinct actions, both contexts map to the SAME InputAction // M2 REWORK (2026-08-11 review): CameraControls (ctx 0x5, the
// (alternate/numpad chords for the same camera verb). 22/22. ── // Numpad-default scheme RetailDefaults() actually carries) and
foreach (uint ctx in new uint[] { 0x5, 0x6 }) // CameraAlternateControls (ctx 0x6, the arrow-key alternate scheme
{ // RetailDefaults() never had — see
M(ctx, 0x33, InputAction.CameraMoveToward); // RetailActionIdentityRoundTripTests' now-retired camera allowlist
M(ctx, 0x34, InputAction.CameraMoveAway); // entries) were both previously mapped to the SAME InputAction.
M(ctx, 0x35, InputAction.CameraRotateLeft); // KeyBindings/Binding has no "which scheme" tag, and SetForAction is
M(ctx, 0x36, InputAction.CameraRotateRight); // whole-action replacement, so the two rows aliased one live target:
M(ctx, 0x37, InputAction.CameraRotateUp); // both showed identical (stale) chords, rebinding one silently wiped
M(ctx, 0x38, InputAction.CameraRotateDown); // the other, and a row could conflict with its own twin. Building
M(ctx, 0x39, InputAction.CameraViewDefault); // real per-scheme dual-binding storage (or ten new InputAction
M(ctx, 0x3A, InputAction.CameraViewFirstPerson); // members plus the camera-dispatch code to consume them) is a real
M(ctx, 0x3B, InputAction.CameraViewLookDown); // feature, not a one-line fix, and out of scope for this rework. Only
M(ctx, 0x3C, InputAction.CameraViewMapMode); // ctx 0x5 — the scheme that already has a live, verified
} // RetailDefaults() presence — maps here; ctx 0x6 falls through to the
// 0x3D/0x3E ("Toggle Mouselook"/"Toggle Alternate Camera Mode") only // generic unmapped/store-only path below (AP-203), fully renderable,
// author defaults under ctx 0x5 (dev=1 mouse chord + F2/Numpad-Divide). // bindable and persisted, honestly carrying no live effect, exactly
// like every other unmapped row.
M(0x5, 0x33, InputAction.CameraMoveToward);
M(0x5, 0x34, InputAction.CameraMoveAway);
M(0x5, 0x35, InputAction.CameraRotateLeft);
M(0x5, 0x36, InputAction.CameraRotateRight);
M(0x5, 0x37, InputAction.CameraRotateUp);
M(0x5, 0x38, InputAction.CameraRotateDown);
M(0x5, 0x39, InputAction.CameraViewDefault);
M(0x5, 0x3A, InputAction.CameraViewFirstPerson);
M(0x5, 0x3B, InputAction.CameraViewLookDown);
M(0x5, 0x3C, InputAction.CameraViewMapMode);
M(0x5, 0x3D, InputAction.CameraInstantMouseLook); M(0x5, 0x3D, InputAction.CameraInstantMouseLook);
M(0x5, 0x3E, InputAction.CameraActivateAlternateMode); M(0x5, 0x3E, InputAction.CameraActivateAlternateMode);

View file

@ -16,6 +16,13 @@ namespace AcDream.App.Tests.UI.Layout;
/// so the behavioral assertions stay focused. Live-DAT row-count/label conformance /// so the behavioral assertions stay focused. Live-DAT row-count/label conformance
/// lives in <c>AcDream.Core.Tests.Input.RetailActionMapReaderTests</c> and /// lives in <c>AcDream.Core.Tests.Input.RetailActionMapReaderTests</c> and
/// <c>RetailActionIdentityRoundTripTests</c>. /// <c>RetailActionIdentityRoundTripTests</c>.
///
/// <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.
/// </para>
/// </summary> /// </summary>
public sealed class KeyboardConfigControllerTests public sealed class KeyboardConfigControllerTests
{ {
@ -51,14 +58,15 @@ public sealed class KeyboardConfigControllerTests
private sealed class FakeBindings private sealed class FakeBindings
{ {
public Dictionary<InputAction, List<KeyChord>> Mapped { get; } = new(); public Dictionary<InputAction, List<Binding>> Mapped { get; } = new();
public Dictionary<(uint, uint), List<KeyChord>> Unmapped { get; } = new(); public Dictionary<(uint, uint), List<KeyChord>> Unmapped { get; } = new();
public List<(InputAction Action, IReadOnlyList<KeyChord> Value)> MappedSets { get; } = new(); public List<(InputAction Action, IReadOnlyList<Binding> Value)> MappedSets { get; } = new();
public List<((uint, uint) Row, IReadOnlyList<KeyChord> Value)> UnmappedSets { get; } = new(); public List<((uint, uint) Row, IReadOnlyList<KeyChord> Value)> UnmappedSets { get; } = new();
public List<string> Messages { get; } = new(); public List<string> Messages { get; } = new();
public int SaveCalls { get; private set; } public int SaveCalls { get; private set; }
public int ToggleCalls { get; private set; } public int ToggleCalls { get; private set; }
public Action<KeyChord?>? PendingCapture { get; private set; } public Action<KeyChord?>? PendingCapture { get; private set; }
public (string Message, Action<bool> OnResult)? PendingConfirm { get; private set; }
public void Capture(KeyChord? chord) public void Capture(KeyChord? chord)
{ {
@ -67,8 +75,15 @@ public sealed class KeyboardConfigControllerTests
cb?.Invoke(chord); cb?.Invoke(chord);
} }
public void RespondToConfirm(bool accept)
{
var pending = PendingConfirm ?? throw new InvalidOperationException("no pending confirm");
PendingConfirm = null;
pending.OnResult(accept);
}
public KeyboardConfigController.Bindings ToBindings() => new( public KeyboardConfigController.Bindings ToBindings() => new(
CurrentForAction: a => Mapped.TryGetValue(a, out var v) ? v : Array.Empty<KeyChord>(), CurrentForAction: a => Mapped.TryGetValue(a, out var v) ? v : Array.Empty<Binding>(),
SetForAction: (a, v) => SetForAction: (a, v) =>
{ {
Mapped[a] = v.ToList(); Mapped[a] = v.ToList();
@ -85,7 +100,7 @@ public sealed class KeyboardConfigControllerTests
Toggle: () => ToggleCalls++, Toggle: () => ToggleCalls++,
DisplaySystemMessage: msg => Messages.Add(msg), DisplaySystemMessage: msg => Messages.Add(msg),
NonBindableRefusalText: "cannot overwrite", NonBindableRefusalText: "cannot overwrite",
NotifyReassigned: label => $"reassigned from {label}"); ConfirmOverwrite: (message, onResult) => PendingConfirm = (message, onResult));
} }
private static readonly KeyChord ChordW = new(Silk.NET.Input.Key.W, ModifierMask.None); private static readonly KeyChord ChordW = new(Silk.NET.Input.Key.W, ModifierMask.None);
@ -143,7 +158,11 @@ public sealed class KeyboardConfigControllerTests
}); });
var fake = new FakeBindings(); var fake = new FakeBindings();
fake.Mapped[InputAction.MovementForward] = new List<KeyChord> { ChordW, ChordUp }; fake.Mapped[InputAction.MovementForward] = new List<Binding>
{
new(ChordW, InputAction.MovementForward),
new(ChordUp, InputAction.MovementForward),
};
fake.Unmapped[(0x10000006u, 0x100000A0u)] = new List<KeyChord> { ChordA }; fake.Unmapped[(0x10000006u, 0x100000A0u)] = new List<KeyChord> { ChordA };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
@ -174,7 +193,14 @@ public sealed class KeyboardConfigControllerTests
fake.Capture(ChordW); fake.Capture(ChordW);
Assert.Contains(ChordW, row.Model.Current); Assert.Contains(ChordW, row.Model.Current);
Assert.Contains((InputAction.MovementForward, (IReadOnlyList<KeyChord>)row.Model.Current), fake.MappedSets); (InputAction Action, IReadOnlyList<Binding> Value) written = Assert.Single(fake.MappedSets);
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.
Assert.Equal(ActivationType.Press, onlyBinding.Activation);
Assert.Equal(InputScope.Game, onlyBinding.Scope);
Assert.Equal("W", row.KeyButtons[0].Label); Assert.Equal("W", row.KeyButtons[0].Label);
} }
@ -183,7 +209,7 @@ public sealed class KeyboardConfigControllerTests
{ {
var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x29, RetailActionClass.Movement) }); var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x29, RetailActionClass.Movement) });
var fake = new FakeBindings(); var fake = new FakeBindings();
fake.Mapped[InputAction.MovementForward] = new List<KeyChord> { ChordW }; fake.Mapped[InputAction.MovementForward] = new List<Binding> { new(ChordW, InputAction.MovementForward) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind( KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
@ -201,7 +227,11 @@ public sealed class KeyboardConfigControllerTests
{ {
var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x29, RetailActionClass.Movement) }); var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x29, RetailActionClass.Movement) });
var fake = new FakeBindings(); var fake = new FakeBindings();
fake.Mapped[InputAction.MovementForward] = new List<KeyChord> { ChordW, ChordUp }; fake.Mapped[InputAction.MovementForward] = new List<Binding>
{
new(ChordW, InputAction.MovementForward),
new(ChordUp, InputAction.MovementForward),
};
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind( KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
@ -216,7 +246,55 @@ public sealed class KeyboardConfigControllerTests
} }
[Fact] [Fact]
public void Capture_ConflictWithAnotherRow_AutoReassignsAndNotifies() public void KeyButtonRightClick_OnAlreadyEmptySlot_IsANoOp()
{
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())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
Assert.Empty(row.Model.Current);
row.KeyButtons[0].OnRightClick!.Invoke();
Assert.Empty(row.Model.Current);
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>
[Fact]
public void KeyButtonClick_OnSparseRow_ThirdSlotLandsOnThirdButton()
{
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())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
Assert.Equal(3, row.KeyButtons.Count);
Assert.Empty(row.Model.Current);
row.KeyButtons[2].OnClick!.Invoke(); // "Mapping 3"
fake.Capture(ChordW);
Assert.Null(row.KeyButtons[0].Label);
Assert.Null(row.KeyButtons[1].Label);
Assert.Equal("W", 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);
}
[Fact]
public void Capture_ConflictWithAnotherRow_OpensConfirmDialog_AcceptReassigns()
{ {
var snapshot = new RetailActionMapSnapshot(new[] var snapshot = new RetailActionMapSnapshot(new[]
{ {
@ -224,7 +302,7 @@ public sealed class KeyboardConfigControllerTests
Row(0x4, 0x2A, RetailActionClass.Movement), // MovementBackup — will hold ChordA Row(0x4, 0x2A, RetailActionClass.Movement), // MovementBackup — will hold ChordA
}); });
var fake = new FakeBindings(); var fake = new FakeBindings();
fake.Mapped[InputAction.MovementBackup] = new List<KeyChord> { ChordA }; fake.Mapped[InputAction.MovementBackup] = new List<Binding> { new(ChordA, InputAction.MovementBackup) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind( KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
@ -235,19 +313,53 @@ public sealed class KeyboardConfigControllerTests
forward.KeyButtons[0].OnClick!.Invoke(); forward.KeyButtons[0].OnClick!.Invoke();
fake.Capture(ChordA); // steal MovementBackup's chord fake.Capture(ChordA); // steal MovementBackup's chord
// M3: nothing is applied yet — a confirm dialog is pending.
Assert.NotNull(fake.PendingConfirm);
Assert.DoesNotContain(ChordA, forward.Model.Current);
Assert.Contains(ChordA, backup.Model.Current);
Assert.Empty(fake.Messages);
fake.RespondToConfirm(true);
Assert.Contains(ChordA, forward.Model.Current); Assert.Contains(ChordA, forward.Model.Current);
Assert.DoesNotContain(ChordA, backup.Model.Current); Assert.DoesNotContain(ChordA, backup.Model.Current);
Assert.Contains(fake.Messages, m => m.Contains("reassigned"));
} }
[Fact] [Fact]
public void Capture_ConflictWithNonBindableAcdreamAction_RefusesAndLeavesBoth() public void Capture_ConflictWithAnotherRow_DeclineLeavesBothRowsUnchanged()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x4, 0x29, RetailActionClass.Movement),
Row(0x4, 0x2A, RetailActionClass.Movement),
});
var fake = new FakeBindings();
fake.Mapped[InputAction.MovementForward] = new List<Binding> { new(ChordW, InputAction.MovementForward) };
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())!;
KeyboardConfigController.RowView forward = controller.Rows.Single(r => r.ActionId == 0x29u);
KeyboardConfigController.RowView backup = controller.Rows.Single(r => r.ActionId == 0x2Au);
forward.KeyButtons[0].OnClick!.Invoke();
fake.Capture(ChordA);
fake.RespondToConfirm(false);
Assert.Equal(new[] { ChordW }, forward.Model.Current); // untouched
Assert.Equal(new[] { ChordA }, backup.Model.Current); // untouched
}
[Fact]
public void Capture_ConflictWithNonBindableAcdreamAction_RefusesWithoutADialog()
{ {
var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x29, RetailActionClass.Movement) }); var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x29, RetailActionClass.Movement) });
var fake = new FakeBindings(); var fake = new FakeBindings();
// AcdreamToggleAudioMute has no RetailActionIdentityTable row at all. // AcdreamToggleAudioMute has no RetailActionIdentityTable row at all.
var muteChord = new KeyChord(Silk.NET.Input.Key.M, ModifierMask.Ctrl); var muteChord = new KeyChord(Silk.NET.Input.Key.M, ModifierMask.Ctrl);
fake.Mapped[InputAction.AcdreamToggleAudioMute] = new List<KeyChord> { muteChord }; fake.Mapped[InputAction.AcdreamToggleAudioMute] =
new List<Binding> { new(muteChord, InputAction.AcdreamToggleAudioMute) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind( KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
@ -256,9 +368,39 @@ public sealed class KeyboardConfigControllerTests
forward.KeyButtons[0].OnClick!.Invoke(); forward.KeyButtons[0].OnClick!.Invoke();
fake.Capture(muteChord); fake.Capture(muteChord);
// S1: refused outright, no confirm dialog offered.
Assert.Null(fake.PendingConfirm);
Assert.DoesNotContain(muteChord, forward.Model.Current); Assert.DoesNotContain(muteChord, forward.Model.Current);
Assert.Contains("cannot overwrite", fake.Messages); Assert.Contains("cannot overwrite", fake.Messages);
Assert.Equal(new[] { muteChord }, fake.Mapped[InputAction.AcdreamToggleAudioMute]); Assert.Equal(muteChord, Assert.Single(fake.Mapped[InputAction.AcdreamToggleAudioMute]).Chord);
}
/// <summary>S1: retail checks the non-user-bindable target BEFORE any
/// user-bindable row conflict, even when both exist for the same chord.</summary>
[Fact]
public void Capture_ConflictWithBothARowAndANonBindableAction_NonBindableWins()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x4, 0x29, RetailActionClass.Movement),
Row(0x4, 0x2A, RetailActionClass.Movement),
});
var fake = new FakeBindings();
var sharedChord = new KeyChord(Silk.NET.Input.Key.M, ModifierMask.Ctrl);
fake.Mapped[InputAction.MovementBackup] = new List<Binding> { new(sharedChord, InputAction.MovementBackup) };
fake.Mapped[InputAction.AcdreamToggleAudioMute] =
new List<Binding> { new(sharedChord, InputAction.AcdreamToggleAudioMute) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, 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.DoesNotContain(sharedChord, forward.Model.Current);
} }
[Fact] [Fact]
@ -288,7 +430,7 @@ public sealed class KeyboardConfigControllerTests
{ {
var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x29, RetailActionClass.Movement) }); var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x29, RetailActionClass.Movement) });
var fake = new FakeBindings(); var fake = new FakeBindings();
fake.Mapped[InputAction.MovementForward] = new List<KeyChord> { ChordW }; fake.Mapped[InputAction.MovementForward] = new List<Binding> { new(ChordW, InputAction.MovementForward) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind( KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
@ -314,7 +456,7 @@ public sealed class KeyboardConfigControllerTests
new[] { new RetailKeyChord(0x11, 0, 0, 3) }), // DIK_W new[] { new RetailKeyChord(0x11, 0, 0, 3) }), // DIK_W
}); });
var fake = new FakeBindings(); var fake = new FakeBindings();
fake.Mapped[InputAction.MovementForward] = new List<KeyChord> { ChordUp }; fake.Mapped[InputAction.MovementForward] = new List<Binding> { new(ChordUp, InputAction.MovementForward) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig(); ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind( KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!; layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
@ -329,6 +471,106 @@ public sealed class KeyboardConfigControllerTests
Assert.True(row.Model.Changed); // live but uncommitted, matching retail Assert.True(row.Model.Changed); // live but uncommitted, matching retail
} }
/// <summary>M1 (2026-08-11 review): Defaults must restore the DAT-sourced
/// KEY only — the row's live Activation/Scope (Hold + MeleeCombat here,
/// captured from the action's live binding at build time) must survive the
/// click unchanged, across the FULL 306-row shape this test represents with
/// one Hold+scoped action.</summary>
[Fact]
public void DefaultsButton_PreservesActivationAndScope_ForAHoldScopedAction()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x10000003, 0x1000005D, RetailActionClass.Combat, defaults:
new[] { new RetailKeyChord(0xD3, 0, 0, 3) }), // CombatLowAttack, DIK_DELETE
});
var fake = new FakeBindings();
fake.Mapped[InputAction.CombatLowAttack] = new List<Binding>
{
new(ChordUp, InputAction.CombatLowAttack, ActivationType.Hold, InputScope.MeleeCombat),
};
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
UiButton defaultsButton = (UiButton)layout.FindElement(0x1000002Au)!;
defaultsButton.OnClick!.Invoke();
(InputAction Action, IReadOnlyList<Binding> Value) written = Assert.Single(fake.MappedSets);
Assert.Equal(InputAction.CombatLowAttack, written.Action);
Binding result = Assert.Single(written.Value);
Assert.Equal(Silk.NET.Input.Key.Delete, result.Chord.Key); // the DAT default key
Assert.Equal(ActivationType.Hold, result.Activation); // preserved, not reset to Press
Assert.Equal(InputScope.MeleeCombat, result.Scope); // preserved, not reset to Game
}
/// <summary>M1: Cancel/Revert (RestoreSavedValue) must ALSO preserve
/// Activation/Scope, not just Defaults.</summary>
[Fact]
public void CancelButton_PreservesActivationAndScope()
{
var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x32, RetailActionClass.Movement) });
var fake = new FakeBindings();
fake.Mapped[InputAction.MovementWalkMode] = new List<Binding>
{
new(ChordW, InputAction.MovementWalkMode, ActivationType.Hold, InputScope.Game),
};
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
row.KeyButtons[0].OnClick!.Invoke();
fake.Capture(ChordUp); // uncommitted edit
UiButton cancel = (UiButton)layout.FindElement(0x1000002Du)!;
cancel.OnClick!.Invoke();
// MappedSets also carries the capture's own write (ChordUp) before the
// revert — take the LAST write, which is Cancel's RestoreSavedValue.
(InputAction Action, IReadOnlyList<Binding> Value) written = fake.MappedSets[^1];
Binding result = Assert.Single(written.Value);
Assert.Equal(ChordW, result.Chord); // reverted to saved
Assert.Equal(ActivationType.Hold, result.Activation);
}
/// <summary>M2 (2026-08-11 review): InputMap 0x6 (CameraAlternateControls) no
/// longer aliases InputMap 0x5's (CameraControls) InputAction — each row is
/// independent, so rebinding one never clobbers the other, and a row cannot
/// conflict with its own former twin.</summary>
[Fact]
public void CameraContext5And6_AreIndependentRows_NotAliased()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x5, 0x35, RetailActionClass.Camera, defaults: new[] { new RetailKeyChord(0x4B, 0, 0, 3) }),
Row(0x6, 0x35, RetailActionClass.Camera, defaults: new[] { new RetailKeyChord(0xCB, 0, 0, 3) }),
});
var fake = new FakeBindings();
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, 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)
// Rebinding ctx5's row must not touch ctx6's storage, and vice versa.
ctx5.KeyButtons[0].OnClick!.Invoke();
fake.Capture(ChordW);
Assert.Contains(ChordW, ctx5.Model.Current);
Assert.Empty(ctx6.Model.Current); // ctx6 never seeded/written by ctx5's edit
Assert.Empty(fake.Unmapped); // ctx6 untouched — only ctx5's mapped write happened
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)));
}
[Fact] [Fact]
public void Bind_MissingWindowRoot_ReturnsNull() public void Bind_MissingWindowRoot_ReturnsNull()
{ {

View file

@ -18,11 +18,18 @@ namespace AcDream.Core.Tests.Input;
/// unavailable (CI), matching every other live-DAT conformance test in this project. /// unavailable (CI), matching every other live-DAT conformance test in this project.
/// ///
/// <para> /// <para>
/// <b>Three real, byte-verified disagreements survive after the mechanism fixes</b> /// <b>Two real, byte-verified disagreements survive after the mechanism fixes</b>
/// (2026-08-11 investigation — none are bugs in this slice's table; all three are /// (2026-08-11 investigation, updated at the M2 rework — none are bugs in this
/// PRE-EXISTING <see cref="KeyBindings.RetailDefaults"/> gaps/design choices this /// slice's table; both are PRE-EXISTING <see cref="KeyBindings.RetailDefaults"/>
/// slice does not touch, listed in <see cref="KnownRetailDefaultsDisagreements"/> /// gaps/design choices this slice does not touch, listed in
/// with citations): /// <see cref="KnownRetailDefaultsDisagreements"/> with citations). A THIRD
/// disagreement — ten CameraAlternateControls (InputMap 0x6) actions — was RETIRED
/// at the M2 rework: <see cref="RetailActionIdentityTable"/> no longer maps InputMap
/// 0x6 to any <see cref="InputAction"/> at all (the aliasing that produced two
/// independent rows fighting over one live target — M2, 2026-08-11 review), so this
/// test never sees a ctx-0x6 row and the ctx-0x5-only union now matches
/// <c>RetailDefaults()</c> exactly for all twelve Camera actions with no allowlist
/// entry needed:
/// </para> /// </para>
/// <list type="number"> /// <list type="number">
/// <item><description><b>MovementWalkMode.</b> The DAT's raw <c>QualifiedControl.Modifier</c> /// <item><description><b>MovementWalkMode.</b> The DAT's raw <c>QualifiedControl.Modifier</c>
@ -33,18 +40,6 @@ namespace AcDream.Core.Tests.Input;
/// <c>CurrentModifiers=Shift</c> alongside a Shift key-DOWN event, so the chord must /// <c>CurrentModifiers=Shift</c> alongside a Shift key-DOWN event, so the chord must
/// carry the flag to match at dispatch time. Not a disagreement to fix; a raw-DAT /// carry the flag to match at dispatch time. Not a disagreement to fix; a raw-DAT
/// artifact this slice's reader faithfully reproduces.</description></item> /// artifact this slice's reader faithfully reproduces.</description></item>
/// <item><description><b>Ten CameraAlternateControls (InputMap 0x6) actions.</b> Retail
/// ships TWO camera-control schemes with DIFFERENT default keys: InputMap 0x5's
/// (Numpad: Keypad4/6/8/2 for rotate, KeypadSubtract/Add for zoom, ...) and InputMap
/// 0x6's (Arrow keys: Left/Right/Up/Down for rotate, ...). Both InputMaps' actions
/// share the SAME <see cref="RetailActionClass.Camera"/> bucket and this slice
/// correctly maps BOTH to the same <see cref="InputAction"/> (research doc §5.3: a
/// user can rebind either scheme's row independently). <c>RetailDefaults()</c> — a
/// PRE-EXISTING, OP8-independent file — only carries the Numpad (0x5) scheme; it does
/// not carry the arrow-key (0x6) alternates as SECOND bindings for the same action.
/// This is a genuine <c>RetailDefaults()</c> completeness gap, reported here rather
/// than silently patched into a foundational, heavily-tested file outside this
/// slice's scope (register row filed).</description></item>
/// <item><description><b>Quickslot 1-9's Ctrl+N chord (and its SelectQuickSlot_1-9 /// <item><description><b>Quickslot 1-9's Ctrl+N chord (and its SelectQuickSlot_1-9
/// counterpart).</b> The DAT's own default /// counterpart).</b> The DAT's own default
/// master map binds Ctrl+1..9 to the SAME action id as bare 1..9 ("Quickslot N" — /// master map binds Ctrl+1..9 to the SAME action id as bare 1..9 ("Quickslot N" —
@ -67,16 +62,6 @@ public sealed class RetailActionIdentityRoundTripTests
private static readonly HashSet<InputAction> KnownRetailDefaultsDisagreements = new() private static readonly HashSet<InputAction> KnownRetailDefaultsDisagreements = new()
{ {
InputAction.MovementWalkMode, InputAction.MovementWalkMode,
InputAction.CameraMoveToward,
InputAction.CameraMoveAway,
InputAction.CameraRotateLeft,
InputAction.CameraRotateRight,
InputAction.CameraRotateUp,
InputAction.CameraRotateDown,
InputAction.CameraViewDefault,
InputAction.CameraViewFirstPerson,
InputAction.CameraViewLookDown,
InputAction.CameraViewMapMode,
InputAction.UseQuickSlot_1, InputAction.UseQuickSlot_1,
InputAction.UseQuickSlot_2, InputAction.UseQuickSlot_2,
InputAction.UseQuickSlot_3, InputAction.UseQuickSlot_3,