fix(client): restore retail interaction parity
All checks were successful
CI / linux-portable (push) Successful in 3m27s
CI / windows-gate (push) Successful in 6m42s
CI / release (push) Successful in 2m12s

Harden keyboard and camera routing, inventory and vendor interactions, chat/emotes, relog portal flow, and paperdoll rendering. Add retail research, connected gate coverage, and release-gate validation.
This commit is contained in:
Erik 2026-08-26 20:45:11 +02:00
parent 0c699240e0
commit f6fe0f2a4f
151 changed files with 10162 additions and 1211 deletions

View file

@ -17,6 +17,22 @@ public sealed class ChatCommandTargetStateTests
Assert.Equal("Caith", targets.LastOutgoingTellTarget);
}
[Fact]
public void TracksIndependentMonarchAndPatronReplyTargetsFromLegacyBroadcasts()
{
var chat = new ChatLog();
using var targets = new ChatCommandTargetState(chat);
// 0x0147 does not carry a sender GUID; the incoming sender name is
// nevertheless authoritative for retail's monarch/patron reply keys.
chat.OnChannelBroadcast(0x4000u, "Monarch", "orders");
chat.OnChannelBroadcast(0x2000u, "Patron", "hello");
chat.OnChannelBroadcast(0x4000u, "New Monarch", "new orders");
Assert.Equal("New Monarch", targets.LastMonarchSender);
Assert.Equal("Patron", targets.LastPatronSender);
}
[Fact]
public void ResetSessionForgetsTargetsButPreservesTranscript()
{
@ -29,6 +45,8 @@ public sealed class ChatCommandTargetStateTests
Assert.Null(targets.LastIncomingTellSender);
Assert.Null(targets.LastOutgoingTellTarget);
Assert.Null(targets.LastMonarchSender);
Assert.Null(targets.LastPatronSender);
Assert.Equal(2, chat.Count);
}

View file

@ -32,6 +32,12 @@ public sealed class InventoryFailureMessagesTests
[InlineData(
InventoryRequestKind.SplitToContainer, "Arrows", 0x36u,
"The Arrows can't be split - action cancelled")]
[InlineData(
InventoryRequestKind.Move, "Sword", 0u,
"The Sword can't be moved")]
[InlineData(
InventoryRequestKind.Wield, "Sword", 0x1Du,
"The Sword can't be wielded - you're too busy")]
public void ComposeMatchesServerSaysAttemptFailed(
InventoryRequestKind kind,
string name,

View file

@ -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));
}
}

View file

@ -169,6 +169,38 @@ public sealed class RetailActionMapReaderTests
Assert.Empty(row.DefaultBindings);
}
[Fact]
public void Read_PreservesRetailInputMapConflictPolicy()
{
var actionMap = new ActionMap
{
InputMaps = new Dictionary<uint, Dictionary<uint, ActionMapValue>>(),
ConflictingMaps = new Dictionary<uint, InputsConflictsValue>
{
[0x10000003u] = new InputsConflictsValue
{
InputMap = 0x10000003u,
ConflictingInputMaps = new List<uint>
{
0x10000003u,
0x10000002u,
},
},
},
};
var dats = new FakeDatObjectSource();
dats.Add(RetailActionMapIds.ActionMapId, actionMap);
RetailActionMapSnapshot snapshot = Assert.IsType<RetailActionMapSnapshot>(
RetailActionMapReader.Read(dats));
Assert.True(snapshot.InputMapsConflict(0x10000003u, 0x10000003u));
Assert.True(snapshot.InputMapsConflict(0x10000003u, 0x10000002u));
Assert.False(snapshot.InputMapsConflict(0x10000003u, 0x10000004u));
Assert.True(snapshot.InputMapsConflict(0xDEADBEEFu, 0xDEADBEEFu));
Assert.False(snapshot.InputMapsConflict(0xDEADBEEFu, 0x10000003u));
}
[Fact]
public void RetailInputMapHeaders_HasAllNineteenByteVerifiedEntries()
{
@ -245,5 +277,12 @@ public sealed class RetailActionMapReader_LiveDatTests
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
// The authored combat modes intentionally share the five attack/aim
// keys. Retail's conflict table keeps those mode-local contexts apart;
// Configure Keyboard must not erase one mode while editing another.
Assert.False(snapshot.InputMapsConflict(0x10000003u, 0x10000004u));
Assert.False(snapshot.InputMapsConflict(0x10000003u, 0x10000005u));
Assert.False(snapshot.InputMapsConflict(0x10000004u, 0x10000005u));
}
}

