fix(client): restore retail interaction parity
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:
parent
0c699240e0
commit
f6fe0f2a4f
151 changed files with 10162 additions and 1211 deletions
|
|
@ -2,6 +2,7 @@ using System.Collections.Generic;
|
|||
using System.Linq;
|
||||
using AcDream.Content;
|
||||
using AcDream.Core.Input;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.UI.Abstractions.Input;
|
||||
using DatReaderWriter;
|
||||
using Xunit;
|
||||
|
|
@ -9,84 +10,13 @@ using Xunit;
|
|||
namespace AcDream.Core.Tests.Input;
|
||||
|
||||
/// <summary>
|
||||
/// Campaign OP slice OP8: pins <see cref="RetailActionIdentityTable"/>'s agreement
|
||||
/// with <see cref="KeyBindings.RetailDefaults"/> — for every <see cref="InputAction"/>
|
||||
/// this slice's table resolves, the UNION of DAT default bindings across every DAT
|
||||
/// row mapped to that action must equal <c>KeyBindings.RetailDefaults()</c>'s chord
|
||||
/// set for it. Per the slice contract: "investigate + report any disagreement rather
|
||||
/// than silently preferring one." Skips cleanly when the installed dats are
|
||||
/// unavailable (CI), matching every other live-DAT conformance test in this project.
|
||||
///
|
||||
/// <para>
|
||||
/// <b>Two real, byte-verified disagreements survive after the mechanism fixes</b>
|
||||
/// (2026-08-11 investigation, updated at the M2 rework — none are bugs in this
|
||||
/// slice's table; both are PRE-EXISTING <see cref="KeyBindings.RetailDefaults"/>
|
||||
/// gaps/design choices this slice does not touch, listed in
|
||||
/// <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>
|
||||
/// <list type="number">
|
||||
/// <item><description><b>MovementWalkMode.</b> The DAT's raw <c>QualifiedControl.Modifier</c>
|
||||
/// for the Shift-key binding is 0 (the key itself IS Shift — there is no separate
|
||||
/// "modifier" to report when the primary key and the modifier are the same physical
|
||||
/// key). <c>RetailDefaults()</c> deliberately encodes <c>Modifiers=Shift</c> anyway —
|
||||
/// its own comment (K-fix1, 2026-04-26) explains the OS echoes
|
||||
/// <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
|
||||
/// artifact this slice's reader faithfully reproduces.</description></item>
|
||||
/// <item><description><b>Quickslot 1-9's Ctrl+N chord (and its SelectQuickSlot_1-9
|
||||
/// counterpart).</b> The DAT's own default
|
||||
/// master map binds Ctrl+1..9 to the SAME action id as bare 1..9 ("Quickslot N" —
|
||||
/// <c>UseQuickSlot_N</c>), NOT to the separate "Select Quickslot N" action id
|
||||
/// (<c>SelectQuickSlot_N</c>, DAT action ids <c>0x1000004E-56</c>) — those carry NO
|
||||
/// default binding at all in the shipped DAT. <c>RetailDefaults()</c>'s own comment
|
||||
/// (citing <c>gmToolbarUI::ListenToGlobalMessage @0x004BE4E0</c>) asserts retail's
|
||||
/// CLIENT reinterprets Ctrl+N contextually as Select — a runtime behavior this raw
|
||||
/// keymap-default probe cannot see (it reads bound ACTIONS, not the dispatch
|
||||
/// function's own modifier branching). Both readings are independently retail-
|
||||
/// sourced; reconciling them needs the decompiled dispatch function, out of scope
|
||||
/// here. Reported, not silently resolved either way.</description></item>
|
||||
/// </list>
|
||||
/// Campaign KB's installed-DAT contract: all 306 user-bindable ActionMap rows
|
||||
/// have distinct live identities and their default chord sets match the two
|
||||
/// retail MasterInputMaps exactly. Missing, aliased, or guessed rows fail here.
|
||||
/// </summary>
|
||||
[Trait("Lane", "InstalledDat")]
|
||||
public sealed class RetailActionIdentityRoundTripTests
|
||||
{
|
||||
/// <summary>Actions with a citation-backed, pre-existing reason their DAT-union
|
||||
/// default set legitimately differs from <see cref="KeyBindings.RetailDefaults"/>
|
||||
/// — see class doc. Every other mapped action must match exactly.</summary>
|
||||
private static readonly HashSet<InputAction> KnownRetailDefaultsDisagreements = new()
|
||||
{
|
||||
InputAction.MovementWalkMode,
|
||||
InputAction.UseQuickSlot_1,
|
||||
InputAction.UseQuickSlot_2,
|
||||
InputAction.UseQuickSlot_3,
|
||||
InputAction.UseQuickSlot_4,
|
||||
InputAction.UseQuickSlot_5,
|
||||
InputAction.UseQuickSlot_6,
|
||||
InputAction.UseQuickSlot_7,
|
||||
InputAction.UseQuickSlot_8,
|
||||
InputAction.UseQuickSlot_9,
|
||||
// Same Use-vs-Select ambiguity as the bare-numeral block above: the DAT's
|
||||
// own "Select Quickslot N" action ids carry NO default binding at all —
|
||||
// RetailDefaults()'s Ctrl+N->Select mapping rests on the decompiled
|
||||
// dispatch function's runtime modifier check, not the raw keymap default.
|
||||
InputAction.SelectQuickSlot_1,
|
||||
InputAction.SelectQuickSlot_2,
|
||||
InputAction.SelectQuickSlot_3,
|
||||
InputAction.SelectQuickSlot_4,
|
||||
InputAction.SelectQuickSlot_5,
|
||||
InputAction.SelectQuickSlot_6,
|
||||
InputAction.SelectQuickSlot_7,
|
||||
InputAction.SelectQuickSlot_8,
|
||||
InputAction.SelectQuickSlot_9,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void MappedActions_DatUnionDefaultBindings_MatchRetailDefaults()
|
||||
{
|
||||
|
|
@ -98,13 +28,44 @@ public sealed class RetailActionIdentityRoundTripTests
|
|||
RetailActionMapSnapshot? snapshot = RetailActionMapReader.Read(source);
|
||||
Assert.NotNull(snapshot);
|
||||
|
||||
Assert.Equal(306, snapshot!.Rows.Count);
|
||||
var unresolvedRows = snapshot.Rows
|
||||
.Where(row => !RetailActionIdentityTable.TryResolve(
|
||||
row.InputMapId,
|
||||
row.ActionId,
|
||||
out _))
|
||||
.Select(row => $"0x{row.InputMapId:X8}/0x{row.ActionId:X8}")
|
||||
.ToArray();
|
||||
Assert.Empty(unresolvedRows);
|
||||
Assert.Equal(306, RetailActionIdentityTable.Map.Count);
|
||||
Assert.Equal(306, RetailActionIdentityTable.Map.Values.Distinct().Count());
|
||||
Assert.Equal(306, RetailActionIdentityTable.ReverseMap.Count);
|
||||
|
||||
var optionIds = new HashSet<uint>();
|
||||
foreach (RetailActionMapRow row in snapshot.Rows.Where(
|
||||
static row => row.InputMapId == 0x10000008u))
|
||||
{
|
||||
Assert.True(RetailActionIdentityTable.TryResolve(
|
||||
row.InputMapId,
|
||||
row.ActionId,
|
||||
out InputAction action));
|
||||
Assert.True(
|
||||
RetailActionIdentityTable.TryGetCharacterOptionId(
|
||||
action,
|
||||
out uint optionId),
|
||||
$"CharacterSettings row 0x{row.ActionId:X8} has no PlayerOption id");
|
||||
Assert.True(CharacterOptionTable.TryGet(optionId, out _));
|
||||
Assert.True(optionIds.Add(optionId), $"duplicate PlayerOption id 0x{optionId:X2}");
|
||||
}
|
||||
Assert.Equal(48, optionIds.Count);
|
||||
|
||||
KeyBindings retailDefaults = KeyBindings.RetailDefaults();
|
||||
|
||||
// Aggregate DAT default chords by resolved InputAction — a single action can
|
||||
// be reached by more than one DAT row (e.g. the Camera/CameraAlternate pair).
|
||||
var datChordsByAction = new Dictionary<InputAction, HashSet<KeyChord>>();
|
||||
var unresolvedScanCodes = new List<string>();
|
||||
foreach (RetailActionMapRow row in snapshot!.Rows)
|
||||
foreach (RetailActionMapRow row in snapshot.Rows)
|
||||
{
|
||||
if (!RetailActionIdentityTable.TryResolve(row.InputMapId, row.ActionId, out InputAction action))
|
||||
continue;
|
||||
|
|
@ -125,15 +86,12 @@ public sealed class RetailActionIdentityRoundTripTests
|
|||
}
|
||||
}
|
||||
|
||||
Assert.True(datChordsByAction.Count > 100,
|
||||
$"expected >100 mapped actions, got {datChordsByAction.Count}");
|
||||
Assert.Equal(306, datChordsByAction.Count);
|
||||
Assert.Empty(unresolvedScanCodes);
|
||||
|
||||
var mismatches = new List<string>();
|
||||
foreach ((InputAction action, HashSet<KeyChord> datChords) in datChordsByAction)
|
||||
{
|
||||
if (KnownRetailDefaultsDisagreements.Contains(action)) continue;
|
||||
|
||||
var acdreamChords = retailDefaults.ForAction(action).Select(b => b.Chord).ToHashSet();
|
||||
if (!datChords.SetEquals(acdreamChords))
|
||||
{
|
||||
|
|
@ -144,8 +102,7 @@ public sealed class RetailActionIdentityRoundTripTests
|
|||
}
|
||||
|
||||
Assert.True(mismatches.Count == 0,
|
||||
$"{mismatches.Count} unexpected DAT-vs-RetailDefaults() disagreements "
|
||||
+ "(not in the documented KnownRetailDefaultsDisagreements allowlist):\n"
|
||||
$"{mismatches.Count} DAT-vs-RetailDefaults() disagreements:\n"
|
||||
+ string.Join("\n", mismatches));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue