acdream/tests/AcDream.Core.Tests/Input/RetailActionMapReaderTests.cs
Erik b4edee970f feat(ui): Campaign OP slice OP8 — Configure Keyboard
Ports retail's Configure Keyboard screen (gmKeyboardUI, LayoutDesc
0x21000009) — its own separate full-screen window, not a fifth Options-
panel tab. Retires OP3's INERT contract for the Gameplay tab's Configure
Keyboard button (0x10000204).

DAT reader (src/AcDream.Core/Input/RetailActionMap.cs): reads the
ActionMap singleton (DID 0x26000000, empirically the only one — not
0x27000000 as GetDBOType's Turbine-internal tag would suggest) and both
MasterInputMap defaults (0x14000000 "gmDefaultMap"/0x14000002
"DefaultMap"), union-merged per (InputMapId, ActionId) — proven order-
independent since the two maps' one shared context (0x5) has disjoint
action-id sets. Empirically resolved three lane-D unknowns against the
live DAT: the six ActionClass values (1=Movement, 2=Camera, 3=UI,
4=Combat, 5=Emote, 7=CharacterSettings — 6 is genuinely absent), that
the six unnamed InputMaps are 100% non-bindable (render nothing, not an
unlabeled group), and that the enum-to-DID pairing for the two master
maps is inconsequential to the merge result.

Identity table (src/AcDream.UI.Abstractions/Input/RetailActionIdentityTable.cs):
maps DAT (InputMapId, ActionId) pairs to acdream's InputAction where a
live consumer exists (~140 of 306 user-bindable rows — Movement/Camera/
Combat map almost completely; UI/Quickslot/Chat partially; only 5 of 87
Emotes and none of 48 CharacterSettings hotkeys, since acdream has no
general emote player or hotkey-to-option-toggle dispatcher yet). Every
entry cross-verified by label match AND a DAT-default-vs-
KeyBindings.RetailDefaults() byte comparison (RetailActionIdentityRoundTripTests),
which caught a real off-by-one in the Quickslot 13-18 block before it
shipped and found three genuine pre-existing RetailDefaults() gaps
(walk-mode's Shift-echoed chord, ten CameraAlternateControls arrow-key
alternates, and the Quickslot Ctrl+N use-vs-select ambiguity) — none
introduced by this slice, all documented rather than silently patched.

KeyboardConfigController: six ActionClass list boxes built from the
DAT, merged with live KeyBindings for mapped rows (rebind applies
immediately through the same InputDispatcher every other input path
uses) and a new sibling RetailUnmappedKeyBindings store for rows with
no InputAction yet. Left-click a key button opens real InputDispatcher
modal capture; right-click erases that slot. N-way conflict detection
scans every other row plus the live KeyBindings table for acdream-only
actions (Ctrl+M mute, debug F-keys) as the non-user-bindable refusal
analogue, using retail's own byte-verified "Could not overwrite "
string (table 0x23000004). OK/Cancel/Defaults/Revert reuse the
OptionPage/IOptionRow verb model via a new ActionKeyMapOptionRow.
Persistence is keybinds.json only (D4 — no .keymap file interchange).

Five register rows: AP-202 (.keymap interchange narrowing), AP-203
(store-only rows with no live consumer), AP-204 (silent auto-reassign
instead of retail's confirm dialog; OK/Cancel ported as left-click not
right-click-release).

Small supporting additions: UiButton.OnRightClick (additive, no
existing behavior changed), InputDispatcher.Bindings getter (the
screen's single live-truth read seam), RetailScanCodeMap (DIK scan
code <-> Silk.NET Key, keyboard + the one mouse-device row).

19 new tests (6 ActionMap reader conformance incl. live-DAT row-count/
label pins, 1 DAT-vs-RetailDefaults round-trip, 12 controller
behavior tests against the committed keyboard_config_21000009.json
fixture) — full solution suite 13,147 passed / 4 skipped / 0 failed
(baseline 13,128/4/0, zero regressions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 09:19:54 +02:00

248 lines
10 KiB
C#

using System.Collections.Generic;
using AcDream.Content;
using AcDream.Core.Content;
using AcDream.Core.Input;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Types;
using DatReaderWriter.Lib.IO;
using Xunit;
namespace AcDream.Core.Tests.Input;
/// <summary>
/// Campaign OP slice OP8: <see cref="RetailActionMapReader"/> conformance.
/// Hermetic tests pin the merge/filter mechanism against a synthetic
/// <see cref="FakeDatObjectSource"/> (no DAT dependency — always runs);
/// <see cref="RetailActionMapReader_LiveDatTests"/> pins the exact row
/// counts and spot-labels against the installed dats (skips cleanly when
/// unavailable, matching <c>ConformanceDats.ResolveDatDir</c>'s pattern).
/// </summary>
public sealed class RetailActionMapReaderTests
{
[Fact]
public void Read_FiltersNonUserBindableActions()
{
var actionMap = new ActionMap
{
StringTableId = 0x23000005u,
InputMaps = new Dictionary<uint, Dictionary<uint, ActionMapValue>>
{
[4u] = new Dictionary<uint, ActionMapValue>
{
// Class 0 == not user-bindable — the reader must drop this row.
[0x1u] = new ActionMapValue { UserBinding = new UserBindingData { ActionClass = 0u } },
// Class 1 (Movement) — kept.
[0x29u] = new ActionMapValue
{
UserBinding = new UserBindingData
{
ActionClass = 1u,
ActionName = 0x111u,
ActionDescription = 0x222u,
},
},
},
},
ConflictingMaps = new Dictionary<uint, InputsConflictsValue>(),
};
var dats = new FakeDatObjectSource();
dats.Add(RetailActionMapIds.ActionMapId, actionMap);
RetailActionMapSnapshot? snapshot = RetailActionMapReader.Read(dats);
Assert.NotNull(snapshot);
RetailActionMapRow row = Assert.Single(snapshot!.Rows);
Assert.Equal(4u, row.InputMapId);
Assert.Equal(0x29u, row.ActionId);
Assert.Equal(RetailActionClass.Movement, row.ActionClass);
Assert.Equal(0x111u, row.LabelHash);
Assert.Equal(0x222u, row.TooltipHash);
}
[Fact]
public void Read_UnionMergesBothMasterMapsWithoutOrderDependency()
{
// The one real collision the live probe found: context 5 exists in BOTH
// master maps with completely disjoint action-id sets (2026-08-11 probe,
// see RetailActionMap.cs class doc unknown #4). Reproduce that shape here
// and assert the merge picks up defaults from BOTH sources for the SAME
// context, order-independent.
var actionMap = new ActionMap
{
InputMaps = new Dictionary<uint, Dictionary<uint, ActionMapValue>>
{
[5u] = new Dictionary<uint, ActionMapValue>
{
[0x3Eu] = new ActionMapValue { UserBinding = new UserBindingData { ActionClass = 2u } },
[0x33u] = new ActionMapValue { UserBinding = new UserBindingData { ActionClass = 2u } },
},
},
ConflictingMaps = new Dictionary<uint, InputsConflictsValue>(),
};
var gameplayMap = new MasterInputMap
{
InputMaps = new Dictionary<uint, CInputMap>
{
[5u] = new CInputMap
{
Mappings = new List<QualifiedControl>
{
new QualifiedControl
{
Key = new ControlSpecification { Key = (0xB5u << 16) | 0u, Modifier = 0u },
Activation = 3u,
Unknown = 0x3Eu,
},
},
},
},
};
var systemMap = new MasterInputMap
{
InputMaps = new Dictionary<uint, CInputMap>
{
[5u] = new CInputMap
{
Mappings = new List<QualifiedControl>
{
new QualifiedControl
{
Key = new ControlSpecification { Key = (0x4Au << 16) | 0u, Modifier = 0u },
Activation = 3u,
Unknown = 0x33u,
},
},
},
},
};
var dats = new FakeDatObjectSource();
dats.Add(RetailActionMapIds.ActionMapId, actionMap);
dats.Add(RetailActionMapIds.GameplayMasterMapId, gameplayMap);
dats.Add(RetailActionMapIds.SystemMasterMapId, systemMap);
RetailActionMapSnapshot? snapshot = RetailActionMapReader.Read(dats);
Assert.NotNull(snapshot);
Assert.Equal(2, snapshot!.Rows.Count);
RetailActionMapRow rowFromGameplayMap = Assert.Single(snapshot.Rows, r => r.ActionId == 0x3Eu);
RetailKeyChord chord1 = Assert.Single(rowFromGameplayMap.DefaultBindings);
Assert.Equal(0xB5u, chord1.Scan);
RetailActionMapRow rowFromSystemMap = Assert.Single(snapshot.Rows, r => r.ActionId == 0x33u);
RetailKeyChord chord2 = Assert.Single(rowFromSystemMap.DefaultBindings);
Assert.Equal(0x4Au, chord2.Scan);
}
[Fact]
public void Read_MissingActionMap_ReturnsNull()
{
var dats = new FakeDatObjectSource();
Assert.Null(RetailActionMapReader.Read(dats));
}
[Fact]
public void Read_MissingMasterMaps_StillReturnsRowsWithEmptyDefaults()
{
var actionMap = new ActionMap
{
InputMaps = new Dictionary<uint, Dictionary<uint, ActionMapValue>>
{
[4u] = new Dictionary<uint, ActionMapValue>
{
[0x29u] = new ActionMapValue { UserBinding = new UserBindingData { ActionClass = 1u } },
},
},
ConflictingMaps = new Dictionary<uint, InputsConflictsValue>(),
};
var dats = new FakeDatObjectSource();
dats.Add(RetailActionMapIds.ActionMapId, actionMap);
RetailActionMapSnapshot? snapshot = RetailActionMapReader.Read(dats);
Assert.NotNull(snapshot);
RetailActionMapRow row = Assert.Single(snapshot!.Rows);
Assert.Empty(row.DefaultBindings);
}
[Fact]
public void RetailInputMapHeaders_HasAllNineteenByteVerifiedEntries()
{
// docs/research/2026-08-10-keyboard-config-and-gameplay-tab.md §5.3.
Assert.Equal(19, RetailInputMapHeaders.NameByInputMapId.Count);
Assert.Equal("ID_InputMap_MovementCommands", RetailInputMapHeaders.NameByInputMapId[0x00000004u]);
Assert.Equal("ID_InputMap_CameraControls", RetailInputMapHeaders.NameByInputMapId[0x00000005u]);
Assert.Equal("ID_InputMap_Emotes", RetailInputMapHeaders.NameByInputMapId[0x10000006u]);
Assert.Equal("ID_InputMap_CharacterOptionCommands", RetailInputMapHeaders.NameByInputMapId[0x10000008u]);
Assert.Equal("ID_InputMap_ToggleChatEntry", RetailInputMapHeaders.NameByInputMapId[0x1000000Du]);
}
/// <summary>Minimal in-memory <see cref="IDatObjectSource"/> for hermetic tests —
/// no DAT file dependency.</summary>
private sealed class FakeDatObjectSource : IDatObjectSource
{
private readonly Dictionary<uint, object> _objects = new();
public void Add<T>(uint fileId, T value) where T : IDBObj => _objects[fileId] = value!;
public T? Get<T>(uint fileId) where T : IDBObj =>
_objects.TryGetValue(fileId, out object? value) && value is T typed ? typed : default;
public bool TryGet<T>(uint fileId, out T value) where T : IDBObj
{
T? found = Get<T>(fileId);
value = found!;
return found is not null;
}
}
}
/// <summary>Live-DAT conformance: pins the exact shape of the installed
/// <c>client_portal.dat</c>/<c>client_local_English.dat</c> ActionMap +
/// MasterInputMap objects. Skips cleanly when the dats are unavailable.</summary>
public sealed class RetailActionMapReader_LiveDatTests
{
[Fact]
public void Read_AgainstInstalledDats_MatchesPinnedShape()
{
string? datDir = Conformance.ConformanceDats.ResolveDatDir();
if (datDir is null) return; // CI / no local install — skip cleanly.
using var dats = new DatCollection(datDir);
var source = new DatCollectionAdapter(dats);
RetailActionMapSnapshot? snapshot = RetailActionMapReader.Read(source);
Assert.NotNull(snapshot);
// Pinned 2026-08-11 against the installed EoR dats — 306 user-bindable rows
// across the six non-zero ActionClass buckets (1,2,3,4,5,7 — never 0 or 6).
Assert.Equal(306, snapshot!.Rows.Count);
var byClass = new Dictionary<RetailActionClass, int>();
foreach (RetailActionMapRow row in snapshot.Rows)
{
Assert.NotEqual(RetailActionClass.None, row.ActionClass);
byClass.TryGetValue(row.ActionClass, out int count);
byClass[row.ActionClass] = count + 1;
}
Assert.Equal(14, byClass[RetailActionClass.Movement]);
Assert.Equal(22, byClass[RetailActionClass.Camera]);
Assert.Equal(103, byClass[RetailActionClass.Ui]);
Assert.Equal(32, byClass[RetailActionClass.Combat]);
Assert.Equal(87, byClass[RetailActionClass.Emote]);
Assert.Equal(48, byClass[RetailActionClass.CharacterSettings]);
// Spot-pin Movement's "Move Forward" (action 0x29, InputMap 0x4): two
// default bindings (W + Up arrow), matching KeyBindings.RetailDefaults()'s
// MovementForward chords.
RetailActionMapRow moveForward = Assert.Single(
snapshot.Rows, r => r.InputMapId == 0x4u && r.ActionId == 0x29u);
Assert.Equal(2, moveForward.DefaultBindings.Count);
Assert.Contains(moveForward.DefaultBindings, c => c.Scan == 0x11u); // DIK_W
Assert.Contains(moveForward.DefaultBindings, c => c.Scan == 0xC8u); // DIK_UPARROW
}
}