View file

@ -94,6 +94,40 @@ public sealed class ExternalContainerStateTests
Assert.Equal(2, delivered);
}
[Fact]
public void OpenedCorpseHistoryMatchesRetailSetAndDeleteLifetime()
{
var state = new ExternalContainerState();
const uint corpse = 0x70000010u;
Assert.True(state.RequestOpen(corpse, isCorpse: true));
Assert.True(state.HasCorpseBeenOpened(corpse));
Assert.Equal(1, state.OpenedCorpseCount);
state.ApplyViewContents(corpse);
state.ApplyClose(corpse);
Assert.True(state.HasCorpseBeenOpened(corpse));
Assert.True(state.SetCorpseDeleted(corpse));
Assert.False(state.HasCorpseBeenOpened(corpse));
state.RequestOpen(corpse, isCorpse: true);
Assert.True(state.Reset());
Assert.False(state.HasCorpseBeenOpened(corpse));
Assert.Equal(0, state.OpenedCorpseCount);
}
[Fact]
public void RepeatedGroundObjectRequestStillRecordsCorpseIdentity()
{
var state = new ExternalContainerState();
const uint corpse = 0x70000011u;
Assert.True(state.RequestOpen(corpse));
Assert.False(state.RequestOpen(corpse, isCorpse: true));
Assert.True(state.HasCorpseBeenOpened(corpse));
}
private static ExternalContainerState Open(uint id)
{
var state = new ExternalContainerState();

View file

@ -0,0 +1,76 @@
using AcDream.Core.Items;
namespace AcDream.Core.Tests.Items;
public sealed class InventoryContainerPlacementPolicyTests
{
private const uint Player = 0x50000001u;
[Fact]
public void FullItemCapacityRejectsNewItemButAllowsReorder()
{
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = Player,
Name = "Player",
ItemsCapacity = 1,
ContainersCapacity = 7,
});
objects.AddOrUpdate(new ClientObject { ObjectId = 2u });
objects.MoveItem(2u, Player, 0);
objects.AddOrUpdate(new ClientObject { ObjectId = 3u });
Assert.Equal(
InventoryContainerPlacementRejection.ItemCapacityFull,
InventoryContainerPlacementPolicy.Evaluate(objects, 3u, Player, Player));
Assert.Equal(
InventoryContainerPlacementRejection.None,
InventoryContainerPlacementPolicy.Evaluate(objects, 2u, Player, Player));
}
[Fact]
public void ContainerCycleAndTradeAreRejected()
{
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = 10u, Type = ItemType.Container, ItemsCapacity = 24,
});
objects.AddOrUpdate(new ClientObject
{
ObjectId = 11u, Type = ItemType.Container, ItemsCapacity = 24,
});
objects.MoveItem(11u, 10u, 0);
Assert.Equal(
InventoryContainerPlacementRejection.RecursiveContainment,
InventoryContainerPlacementPolicy.Evaluate(objects, 10u, 11u, Player));
objects.Get(10u)!.TradeState = 1;
Assert.Equal(
InventoryContainerPlacementRejection.SourceBeingTraded,
InventoryContainerPlacementPolicy.Evaluate(objects, 10u, Player, Player));
}
[Fact]
public void FullMessageMatchesRetailContainerTypeBranches()
{
var player = new ClientObject { ObjectId = Player, Name = "Backpack" };
var bag = new ClientObject { ObjectId = 2u, Name = "Pack" };
Assert.Equal(
"Backpack is completely full!",
InventoryContainerPlacementPolicy.ComposeClientLocal(
InventoryContainerPlacementRejection.ItemCapacityFull,
null,
player,
Player));
Assert.Equal(
"The Pack can fit no more containers!",
InventoryContainerPlacementPolicy.ComposeClientLocal(
InventoryContainerPlacementRejection.ContainerCapacityFull,
null,
bag,
Player));
}
}

