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>
248 lines
10 KiB
C#
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
|
|
}
|
|
}
|