View file

@ -147,9 +147,53 @@ public sealed class ItemInteractionPolicyTests
Assert.Equal(ItemPolicyActionKind.Reject,
Assert.Single(ItemInteractionPolicy.DecideUse(
Use(direct with { TradeState = 1 })).Actions).Kind);
Assert.Contains("wield", Assert.Single(ItemInteractionPolicy.DecideUse(
Use(direct with { Useability = ItemUseability.Wielded })).Actions).Message,
StringComparison.OrdinalIgnoreCase);
Assert.Equal("You cannot use the item because you are trading it",
Assert.Single(ItemInteractionPolicy.DecideUse(
Use(direct with { TradeState = 1 })).Actions).Message);
Assert.Equal("You must wield the item to use it",
Assert.Single(ItemInteractionPolicy.DecideUse(
Use(direct with { Useability = ItemUseability.Wielded })).Actions).Message);
}
[Fact]
public void UseObject_targetCompatibilityFailures_useRetailVerbatimMessages()
{
var source = OwnedDirect() with
{
Name = "mana stone",
Useability = 0x00080008u,
TargetType = (uint)ItemType.Misc,
};
var target = Obj(0x6002) with
{
Name = "armor",
Type = ItemType.Armor,
ContainerId = Player,
OwnedByPlayer = true,
};
var missing = ItemInteractionPolicy.DecideUse(Use(source) with
{
UseCurrentSelection = true,
});
Assert.Equal("Select your target before using the mana stone",
Assert.Single(missing.Actions).Message);
var incompatible = ItemInteractionPolicy.DecideUse(Use(source) with
{
UseCurrentSelection = true,
SelectedTarget = target,
});
Assert.Equal("Cannot use the mana stone with the armor",
Assert.Single(incompatible.Actions).Message);
var traded = ItemInteractionPolicy.DecideUse(Use(source) with
{
UseCurrentSelection = true,
SelectedTarget = target with { TradeState = 1 },
});
Assert.Equal("You can't use the mana stone on an item you are trading",
Assert.Single(traded.Actions).Message);
}
[Fact]
@ -219,7 +263,7 @@ public sealed class ItemInteractionPolicyTests
PlayerOnGround = false,
});
Assert.False(airborne.ReturnValue);
Assert.Equal(ItemPolicyActionKind.Reject, Assert.Single(airborne.Actions).Kind);
Assert.Equal("You cannot do that in mid air", Assert.Single(airborne.Actions).Message);
var split = ItemInteractionPolicy.DecidePlacement(PlaceOnGround(item) with { SplitSize = 4 });
Assert.True(split.ReturnValue);
@ -232,8 +276,7 @@ public sealed class ItemInteractionPolicyTests
var alreadyWorld = ItemInteractionPolicy.DecidePlacement(
PlaceOnGround(item with { IsIn3DView = true }));
Assert.False(alreadyWorld.ReturnValue);
Assert.Contains("cancelled", Assert.Single(alreadyWorld.Actions).Message,
StringComparison.OrdinalIgnoreCase);
Assert.Equal("Move cancelled", Assert.Single(alreadyWorld.Actions).Message);
}
[Fact]

View file

@ -204,6 +204,28 @@ public sealed class VendorStagingListTests
Assert.False(list.TryGet(ItemB, out _));
}
[Fact]
public void ReplacePreservesTheSplitPlaceholderPositionAndQuantity()
{
var list = new VendorStagingList();
list.Add(ItemA, 2);
list.Add(ItemB, 9);
const uint splitGuid = 0x60000003u;
int fired = 0;
list.Changed += () => fired++;
Assert.True(list.Replace(ItemA, splitGuid));
Assert.Equal(
new[]
{
new VendorStagingEntry(splitGuid, 2),
new VendorStagingEntry(ItemB, 9),
},
list.Entries);
Assert.Equal(1, fired);
}
[Fact]
public void ClearRemovesEveryEntryAndFiresChangedOnce()
{