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

@ -61,6 +61,33 @@ public sealed class WorldLifecycleAutomationControllerTests
}
}
[Fact]
public void RetailScreenshotRequest_UsesFirstFreeScreenShotNumber()
{
string directory = NewDirectory();
Directory.CreateDirectory(directory);
File.WriteAllBytes(Path.Combine(directory, "ScreenShot00000.png"), [0]);
var controller = new FrameScreenshotController(
(_, _) => [255, 255, 255, 255],
directory);
try
{
Assert.True(controller.TryRequestRetailScreenshot(
out string path,
out string error), error);
Assert.Equal(
Path.Combine(directory, "ScreenShot00001.png"),
path);
Assert.True(controller.CapturePending(1, 1));
Assert.True(File.Exists(path));
}
finally
{
Directory.Delete(directory, recursive: true);
}
}
[Fact]
public void ScreenshotCapture_ReportsNoWorkAndFailedCapture()
{

View file

@ -1,6 +1,7 @@
using System.Numerics;
using AcDream.App.Input;
using AcDream.App.Rendering;
using AcDream.Core.Rendering;
using AcDream.UI.Abstractions.Input;
using Silk.NET.Input;
@ -175,31 +176,63 @@ public sealed class CameraPointerInputControllerTests
Assert.Equal(flyBefore * 1.2f, fixture.Owner.ActiveSensitivity, 5);
}
[Fact]
public void ChaseMouseWheel_RetainsExtendedZoomOutRange()
{
var fixture = Create([new RawSurface()]);
var legacy = new ChaseCamera();
var retail = new RetailChaseCamera();
fixture.Mode.IsPlayerMode = true;
fixture.Chase.Legacy = legacy;
fixture.Chase.Retail = retail;
fixture.Camera.EnterChaseMode(legacy, retail);
float before = CameraDiagnostics.UseRetailChaseCamera
? retail.Distance
: legacy.Distance;
fixture.Owner.HandleScroll(InputAction.ScrollDown);
float afterOne = CameraDiagnostics.UseRetailChaseCamera
? retail.Distance
: legacy.Distance;
for (int i = 0; i < 100; i++)
fixture.Owner.HandleScroll(InputAction.ScrollDown);
float afterMany = CameraDiagnostics.UseRetailChaseCamera
? retail.Distance
: legacy.Distance;
Assert.Equal(before + 0.8f, afterOne, 5);
Assert.Equal(40f, afterMany, 5);
}
private static Fixture Create(IReadOnlyList<RawSurface> surfaces)
{
var camera = new CameraController(new OrbitCamera(), new FlyCamera());
var capture = new Capture();
var mouse = new Mouse();
var cursor = new Cursor();
var mode = new LocalPlayerModeState();
var chase = new ChaseCameraInputState();
var owner = new CameraPointerInputController(
surfaces,
cursor,
new HostQuiescenceGate(),
capture,
new LocalPlayerModeState(),
mode,
camera,
new ChaseCameraInputState(),
chase,
mouse,
new PointerPositionState(),
new Clock());
return new Fixture(owner, camera, capture, cursor);
return new Fixture(owner, camera, capture, cursor, mode, chase);
}
private sealed record Fixture(
CameraPointerInputController Owner,
CameraController Camera,
Capture Capture,
Cursor Cursor);
Cursor Cursor,
LocalPlayerModeState Mode,
ChaseCameraInputState Chase);
private sealed class RawSurface : IRawPointerSurface
{
@ -282,6 +315,7 @@ public sealed class CameraPointerInputControllerTests
{
public void Tick() { }
public void HandleMovementInput(InputAction action, ActivationType activation) { }
public void AbortAutomaticAttack() { }
public bool HandleInputAction(InputAction action, ActivationType activation) => false;
}
}

View file

@ -1,6 +1,7 @@
using AcDream.App.Input;
using AcDream.App.Rendering;
using AcDream.Core.Combat;
using AcDream.Runtime;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Tests.Input;
@ -11,9 +12,10 @@ public sealed class GameplayInputActionRouterTests
[InlineData("pointer", "pointer")]
[InlineData("combat", "pointer,combat")]
[InlineData("retained", "pointer,combat,retained")]
[InlineData("selection", "pointer,combat,retained,selection")]
[InlineData("movement", "pointer,combat,retained,selection,movement")]
[InlineData("command", "pointer,combat,retained,selection,movement,command")]
[InlineData("character-option", "pointer,combat,retained,character-option")]
[InlineData("selection", "pointer,combat,retained,character-option,selection")]
[InlineData("movement", "pointer,combat,retained,character-option,selection,movement")]
[InlineData("command", "pointer,combat,retained,character-option,selection,movement,command")]
public void Press_PreservesFrozenPriorityAndStopsAtConsumer(
string consumeAt,
string expectedCsv)
@ -66,7 +68,7 @@ public sealed class GameplayInputActionRouterTests
ActivationType.DoubleClick);
Assert.Equal(
["pointer", "combat", "retained", "selection", "movement", "command"],
["pointer", "combat", "retained", "character-option", "selection", "movement", "command"],
harness.Targets.Calls);
}
@ -81,7 +83,7 @@ public sealed class GameplayInputActionRouterTests
ActivationType.Click);
Assert.Equal(
["pointer", "combat", "retained", "selection"],
["pointer", "combat", "retained", "character-option", "selection"],
harness.Targets.Calls);
}
@ -100,6 +102,60 @@ public sealed class GameplayInputActionRouterTests
harness.Actions.Scopes);
}
[Theory]
[InlineData(InputAction.Ready, RuntimeMovementCommand.Ready)]
[InlineData(InputAction.Sitting, RuntimeMovementCommand.Sit)]
[InlineData(InputAction.Crouch, RuntimeMovementCommand.Crouch)]
[InlineData(InputAction.Sleeping, RuntimeMovementCommand.Sleep)]
public void RetailPostureKeys_MapToCanonicalRuntimeCommands(
InputAction action,
RuntimeMovementCommand expected)
{
Assert.Equal(
expected,
RuntimeGameplayInputPriorityTargets.ResolvePressedMovementCommand(action));
}
[Fact]
public void EscapeMovementRung_PreservesRetailPriority()
{
var charging = new AcDream.Runtime.Gameplay.JumpChargeSnapshot(
IsCharging: true,
Power: 0.5f);
var repeat = new RuntimeCombatAttackSnapshot(
0,
AttackHeight.Medium,
0f,
0f,
false,
false,
0f,
RepeatAttackInProgress: true);
Assert.Equal(
RuntimeMovementCommand.FinishJump,
RuntimeGameplayInputPriorityTargets.ResolveEscapeMovementCommand(
InputAction.EscapeKey,
isStandingStill: false,
charging,
repeat));
Assert.Equal(
RuntimeMovementCommand.StopCompletely,
RuntimeGameplayInputPriorityTargets.ResolveEscapeMovementCommand(
InputAction.EscapeKey,
isStandingStill: false,
jumpCharge: default,
repeat));
Assert.Null(
RuntimeGameplayInputPriorityTargets.ResolveEscapeMovementCommand(
InputAction.EscapeKey,
isStandingStill: true,
jumpCharge: default,
repeat with { RepeatAttackInProgress = false }));
}
[Theory]
[InlineData(0, "remove-actions")]
[InlineData(1, "remove-combat,remove-actions")]
@ -277,6 +333,9 @@ public sealed class GameplayInputActionRouterTests
public void SetCombatScope(InputScope? scope) => Scopes.Add(scope);
public void SetCameraAlternateScope(bool active) =>
calls.Add($"camera-scope:{active}");
public void Raise(InputAction action, ActivationType activation) =>
Callback?.Invoke(action, activation);
}
@ -326,6 +385,9 @@ public sealed class GameplayInputActionRouterTests
public bool HandleRetainedUiAction(InputAction action) =>
Record("retained");
public bool HandleCharacterOptionAction(InputAction action) =>
Record("character-option");
public bool HandleSelectionAction(InputAction action) =>
Record("selection");

View file

@ -23,6 +23,9 @@ public sealed class GameplayInputCommandControllerTests
[InlineData(InputAction.ToggleFloatingChatWindow2, "chat-window-2")]
[InlineData(InputAction.ToggleFloatingChatWindow3, "chat-window-3")]
[InlineData(InputAction.ToggleFloatingChatWindow4, "chat-window-4")]
[InlineData(InputAction.ToggleChatEntry, "focus-chat")]
[InlineData(InputAction.EnterChatMode, "focus-chat")]
[InlineData(InputAction.LOGOUT, "logout")]
public void RecognizedCommand_RoutesToTypedOwner(
InputAction action,
string expected)
@ -35,21 +38,12 @@ public sealed class GameplayInputCommandControllerTests
Assert.Equal([expected], harness.Calls);
}
// OP9: AcdreamToggleDebugPanel/ToggleChatEntry retired the
// IDevToolsGameplayCommands seam they used to forward to — both
// targets (the ImGui-era DebugPanel/ChatPanel) were already gone
// (Campaign V slice V11), so the seam's own body was an unconditional
// no-op. The action is still consumed (handled == true, matching the
// prior no-op's contract) but claims no typed-owner call.
[Theory]
[InlineData(InputAction.AcdreamToggleDebugPanel)]
[InlineData(InputAction.ToggleChatEntry)]
public void RetiredDevToolsCommand_IsConsumedWithoutClaimingATypedOwner(
InputAction action)
[Fact]
public void RetiredDebugPanelCommand_IsConsumedWithoutClaimingATypedOwner()
{
var harness = new Harness();
bool handled = harness.Controller.Handle(action);
bool handled = harness.Controller.Handle(InputAction.AcdreamToggleDebugPanel);
Assert.True(handled);
Assert.Empty(harness.Calls);
@ -80,8 +74,9 @@ public sealed class GameplayInputCommandControllerTests
}
/// <summary>
/// Escape's priority chain: cancel a target mode, else leave player mode,
/// else close a window.
/// Escape's command-tier priority: cancel a target mode, otherwise toggle
/// retail's Gameplay Options page. It must never expose the developer/fly
/// camera or close the game window.
/// </summary>
/// <remarks>
/// The free-fly rung was REMOVED (2026-08-21, user direction: free-fly
@ -90,21 +85,15 @@ public sealed class GameplayInputCommandControllerTests
/// rather than silently exiting a camera the player has no way to enter.
/// </remarks>
[Theory]
[InlineData(true, true, true, "cancel-target")]
[InlineData(false, true, true, "exit-player")]
[InlineData(false, false, true, "exit-player")]
[InlineData(false, false, false, "close")]
public void Escape_PreservesTargetPlayerWindowPriority(
[InlineData(true, "cancel-target")]
[InlineData(false, "gameplay-options")]
public void Escape_PreservesRetailTargetThenGameplayOptionsPriority(
bool targetMode,
bool flyMode,
bool playerMode,
string expected)
{
var harness = new Harness
{
TargetMode = { IsActive = targetMode },
Camera = { IsFly = flyMode },
Player = { IsPlayer = playerMode },
};
bool handled = harness.Controller.Handle(InputAction.EscapeKey);
@ -121,19 +110,15 @@ public sealed class GameplayInputCommandControllerTests
Diagnostics = new FakeDiagnostics(Calls);
Player = new FakePlayerMode(Calls);
TargetMode = new FakeTargetMode(Calls);
Camera = new FakeCamera(Calls);
Combat = new FakeCombat(Calls);
Runtime = new FakeRuntimeView();
Window = new FakeWindow(Calls);
Controller = new GameplayInputCommandController(
Retained,
Diagnostics,
Player,
TargetMode,
Camera,
Runtime,
Combat,
Window);
Combat);
}
public List<string> Calls { get; } = [];
@ -141,10 +126,8 @@ public sealed class GameplayInputCommandControllerTests
public FakeDiagnostics Diagnostics { get; }
public FakePlayerMode Player { get; }
public FakeTargetMode TargetMode { get; }
public FakeCamera Camera { get; }
public FakeCombat Combat { get; }
public FakeRuntimeView Runtime { get; }
public FakeWindow Window { get; }
public GameplayInputCommandController Controller { get; }
}
@ -157,6 +140,12 @@ public sealed class GameplayInputCommandControllerTests
calls.Add($"chat-window-{windowId}");
public void ToggleOptionsPanel() => calls.Add("options");
public void ToggleGameplayOptionsPage() => calls.Add("gameplay-options");
public void FocusChatEntry() => calls.Add("focus-chat");
public void LogOutCharacter() => calls.Add("logout");
}
private sealed class FakeDiagnostics(List<string> calls)
@ -180,11 +169,8 @@ public sealed class GameplayInputCommandControllerTests
private sealed class FakePlayerMode(List<string> calls)
: IPlayerModeGameplayCommands
{
public bool IsPlayer { get; set; }
public bool IsPlayerMode => IsPlayer;
public void ToggleFlyOrChase() => calls.Add("fly-or-chase");
public void TogglePlayerMode() => calls.Add("player-mode");
public void ExitPlayerMode() => calls.Add("exit-player");
}
private sealed class FakeTargetMode(List<string> calls)
@ -195,14 +181,6 @@ public sealed class GameplayInputCommandControllerTests
public void CancelTargetMode() => calls.Add("cancel-target");
}
private sealed class FakeCamera(List<string> calls)
: IGameplayCameraModeCommands
{
public bool IsFly { get; set; }
public bool IsFlyMode => IsFly;
public void ExitFlyMode() => calls.Add("exit-fly");
}
private sealed class FakeCombat(List<string> calls) : IRuntimeCombatCommands
{
public RuntimeCommandResult Execute(
@ -248,8 +226,4 @@ public sealed class GameplayInputCommandControllerTests
throw new NotSupportedException();
}
private sealed class FakeWindow(List<string> calls) : IGameplayWindowCommands
{
public void Close() => calls.Add("close");
}
}

View file

@ -127,6 +127,7 @@ public sealed class GameplayInputFrameControllerTests
public void Tick() => _calls.Add("combat");
public void HandleMovementInput(InputAction action, ActivationType activation) =>
_calls.Add("combat-movement");
public void AbortAutomaticAttack() => _calls.Add("combat-abort");
public bool HandleInputAction(InputAction action, ActivationType activation)
{
_calls.Add("combat-action");

View file

@ -0,0 +1,45 @@
using AcDream.App.Input;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Tests.Input;
public sealed class RetailEmoteMotionTableTests
{
[Fact]
public void EveryRetailEmoteActionHasExactMotionConsumer()
{
InputAction[] emotes = RetailActionIdentityTable.Map
.Where(entry => entry.Key.InputMapId == 0x10000006u)
.OrderBy(entry => entry.Key.ActionId)
.Select(entry => entry.Value)
.ToArray();
Assert.Equal(87, RetailEmoteMotionTable.Count);
Assert.Equal(87, emotes.Length);
foreach (InputAction emote in emotes)
Assert.True(RetailEmoteMotionTable.TryGetMotion(emote, out _));
}
[Theory]
[InlineData(InputAction.EmoteAfkState, 0x43000118u)]
[InlineData(InputAction.Cheer, 0x1300004Cu)]
[InlineData(InputAction.Cry, 0x1300007Fu)]
[InlineData(InputAction.Laugh, 0x13000080u)]
[InlineData(InputAction.PointState, 0x430000F0u)]
[InlineData(InputAction.Wave, 0x13000087u)]
[InlineData(InputAction.EmoteYmca, 0x1200009Bu)]
public void RepresentativeActionsMatchNamedRetailGlobals(
InputAction action,
uint expectedMotion)
{
Assert.True(RetailEmoteMotionTable.TryGetMotion(action, out uint motion));
Assert.Equal(expectedMotion, motion);
}
[Theory]
[InlineData(InputAction.Ready)]
[InlineData(InputAction.ToggleOptionsPanel)]
[InlineData(InputAction.UseQuickSlot_1)]
public void NonEmoteActionsAreRejected(InputAction action) =>
Assert.False(RetailEmoteMotionTable.TryGetMotion(action, out _));
}

View file

@ -0,0 +1,113 @@
using AcDream.App.Input;
using AcDream.UI.Abstractions.Input;
using Silk.NET.Input;
namespace AcDream.App.Tests.Input;
public sealed class RetailKeymapFileTests
{
[Fact]
public void Parse_CommittedRetailFile_ReproducesEveryUserBindableDefault()
{
string text = File.ReadAllText(Path.Combine(
FindRepoRoot(), "docs", "research", "named-retail",
"retail-default.keymap.txt"));
KeyBindings expected = KeyBindings.RetailDefaults();
KeyBindings actual = RetailKeymapFile.Parse(text, expected);
Assert.Equal(
expected.ForAction(InputAction.MovementForward).ToArray(),
actual.ForAction(InputAction.MovementForward).ToArray());
Assert.Equal(
expected.ForAction(InputAction.MovementWalkMode).ToArray(),
actual.ForAction(InputAction.MovementWalkMode).ToArray());
Assert.Equal(
expected.ForAction(InputAction.ToggleInventoryPanel).ToArray(),
actual.ForAction(InputAction.ToggleInventoryPanel).ToArray());
Assert.Equal(
expected.ForAction(InputAction.CameraAlternateRotateLeft).ToArray(),
actual.ForAction(InputAction.CameraAlternateRotateLeft).ToArray());
// The user's captured retail file omits slots 10-13; omission means
// unbound rather than "inherit a compiled default".
Assert.Empty(actual.ForAction(InputAction.UseQuickSlot_10));
// Host-only actions are outside retail's fourteen editable maps and survive.
Assert.Equal(
expected.ForAction(InputAction.AcdreamToggleAudioMute).ToArray(),
actual.ForAction(InputAction.AcdreamToggleAudioMute).ToArray());
}
[Fact]
public void WriteThenParse_RoundTripsAll306RetailActionIdentities()
{
var source = new KeyBindings();
foreach (((uint inputMapId, uint actionId), InputAction action) in
RetailActionIdentityTable.Map)
{
source.Add(new Binding(
new KeyChord(
Key.SuperRight,
ModifierMask.Shift | ModifierMask.Ctrl | ModifierMask.Win),
action,
RetailActionIdentityTable.ActivationFor(inputMapId, actionId),
RetailActionIdentityTable.ScopeForInputMap(inputMapId)));
}
string text = RetailKeymapFile.Write(source);
KeyBindings loaded = RetailKeymapFile.Parse(text, new KeyBindings());
Assert.Equal(306, loaded.All.Count);
foreach (Binding expected in source.All)
Assert.Contains(expected, loaded.All);
Assert.Contains("CharacterOptionCommands", text, StringComparison.Ordinal);
Assert.Contains("AutoRepeatAttacks", text, StringComparison.Ordinal);
Assert.Contains("AFKState", text, StringComparison.Ordinal);
Assert.Contains("DIK_RWIN", text, StringComparison.Ordinal);
Assert.Contains("EscapeKey [ \"\" [ 0 DIK_ESCAPE ] ]", text, StringComparison.Ordinal);
Assert.Contains("TargetedUsage", text, StringComparison.Ordinal);
}
[Fact]
public void ProfileStore_SaveAsSelectsProfile_AndLoadReplacesRetailRows()
{
string root = Path.Combine(Path.GetTempPath(), "acdream-keymap-" + Guid.NewGuid().ToString("N"));
string config = Path.Combine(root, "config");
string documents = Path.Combine(root, "documents", "Asheron's Call");
string json = Path.Combine(config, "keybinds.json");
try
{
var source = KeyBindings.RetailDefaults();
var store = new RetailKeymapProfileStore(json, documents);
RetailKeymapSaveResult saved = store.Save("friends", source, overwrite: false);
Assert.Equal(RetailKeymapSaveStatus.Saved, saved.Status);
Assert.Equal("friends.keymap", store.CurrentFileName);
Assert.Equal(new[] { "friends.keymap" }, store.ListFiles());
Assert.True(store.TryLoad(
"friends.keymap", new KeyBindings(), out KeyBindings loaded, out string? error),
error);
Assert.Equal(
source.ForAction(InputAction.MovementForward).ToArray(),
loaded.ForAction(InputAction.MovementForward).ToArray());
Assert.Equal(
RetailKeymapSaveStatus.Exists,
store.Save("friends", source, overwrite: false).Status);
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
private static string FindRepoRoot()
{
string? directory = AppContext.BaseDirectory;
while (directory is not null)
{
if (File.Exists(Path.Combine(directory, "AcDream.slnx")))
return directory;
directory = Directory.GetParent(directory)?.FullName;
}
throw new DirectoryNotFoundException("Could not locate acdream.sln.");
}
}

View file

@ -234,6 +234,25 @@ public sealed class SelectionInteractionControllerTests
}
}
[Fact]
public void Escape_ClearsCurrentSelectionBeforeTheOptionsFallback()
{
var h = new Harness();
h.Selection.Select(Target, SelectionChangeSource.World);
Assert.True(h.Controller.HandleInputAction(InputAction.EscapeKey));
Assert.Null(h.Selection.SelectedObjectId);
}
[Fact]
public void Escape_WithNoTargetModeOrSelection_FallsThrough()
{
var h = new Harness();
Assert.False(h.Controller.HandleInputAction(InputAction.EscapeKey));
}
[Fact]
public void TargetModeClickPulsesBeforeItIsConsumedAndIncludesSelf()
{
@ -323,6 +342,25 @@ public sealed class SelectionInteractionControllerTests
Assert.Contains(h.Toasts, text => text.Contains("Target 70000001"));
}
[Fact]
public void EveryRetailItemSelectionRowHasALiveControllerConsumer()
{
InputAction[] actions = RetailActionIdentityTable.Map
.Where(entry => entry.Key.InputMapId == 0x10000007u)
.OrderBy(entry => entry.Key.ActionId)
.Select(entry => entry.Value)
.ToArray();
Assert.Equal(26, actions.Length);
foreach (InputAction action in actions)
{
var harness = new Harness();
Assert.True(
harness.Controller.HandleInputAction(action),
$"No selection consumer for {action}");
}
}
[Fact]
public void CloseUseSendsImmediatelyWithoutSpeculativeMovement()
{

View file

@ -11,6 +11,7 @@ using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Core.Selection;
using AcDream.Core.Ui;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
@ -45,7 +46,10 @@ public sealed class WorldSelectionQueryTests
public readonly LiveEntityRuntime Runtime;
public readonly RetailSelectionScene Scene;
public readonly WorldSelectionQuery Query;
public readonly ExternalContainerState ExternalContainers = new();
public readonly HashSet<uint> Fellows = [];
public PlayerInteractionPose? PlayerPose = new(0x0101_0001u, Vector3.Zero);
public CombatMode CurrentCombatMode = CombatMode.NonCombat;
/// <summary>
/// Stands in for EntityEffectPoseRegistry: the composed equipped-child
@ -74,7 +78,10 @@ public sealed class WorldSelectionQueryTests
_ => (new Vector3(1f, 0f, 0f), 2f),
localEntityId => ChildRoots.TryGetValue(localEntityId, out Matrix4x4 root)
? root
: null);
: null,
ExternalContainers.HasCorpseBeenOpened,
() => CurrentCombatMode,
Fellows.Contains);
Add(Player, Vector3.Zero, ItemType.Creature, SelectedObjectHealthPolicy.BfPlayer);
}
@ -89,7 +96,8 @@ public sealed class WorldSelectionQueryTests
ushort instance = 1,
float scale = 1f,
Quaternion? rotation = null,
float? useRadius = null)
float? useRadius = null,
byte? radarBehavior = null)
{
WorldSession.EntitySpawn spawn = Spawn(guid, instance) with
{
@ -109,6 +117,7 @@ public sealed class WorldSelectionQueryTests
Name = $"Object {guid:X8}",
Type = type,
PublicWeenieBitfield = publicFlags,
RadarBehavior = radarBehavior,
});
return entity;
}
@ -301,6 +310,187 @@ public sealed class WorldSelectionQueryTests
Assert.Equal(64f, closest?.DistanceSquared);
}
[Fact]
public void RetailItemSelectionUsesRadarAndSpecialObjectRules()
{
var h = new Harness();
const uint radarItem = 0x7000_0020u;
const uint ordinaryItem = 0x7000_0021u;
const uint portal = 0x7000_0022u;
h.Add(
radarItem,
new Vector3(1f, 0f, 0f),
ItemType.Misc,
radarBehavior: (byte)RadarBehavior.ShowAlways);
h.Add(ordinaryItem, new Vector3(2f, 0f, 0f), ItemType.Misc);
h.Add(
portal,
new Vector3(3f, 0f, 0f),
ItemType.Misc,
publicFlags: (uint)PublicWeenieFlags.Portal,
radarBehavior: (byte)RadarBehavior.ShowAlways);
Assert.Equal(
ordinaryItem,
h.Query.FindSelectionTarget(
RetailSelectionKind.Item,
RetailSelectionDirection.Closest,
anchor: null));
// A radar-authored item is not in the Item cycle unless it carries
// one of retail's three explicit exceptions (lifestone/portal/
// bindstone).
h.Objects.Get(ordinaryItem)!.ContainerId = Player;
Assert.Equal(
portal,
h.Query.FindSelectionTarget(
RetailSelectionKind.Item,
RetailSelectionDirection.Closest,
anchor: null));
}
[Fact]
public void RetailCompassSelectionChangesPredicateInPhysicalCombat()
{
var h = new Harness();
const uint peaceful = 0x7000_0030u;
const uint fellow = 0x7000_0031u;
const uint vendor = 0x7000_0032u;
const uint environment = 0x7000_0033u;
const uint hostile = 0x7000_0034u;
h.Add(
peaceful,
new Vector3(1f, 0f, 0f),
ItemType.Misc,
radarBehavior: (byte)RadarBehavior.ShowAlways);
h.Add(
fellow,
new Vector3(2f, 0f, 0f),
ItemType.Creature,
publicFlags: (uint)PublicWeenieFlags.Attackable,
radarBehavior: (byte)RadarBehavior.ShowAlways);
h.Fellows.Add(fellow);
h.Add(
vendor,
new Vector3(3f, 0f, 0f),
ItemType.Creature,
publicFlags: (uint)(PublicWeenieFlags.Attackable | PublicWeenieFlags.Vendor),
radarBehavior: (byte)RadarBehavior.ShowAlways);
h.Add(
environment,
new Vector3(4f, 0f, 0f),
ItemType.Creature,
publicFlags: (uint)PublicWeenieFlags.Attackable,
radarBehavior: (byte)RadarBehavior.ShowAlways);
Assert.True(h.Runtime.TryApplyState(
new SetState.Parsed(
environment,
(uint)(PhysicsStateFlags.ReportCollisions
| PhysicsStateFlags.ReportAsEnvironment),
InstanceSequence: 1,
StateSequence: 2),
out _));
h.Add(
hostile,
new Vector3(5f, 0f, 0f),
ItemType.Creature,
publicFlags: (uint)PublicWeenieFlags.Attackable,
radarBehavior: (byte)RadarBehavior.ShowAlways);
Assert.Equal(
peaceful,
h.Query.FindSelectionTarget(
RetailSelectionKind.CompassItem,
RetailSelectionDirection.Closest,
anchor: null));
h.CurrentCombatMode = CombatMode.Melee;
Assert.Equal(
hostile,
h.Query.FindSelectionTarget(
RetailSelectionKind.CompassItem,
RetailSelectionDirection.Closest,
anchor: null));
// Retail's special combat-only compass restriction does not apply
// in magic mode.
h.CurrentCombatMode = CombatMode.Magic;
Assert.Equal(
peaceful,
h.Query.FindSelectionTarget(
RetailSelectionKind.CompassItem,
RetailSelectionDirection.Closest,
anchor: null));
}
[Fact]
public void RetailMonsterSelectionUsesObjectIsAttackableAndRejectsFellowsAndVendors()
{
var h = new Harness();
const uint fellow = 0x7000_0040u;
const uint vendor = 0x7000_0041u;
const uint hostile = 0x7000_0042u;
h.Add(
fellow,
new Vector3(1f, 0f, 0f),
ItemType.Creature,
publicFlags: (uint)PublicWeenieFlags.Attackable,
radarBehavior: (byte)RadarBehavior.ShowAlways);
h.Fellows.Add(fellow);
h.Add(
vendor,
new Vector3(2f, 0f, 0f),
ItemType.Creature,
publicFlags: (uint)(PublicWeenieFlags.Attackable | PublicWeenieFlags.Vendor),
radarBehavior: (byte)RadarBehavior.ShowAlways);
h.Add(
hostile,
new Vector3(3f, 0f, 0f),
ItemType.Creature,
publicFlags: (uint)PublicWeenieFlags.Attackable,
radarBehavior: (byte)RadarBehavior.ShowAlways);
Assert.Equal(
hostile,
h.Query.FindSelectionTarget(
RetailSelectionKind.Monster,
RetailSelectionDirection.Closest,
anchor: null));
}
[Fact]
public void RetailUnopenedCorpseSelectionRemembersOpenUntilDelete()
{
var h = new Harness();
const uint corpse = 0x7000_0050u;
h.Add(
corpse,
new Vector3(1f, 0f, 0f),
ItemType.Container,
publicFlags: (uint)PublicWeenieFlags.Corpse);
Assert.Equal(
corpse,
h.Query.FindSelectionTarget(
RetailSelectionKind.UnopenedCorpse,
RetailSelectionDirection.Closest,
anchor: null));
Assert.True(h.ExternalContainers.RequestOpen(corpse, isCorpse: true));
Assert.Null(h.Query.FindSelectionTarget(
RetailSelectionKind.UnopenedCorpse,
RetailSelectionDirection.Closest,
anchor: null));
Assert.True(h.ExternalContainers.SetCorpseDeleted(corpse));
Assert.Equal(
corpse,
h.Query.FindSelectionTarget(
RetailSelectionKind.UnopenedCorpse,
RetailSelectionDirection.Closest,
anchor: null));
}
/// <summary>
/// #298 follow-up: retail <c>ClientCombatSystem::UpdateTargetTracking
/// @ 0x0056A950</c> (pc:375691-375696) gates <c>CameraSet::TrackTarget</c>

View file

@ -9,17 +9,18 @@ namespace AcDream.App.Tests.Rendering;
public sealed class PaperdollFramePresenterTests
{
[Fact]
public void HiddenView_DoesNotBuildOrRender()
public void HiddenView_BuildsAndPrewarmsWithoutRendering()
{
var renderer = new RecordingRenderer();
var view = new RecordingView { Visible = false };
var factory = new RecordingFactory();
var factory = new RecordingFactory { Doll = CreateDoll() };
var presenter = new PaperdollFramePresenter(renderer, view, factory);
presenter.Render();
Assert.True(presenter.IsDirty);
Assert.Equal(0, factory.BuildCount);
Assert.False(presenter.IsDirty);
Assert.Equal(1, factory.BuildCount);
Assert.Equal(1, renderer.PrepareCount);
Assert.Equal(0, renderer.RenderCount);
Assert.Empty(view.TextureHandles);
}
@ -255,9 +256,12 @@ public sealed class PaperdollFramePresenterTests
public List<WorldEntity?> Dolls { get; } = [];
public List<(int Width, int Height)> RenderSizes { get; } = [];
public int RenderCount => RenderSizes.Count;
public int PrepareCount { get; private set; }
public void SetDoll(WorldEntity? doll) => Dolls.Add(doll);
public void Prepare() => PrepareCount++;
public uint Render(int width, int height)
{
RenderSizes.Add((width, height));

View file

@ -209,6 +209,25 @@ public class RetailChaseCameraTests
Assert.Equal(Vector3.Normalize(pivot - eye), forward);
}
[Fact]
public void MapMode_TargetDirectionTransformsViewerOffsetIntoOverheadPose()
{
var pivot = new Vector3(10f, 20f, 1.5f);
float distance = MathF.Sqrt(450f * 450f + 0.75f * 0.75f);
float pitch = MathF.Atan2(0.75f, 450f);
var (eye, forward) = RetailChaseCamera.ComputeTargetDirectionPose(
pivot,
Vector3.UnitX,
distance,
pitch,
new Vector3(0f, 0.5f, -1.8f));
Assert.True(eye.Z > 430f, $"expected retail overhead eye, got Z={eye.Z}");
Assert.InRange(Vector2.Distance(new Vector2(eye.X, eye.Y), new Vector2(pivot.X, pivot.Y)), 119f, 122f);
Assert.True(forward.Z < -0.95f, $"expected steep downward view, got {forward}");
}
[Fact]
public void Basis_HorizontalHeading_IsOrthonormalAndRightHanded()
{
@ -528,6 +547,41 @@ public class RetailChaseCameraTests
Assert.Equal(RetailChaseCamera.DistanceMax, cam.Distance);
}
[Fact]
public void SetRetailFirstPersonView_PlacesEyeAheadAndLooksForward()
{
var cam = new RetailChaseCamera();
cam.SetRetailFirstPersonView();
cam.Update(
playerPosition: Vector3.Zero,
playerYaw: 0f,
playerVelocity: Vector3.Zero,
isOnGround: true,
contactPlaneNormal: Vector3.UnitZ,
dt: 1f / 60f);
Assert.True(cam.IsInHead);
Assert.Equal(new Vector3(0.18f, 0f, 1.5f), cam.Position);
Assert.Equal(1f, cam.PlayerTranslucency, 5);
var (_, forward) = RetailChaseCamera.ComputeInHeadPose(
new Vector3(0f, 0f, 1.5f),
Vector3.UnitX);
Assert.Equal(Vector3.UnitX, forward);
}
[Fact]
public void AdjustingZoomExitsRetailFirstPersonView()
{
var cam = new RetailChaseCamera();
cam.SetRetailFirstPersonView();
cam.AdjustDistance(1f);
Assert.False(cam.IsInHead);
Assert.Equal(RetailChaseCamera.DistanceMin + 1f, cam.Distance);
}
[Fact]
public void AdjustPitch_ClampsToRange()
{

View file

@ -338,6 +338,11 @@ public sealed class CurrentGameRuntimeAdapterTests
Assert.True(graphicalInput.HandleInputAction(
InputAction.CombatLowAttack,
ActivationType.Press));
Assert.True(graphical.Actions.View.Snapshot.CombatAttack.RequestInProgress);
Assert.True(graphicalInput.HandleInputAction(
InputAction.CombatLowAttack,
ActivationType.Hold));
Assert.True(graphical.Actions.View.Snapshot.CombatAttack.RequestInProgress);
Assert.True(graphicalInput.HandleInputAction(
InputAction.CombatLowAttack,
ActivationType.Release));
@ -1280,6 +1285,10 @@ public sealed class CurrentGameRuntimeAdapterTests
{
}
public void AbortAutomaticAttack()
{
}
public bool HandleInputAction(
InputAction action,
ActivationType activation) => false;

View file

@ -1400,6 +1400,7 @@ public sealed class LocalPlayerTeleportControllerTests
public bool IsRecenterPending => RecenterPending;
public bool RecenterPending;
public bool ResetResult = true;
public int ResetCalls;
public bool LastResetWasSessionEnding;
public readonly List<(int X, int Y, bool Sealed)> Recenters = new();
public readonly List<(long Generation, uint Cell, int Radius)>
@ -1414,6 +1415,7 @@ public sealed class LocalPlayerTeleportControllerTests
public bool ResetRecenter(bool sessionEnding)
{
ResetCalls++;
LastResetWasSessionEnding = sessionEnding;
return ResetResult;
}
@ -1954,6 +1956,7 @@ public sealed class LocalPlayerTeleportControllerTests
harness.Controller.ResetGenerationPresentation();
harness.Controller.Tick(0.016f);
Assert.Equal(1, harness.Logout.CompleteCalls);
Assert.Equal(1, harness.Streaming.ResetCalls);
Assert.Equal(RuntimeLogoutStage.None, harness.Transit.LogoutStage);
// The transaction's world reset retired the presentation.
Assert.Contains("presentation-reset", order);
@ -1966,6 +1969,34 @@ public sealed class LocalPlayerTeleportControllerTests
Assert.Equal(1, harness.Logout.CompleteCalls);
}
[Fact]
public void LogoutConfirmation_WaitsForOldStreamingWindowBeforeFreshGeneration()
{
var harness = new Harness(worldReady: true);
Assert.True(harness.Controller.TryRequestLogout());
harness.Logout.IsCharacterLogOffConfirmed = true;
harness.Streaming.ResetResult = false;
harness.Logout.OnComplete = () =>
harness.Controller.ResetGenerationPresentation();
harness.Controller.Tick(0.016f);
Assert.Equal(RuntimeLogoutStage.Confirmed, harness.Transit.LogoutStage);
Assert.Equal(0, harness.Logout.CompleteCalls);
Assert.Equal(1, harness.Streaming.ResetCalls);
// StreamingController.Tick advances the retained retirement between
// these controller ticks. Once converged, the logout transaction may
// expose the next generation. Its reset callback consumes the same
// retirement instead of beginning a second one.
harness.Streaming.ResetResult = true;
harness.Controller.Tick(0.016f);
Assert.Equal(RuntimeLogoutStage.None, harness.Transit.LogoutStage);
Assert.Equal(1, harness.Logout.CompleteCalls);
Assert.Equal(2, harness.Streaming.ResetCalls);
}
[Fact]
public void LogoutConfirmationBeforeHoldEnd_SkipsTheWormholeEntirely()
{

View file

@ -35,8 +35,7 @@ public sealed class AutoWieldGenerationTests
objects,
() => Player,
sendWield: null,
sendPutItemInContainer: (_, _, _) => { },
toast: null);
sendPutItemInContainer: (_, _, _) => { });
Assert.True(controller.TryWield(requested));
Assert.True(controller.IsBusy);

View file

@ -182,6 +182,38 @@ public class DragDropSpineTests
Assert.Equal((0x99u, 32, 32), root.DragGhostForTest);
}
[Fact]
public void RootOwnedDrag_survivesProceduralSourceCellReplacement_untilRelease()
{
var (root, list, cell) = RootWithBoundSlot(0x5001u);
object? released = null;
root.DragReleasedOutsideUi += (payload, _, _) => released = payload;
root.OnMouseDown(UiMouseButton.Left, 10, 10);
root.OnMouseMove(20, 10); // BeginDrag → root owns ghost
object payload = Assert.IsType<ItemDragPayload>(root.DragPayload);
// InventoryController.Populate/ExternalContainerController.Populate use
// UiItemList.Flush when an unrelated authoritative item update arrives.
// The old source cell is replaced while the physical button is still down.
list.Flush();
Assert.Null(cell.Parent);
Assert.Same(cell, root.DragSource);
Assert.Same(payload, root.DragPayload);
Assert.Equal((0x99u, 32, 32), root.DragGhostForTest);
Assert.Same(root, root.Captured); // retail drag element owns capture
root.OnMouseMove(600, 500);
root.OnMouseUp(UiMouseButton.Left, 600, 500);
Assert.Same(payload, released);
Assert.Null(root.DragSource);
Assert.Null(root.DragPayload);
Assert.Null(root.DragGhostForTest);
Assert.Null(root.Captured);
}
[Fact]
public void FinishDrag_overNothing_deliversNoDrop_butLiftStands()
{

View file

@ -23,6 +23,7 @@ public sealed class ItemInteractionControllerTests
public readonly List<(uint Item, uint Mask)> Wields = new();
public readonly List<(uint Item, uint Container, int Placement)> Puts = new();
public readonly List<(uint Item, uint Container, uint Placement, uint Amount)> SplitPuts = new();
public readonly List<(uint Source, uint Target, uint Amount)> Merges = new();
public readonly List<uint> ExternalRequests = new();
public readonly List<(uint Item, uint Container, int Placement)> BackpackPlacements = new();
public readonly List<uint> Drops = new();
@ -130,7 +131,9 @@ public sealed class ItemInteractionControllerTests
Sells.Add((vendorGuid, items));
return true;
},
interfaceText: (text, type) => InterfaceTexts.Add((text, type)));
interfaceText: (text, type) => InterfaceTexts.Add((text, type)),
sendStackableMerge: (source, target, amount) =>
Merges.Add((source, target, amount)));
}
public ItemInteractionController Controller { get; }
@ -589,7 +592,7 @@ public sealed class ItemInteractionControllerTests
}
[Fact]
public void EquippableItemWithFreeSlot_sendsGetAndWieldAndMovesOptimistically()
public void EquippableItemWithFreeSlot_sendsGetAndWieldAndWaitsForServer()
{
var h = new Harness();
h.AddContained(0x50000A05u, item =>
@ -602,12 +605,12 @@ public sealed class ItemInteractionControllerTests
Assert.Equal(new[] { (0x50000A05u, (uint)EquipMask.HeadWear) }, h.Wields);
var equipped = h.Objects.Get(0x50000A05u)!;
Assert.Equal(Player, equipped.ContainerId);
Assert.Equal(EquipMask.HeadWear, equipped.CurrentlyEquippedLocation);
Assert.Equal(Pack, equipped.ContainerId);
Assert.Equal(EquipMask.None, equipped.CurrentlyEquippedLocation);
}
[Fact]
public void EquippableMultiSlotItemWithFreeSlots_sendsFullCoverageMaskAndMovesOptimistically()
public void EquippableMultiSlotItemWithFreeSlots_sendsFullCoverageMaskAndWaitsForServer()
{
var h = new Harness();
const EquipMask coatMask =
@ -624,8 +627,8 @@ public sealed class ItemInteractionControllerTests
Assert.Equal(new[] { (0x50000A15u, (uint)coatMask) }, h.Wields);
var equipped = h.Objects.Get(0x50000A15u)!;
Assert.Equal(Player, equipped.ContainerId);
Assert.Equal(coatMask, equipped.CurrentlyEquippedLocation);
Assert.Equal(Pack, equipped.ContainerId);
Assert.Equal(EquipMask.None, equipped.CurrentlyEquippedLocation);
}
[Fact]
@ -654,7 +657,7 @@ public sealed class ItemInteractionControllerTests
Assert.True(h.Controller.ActivateItem(0x50000A16u));
Assert.Equal(new[] { (0x50000A16u, (uint)coatMask) }, h.Wields);
Assert.Equal(coatMask, h.Objects.Get(0x50000A16u)!.CurrentlyEquippedLocation);
Assert.Equal(EquipMask.None, h.Objects.Get(0x50000A16u)!.CurrentlyEquippedLocation);
}
[Fact]
@ -692,12 +695,13 @@ public sealed class ItemInteractionControllerTests
}
[Fact]
public void EquippableItemWithNoFreeSlot_sendsNothing()
public void EquippableItemWithNoFreeSlot_movesBlockerThenWieldsAfterServerConfirm()
{
var h = new Harness();
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = 0x50000AF0u,
Name = "Old Shield",
Type = ItemType.Armor,
CurrentlyEquippedLocation = EquipMask.Shield,
});
@ -712,9 +716,17 @@ public sealed class ItemInteractionControllerTests
bool activated = h.Controller.ActivateItem(0x50000A06u);
Assert.True(activated);
Assert.Empty(h.Wields);
Assert.False(activated);
Assert.Equal(new[] { (0x50000AF0u, Player, 0) }, h.Puts);
Assert.Equal(new[] { "Moving Old Shield to your backpack" }, h.SystemMessages);
Assert.Equal(Pack, h.Objects.Get(0x50000A06u)!.ContainerId);
Assert.True(h.Objects.ApplyConfirmedServerMove(0x50000AF0u, Player, 0u, 0));
Assert.Equal(
new[] { (0x50000A06u, (uint)EquipMask.Shield) },
h.Wields);
}
[Theory]
@ -752,14 +764,18 @@ public sealed class ItemInteractionControllerTests
Assert.Equal(Pack, h.Objects.Get(bow)!.ContainerId);
// Authoritative 0x0022: only now does retail retry AutoWield.
h.Objects.MoveItem(sword, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(sword, Player, 0u, 0));
Assert.Equal(new[] { (bow, (uint)EquipMask.MissileWeapon) }, h.Wields);
Assert.Equal(EquipMask.None,
h.Objects.Get(sword)!.CurrentlyEquippedLocation);
Assert.Equal(EquipMask.None,
h.Objects.Get(bow)!.CurrentlyEquippedLocation);
Assert.Equal(Pack, h.Objects.Get(bow)!.ContainerId);
Assert.True(h.Objects.ApplyConfirmedServerWield(
bow, Player, EquipMask.MissileWeapon));
Assert.Equal(EquipMask.MissileWeapon,
h.Objects.Get(bow)!.CurrentlyEquippedLocation);
Assert.Equal(Player, h.Objects.Get(bow)!.ContainerId);
}
[Theory]
@ -797,7 +813,7 @@ public sealed class ItemInteractionControllerTests
// wand away. The transaction retains the initial active-combat intent
// rather than consulting this intermediate state on its second pass.
h.Combat.SetCombatMode(CombatMode.Melee);
h.Objects.MoveItem(wand, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(wand, Player, 0u, 0));
Assert.Equal(new[] { (bow, (uint)EquipMask.MissileWeapon) }, h.Wields);
Assert.Empty(h.CombatModeRequests);
@ -860,7 +876,7 @@ public sealed class ItemInteractionControllerTests
// replacement wield.
h.Combat.SetCombatMode(CombatMode.Melee);
h.Combat.SetCombatMode(CombatMode.NonCombat);
h.Objects.MoveItem(bow, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(bow, Player, 0u, 0));
Assert.Equal(new[] { (wand, (uint)EquipMask.Held) }, h.Wields);
Assert.True(h.Objects.ApplyConfirmedServerWield(
@ -899,7 +915,7 @@ public sealed class ItemInteractionControllerTests
Assert.True(h.Controller.ActivateItem(wand));
h.Combat.SetCombatMode(CombatMode.NonCombat);
h.Objects.MoveItem(bow, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(bow, Player, 0u, 0));
Assert.True(h.Objects.ApplyConfirmedServerWield(
wand,
Player,
@ -935,7 +951,7 @@ public sealed class ItemInteractionControllerTests
});
Assert.True(h.Controller.ActivateItem(bow));
h.Objects.MoveItem(wand, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(wand, Player, 0u, 0));
Assert.True(h.Objects.ApplyConfirmedServerWield(
bow, Player, EquipMask.MissileWeapon));
@ -1027,7 +1043,7 @@ public sealed class ItemInteractionControllerTests
});
Assert.True(h.Controller.ActivateItem(bow));
h.Objects.MoveItem(sword, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(sword, Player, 0u, 0));
Assert.Single(h.Wields);
// Bow is optimistic but ACE has not sent WieldObject yet. Retail's
@ -1121,13 +1137,13 @@ public sealed class ItemInteractionControllerTests
Assert.True(h.Controller.ActivateItem(bow));
Assert.Equal(new[] { (sword, Player, 0) }, h.Puts);
h.Objects.MoveItem(sword, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(sword, Player, 0u, 0));
Assert.Equal(
new[] { (sword, Player, 0), (shield, Player, 0) },
h.Puts);
Assert.Empty(h.Wields);
h.Objects.MoveItem(shield, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(shield, Player, 0u, 0));
Assert.Equal(new[] { (bow, (uint)EquipMask.MissileWeapon) }, h.Wields);
}
@ -1164,12 +1180,12 @@ public sealed class ItemInteractionControllerTests
});
Assert.True(h.Controller.ActivateItem(bow));
h.Objects.MoveItem(sword, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(sword, Player, 0u, 0));
Assert.Equal(
new[] { (sword, Player, 0), (arrows, Player, 0) },
h.Puts);
h.Objects.MoveItem(arrows, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(arrows, Player, 0u, 0));
Assert.Equal(new[] { (bow, (uint)EquipMask.MissileWeapon) }, h.Wields);
}
@ -1213,7 +1229,7 @@ public sealed class ItemInteractionControllerTests
Assert.Empty(h.Wields);
Assert.Equal(new[] { "Moving Shortbow to your backpack" }, h.SystemMessages);
h.Objects.MoveItem(bow, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(bow, Player, 0u, 0));
Assert.Equal(new[] { (sword, (uint)EquipMask.MeleeWeapon) }, h.Wields);
Assert.Equal(EquipMask.MissileAmmo,
@ -1247,7 +1263,7 @@ public sealed class ItemInteractionControllerTests
Assert.Empty(h.Wields);
Assert.Equal(new[] { "Moving Wand to your backpack" }, h.SystemMessages);
h.Objects.MoveItem(wand, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(wand, Player, 0u, 0));
Assert.Equal(new[] { (sword, (uint)EquipMask.MeleeWeapon) }, h.Wields);
}
@ -1292,14 +1308,14 @@ public sealed class ItemInteractionControllerTests
crossbow, EquipMask.MissileWeapon));
Assert.Equal(new[] { (bow, Player, 0) }, h.Puts);
h.Objects.MoveItem(bow, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(bow, Player, 0u, 0));
Assert.Equal(
new[] { (bow, Player, 0), (arrows, Player, 0) },
h.Puts);
Assert.Empty(h.Wields);
h.Objects.MoveItem(arrows, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(arrows, Player, 0u, 0));
Assert.Equal(
new[] { (crossbow, (uint)EquipMask.MissileWeapon) },
@ -1333,7 +1349,11 @@ public sealed class ItemInteractionControllerTests
new[] { (ring, (uint)EquipMask.FingerWearRight) },
h.Wields);
Assert.Equal(
EquipMask.FingerWearRight,
EquipMask.FingerWearLeft,
h.Objects.Get(ring)!.CurrentlyEquippedLocation);
Assert.True(h.Objects.ApplyConfirmedServerWield(
ring, Player, EquipMask.FingerWearRight));
Assert.Equal(EquipMask.FingerWearRight,
h.Objects.Get(ring)!.CurrentlyEquippedLocation);
}
@ -1366,14 +1386,61 @@ public sealed class ItemInteractionControllerTests
Assert.Empty(h.Wields);
Assert.Equal(new[] { "Moving Right Ring to your backpack" }, h.SystemMessages);
h.Objects.MoveItem(rightRing, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(rightRing, Player, 0u, 0));
Assert.Equal(
new[] { (leftRing, (uint)EquipMask.FingerWearRight) },
h.Wields);
Assert.Equal(
EquipMask.FingerWearRight,
EquipMask.FingerWearLeft,
h.Objects.Get(leftRing)!.CurrentlyEquippedLocation);
Assert.True(h.Objects.ApplyConfirmedServerWield(
leftRing, Player, EquipMask.FingerWearRight));
Assert.Equal(EquipMask.FingerWearRight,
h.Objects.Get(leftRing)!.CurrentlyEquippedLocation);
}
[Fact]
public void ActivateItem_whenEveryCompatibleSlotIsOccupied_movesRetailPreferredBlockerThenWields()
{
var h = new Harness();
const uint leftRing = 0x50000B74u;
const uint rightRing = 0x50000B75u;
const uint requestedRing = 0x50000B76u;
EquipMask valid = EquipMask.FingerWearLeft | EquipMask.FingerWearRight;
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = leftRing,
Name = "Left Ring",
Type = ItemType.Jewelry,
ValidLocations = valid,
});
h.Objects.MoveItem(leftRing, Player, -1, EquipMask.FingerWearLeft);
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = rightRing,
Name = "Right Ring",
Type = ItemType.Jewelry,
ValidLocations = valid,
});
h.Objects.MoveItem(rightRing, Player, -1, EquipMask.FingerWearRight);
h.AddContained(requestedRing, item =>
{
item.Name = "New Ring";
item.Type = ItemType.Jewelry;
item.ValidLocations = valid;
});
Assert.True(h.Controller.ActivateItem(requestedRing));
Assert.Equal(new[] { (leftRing, Player, 0) }, h.Puts);
Assert.Empty(h.Wields);
Assert.Equal(new[] { "Moving Left Ring to your backpack" }, h.SystemMessages);
Assert.True(h.Objects.ApplyConfirmedServerMove(leftRing, Player, 0u, 0));
Assert.Equal(
new[] { (requestedRing, (uint)EquipMask.FingerWearLeft) },
h.Wields);
}
[Fact]
@ -1400,7 +1467,7 @@ public sealed class ItemInteractionControllerTests
}
[Fact]
public void InventoryDragOutsideUi_sendsDropAndMovesToWorldOptimistically()
public void InventoryDragOutsideUi_sendsDropAndWaitsForServerPlacement()
{
var h = new Harness();
h.AddContained(0x50000A07u);
@ -1413,7 +1480,7 @@ public sealed class ItemInteractionControllerTests
Assert.True(h.Controller.DropToWorld(payload));
Assert.Equal(new[] { 0x50000A07u }, h.Drops);
Assert.Equal(0u, h.Objects.Get(0x50000A07u)!.ContainerId);
Assert.Equal(Pack, h.Objects.Get(0x50000A07u)!.ContainerId);
}
/// <summary>
@ -1517,7 +1584,7 @@ public sealed class ItemInteractionControllerTests
Assert.Empty(h.Gives);
Assert.Equal(new[] { item }, h.Drops);
Assert.Equal(0u, h.Objects.Get(item)!.ContainerId);
Assert.Equal(Pack, h.Objects.Get(item)!.ContainerId);
}
[Theory]
@ -1898,6 +1965,126 @@ public sealed class ItemInteractionControllerTests
Assert.False(h.Controller.TryGetPendingInventoryRequest(out _));
}
[Fact]
public void KeyboardPickup_AutoMergesWholeStackBeforeContainerPlacement()
{
var h = new Harness();
const uint source = 0x70000B01u;
const uint target = 0x50000B02u;
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = source,
WeenieClassId = 77u,
Name = "World stack",
StackSize = 3,
StackSizeMax = 10,
});
h.AddContained(target, item =>
{
item.WeenieClassId = 77u;
item.StackSize = 5;
item.StackSizeMax = 10;
});
var attempts = new List<(uint Source, uint Target)>();
h.Controller.MergeAttempted += (from, into) => attempts.Add((from, into));
Assert.True(h.Controller.PlaceWorldItemInBackpack(source));
Assert.Equal(new[] { (source, target, 3u) }, h.Merges);
Assert.Equal(new[] { (source, target) }, attempts);
Assert.Empty(h.BackpackPlacements);
Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending));
Assert.Equal(InventoryRequestKind.Merge, pending.Kind);
Assert.True(pending.Dispatched);
}
[Fact]
public void KeyboardPickup_UsesSelectedSplitQuantityAndSearchesNestedPacks()
{
var h = new Harness();
const uint nestedPack = 0x50000B10u;
const uint source = 0x70000B11u;
const uint target = 0x50000B12u;
h.AddContained(nestedPack, item => item.Type = ItemType.Container);
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = source,
WeenieClassId = 88u,
StackSize = 10,
StackSizeMax = 10,
});
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = target,
WeenieClassId = 88u,
StackSize = 8,
StackSizeMax = 10,
});
h.Objects.MoveItem(target, nestedPack, 0);
h.SelectedObject = source;
h.SplitQuantity.Reset(10u, 2u);
Assert.True(h.Controller.PlaceWorldItemInBackpack(source));
Assert.Equal(new[] { (source, target, 2u) }, h.Merges);
Assert.Empty(h.BackpackPlacements);
}
[Fact]
public void KeyboardPickup_SkipsPartialMergeTargetAndFallsBackToPlacement()
{
var h = new Harness();
const uint source = 0x70000B20u;
const uint partialTarget = 0x50000B21u;
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = source,
WeenieClassId = 99u,
StackSize = 3,
StackSizeMax = 10,
});
h.AddContained(partialTarget, item =>
{
item.WeenieClassId = 99u;
item.StackSize = 9;
item.StackSizeMax = 10;
});
Assert.True(h.Controller.PlaceWorldItemInBackpack(source));
Assert.Empty(h.Merges);
Assert.Equal(new[] { (source, Player, 0) }, h.BackpackPlacements);
}
[Fact]
public void TrySplitToContainerDispatchesTheExactSelectedQuantity()
{
var h = new Harness();
const uint source = 0x50000A30u;
h.AddContained(source, item => item.StackSize = 10);
Assert.True(h.Controller.TrySplitToContainer(source, Pack, 3u, 2u));
Assert.Equal(new[] { (source, Pack, 3u, 2u) }, h.SplitPuts);
Assert.True(h.Controller.TryGetPendingInventoryRequest(out var pending));
Assert.Equal(InventoryRequestKind.SplitToContainer, pending.Kind);
Assert.Equal(source, pending.ItemId);
}
[Fact]
public void TrySplitToContainerRejectsZeroAndWholeStackAmounts()
{
var h = new Harness();
const uint source = 0x50000A31u;
h.AddContained(source, item => item.StackSize = 10);
Assert.False(h.Controller.TrySplitToContainer(source, Pack, 0u, 0u));
Assert.False(h.Controller.TrySplitToContainer(source, Pack, 0u, 10u));
Assert.Empty(h.SplitPuts);
Assert.False(h.Controller.TryGetPendingInventoryRequest(out _));
}
[Fact]
public void MatchingInventoryFailureReleasesGlobalRequest()
{

View file

@ -1156,6 +1156,28 @@ public class CharacterStatControllerTests
Assert.Equal(RetailUiStateIds.Open, Assert.IsAssignableFrom<IUiDatStateful>(child).ActiveRetailStateId));
}
[Fact]
public void ProgrammaticShowTab_UsesTheSameAuthoredStateAsAKeyboardAction()
{
ImportedLayout layout = FixtureLoader.LoadCharacter();
var attributes = Assert.IsType<UiText>(
layout.FindElement(CharacterStatController.TabAttribId));
var skills = Assert.IsType<UiText>(
layout.FindElement(CharacterStatController.TabSkillsId));
CharacterStatController.Binding binding = CharacterStatController.Bind(
layout,
SampleData.SampleCharacter,
spriteResolve: id => (id, 16, 16));
binding.ShowTab(CharacterStatController.CharacterStatTab.Skills);
Assert.Equal(
CharacterStatController.CharacterStatTab.Skills,
binding.CurrentTab());
Assert.Equal(RetailUiStateIds.Closed, attributes.ActiveRetailStateId);
Assert.Equal(RetailUiStateIds.Open, skills.ActiveRetailStateId);
}
/// <summary>
/// CT3 (2026-08-24): unlike Attributes/Skills (which share ONE mounted
/// page and only rebind its content), the Titles page (0x10000539) is a
@ -1267,7 +1289,7 @@ public class CharacterStatControllerTests
CharacterSheet sheet = SampleData.SampleCharacter();
Action refresh = CharacterStatController.Bind(layout, () => sheet,
spriteResolve: id => (id, 16, 16));
spriteResolve: id => (id, 16, 16)).Refresh;
ClickTab(layout, left: 92f);
var untrained = sheet.Skills.First(

View file

@ -225,6 +225,47 @@ public sealed class ChatTranscriptRunsTests
Assert.Equal(detailed[^1].Text, lines[^1].Text);
}
[Fact]
public void OversizedMultilineServerMessageKeepsItsNewestCompleteLines()
{
string response = string.Join(
'\n',
Enumerable.Range(0, 1_500).Select(i => $"@command-{i:D4}"));
var detailed = new List<FormattedLine> { Plain(response) };
List<UiText.Line> lines = ChatTranscriptRenderer.BuildLines(
detailed,
maxW: 100_000f,
Measure,
accept: null,
defaultColor: LineColor);
Assert.NotEmpty(lines);
Assert.Equal("@command-1499", lines[^1].Text);
Assert.DoesNotContain(lines, line => line.Text == "@command-0000");
Assert.All(lines, line => Assert.False(string.IsNullOrWhiteSpace(line.Text)));
}
[Fact]
public void MultilineServerMessageWithinBudgetRendersEveryAuthoredLine()
{
var detailed = new List<FormattedLine>
{
Plain("@acecommands\n@help\n@teleport"),
};
List<UiText.Line> lines = ChatTranscriptRenderer.BuildLines(
detailed,
maxW: 100_000f,
Measure,
accept: null,
defaultColor: LineColor);
Assert.Equal(
new[] { "@acecommands", "@help", "@teleport" },
lines.Select(line => line.Text));
}
[Fact]
public void TheBudgetIsRetailsOwnNumber()
=> Assert.Equal(0x2710, ChatTranscriptRenderer.MaxTranscriptCharacters);

View file

@ -494,6 +494,28 @@ public class InventoryControllerTests
Assert.True(containers.GetItem(0)!.Selected); // square — the bag is also the selected item
}
[Fact]
public void DoubleClickOwnedBag_opensOnceOnFirstPress_andNeverRunsGenericUse()
{
var (layout, _, containers, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
SeedBag(objects, 0xCu, slot: 0);
var uses = new List<uint>();
Bind(layout, objects, uses: uses);
UiItemSlot bag = containers.GetItem(0)!;
bag.OnEvent(new UiEvent(0u, bag, UiEventType.MouseDown));
bag.OnEvent(new UiEvent(0u, bag, UiEventType.Click));
bag.OnEvent(new UiEvent(0u, bag, UiEventType.MouseDown));
bag.OnEvent(new UiEvent(0u, bag, UiEventType.Click));
bag.OnEvent(new UiEvent(0u, bag, UiEventType.DoubleClick));
Assert.Equal(new[] { 0xCu }, uses);
Assert.Null(bag.DoubleClicked);
Assert.True(containers.GetItem(0)!.IsOpenContainer);
Assert.Equal(0xCu, objects.Get(0xCu)!.ObjectId);
}
[Fact]
public void MouseDownGridItem_movesSquareImmediately_noWire_keepsOpenContainer()
{
@ -684,7 +706,7 @@ public class InventoryControllerTests
Workmanship: null);
[Fact]
public void Drop_onOccupiedGridCell_insertsBefore_andMovesLocally()
public void Drop_onOccupiedGridCell_insertsBefore_andWaitsForServer()
{
var (layout, grid, _, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
@ -699,7 +721,7 @@ public class InventoryControllerTests
((IItemListDragHandler)ctrl).HandleDropRelease(grid, bCell, Payload(0xFFFFu));
Assert.Contains((0xFFFFu, Player, 1), puts); // insert-before slot 1, into the open container
Assert.Equal(Player, objects.Get(0xFFFFu)!.ContainerId); // moved locally (instant)
Assert.Equal(0u, objects.Get(0xFFFFu)!.ContainerId);
}
[Fact]
@ -1316,11 +1338,12 @@ public class InventoryControllerTests
StackSizeMax = 100,
});
objects.MoveItem(0xAu, Player, 0);
SeedBag(objects, 0xCu, slot: 1);
var selection = new SelectionState();
selection.Select(0xAu, SelectionChangeSource.Inventory);
var splitQuantity = new StackSplitQuantityState();
splitQuantity.Reset(10u);
splitQuantity.SetValue(1u);
splitQuantity.SetValue(2u);
var splits = new List<(uint item, uint container, uint placement, uint amount)>();
var puts = new List<(uint item, uint container, int placement)>();
var ctrl = Bind(layout, objects, puts: puts, splits: splits,
@ -1328,7 +1351,9 @@ public class InventoryControllerTests
ctrl.HandleDropRelease(grid, grid.GetItem(5)!, Payload(0xAu));
Assert.Equal(new[] { (0xAu, Player, 1u, 1u) }, splits);
// Placement counts the main pack's visible loose-item list only;
// side bags occupy the separate selector list and must not shift it.
Assert.Equal(new[] { (0xAu, Player, 1u, 2u) }, splits);
Assert.Empty(puts);
Assert.Equal(Player, objects.Get(0xAu)!.ContainerId);
Assert.Equal(0, objects.Get(0xAu)!.ContainerSlot);
@ -1464,7 +1489,7 @@ public class InventoryControllerTests
((IItemListDragHandler)ctrl).HandleDropRelease(containers, bagCell, Payload(0xFFFFu));
Assert.Contains((0xFFFFu, 0xCu, 0), puts); // into the bag, append (placement 0)
Assert.Equal(0xCu, objects.Get(0xFFFFu)!.ContainerId);
Assert.Equal(0u, objects.Get(0xFFFFu)!.ContainerId);
}
[Fact]
@ -1483,6 +1508,34 @@ public class InventoryControllerTests
ctrl.OnDragOver(grid, grid.GetItem(0)!, Payload(0xFFFFu))); // grid → green
}
[Fact]
public void MainPackFullness_countsLooseItems_notSideBags_afterAFreeSlotAppears()
{
var (layout, _, _, top, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = Player,
Type = ItemType.Creature,
ItemsCapacity = 2,
});
SeedContained(objects, 0xA0u, Player, slot: 0);
SeedContained(objects, 0xA1u, Player, slot: 1);
SeedBag(objects, 0xC0u, slot: 0);
SeedContained(objects, 0xB0u, 0xC0u, slot: 0);
var controller = (IItemListDragHandler)Bind(layout, objects);
UiItemSlot mainPack = top.GetItem(0)!;
Assert.Equal(ItemDragAcceptance.Reject,
controller.OnDragOver(top, mainPack, Payload(0xB0u)));
Assert.True(objects.Remove(0xA1u));
Assert.Equal(ItemDragAcceptance.Accept,
controller.OnDragOver(top, top.GetItem(0)!, Payload(0xB0u)));
Assert.Equal(0.5f, top.GetItem(0)!.CapacityFill);
}
[Fact]
public void GroundPack_rejectsContentsGrid_butEmptyPackSlotAcceptsAndPicksUpAtThatSlot()
{
@ -1617,7 +1670,7 @@ public class InventoryControllerTests
}
[Fact]
public void Drop_thenServerRollback_revertsTheMove() // optimistic + InventoryServerSaveFailed snap-back
public void Drop_thenServerReject_keepsCanonicalPlacement()
{
var (layout, _, containers, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
@ -1626,11 +1679,10 @@ public class InventoryControllerTests
var ctrl = Bind(layout, objects);
((IItemListDragHandler)ctrl).HandleDropRelease(containers, containers.GetItem(0)!, Payload(0xAu));
Assert.Equal(0xCu, objects.Get(0xAu)!.ContainerId); // moved into the bag optimistically (instant)
objects.RollbackMove(0xAu); // server rejected (InventoryServerSaveFailed)
Assert.Equal(Player, objects.Get(0xAu)!.ContainerId); // snapped back to the main pack
Assert.Equal(3, objects.Get(0xAu)!.ContainerSlot); // and the original slot
Assert.Equal(Player, objects.Get(0xAu)!.ContainerId);
Assert.False(objects.RejectMove(0xAu, 0x426u));
Assert.Equal(Player, objects.Get(0xAu)!.ContainerId);
Assert.Equal(3, objects.Get(0xAu)!.ContainerSlot);
}
// Reads the text of the UiText caption child attached by the controller.

View file

@ -20,9 +20,8 @@ namespace AcDream.App.Tests.UI.Layout;
///
/// <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.
/// carries <see cref="Binding"/> (chord + activation + scope). Campaign KB gives
/// both camera contexts distinct live identities and removes the store-only tier.
/// </para>
/// </summary>
public sealed class KeyboardConfigControllerTests
@ -55,7 +54,16 @@ public sealed class KeyboardConfigControllerTests
uint inputMapId, uint actionId, RetailActionClass cls,
uint labelHash = 0, uint tooltipHash = 0,
params RetailKeyChord[] defaults) =>
new(inputMapId, actionId, cls, labelHash, tooltipHash, defaults);
new(
inputMapId,
actionId,
cls,
labelHash == 0 ? 0xDE000000u | (actionId & 0x00FFFFFFu) : labelHash,
tooltipHash,
defaults);
private static string? ResolveSyntheticString(uint _, uint hash) =>
(hash & 0xFF000000u) == 0xDE000000u ? $"Action {hash & 0x00FFFFFFu:X}" : null;
private sealed class FakeBindings
{
@ -72,6 +80,9 @@ public sealed class KeyboardConfigControllerTests
public List<uint> InstructionCloses { get; } = new();
public uint NextInstructionContext { get; set; } = 7u;
public bool WireInstructions { get; set; }
public string CurrentKeymapFilename { get; set; } = "acdream.keymap";
public Action? PendingLoadCompleted { get; private set; }
public Action? PendingSaveCompleted { get; private set; }
public void Capture(KeyChord? chord)
{
@ -103,8 +114,26 @@ public sealed class KeyboardConfigControllerTests
BeginCapture: cb => PendingCapture = cb,
Save: () => SaveCalls++,
Toggle: () => ToggleCalls++,
DisplaySystemMessage: msg => Messages.Add(msg),
NonBindableRefusalText: "cannot overwrite",
ResolveTemplate: (key, variables) => key switch
{
"ID_ActionKeyMap_ButtonLabel" => variables[DatStringResolver.ComputeHash("LABEL")],
"ID_ActionKeyMap_TT_ExistingBinding" =>
$"({variables[DatStringResolver.ComputeHash("VALUE")]}) existing binding",
"ID_ActionKeyMap_TT_NewBinding" => "new binding",
"ID_ActionKeyMap_NonUserBindableBinding" =>
$"cannot overwrite {variables[DatStringResolver.ComputeHash("KEY")]}",
"ID_ActionKeyMap_OverwriteExistingBinding" =>
$"overwrite {variables[DatStringResolver.ComputeHash("KEY")]} "
+ variables[DatStringResolver.ComputeHash("ACTION")],
"ID_ActionKeyMap_Binding" =>
$"{variables[DatStringResolver.ComputeHash("ACTION")]} "
+ $"({variables[DatStringResolver.ComputeHash("KEY")]})",
"ID_ActionKeyMap_OverwriteExistingBindings" =>
$"overwrite {variables[DatStringResolver.ComputeHash("KEY")]}\n"
+ variables[DatStringResolver.ComputeHash("BINDINGS")],
_ => null,
},
ShowMessage: msg => Messages.Add(msg),
ConfirmOverwrite: (message, onResult) => PendingConfirm = (message, onResult),
OpenCaptureInstructions: WireInstructions
? label =>
@ -114,11 +143,23 @@ public sealed class KeyboardConfigControllerTests
}
: null,
CloseCaptureInstructions: context => InstructionCloses.Add(context));
public KeyboardConfigController.Bindings ToProfileBindings() =>
ToBindings() with
{
CurrentKeymapFilename = () => this.CurrentKeymapFilename,
OpenLoadKeymap = completed => PendingLoadCompleted = completed,
OpenSaveKeymap = completed => PendingSaveCompleted = completed,
};
}
private static readonly KeyChord ChordW = new(Silk.NET.Input.Key.W, ModifierMask.None);
private static readonly KeyChord ChordUp = new(Silk.NET.Input.Key.Up, ModifierMask.None);
private static readonly KeyChord ChordA = new(Silk.NET.Input.Key.A, ModifierMask.None);
private static readonly KeyChord LeftMouse = new(
InputDispatcher.MouseButtonToKey(Silk.NET.Input.MouseButton.Left),
ModifierMask.None,
Device: 1);
[Fact]
public void Bind_Succeeds_AndBuildsOneRowPerSnapshotRow()
@ -133,7 +174,7 @@ public sealed class KeyboardConfigControllerTests
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
var fake = new FakeBindings();
KeyboardConfigController? controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings());
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings());
Assert.NotNull(controller);
Assert.Equal(3, controller!.Rows.Count);
@ -158,7 +199,7 @@ public sealed class KeyboardConfigControllerTests
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
var fake = new FakeBindings();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
Assert.NotNull(controller);
UiTabPanel tabHost = Assert.IsType<UiTabPanel>(layout.FindElement(0x1000049Bu));
@ -188,28 +229,28 @@ public sealed class KeyboardConfigControllerTests
}
[Fact]
public void Bind_MapsKnownActionsAndLeavesUnknownOnesUnmapped()
public void Bind_MapsEveryRetailActionRow()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x4, 0x29, RetailActionClass.Movement), // -> MovementForward
Row(0x10000006, 0x100000A0, RetailActionClass.Emote), // "Bow Deep" -> no InputAction
Row(0x10000006, 0x100000A0, RetailActionClass.Emote), // -> EmoteBowDeep
});
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
var fake = new FakeBindings();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView forward = controller.Rows.Single(r => r.ActionId == 0x29u);
Assert.Equal(InputAction.MovementForward, forward.MappedAction);
KeyboardConfigController.RowView bowDeep = controller.Rows.Single(r => r.ActionId == 0x100000A0u);
Assert.Null(bowDeep.MappedAction);
Assert.Equal(InputAction.EmoteBowDeep, bowDeep.MappedAction);
}
[Fact]
public void Bind_SeedsRowFromLiveBindings_MappedAndUnmapped()
public void Bind_SeedsEveryRowFromLiveBindings()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
@ -223,11 +264,14 @@ public sealed class KeyboardConfigControllerTests
new(ChordW, InputAction.MovementForward),
new(ChordUp, InputAction.MovementForward),
};
fake.Unmapped[(0x10000006u, 0x100000A0u)] = new List<KeyChord> { ChordA };
fake.Mapped[InputAction.EmoteBowDeep] = new List<Binding>
{
new(ChordA, InputAction.EmoteBowDeep),
};
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView forward = controller.Rows.Single(r => r.ActionId == 0x29u);
Assert.Equal(new[] { ChordW, ChordUp }, forward.Model.Current);
@ -243,7 +287,7 @@ public sealed class KeyboardConfigControllerTests
var fake = new FakeBindings();
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
Assert.NotEmpty(row.KeyButtons);
@ -257,8 +301,8 @@ public sealed class KeyboardConfigControllerTests
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.
// No live binding existed at build time — falls back to the retail
// action identity's activation/scope metadata.
Assert.Equal(ActivationType.Press, onlyBinding.Activation);
Assert.Equal(InputScope.Game, onlyBinding.Scope);
Assert.Equal("W", row.KeyButtons[0].Label);
@ -272,7 +316,7 @@ public sealed class KeyboardConfigControllerTests
fake.Mapped[InputAction.MovementForward] = new List<Binding> { new(ChordW, InputAction.MovementForward) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
row.KeyButtons[0].OnClick!.Invoke();
@ -293,7 +337,7 @@ public sealed class KeyboardConfigControllerTests
var fake = new FakeBindings { WireInstructions = true, NextInstructionContext = 42u };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
row.KeyButtons[0].OnClick!.Invoke();
@ -314,7 +358,7 @@ public sealed class KeyboardConfigControllerTests
var fake = new FakeBindings { WireInstructions = true, NextInstructionContext = 9u };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
row.KeyButtons[0].OnClick!.Invoke();
@ -331,7 +375,7 @@ public sealed class KeyboardConfigControllerTests
var fake = new FakeBindings { WireInstructions = true, NextInstructionContext = 0u };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
row.KeyButtons[0].OnClick!.Invoke();
@ -354,7 +398,7 @@ public sealed class KeyboardConfigControllerTests
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
var requests = new List<(uint LayoutId, uint ElementId)>();
KeyboardConfigController? controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings(),
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings(),
resolveTemplateFont: (layoutId, elementId) =>
{
requests.Add((layoutId, elementId));
@ -382,7 +426,7 @@ public sealed class KeyboardConfigControllerTests
};
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
Assert.Equal(2, row.Model.Current.Count);
@ -400,7 +444,7 @@ public sealed class KeyboardConfigControllerTests
var fake = new FakeBindings();
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
Assert.Empty(row.Model.Current);
@ -411,17 +455,17 @@ public sealed class KeyboardConfigControllerTests
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>
/// <summary>Retail SetBinding clamps a requested slot past the dense
/// current-list tail to Count. Mapping 3 on an empty row therefore appends
/// at Mapping 1.</summary>
[Fact]
public void KeyButtonClick_OnSparseRow_ThirdSlotLandsOnThirdButton()
public void KeyButtonClick_PastDenseTail_AppendsAtFirstAvailableButton()
{
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())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
Assert.Equal(3, row.KeyButtons.Count);
@ -430,12 +474,10 @@ public sealed class KeyboardConfigControllerTests
row.KeyButtons[2].OnClick!.Invoke(); // "Mapping 3"
fake.Capture(ChordW);
Assert.Null(row.KeyButtons[0].Label);
Assert.Equal("W", row.KeyButtons[0].Label);
Assert.Null(row.KeyButtons[1].Label);
Assert.Equal("W", row.KeyButtons[2].Label);
Assert.Null(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);
@ -453,7 +495,7 @@ public sealed class KeyboardConfigControllerTests
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())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView forward = controller.Rows.Single(r => r.ActionId == 0x29u);
KeyboardConfigController.RowView backup = controller.Rows.Single(r => r.ActionId == 0x2Au);
@ -463,6 +505,7 @@ public sealed class KeyboardConfigControllerTests
// M3: nothing is applied yet — a confirm dialog is pending.
Assert.NotNull(fake.PendingConfirm);
Assert.Equal("overwrite A Action 2A", fake.PendingConfirm?.Message);
Assert.DoesNotContain(ChordA, forward.Model.Current);
Assert.Contains(ChordA, backup.Model.Current);
Assert.Empty(fake.Messages);
@ -473,6 +516,153 @@ public sealed class KeyboardConfigControllerTests
Assert.DoesNotContain(ChordA, backup.Model.Current);
}
[Fact]
public void Capture_BareShiftConflictWithRetailWalkMode_AlwaysPrompts()
{
var shift = new KeyChord(
Silk.NET.Input.Key.ShiftLeft,
ModifierMask.None);
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x4, 0x29, RetailActionClass.Movement), // Move forward
Row(0x4, 0x32, RetailActionClass.Movement), // Toggle walk/run
});
var fake = new FakeBindings();
fake.Mapped[InputAction.MovementWalkMode] =
[new Binding(
shift,
InputAction.MovementWalkMode,
ActivationType.Hold,
InputScope.Game)];
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout,
snapshot,
MakeTemplateResolver(),
ResolveSyntheticString,
fake.ToBindings())!;
KeyboardConfigController.RowView forward = controller.Rows.Single(
row => row.ActionId == 0x29u);
forward.KeyButtons[0].OnClick!.Invoke();
fake.Capture(shift);
Assert.NotNull(fake.PendingConfirm);
Assert.DoesNotContain(shift, forward.Model.Current);
Assert.Equal(
[shift],
controller.Rows.Single(row => row.ActionId == 0x32u).Model.Current);
}
[Fact]
public void Capture_ConflictWithMultipleRows_UsesRetailPluralBindingList()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x4, 0x29, RetailActionClass.Movement),
Row(0x4, 0x2A, RetailActionClass.Movement),
Row(0x4, 0x2B, RetailActionClass.Movement),
});
var fake = new FakeBindings();
fake.Mapped[InputAction.MovementBackup] =
new List<Binding> { new(ChordA, InputAction.MovementBackup) };
fake.Mapped[InputAction.MovementStop] =
new List<Binding> { new(ChordA, InputAction.MovementStop) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
controller.Rows.Single(row => row.ActionId == 0x29u).KeyButtons[0].OnClick!.Invoke();
fake.Capture(ChordA);
Assert.Equal(
"overwrite A\nAction 2A (A)\nAction 2B (A)",
fake.PendingConfirm?.Message);
}
[Fact]
public void Refresh_UsesRetailExistingAndNewBindingTooltipTemplates()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x4, 0x29, RetailActionClass.Movement),
});
var fake = new FakeBindings();
fake.Mapped[InputAction.MovementForward] =
new List<Binding> { new(ChordW, InputAction.MovementForward) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = Assert.Single(controller.Rows);
Assert.Equal("(W) existing binding", row.KeyButtons[0].TooltipText);
Assert.Equal("new binding", row.KeyButtons[1].TooltipText);
Assert.Equal("new binding", row.KeyButtons[2].TooltipText);
row.KeyButtons[0].OnRightClick!.Invoke();
Assert.Equal("new binding", row.KeyButtons[0].TooltipText);
}
[Fact]
public void Capture_ChordAlreadyInAnotherSlotOfSameRow_IsRetailNoOp()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x4, 0x29, RetailActionClass.Movement),
});
var fake = new FakeBindings();
fake.Mapped[InputAction.MovementForward] = new List<Binding>
{
new(ChordW, InputAction.MovementForward),
new(ChordUp, InputAction.MovementForward),
};
// Prove retail's same-row early return happens before the
// non-user-bindable conflict check too.
fake.Mapped[InputAction.AcdreamToggleAudioMute] = new List<Binding>
{
new(ChordW, InputAction.AcdreamToggleAudioMute),
};
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = Assert.Single(controller.Rows);
row.KeyButtons[2].OnClick!.Invoke();
fake.Capture(ChordW);
Assert.Equal(new[] { ChordW, ChordUp }, row.Model.Current);
Assert.Null(fake.PendingConfirm);
Assert.Empty(fake.Messages);
Assert.Empty(fake.MappedSets);
}
[Fact]
public void Capture_LeftOrRightMouseButton_RemainsArmedUntilSupportedInput()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x4, 0x29, RetailActionClass.Movement),
});
var fake = new FakeBindings { WireInstructions = true };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = Assert.Single(controller.Rows);
row.KeyButtons[0].OnClick!.Invoke();
fake.Capture(LeftMouse);
Assert.NotNull(fake.PendingCapture);
Assert.Empty(fake.InstructionCloses);
Assert.Empty(row.Model.Current);
fake.Capture(ChordW);
Assert.Null(fake.PendingCapture);
Assert.Equal(new[] { ChordW }, row.Model.Current);
Assert.Equal(new[] { 7u }, fake.InstructionCloses);
}
[Fact]
public void Capture_ConflictWithAnotherRow_DeclineLeavesBothRowsUnchanged()
{
@ -486,7 +676,7 @@ public sealed class KeyboardConfigControllerTests
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())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView forward = controller.Rows.Single(r => r.ActionId == 0x29u);
KeyboardConfigController.RowView backup = controller.Rows.Single(r => r.ActionId == 0x2Au);
@ -500,7 +690,71 @@ public sealed class KeyboardConfigControllerTests
}
[Fact]
public void Capture_ConflictWithNonBindableAcdreamAction_RefusesWithoutADialog()
public void Capture_SharedChordAcrossNonConflictingCombatContexts_KeepsBothBindings()
{
const uint meleeMap = 0x10000003u;
const uint missileMap = 0x10000004u;
var conflicts = new Dictionary<uint, IReadOnlySet<uint>>
{
[meleeMap] = new HashSet<uint> { meleeMap },
[missileMap] = new HashSet<uint> { missileMap },
};
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(meleeMap, 0x1000005Du, RetailActionClass.Combat),
Row(missileMap, 0x100000F1u, RetailActionClass.Combat),
}, conflicts);
var fake = new FakeBindings();
fake.Mapped[InputAction.CombatAimLow] =
new List<Binding> { new(ChordA, InputAction.CombatAimLow, Scope: InputScope.MissileCombat) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView melee = controller.Rows.Single(
row => row.InputMapId == meleeMap);
KeyboardConfigController.RowView missile = controller.Rows.Single(
row => row.InputMapId == missileMap);
melee.KeyButtons[0].OnClick!.Invoke();
fake.Capture(ChordA);
Assert.Null(fake.PendingConfirm);
Assert.Contains(ChordA, melee.Model.Current);
Assert.Contains(ChordA, missile.Model.Current);
}
[Fact]
public void Capture_SharedChordAcrossDatConflictingContexts_StillPrompts()
{
const uint movementMap = 0x4u;
const uint uiMap = 0x10000009u;
var conflicts = new Dictionary<uint, IReadOnlySet<uint>>
{
[movementMap] = new HashSet<uint> { movementMap, uiMap },
};
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(movementMap, 0x29u, RetailActionClass.Movement),
Row(uiMap, 0x10000019u, RetailActionClass.Ui),
}, conflicts);
var fake = new FakeBindings();
fake.Mapped[InputAction.ToggleInventoryPanel] =
new List<Binding> { new(ChordA, InputAction.ToggleInventoryPanel) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView movement = controller.Rows.Single(
row => row.InputMapId == movementMap);
movement.KeyButtons[0].OnClick!.Invoke();
fake.Capture(ChordA);
Assert.NotNull(fake.PendingConfirm);
Assert.DoesNotContain(ChordA, movement.Model.Current);
}
[Fact]
public void Capture_ConflictWithNonBindableAcdreamAction_ShowsRetailMessageDialog()
{
var snapshot = new RetailActionMapSnapshot(new[] { Row(0x4, 0x29, RetailActionClass.Movement) });
var fake = new FakeBindings();
@ -510,16 +764,17 @@ public sealed class KeyboardConfigControllerTests
new List<Binding> { new(muteChord, InputAction.AcdreamToggleAudioMute) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView forward = controller.Rows.Single();
forward.KeyButtons[0].OnClick!.Invoke();
fake.Capture(muteChord);
// S1: refused outright, no confirm dialog offered.
// S1: refused outright, with the distinct retail message dialog and no
// overwrite-confirmation dialog.
Assert.Null(fake.PendingConfirm);
Assert.DoesNotContain(muteChord, forward.Model.Current);
Assert.Contains("cannot overwrite", fake.Messages);
Assert.Contains(fake.Messages, message => message.Contains("cannot overwrite"));
Assert.Equal(muteChord, Assert.Single(fake.Mapped[InputAction.AcdreamToggleAudioMute]).Chord);
}
@ -540,14 +795,14 @@ public sealed class KeyboardConfigControllerTests
new List<Binding> { new(sharedChord, InputAction.AcdreamToggleAudioMute) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, 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.Contains(fake.Messages, message => message.Contains("cannot overwrite"));
Assert.DoesNotContain(sharedChord, forward.Model.Current);
}
@ -558,7 +813,7 @@ public sealed class KeyboardConfigControllerTests
var fake = new FakeBindings();
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
row.KeyButtons[0].OnClick!.Invoke();
@ -573,6 +828,102 @@ public sealed class KeyboardConfigControllerTests
Assert.Equal(1, fake.ToggleCalls);
}
[Fact]
public void LoadFile_ReplacesRowsAndRevertBaseline_AndRefreshesFilename()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x4, 0x29, RetailActionClass.Movement),
});
var fake = new FakeBindings();
fake.Mapped[InputAction.MovementForward] =
[new Binding(ChordW, InputAction.MovementForward)];
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString,
fake.ToProfileBindings())!;
((UiButton)layout.FindElement(0x10000027u)!).OnClick!.Invoke();
Assert.NotNull(fake.PendingLoadCompleted);
fake.Mapped[InputAction.MovementForward] =
[new Binding(ChordUp, InputAction.MovementForward)];
fake.CurrentKeymapFilename = "friends.keymap";
fake.PendingLoadCompleted!();
ActionKeyMapOptionRow row = controller.Rows.Single().Model;
Assert.Equal(new[] { ChordUp }, row.Current);
Assert.Equal(new[] { ChordUp }, row.Saved);
Assert.False(row.Changed);
UiText filename = (UiText)layout.FindElement(0x10000028u)!;
Assert.Equal("friends.keymap", Assert.Single(filename.LinesProvider()).Text);
}
[Fact]
public void SaveAs_RefreshesActiveFilenameOnlyAfterSuccessfulCallback()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x4, 0x29, RetailActionClass.Movement),
});
var fake = new FakeBindings();
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
_ = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString,
fake.ToProfileBindings());
UiText filename = (UiText)layout.FindElement(0x10000028u)!;
((UiButton)layout.FindElement(0x10000029u)!).OnClick!.Invoke();
Assert.NotNull(fake.PendingSaveCompleted);
Assert.Equal("acdream.keymap", Assert.Single(filename.LinesProvider()).Text);
fake.CurrentKeymapFilename = "alternate.keymap";
fake.PendingSaveCompleted!();
Assert.Equal("alternate.keymap", Assert.Single(filename.LinesProvider()).Text);
}
[Fact]
public void RevertButton_IsEnabledExactlyWhileWorkingMapDiffersFromSavedMap()
{
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x4, 0x29, RetailActionClass.Movement),
});
var fake = new FakeBindings();
fake.Mapped[InputAction.MovementForward] =
new List<Binding> { new(ChordW, InputAction.MovementForward) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
UiButton revert = (UiButton)layout.FindElement(0x1000002Bu)!;
// gmKeyboardUI::OnOptionChanged @ 0x004DA890: Ghosted while clean.
Assert.False(revert.Enabled);
KeyboardConfigController.RowView row = controller.Rows.Single();
row.KeyButtons[0].OnClick!.Invoke();
fake.Capture(ChordUp);
Assert.True(controller.Page.Changed);
Assert.True(revert.Enabled);
revert.OnClick!.Invoke();
Assert.Equal(new[] { ChordW }, row.Model.Current);
Assert.False(controller.Page.Changed);
Assert.False(revert.Enabled);
// Defaults is also a live uncommitted edit when the DAT default does
// not equal the saved user map, and therefore re-enables Revert.
UiButton defaults = (UiButton)layout.FindElement(0x1000002Au)!;
defaults.OnClick!.Invoke();
Assert.True(controller.Page.Changed);
Assert.True(revert.Enabled);
UiButton ok = (UiButton)layout.FindElement(0x1000002Cu)!;
ok.OnClick!.Invoke();
Assert.False(controller.Page.Changed);
Assert.False(revert.Enabled);
}
[Fact]
public void CancelButton_RevertsUncommittedEditAndToggles()
{
@ -581,7 +932,7 @@ public sealed class KeyboardConfigControllerTests
fake.Mapped[InputAction.MovementForward] = new List<Binding> { new(ChordW, InputAction.MovementForward) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
row.KeyButtons[0].OnClick!.Invoke();
@ -607,7 +958,7 @@ public sealed class KeyboardConfigControllerTests
fake.Mapped[InputAction.MovementForward] = new List<Binding> { new(ChordUp, InputAction.MovementForward) };
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
Assert.Equal(new[] { ChordUp }, row.Model.Current);
@ -639,7 +990,7 @@ public sealed class KeyboardConfigControllerTests
};
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
UiButton defaultsButton = (UiButton)layout.FindElement(0x1000002Au)!;
defaultsButton.OnClick!.Invoke();
@ -665,7 +1016,7 @@ public sealed class KeyboardConfigControllerTests
};
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView row = controller.Rows.Single();
row.KeyButtons[0].OnClick!.Invoke();
@ -695,20 +1046,24 @@ public sealed class KeyboardConfigControllerTests
Row(0x6, 0x35, RetailActionClass.Camera, defaults: new[] { new RetailKeyChord(0xCB, 0, 0, 3) }),
});
var fake = new FakeBindings();
fake.Mapped[InputAction.CameraRotateLeft] =
[new Binding(ChordA, InputAction.CameraRotateLeft)];
fake.Mapped[InputAction.CameraAlternateRotateLeft] =
[new Binding(
ChordUp,
InputAction.CameraAlternateRotateLeft,
Scope: InputScope.Camera)];
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, 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)
Assert.Equal(InputAction.CameraAlternateRotateLeft, ctx6.MappedAction);
// Round-2 SHOULD-FIX: an unmapped row with no persisted chords now
// DISPLAYS its DAT defaults (retail shows the arrow keys; blank read
// as "unbound"). Display-only — storage stays untouched until the
// user edits THIS row.
// Both rows display their independent live bindings.
Assert.NotEmpty(ctx6.Model.Current);
var ctx6InitialDisplay = ctx6.Model.Current.ToArray();
@ -717,13 +1072,15 @@ public sealed class KeyboardConfigControllerTests
fake.Capture(ChordW);
Assert.Contains(ChordW, ctx5.Model.Current);
Assert.Equal(ctx6InitialDisplay, ctx6.Model.Current); // unchanged by ctx5's edit
Assert.Empty(fake.Unmapped); // ctx6's STORE untouched — display seeding writes nothing
Assert.Empty(fake.Unmapped);
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)));
Assert.Contains(fake.MappedSets, write =>
write.Action == InputAction.CameraAlternateRotateLeft
&& write.Value.Any(binding => binding.Chord == ChordA));
}
[Fact]
@ -756,30 +1113,26 @@ public sealed class KeyboardConfigControllerTests
+ $"and (0x{mapId:X}, 0x{actionId:X}) — aliasing reintroduces the M2 twin-row clobber.");
seen[action] = (mapId, actionId);
}
Assert.True(seen.Count > 100, $"sanity: only {seen.Count} mapped actions seen");
Assert.Equal(306, seen.Count);
}
// ── AD-78 caption dimming (user-directed, 2026-08-11, gate 2) ───────────
[Fact]
public void UnmappedRows_DimTheirCaption_MappedRowsStayWhite()
public void EveryRetailRowCaptionIsEnabled()
{
// AP-203's store-only set: a row whose RetailActionIdentityTable
// lookup fails (MappedAction null -- mostly Emotes/CharacterSettings)
// never reaches the InputDispatcher, so its caption dims. Wiring a
// future mapping for "Bow Deep" (or any other unmapped row) means
// this assertion flips from StoreOnlyCaptionColor to Vector4.One --
// a conscious edit, not a silent pass.
// Campaign KB removes AP-203's store-only set. Every DAT row now has
// a live dispatcher identity and uses the enabled retail caption color.
var snapshot = new RetailActionMapSnapshot(new[]
{
Row(0x4, 0x29, RetailActionClass.Movement), // -> MovementForward (mapped)
Row(0x10000006, 0x100000A0, RetailActionClass.Emote), // "Bow Deep" -> unmapped
Row(0x10000006, 0x100000A0, RetailActionClass.Emote), // -> EmoteBowDeep
});
ImportedLayout layout = FixtureLoader.LoadKeyboardConfig();
var fake = new FakeBindings();
KeyboardConfigController controller = KeyboardConfigController.Bind(
layout, snapshot, MakeTemplateResolver(), (_, _) => null, fake.ToBindings())!;
layout, snapshot, MakeTemplateResolver(), ResolveSyntheticString, fake.ToBindings())!;
KeyboardConfigController.RowView forward = controller.Rows.Single(r => r.ActionId == 0x29u);
Assert.NotNull(forward.MappedAction);
@ -787,9 +1140,9 @@ public sealed class KeyboardConfigControllerTests
Assert.Equal(Vector4.One, forwardCaption.DefaultColor);
KeyboardConfigController.RowView bowDeep = controller.Rows.Single(r => r.ActionId == 0x100000A0u);
Assert.Null(bowDeep.MappedAction);
Assert.Equal(InputAction.EmoteBowDeep, bowDeep.MappedAction);
UiText bowDeepCaption = RowCaption(bowDeep);
Assert.Equal(UiRenderContext.StoreOnlyCaptionColor, bowDeepCaption.DefaultColor);
Assert.Equal(Vector4.One, bowDeepCaption.DefaultColor);
}
/// <summary>The row's synthesized caption (composed beside the authored key

View file

@ -0,0 +1,184 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Content;
using AcDream.Core.Input;
using AcDream.Content;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Production-shaped installed-DAT gate for #446. The Core conformance lane
/// proves the 306 identities/defaults; this gate proves the actual retained
/// screen can import its authored layout and row template and expose every one
/// of those identities as a live three-slot row.
/// </summary>
[Trait("Lane", "InstalledDat")]
public sealed class KeyboardConfigInstalledDatConformanceTests
{
[Fact]
public void InstalledEorLayout_MountsEveryBindableActionAsALiveRow()
{
string? datDir = ResolveDatDir();
if (datDir is null)
Assert.Fail("Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
using var dats = new AcDream.App.Tests.BoundedTestDatCollection(datDir);
var strings = new DatStringResolver(dats);
ElementInfo? info = LayoutImporter.ImportInfos(
dats,
KeyboardConfigController.LayoutId);
Assert.NotNull(info);
ImportedLayout layout = LayoutImporter.Build(
info!,
_ => (0u, 0, 0),
datFont: null,
fontResolve: null,
strings.Resolve);
RetailActionMapSnapshot? snapshot = RetailActionMapReader.Read(
(IDatObjectSource)dats);
Assert.NotNull(snapshot);
KeyBindings live = KeyBindings.RetailDefaults();
KeyboardConfigController? controller = KeyboardConfigController.Bind(
layout,
snapshot!,
templateResolver: (templateLayoutId, templateElementId) =>
{
ElementInfo? template = LayoutImporter.ImportInfos(
dats,
templateLayoutId,
templateElementId);
return template is null
? null
: LayoutImporter.Build(
template,
_ => (0u, 0, 0),
datFont: null,
fontResolve: null,
strings.Resolve,
templateLayoutId).Root;
},
resolveString: (tableId, stringId) => strings.Resolve(tableId, stringId),
new KeyboardConfigController.Bindings(
CurrentForAction: action => live.ForAction(action).ToArray(),
SetForAction: (_, _) => { },
CurrentForUnmapped: _ => Array.Empty<KeyChord>(),
SetForUnmapped: (_, _) => { },
BeginCapture: _ => { },
Save: () => { },
Toggle: () => { },
ResolveTemplate: (key, variables) =>
strings.ResolveTemplate(0x23000004u, key, variables),
ShowMessage: _ => { },
ConfirmOverwrite: (_, _) => { },
CurrentKeymapFilename: () => "acdream.keymap",
OpenLoadKeymap: completed => completed(),
OpenSaveKeymap: completed => completed()));
Assert.NotNull(controller);
Assert.Equal(306, snapshot!.Rows.Count);
Assert.Equal(snapshot.Rows.Count, controller!.Rows.Count);
Assert.Equal(306, controller.Page.Rows.Count);
Assert.All(controller.Rows, row =>
{
Assert.NotNull(row.MappedAction);
Assert.False(string.IsNullOrWhiteSpace(row.Label));
Assert.Equal(3, row.KeyButtons.Count);
Assert.All(row.KeyButtons, button =>
{
Assert.NotNull(button.OnClick);
Assert.NotNull(button.OnRightClick);
Assert.False(string.IsNullOrWhiteSpace(button.TooltipText));
});
});
Assert.Equal(
snapshot.Rows.Select(static row => (row.InputMapId, row.ActionId)).ToHashSet(),
controller.Rows.Select(static row => (row.InputMapId, row.ActionId)).ToHashSet());
uint key = DatStringResolver.ComputeHash("KEY");
uint action = DatStringResolver.ComputeHash("ACTION");
uint bindings = DatStringResolver.ComputeHash("BINDINGS");
Assert.Equal(
"'Ctrl+M' is currently bound to a non user-bindable action. Please select a different binding.",
strings.ResolveTemplate(
0x23000004u,
"ID_ActionKeyMap_NonUserBindableBinding",
new Dictionary<uint, string> { [key] = "Ctrl+M" }));
Assert.Equal(
"'A' is currently bound to 'Move Backward'. Do you wish to erase that binding?",
strings.ResolveTemplate(
0x23000004u,
"ID_ActionKeyMap_OverwriteExistingBinding",
new Dictionary<uint, string>
{
[key] = "A",
[action] = "Move Backward",
}));
Assert.Equal(
"'A' conflicts with the following bindings:\n'Move Backward' ('A')\n'Turn Right' ('A')\nDo you wish to erase those bindings?",
strings.ResolveTemplate(
0x23000004u,
"ID_ActionKeyMap_OverwriteExistingBindings",
new Dictionary<uint, string>
{
[key] = "A",
[bindings] = "'Move Backward' ('A')\n'Turn Right' ('A')",
}));
foreach (uint buttonId in new[]
{
0x10000027u, // Load File
0x10000029u, // Save As
0x1000002Au, // Defaults
0x1000002Bu, // Revert
0x1000002Cu, // OK
0x1000002Du, // Cancel
})
{
UiButton button = Assert.IsType<UiButton>(layout.FindElement(buttonId));
Assert.NotNull(button.OnClick);
}
UiText filename = Assert.IsType<UiText>(layout.FindElement(0x10000028u));
Assert.Equal("acdream.keymap", Assert.Single(filename.LinesProvider()).Text);
// The same installed catalog must contain the type-7 presenter retail's
// Load File button opens: root 0x1F, menu 0x21, accept/reject 0x22/0x23.
uint dialogLayoutId = RetailDataIdResolver.Resolve(dats, 2u, 5u);
Assert.NotEqual(0u, dialogLayoutId);
ElementInfo? menuInfo = LayoutImporter.ImportInfos(
dats,
dialogLayoutId,
RetailConfirmationMenuDialogView.RootElementId);
Assert.NotNull(menuInfo);
ImportedLayout menuLayout = LayoutImporter.Build(
menuInfo!, _ => (0u, 0, 0), null, null, strings.Resolve);
Assert.IsType<UiDialogRoot>(menuLayout.Root);
UiMenu catalogMenu = Assert.IsType<UiMenu>(menuLayout.FindElement(
RetailConfirmationMenuDialogView.MenuElementId));
Assert.NotEqual(0u, catalogMenu.NormalSprite);
Assert.NotEqual(0u, catalogMenu.PressedSprite);
Assert.NotEqual(0u, catalogMenu.PopupBgSprite);
Assert.NotEqual(0u, catalogMenu.ItemNormalSprite);
Assert.IsType<UiButton>(menuLayout.FindElement(
RetailConfirmationMenuDialogView.AcceptButtonId));
Assert.IsType<UiButton>(menuLayout.FindElement(
RetailConfirmationMenuDialogView.RejectButtonId));
}
private static string? ResolveDatDir()
{
string? fromEnvironment = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR");
if (!string.IsNullOrWhiteSpace(fromEnvironment) && Directory.Exists(fromEnvironment))
return fromEnvironment;
string installed = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call");
return Directory.Exists(installed) ? installed : null;
}
}

View file

@ -221,6 +221,13 @@ public sealed class KeyboardConfigLiveMountProbeTests
string[] keys =
{
"ID_ActionKeyMap_MapInstructions",
"ID_ActionKeyMap_Binding",
"ID_ActionKeyMap_ButtonLabel",
"ID_ActionKeyMap_NonUserBindableBinding",
"ID_ActionKeyMap_OverwriteExistingBinding",
"ID_ActionKeyMap_OverwriteExistingBindings",
"ID_ActionKeyMap_TT_ExistingBinding",
"ID_ActionKeyMap_TT_NewBinding",
"ID_KeyDescDelimiter",
"ID_KeyNameWithSubControl",
"ID_KeyMapCantOverwriteReadOnlyKeymap_Label",
@ -251,7 +258,11 @@ public sealed class KeyboardConfigLiveMountProbeTests
}
// Candidate variable-name hashes for the MapInstructions template slot.
foreach (string candidate in new[] { "ACTION", "NAME", "KEY", "SUBCONTROL", "PLAYER", "COMMAND" })
foreach (string candidate in new[]
{
"ACTION", "BINDINGS", "KEY", "LABEL", "VALUE",
"NAME", "SUBCONTROL", "PLAYER", "COMMAND",
})
Console.WriteLine(
$"[kbstr] hash('{candidate}') = 0x{DatStringResolver.ComputeHash(candidate):X8}");

View file

@ -214,6 +214,22 @@ public sealed class MapHousePanelControllerTests
Assert.Contains("house-shown", calls);
}
[Fact]
public void ShowMap_UsesTheSameAuthoredTabStateAsAClick()
{
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
MapHousePanelController controller = MapHousePanelController.Bind(
rootInfo, layout, MakeCallbacks())!;
controller.ActivateTabs();
controller.TabPanel.SwitchTo(0x100001F7u); // House
controller.ShowMap();
Assert.True(controller.IsShowingMap);
Assert.False(controller.IsShowingHouse);
}
[Fact]
public void CloseButton_InvokesToggle()
{

View file

@ -162,6 +162,22 @@ public sealed class OptionsPanelControllerTests
Assert.Equal(["flush"], flushes);
}
[Fact]
public void ShowGameplay_UsesTheSameAuthoredTabStateAsAClick()
{
ImportedLayout layout = FixtureLoader.LoadOptionsPanelHost();
var calls = new List<string>();
OptionsPanelController controller = OptionsPanelController.Bind(
layout, MakeCallbacks(calls))!;
controller.ActivateTabs();
controller.TabPanel.SwitchTo(0x10000211u); // Character
controller.ShowGameplay();
Assert.True(controller.IsShowingGameplay);
Assert.Equal(0x10000212u, controller.TabPanel.ActivePageElementId);
}
[Fact]
public void WholeWindowHide_RevertsCurrentlyActivePage()
{

View file

@ -254,7 +254,7 @@ public class PaperdollControllerTests
}
[Fact]
public void HandleDropRelease_wields_optimistically_and_sends_wire()
public void HandleDropRelease_sendsWieldAndWaitsForServerPlacement()
{
var (layout, lists) = BuildLayout();
var objects = new ClientObjectTable();
@ -263,8 +263,8 @@ public class PaperdollControllerTests
var ctrl = Bind(layout, objects, wields);
var payload = new ItemDragPayload(0xD01u, ItemDragSource.Inventory, 0, lists[HeadSlot].Cell);
ctrl.HandleDropRelease(lists[HeadSlot], lists[HeadSlot].Cell, payload);
Assert.Equal(EquipMask.HeadWear, objects.Get(0xD01u)!.CurrentlyEquippedLocation); // equipped instantly
Assert.Equal(Player, objects.Get(0xD01u)!.ContainerId); // contained-by-wielder (the optimistic wield is ContainerId-based; it does NOT write WielderId)
Assert.Equal(EquipMask.None, objects.Get(0xD01u)!.CurrentlyEquippedLocation);
Assert.Equal(Pack, objects.Get(0xD01u)!.ContainerId);
Assert.Single(wields);
Assert.Equal((0xD01u, (uint)EquipMask.HeadWear), wields[0]); // GetAndWieldItem wire
}
@ -334,10 +334,10 @@ public class PaperdollControllerTests
Assert.Equal(EquipMask.None, objects.Get(sword)!.CurrentlyEquippedLocation);
Assert.Equal(new[] { "Moving Shortbow to your backpack" }, messages);
objects.MoveItem(bow, Player, 0, EquipMask.None);
Assert.True(objects.ApplyConfirmedServerMove(bow, Player, 0u, 0));
Assert.Equal(new[] { (sword, (uint)EquipMask.MeleeWeapon) }, wields);
Assert.Equal(EquipMask.MeleeWeapon,
Assert.Equal(EquipMask.None,
objects.Get(sword)!.CurrentlyEquippedLocation);
}
@ -371,7 +371,7 @@ public class PaperdollControllerTests
ctrl.HandleDropRelease(lists[ChestSlot], lists[ChestSlot].Cell, payload);
Assert.Equal((uint)coatMask, wields[0].mask);
Assert.Equal(coatMask, objects.Get(0xE02u)!.CurrentlyEquippedLocation);
Assert.Equal(EquipMask.None, objects.Get(0xE02u)!.CurrentlyEquippedLocation);
}
[Fact]
@ -392,7 +392,7 @@ public class PaperdollControllerTests
ctrl.HandleDropRelease(lists[ChestArmorSlot], lists[ChestArmorSlot].Cell, payload);
Assert.Equal((uint)hauberkMask, wields[0].mask);
Assert.Equal(hauberkMask, objects.Get(0xE03u)!.CurrentlyEquippedLocation);
Assert.Equal(EquipMask.None, objects.Get(0xE03u)!.CurrentlyEquippedLocation);
}
[Fact]

View file

@ -55,6 +55,42 @@ public sealed class RetailDialogFactoryTests
Assert.False(factory.IsOpen);
}
[Fact]
public void ConfirmationMenu_ReturnsSelectedIndex_AndRejectReturnsMinusOne()
{
var root = new UiRoot { Width = 800f, Height = 600f };
var layouts = new List<ImportedLayout>();
var factory = new RetailDialogFactory(root, type =>
{
ImportedLayout layout = BuildDialogLayout(type);
layouts.Add(layout);
return layout;
});
int? result = null;
factory.MakeConfirmationMenu(
new[] { "acdream.keymap", "friends.keymap" },
selectedIndex: 1,
data => result = data.GetInt32(RetailDialogProperty.MenuSelection));
ImportedLayout first = Assert.Single(layouts);
UiMenu menu = Assert.IsType<UiMenu>(
first.FindElement(RetailConfirmationMenuDialogView.MenuElementId));
Assert.Equal(1, menu.Selected);
menu.Selected = 0;
Button(first, RetailConfirmationMenuDialogView.AcceptButtonId).OnClick!();
Assert.Equal(0, result);
result = null;
factory.MakeConfirmationMenu(
new[] { "acdream.keymap" },
selectedIndex: 0,
data => result = data.GetInt32(RetailDialogProperty.MenuSelection));
ImportedLayout second = layouts[^1];
Button(second, RetailConfirmationMenuDialogView.RejectButtonId).OnClick!();
Assert.Equal(-1, result);
}
[Fact]
public void SameQueuePresentsFifoUsingFreshLiveRoots()
{
@ -691,6 +727,7 @@ public sealed class RetailDialogFactoryTests
{
RetailDialogType.Message => 0x17u,
RetailDialogType.ConfirmationTextInput => 0x15u,
RetailDialogType.ConfirmationMenu => 0x14u,
RetailDialogType.Wait => 0x19u,
_ => 0x13u,
};
@ -708,15 +745,18 @@ public sealed class RetailDialogFactoryTests
Width = 400f,
Height = type == RetailDialogType.ConfirmationTextInput ? 125f : 95f,
};
popup.Children.Add(new ElementInfo
if (type != RetailDialogType.ConfirmationMenu)
{
Id = 0x3Eu,
Type = 12u,
X = 15f,
Y = 15f,
Width = 370f,
Height = 18f,
});
popup.Children.Add(new ElementInfo
{
Id = 0x3Eu,
Type = 12u,
X = 15f,
Y = 15f,
Width = 370f,
Height = 18f,
});
}
if (type == RetailDialogType.Message)
{
popup.Children.Add(new ElementInfo
@ -793,6 +833,36 @@ public sealed class RetailDialogFactoryTests
Height = 32f,
});
}
else if (type == RetailDialogType.ConfirmationMenu)
{
popup.Children.Add(new ElementInfo
{
Id = RetailConfirmationMenuDialogView.MenuElementId,
Type = 6u,
X = 80f,
Y = 15f,
Width = 240f,
Height = 24f,
});
popup.Children.Add(new ElementInfo
{
Id = RetailConfirmationMenuDialogView.AcceptButtonId,
Type = 1u,
X = 80f,
Y = 48f,
Width = 80f,
Height = 32f,
});
popup.Children.Add(new ElementInfo
{
Id = RetailConfirmationMenuDialogView.RejectButtonId,
Type = 1u,
X = 240f,
Y = 48f,
Width = 80f,
Height = 32f,
});
}
root.Children.Add(popup);
return LayoutImporter.Build(root, _ => (0u, 0, 0), null);
}

View file

@ -54,9 +54,8 @@ public sealed class RetailKeyNamesTests
[Fact]
public void SelfModifier_ShowsOnlyTheKeyName_NeverShiftPlusShiftLeft()
{
// acdream's wire-side chord for retail's bare DIK_LSHIFT walk-mode row
// carries the self-modifier bit; retail's QualifiedControl has
// meta-mode 0 and displays just the key.
// Also accept a legacy self-modifier bit while migrated JSON is read;
// retail's exact row has meta-mode 0 and displays just the key.
var names = new RetailKeyNames(
NoStrings,
osKeyName: (scan, _) => scan == 0x2A ? "SKIFT" : null);
@ -130,15 +129,37 @@ public sealed class RetailKeyNamesTests
}
[Fact]
public void ControlsOutsideTheDikTable_KeepTheEnumSpelling()
public void KeymapInterchangeControls_UseTheirRetailDikNames()
{
var names = new RetailKeyNames(NoStrings, osKeyName: (_, _) => null);
// Key.F13 never appears in the DAT's 84 observed scan codes.
Assert.Equal("F13", names.Describe(new KeyChord(Key.F13, ModifierMask.None)));
Assert.Equal(
"Shift+F13",
"LSHIFT+F13",
names.Describe(new KeyChord(Key.F13, ModifierMask.Shift)));
Assert.Equal(
"LWIN+F13",
names.Describe(new KeyChord(Key.F13, ModifierMask.Win)));
}
[Fact]
public void MouseButtonUsesRetailSemanticTableThenReadableFallback()
{
var authored = new RetailKeyNames(
Table((RetailKeyNames.KeyNameTableId, "DIMOFS_BUTTON0", "Primary Mouse")),
osKeyName: (_, _) => null);
var fallback = new RetailKeyNames(NoStrings, osKeyName: (_, _) => null);
var left = new KeyChord(
InputDispatcher.MouseButtonToKey(MouseButton.Left),
ModifierMask.None,
Device: 1);
var rightWithCtrl = new KeyChord(
InputDispatcher.MouseButtonToKey(MouseButton.Right),
ModifierMask.Ctrl,
Device: 1);
Assert.Equal("Primary Mouse", authored.Describe(left));
Assert.Equal("LCONTROL+Mouse Button 2", fallback.Describe(rightWithCtrl));
}
[Fact]

View file

@ -94,6 +94,8 @@ public class SelectedObjectControllerTests
public readonly Dictionary<uint, bool> HasHealthMap = new();
public readonly Dictionary<uint, float> ManaMap = new();
public readonly Dictionary<uint, uint> StackMap = new();
public readonly Dictionary<uint, bool> CoinstackMap = new();
public int CoinTotal;
// Slice 6.2: vendor-owned split-exempt predicate — see
// SelectedObjectController.Bind's isVendorSplitExempt parameter.
public readonly Dictionary<uint, bool> VendorSplitExemptMap = new();
@ -139,7 +141,9 @@ public class SelectedObjectControllerTests
{
if (ObjectUpdatedHandler == h) ObjectUpdatedHandler = null;
},
isVendorSplitExempt: g => VendorSplitExemptMap.TryGetValue(g, out var v) && v);
isVendorSplitExempt: g => VendorSplitExemptMap.TryGetValue(g, out var v) && v,
isCoinstack: g => CoinstackMap.TryGetValue(g, out var v) && v,
coinTotal: () => CoinTotal);
}
// ── B1: Bind initialisation ──────────────────────────────────────────────
@ -165,6 +169,25 @@ public class SelectedObjectControllerTests
Assert.True(nameEl.ZOrder > 1000, "name element must be floated above the overlay/meter z-order");
}
[Fact]
public void OwnedCoinstack_usesRetailsExactStackNameAndTotalFormat()
{
var (layout, nameEl, _, _) = FakeLayout();
var h = new Harness { CoinTotal = 12_345 };
const uint coins = 0x50000111u;
h.NameMap[coins] = "Pyreals";
h.StackMap[coins] = 2_345u;
h.OwnedMap[coins] = true;
h.CoinstackMap[coins] = true;
h.Bind(layout);
h.FireSelection(coins);
Assert.Equal(
"2345 Pyreals (of 12345)",
nameEl.Children.OfType<UiText>().First().LinesProvider().Single().Text);
}
[Fact]
public void Bind_nameLinesProvider_yieldsEmpty_whenNothingSelected()
{

View file

@ -0,0 +1,47 @@
using AcDream.App.UI.Layout;
using AcDream.UI.Abstractions.Input;
namespace AcDream.App.Tests.UI.Layout;
public sealed class SpellcastingShortcutInputTests
{
[Fact]
public void EveryRetailFavoriteSpellSlotHasALiveConsumer()
{
InputAction[] slots = RetailActionIdentityTable.Map
.Where(entry => entry.Key.InputMapId == 0x10000005u)
.Select(entry => entry.Value)
.Where(action => action.ToString().StartsWith(
"UseSpellSlot_",
StringComparison.Ordinal))
.ToArray();
Assert.Equal(12, slots.Length);
Assert.Equal(
Enumerable.Range(0, 12),
slots.Select(action =>
{
Assert.True(
SpellcastingUiController.TryMapSpellShortcut(
action,
out int index),
$"No favorite-spell consumer for {action}");
return index;
}).Order());
}
[Theory]
[InlineData(InputAction.UseSpellSlot_1, 0)]
[InlineData(InputAction.UseSpellSlot_9, 8)]
[InlineData(InputAction.UseSpellSlot_10, 9)]
[InlineData(InputAction.UseSpellSlot_11, 10)]
[InlineData(InputAction.UseSpellSlot_12, 11)]
public void AllRetailSpellSlotsMapToFavoriteIndex(
InputAction action,
int expectedIndex)
{
Assert.True(
SpellcastingUiController.TryMapSpellShortcut(action, out int index));
Assert.Equal(expectedIndex, index);
}
}

View file

@ -5,11 +5,41 @@ namespace AcDream.App.Tests.UI.Layout;
public sealed class ToolbarInputControllerTests
{
[Fact]
public void EveryRetailQuickslotRowHasALiveToolbarConsumer()
{
InputAction[] actions = RetailActionIdentityTable.Map
.Where(entry => entry.Key.InputMapId == 0x1000000Cu)
.OrderBy(entry => entry.Key.ActionId)
.Select(entry => entry.Value)
.ToArray();
Assert.Equal(28, actions.Length);
Assert.Equal(InputAction.CreateShortcut, actions[21]);
foreach (InputAction action in actions)
{
if (action == InputAction.CreateShortcut)
continue;
Assert.True(
ToolbarInputController.TryMapShortcut(
action,
out int slot,
out _),
$"No toolbar consumer for {action}");
Assert.InRange(slot, 0, 17);
}
}
[Theory]
[InlineData(InputAction.UseQuickSlot_1, 0, true)]
[InlineData(InputAction.UseQuickSlot_9, 8, true)]
[InlineData(InputAction.SelectQuickSlot_1, 0, false)]
[InlineData(InputAction.SelectQuickSlot_9, 8, false)]
[InlineData(InputAction.UseQuickSlot_10, 9, true)]
[InlineData(InputAction.UseQuickSlot_11, 10, true)]
[InlineData(InputAction.UseQuickSlot_12, 11, true)]
[InlineData(InputAction.UseQuickSlot_13, 12, true)]
[InlineData(InputAction.UseQuickSlot_14, 13, true)]
[InlineData(InputAction.UseQuickSlot_18, 17, true)]
public void ShortcutActions_mapRetailSlotAndIntent(InputAction action, int slot, bool use)

View file

@ -206,6 +206,7 @@ public sealed class VendorUiControllerTests
public readonly List<(uint VendorGuid, uint ItemGuid, int Amount, uint AlternateCurrencyId)> Buys = new();
public readonly List<(uint VendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> Items, uint AlternateCurrencyId)> BuyAlls = new();
public readonly List<(uint VendorGuid, IReadOnlyList<(int Amount, uint ItemGuid)> Items)> Sells = new();
public readonly List<(uint Item, uint Container, uint Placement, uint Amount)> SplitPuts = new();
public readonly List<string> SystemMessages = new();
public readonly ItemInteractionController ItemInteraction;
public readonly RetailDialogFactory Dialogs;
@ -365,6 +366,9 @@ public sealed class VendorUiControllerTests
sendWield: null,
sendDrop: null,
sendExamine: Examines.Add,
systemMessage: SystemMessages.Add,
sendSplitToContainer: (item, container, placement, amount) =>
SplitPuts.Add((item, container, placement, amount)),
sendBuy: (vendorGuid, itemGuid, amount, alternateCurrencyId) =>
{
Buys.Add((vendorGuid, itemGuid, amount, alternateCurrencyId));
@ -655,6 +659,47 @@ public sealed class VendorUiControllerTests
GetText(h.ItemCostText));
}
[Fact]
public void AlternateCurrencyPurchaseUpdatesImmediatelyThenReconcilesToInventory()
{
var h = new Harness();
const uint currencyWcid = 0x12345678u;
const uint currencyGuid = 0x60000A01u;
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = currencyGuid,
WeenieClassId = currencyWcid,
Name = "Colosseum Coin",
Type = ItemType.Misc,
StackSize = 10,
});
h.Objects.MoveItem(currencyGuid, Harness.PlayerGuid, 0);
h.State.Apply(
VendorGuid,
Profile(sellRate: 1.0f, altCurrency: currencyWcid, altName: "Colosseum Coins", altAmount: 10u),
new[]
{
new VendorShopItem(ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 2),
});
h.BuyButton.OnClick!.Invoke();
Assert.Contains("You have 8 Colosseum Coins.", GetText(h.ItemCostText));
Assert.Equal("You have 8 Colosseum Coins.", GetText(h.BuyPurseText));
// An unrelated appraisal/property refresh on the currency object is
// not an authoritative count response and must not erase m_last_sale.
Assert.True(h.Objects.UpdateIntProperty(currencyGuid, 0x7FFFu, 1));
Assert.Contains("You have 8 Colosseum Coins.", GetText(h.ItemCostText));
// The server's stack update replaces the optimistic m_last_sale
// subtraction with the canonical inventory count without bouncing
// the displayed purse back to the stale vendor snapshot.
Assert.True(h.Objects.UpdateStackSize(currencyGuid, 8, value: 0));
Assert.Contains("You have 8 Colosseum Coins.", GetText(h.ItemCostText));
Assert.Equal("You have 8 Colosseum Coins.", GetText(h.BuyPurseText));
}
[Fact]
public void NoSelection_DisablesBuyButton_SelectionEnablesIt()
{
@ -1850,6 +1895,29 @@ public sealed class VendorUiControllerTests
Assert.Empty(h.Buys);
}
[Fact]
public void DoubleClickStagedBuyingRow_RemovesOneUnitAndReportsRetailsNotice()
{
var h = new Harness();
h.State.Apply(VendorGuid, Profile(), new[]
{
new VendorShopItem(
StackedItemGuid, -1, 3u, "Arrows", (uint)ItemType.MissileWeapon, 300u, 1000,
DescStackSize: 100),
});
h.SplitQuantity.Reset(100u, initialValue: 3u);
h.AddButton.OnClick!.Invoke();
h.BuyingList.GetItem(0)!.DoubleClicked!.Invoke();
Assert.Equal(1, h.BuyingList.GetNumUIItems());
Assert.Equal(StackedItemGuid, h.Selection.SelectedObjectId);
Assert.Equal(new[] { "Removing Arrows from shopping list" }, h.SystemMessages);
h.BuyAllButton.OnClick!.Invoke();
(_, IReadOnlyList<(int Amount, uint ItemGuid)> items, _) = Assert.Single(h.BuyAlls);
Assert.Equal(new[] { (2, StackedItemGuid) }, items);
}
/// <summary>
/// F9 (Slice 6b/6c review): clicking a staged Buying-tab row must
/// visibly move the highlight — a prior version of this port only
@ -1887,12 +1955,19 @@ public sealed class VendorUiControllerTests
// Slice 6c — Selling tab drag-to-sell staging
// ══════════════════════════════════════════════════════════════════════
private static void MakePlayerOwned(Harness h, uint guid, ItemType type, int value, int stackSize = 1)
private static void MakePlayerOwned(
Harness h,
uint guid,
ItemType type,
int value,
int stackSize = 1,
uint weenieClassId = 0u)
{
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = guid,
Name = $"Item {guid:X8}",
WeenieClassId = weenieClassId,
Type = type,
Value = value,
StackSize = stackSize,
@ -2041,29 +2116,79 @@ public sealed class VendorUiControllerTests
}
/// <summary>
/// F6 (Slice 6b/6c review, byte-verified): sell staging ALWAYS records
/// the item's FULL stack — retail's <c>AddItemToSell</c> stages via a
/// LITERAL <c>-1</c> "full stack" argument
/// (<c>gmVendorUI::AddItem(..., -1, ...)</c>, <c>pc:203595</c>), never a
/// slider read. A prior version of this port read the LIVE split
/// slider here instead — this proves a PARTIAL slider selection at
/// drop time does not leak into the staged (or sent) quantity.
/// Retail's AcceptDragObject splits the selected amount first, retains
/// the source as a temporary staging row, then substitutes the newly
/// created split stack before Sell All is sent.
/// </summary>
[Fact]
public void HandleDropRelease_StackableItem_StagesTheFullStackIgnoringTheLiveSlider()
public void HandleDropRelease_PartialStack_SplitsThenStagesTheNewExactStack()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.MissileWeapon), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedWeaponGuid, ItemType.MissileWeapon, 100, stackSize: 20);
const uint wcid = 0x2345u;
const uint splitGuid = 0x60000222u;
MakePlayerOwned(
h,
PlayerOwnedWeaponGuid,
ItemType.MissileWeapon,
100,
stackSize: 20,
weenieClassId: wcid);
h.Selection.Select(PlayerOwnedWeaponGuid, SelectionChangeSource.Vendor);
h.SplitQuantity.Reset(20u, initialValue: 5u); // partial -- must be ignored
h.SplitQuantity.Reset(20u, initialValue: 5u);
h.Controller.HandleDropRelease(
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedWeaponGuid));
Assert.Equal(
new[] { (PlayerOwnedWeaponGuid, Harness.PlayerGuid, 0u, 5u) },
h.SplitPuts);
Assert.Equal(PlayerOwnedWeaponGuid, h.SellingList.GetItem(0)!.ItemId);
Assert.Equal(
new[] { "Splitting the Item 60000202 before selling them" },
h.SystemMessages);
// SetStackSize completes the split request. CreateObject + placement
// identify the server-assigned split guid and replace the placeholder.
Assert.True(h.Objects.UpdateStackSize(PlayerOwnedWeaponGuid, 15, value: 75));
MakePlayerOwned(
h,
splitGuid,
ItemType.MissileWeapon,
25,
stackSize: 5,
weenieClassId: wcid);
Assert.Equal(splitGuid, h.SellingList.GetItem(0)!.ItemId);
h.SellAllButton.OnClick!.Invoke();
(_, IReadOnlyList<(int Amount, uint ItemGuid)> items) = Assert.Single(h.Sells);
Assert.Equal(new (int Amount, uint ItemGuid)[] { (20, PlayerOwnedWeaponGuid) }, items);
Assert.Equal(new (int Amount, uint ItemGuid)[] { (5, splitGuid) }, items);
}
[Fact]
public void HandleDropRelease_PartialStackFailure_RemovesTheTemporarySellRow()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.MissileWeapon), Array.Empty<VendorShopItem>());
MakePlayerOwned(
h,
PlayerOwnedWeaponGuid,
ItemType.MissileWeapon,
100,
stackSize: 10,
weenieClassId: 0x2345u);
h.Selection.Select(PlayerOwnedWeaponGuid, SelectionChangeSource.Vendor);
h.SplitQuantity.Reset(10u, initialValue: 2u);
h.Controller.HandleDropRelease(
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedWeaponGuid));
Assert.Equal(1, h.SellingList.GetNumUIItems());
h.Objects.RejectMove(PlayerOwnedWeaponGuid, weenieError: 0x29u);
Assert.Equal(0, h.SellingList.GetNumUIItems());
Assert.Empty(h.Sells);
}
[Fact]
@ -2309,6 +2434,84 @@ public sealed class VendorUiControllerTests
Assert.Empty(h.Sells);
}
[Fact]
public void DoubleClickStagedSellingRow_RemovesTheEntryAndReportsRetailsNotice()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), Array.Empty<VendorShopItem>());
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
h.Controller.HandleDropRelease(
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
h.SellingList.GetItem(0)!.DoubleClicked!.Invoke();
Assert.Equal(0, h.SellingList.GetNumUIItems());
Assert.Equal(PlayerOwnedArmorGuid, h.Selection.SelectedObjectId);
Assert.Equal(
new[] { "Removing Item 60000201 from shopping list" },
h.SystemMessages);
Assert.Empty(h.Sells);
}
[Fact]
public void DragStagedSellingRow_RemovesItAndPartialSelectionPrintsExactRefusalThenResets()
{
var h = new Harness();
h.State.Apply(
VendorGuid,
SellProfile((uint)ItemType.MissileWeapon),
Array.Empty<VendorShopItem>());
MakePlayerOwned(
h,
PlayerOwnedWeaponGuid,
ItemType.MissileWeapon,
100,
stackSize: 10);
h.Controller.HandleDropRelease(
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedWeaponGuid));
h.SplitQuantity.Reset(10u, initialValue: 2u);
UiItemSlot staged = h.SellingList.GetItem(0)!;
h.Controller.OnDragLift(
h.SellingList,
staged,
new ItemDragPayload(
PlayerOwnedWeaponGuid,
ItemDragSource.Inventory,
staged.SlotIndex,
staged));
Assert.Equal(0, h.SellingList.GetNumUIItems());
Assert.Equal(
new[] { "You cannot split items from this panel" },
h.SystemMessages);
Assert.Equal(10u, h.SplitQuantity.Value);
Assert.Equal(10u, h.SplitQuantity.Maximum);
}
[Fact]
public void RightClickStagedBuyingAndSellingRows_SelectsAndExaminesBoth()
{
var h = new Harness();
h.State.Apply(VendorGuid, SellProfile((uint)ItemType.Armor), new[]
{
new VendorShopItem(
ArmorItemGuid, -1, 2u, "Chainmail", (uint)ItemType.Armor, 200u, 500),
});
h.AddButton.OnClick!.Invoke();
MakePlayerOwned(h, PlayerOwnedArmorGuid, ItemType.Armor, 100);
h.Controller.HandleDropRelease(
h.SellingList, new UiItemSlot(), DragFromInventory(PlayerOwnedArmorGuid));
UiItemSlot buying = h.BuyingList.GetItem(0)!;
buying.OnEvent(new UiEvent(0u, buying, UiEventType.RightClick));
UiItemSlot selling = h.SellingList.GetItem(0)!;
selling.OnEvent(new UiEvent(0u, selling, UiEventType.RightClick));
Assert.Equal(new[] { ArmorItemGuid, PlayerOwnedArmorGuid }, h.Examines);
Assert.Equal(PlayerOwnedArmorGuid, h.Selection.SelectedObjectId);
}
// ══════════════════════════════════════════════════════════════════════
// F10 (Slice 6b/6c review) — unstage on removal/dispossession.
// ══════════════════════════════════════════════════════════════════════

View file

@ -297,10 +297,19 @@ public sealed class RetailUiInteractionFlowTests
Assert.True(probe.DoubleClickItem(Hauberk, ItemDragSource.Inventory));
Assert.Equal(new[] { (Hauberk, (uint)HauberkMask) }, h.Wields);
var pending = probe.AssertItem(
Hauberk,
equippedLocation: EquipMask.None,
containerId: Player);
Assert.True(pending.Success, pending.Message);
Assert.True(h.Objects.ApplyConfirmedServerWield(
Hauberk, Player, HauberkMask));
var state = probe.AssertItem(
Hauberk,
equippedLocation: HauberkMask,
containerId: Player);
containerId: 0u);
Assert.True(state.Success, state.Message);
}
@ -337,9 +346,15 @@ public sealed class RetailUiInteractionFlowTests
Assert.Equal(EquipMask.MeleeWeapon,
h.Objects.Get(Sword)!.CurrentlyEquippedLocation);
h.Objects.MoveItem(Sword, Player, 0, EquipMask.None);
Assert.True(h.Objects.ApplyConfirmedServerMove(Sword, Player, 0u, 0));
Assert.Equal(new[] { (Bow, (uint)EquipMask.MissileWeapon) }, h.Wields);
Assert.Equal(EquipMask.None,
h.Objects.Get(Bow)!.CurrentlyEquippedLocation);
Assert.True(h.Objects.ApplyConfirmedServerWield(
Bow, Player, EquipMask.MissileWeapon));
Assert.Equal(EquipMask.MissileWeapon,
h.Objects.Get(Bow)!.CurrentlyEquippedLocation);
}
@ -453,6 +468,11 @@ public sealed class RetailUiInteractionFlowTests
Assert.True(probe.DragItemOutside(Hauberk, 700, 500, ItemDragSource.Inventory));
Assert.Equal(new[] { Hauberk }, h.Drops);
var pending = probe.AssertItem(Hauberk, containerId: Player, slot: 0);
Assert.True(pending.Success, pending.Message);
Assert.True(h.Objects.ApplyConfirmedServerMove(Hauberk, 0u, 0u, -1));
var state = probe.AssertItem(Hauberk, containerId: 0u, slot: -1);
Assert.True(state.Success, state.Message);
}
@ -470,10 +490,19 @@ public sealed class RetailUiInteractionFlowTests
Assert.True(probe.DragItemToElement(Hauberk, ChestArmorSlotId, ItemDragSource.Inventory));
Assert.Equal(new[] { (Hauberk, (uint)HauberkMask) }, h.Wields);
var pending = probe.AssertItem(
Hauberk,
equippedLocation: EquipMask.None,
containerId: Player);
Assert.True(pending.Success, pending.Message);
Assert.True(h.Objects.ApplyConfirmedServerWield(
Hauberk, Player, HauberkMask));
var state = probe.AssertItem(
Hauberk,
equippedLocation: HauberkMask,
containerId: Player);
containerId: 0u);
Assert.True(state.Success, state.Message);
}
}

View file

@ -6,6 +6,44 @@ namespace AcDream.App.Tests.UI;
public class UiRootInputTests
{
[Fact]
public void KeypadEnter_DoesNotUseTheRawChatActivationFallback()
{
var root = new UiRoot { Width = 800, Height = 600 };
var field = new UiField { Width = 100, Height = 20 };
root.AddChild(field);
root.DefaultTextInput = field;
root.OnKeyDown((int)Silk.NET.Input.Key.KeypadEnter);
Assert.Null(root.KeyboardFocus);
}
[Fact]
public void SemanticChatActivation_SuppressesTheSameNativeEnterTail()
{
var root = new UiRoot { Width = 800, Height = 600 };
var field = new UiField { Width = 100, Height = 20 };
int submissions = 0;
field.SetText("hello");
field.OnSubmit = _ => submissions++;
root.AddChild(field);
root.DefaultTextInput = field;
root.SetKeyboardFocus(field);
root.SuppressPhysicalKeyUntilRelease(Silk.NET.Input.Key.Enter);
root.OnKeyDown((int)Silk.NET.Input.Key.Enter);
root.OnChar('x');
Assert.Equal(0, submissions);
Assert.Equal("hello", field.Text);
Assert.Same(field, root.KeyboardFocus);
root.OnKeyUp((int)Silk.NET.Input.Key.Enter);
root.OnChar('x');
Assert.Equal("hellox", field.Text);
}
[Fact]
public void UiNineSlicePanel_IsNotAnchorManaged_SoUserMoveResizeSticks()
{

View file

@ -67,6 +67,7 @@ public sealed class ClientCommandRequestsTests
{
{ ClientCommandRequests.BuildSetAfkMessage, ClientCommandRequests.SetAfkMessageOpcode },
{ ClientCommandRequests.BuildEmote, ClientCommandRequests.EmoteOpcode },
{ ClientCommandRequests.BuildSoulEmote, ClientCommandRequests.SoulEmoteOpcode },
{ ClientCommandRequests.BuildAddFriend, ClientCommandRequests.AddFriendOpcode },
{ ClientCommandRequests.BuildRemoveConsent, ClientCommandRequests.RemoveConsentOpcode },
};

View file

@ -27,6 +27,22 @@ public sealed class ServerMessageTests
Assert.Equal(5u, parsed.Value.ChatType);
}
[Fact]
public void TryParse_PreservesEmbeddedCommandResponseNewlines()
{
const string text = "@acecommands\n@help\n@teleport";
byte[] msg = PackString16L(text);
byte[] body = new byte[4 + msg.Length + 4];
BinaryPrimitives.WriteUInt32LittleEndian(body, ServerMessage.Opcode);
Array.Copy(msg, 0, body, 4, msg.Length);
BinaryPrimitives.WriteUInt32LittleEndian(body.AsSpan(4 + msg.Length), 0u);
ServerMessage.Parsed parsed = Assert.IsType<ServerMessage.Parsed>(
ServerMessage.TryParse(body));
Assert.Equal(text, parsed.Message);
}
[Fact]
public void TryParse_WrongOpcode_ReturnsNull()
{

View file

@ -77,6 +77,18 @@ public sealed class WorldSessionChatTests
Assert.Throws<ArgumentNullException>(() => session.SendTalk(null!));
}
[Fact]
public void SendSoulEmote_EmitsRetailGameAction()
{
using var session = NewSession();
byte[]? captured = null;
session.GameActionCapture = body => captured = body;
session.SendSoulEmote("waves.");
Assert.Equal(ClientCommandRequests.BuildSoulEmote(1u, "waves."), captured);
}
[Fact]
public void SendTeleportToLifestone_EmitsRetailGameAction()
{

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()
{

View file

@ -59,4 +59,69 @@ public sealed class LiveChatCommandRouteTests
route.Publish(new SendServerCommandCmd("@stale"));
Assert.Equal(6, sent.Count);
}
[Fact]
public void Say_ConsumesValidDatPoseAndLeavesUnknownTokenAsSpeech()
{
using var communication = new RuntimeCommunicationState();
using var character = new RuntimeCharacterState();
var sent = new List<string>();
var route = new LiveChatCommandRoute(new LiveChatCommandBindings(
_ => { },
communication,
communication.Chat,
communication.TurbineChat,
character,
() => 0x50000001u,
text => sent.Add($"talk:{text}"),
(_, _) => { },
(_, _) => { },
(_, _, _, _, _, _) => { },
ResolvePose: command => string.Equals(
command,
"wave",
StringComparison.OrdinalIgnoreCase)
? new RetailChatPose(0x13000087u, "wave.", "waves.")
: null,
ExecuteMotion: motion => sent.Add($"motion:{motion:X8}"),
SendSoulEmote: text => sent.Add($"soul:{text}")));
route.Activate();
route.Publish(new SendChatCmd(
ChatChannelKind.Say,
null,
"hello *WAVE* there *not-a-pose*"));
Assert.Equal(
[
"motion:13000087",
"soul:waves.",
"talk:hello there *not-a-pose*",
],
sent);
ChatEntry local = Assert.Single(communication.Chat.Snapshot());
Assert.Equal(ChatKind.SoulEmote, local.Kind);
Assert.Equal("You", local.Sender);
Assert.Equal("wave.", local.Text);
}
[Fact]
public void Say_ContainingOnlyValidPoseDoesNotSendEmptyTalk()
{
using var communication = new RuntimeCommunicationState();
using var character = new RuntimeCharacterState();
var sent = new List<string>();
var route = new LiveChatCommandRoute(new LiveChatCommandBindings(
_ => { }, communication, communication.Chat,
communication.TurbineChat, character, () => 1u,
text => sent.Add($"talk:{text}"), (_, _) => { }, (_, _) => { },
(_, _, _, _, _, _) => { },
ResolvePose: _ => new RetailChatPose(7u, string.Empty, string.Empty),
ExecuteMotion: motion => sent.Add($"motion:{motion}")));
route.Activate();
route.Publish(new SendChatCmd(ChatChannelKind.Say, null, " *wave* "));
Assert.Equal(["motion:7"], sent);
}
}

View file

@ -0,0 +1,35 @@
using AcDream.Runtime.Chat;
namespace AcDream.Runtime.Tests.Chat;
public sealed class RetailPublicChatParserTests
{
[Fact]
public void InvalidAndUnmatchedTokensRemainLiteral()
{
string text = RetailPublicChatParser.ExtractPoses(
"*unknown* and *unfinished",
_ => null,
_ => throw new Xunit.Sdk.XunitException("must not execute"));
Assert.Equal("*unknown* and *unfinished", text);
}
[Fact]
public void MultipleValidStarAndAngleTokensAreRemovedInOrder()
{
var motions = new List<uint>();
string text = RetailPublicChatParser.ExtractPoses(
"a *one* b <two> c",
command => command switch
{
"one" => new RetailChatPose(1u, "", ""),
"two" => new RetailChatPose(2u, "", ""),
_ => null,
},
pose => motions.Add(pose.MotionCommand));
Assert.Equal("a b c", text);
Assert.Equal([1u, 2u], motions);
}
}

View file

@ -153,6 +153,7 @@ public sealed class RuntimeCombatAttackStateTests
now += 0.5d;
controller.ReleaseAttack();
Assert.Single(sent);
Assert.True(controller.RepeatAttackInProgress);
controller.HandleCommand(new RuntimeCombatAttackInput(
RuntimeCombatAttackCommand.AbortForMovement,
@ -161,6 +162,7 @@ public sealed class RuntimeCombatAttackStateTests
Assert.Equal(1, cancels);
Assert.Single(sent);
Assert.False(controller.RepeatAttackInProgress);
Assert.False(controller.BuildInProgress);
Assert.Equal(0f, controller.PowerBarLevel);
}

View file

@ -128,6 +128,26 @@ public sealed class RuntimeInventoryStateTests
Assert.Empty(inventory.Shortcuts.Items);
}
[Fact]
public void RemovingAnObjectRetiresRetailOpenedCorpseHistory()
{
using var entities = new RuntimeEntityObjectLifetime();
using var inventory = new RuntimeInventoryState(entities);
const uint corpse = 0x70000020u;
inventory.Objects.AddOrUpdate(new ClientObject
{
ObjectId = corpse,
PublicWeenieBitfield = (uint)PublicWeenieFlags.Corpse,
});
inventory.ExternalContainers.RequestOpen(corpse, isCorpse: true);
Assert.True(inventory.ExternalContainers.HasCorpseBeenOpened(corpse));
Assert.True(inventory.Objects.Remove(corpse));
Assert.False(inventory.ExternalContainers.HasCorpseBeenOpened(corpse));
Assert.Equal(0, inventory.CaptureOwnership().OpenedCorpseCount);
}
[Fact]
public void DisposalFailureIsReportedAfterTerminalOwnerConvergence()
{

View file

@ -6,6 +6,24 @@ namespace AcDream.Runtime.Tests.Gameplay;
public sealed class RuntimeLocalPlayerMovementStateTests
{
private static PhysicsEngine MakeFlatEngine()
{
var engine = new PhysicsEngine();
var heights = new byte[81];
Array.Fill(heights, (byte)50);
var heightTable = new float[256];
for (int i = 0; i < heightTable.Length; i++)
heightTable[i] = i;
engine.AddLandblock(
0xA9B4FFFFu,
new TerrainSurface(heights, heightTable),
Array.Empty<CellSurface>(),
Array.Empty<PortalPlane>(),
worldOffsetX: 0f,
worldOffsetY: 0f);
return engine;
}
[Fact]
public void ViewProjectsTheExactCanonicalControllerAndAutorunOwner()
{
@ -59,6 +77,30 @@ public sealed class RuntimeLocalPlayerMovementStateTests
Assert.False(movement.View.Snapshot.HasCommandInput);
}
[Fact]
public void EscapeCommandsFinishJumpAndStopThroughCanonicalController()
{
var controller = new PlayerMovementController(MakeFlatEngine());
controller.SeedPlacementForTest(
new Vector3(96f, 96f, 50f),
0xA9B40001u,
new Vector3(96f, 96f, 50f));
using var movement = new RuntimeLocalPlayerMovementState
{
Controller = controller,
};
controller.Update(0.25f, new MovementInput(Jump: true));
Assert.True(movement.View.JumpCharge.IsCharging);
Assert.True(movement.Execute(RuntimeMovementCommand.FinishJump));
Assert.False(movement.View.JumpCharge.IsCharging);
controller.Update(1f / 60f, new MovementInput(Forward: true));
Assert.False(movement.View.IsStandingStill);
Assert.True(movement.Execute(RuntimeMovementCommand.StopCompletely));
Assert.Equal(MotionCommand.Ready, controller.Motion.RawState.ForwardCommand);
}
[Fact]
public void CommandInputIsDeduplicatedAndResetWithSessionIntent()
{
@ -123,6 +165,65 @@ public sealed class RuntimeLocalPlayerMovementStateTests
}
}
[Fact]
public void CommandMotionUsesCanonicalControllerAndEmitsOneMovementEdge()
{
const uint afkState = 0x43000118u;
var controller = new PlayerMovementController(new PhysicsEngine());
using var movement = new RuntimeLocalPlayerMovementState
{
Controller = controller,
};
Assert.True(movement.ExecuteMotion(afkState));
Assert.Equal(afkState, controller.Motion.RawState.ForwardCommand);
MovementResult first = controller.Update(1f / 60f, default);
MovementResult second = controller.Update(1f / 60f, default);
Assert.True(first.ShouldSendMovementEvent);
RawMotionState outbound =
LocalPlayerOutboundController.BuildRawMotionState(first);
Assert.Equal(afkState, outbound.ForwardCommand);
Assert.False(second.ShouldSendMovementEvent);
}
[Fact]
public void OutboundRawOverridePreservesRetailActionAndStamp()
{
const uint cheer = 0x1300004Cu;
var raw = new RawMotionState();
raw.AddAction(
cheer,
speed: 1f,
actionStamp: 7u,
autonomous: true);
var result = new MovementResult(
default,
default,
0u,
false,
true,
null,
null,
null,
null,
null,
null,
RawMotionStateOverride: new RawMotionState(raw));
RawMotionState firstRaw =
LocalPlayerOutboundController.BuildRawMotionState(result);
RawMotionAction firstAction = Assert.Single(firstRaw.Actions);
Assert.Equal((ushort)0x004C, firstAction.Command);
Assert.Equal(7, firstAction.Stamp);
Assert.True(firstAction.Autonomous);
raw.RemoveAction();
Assert.Single(firstRaw.Actions);
}
[Fact]
public void ConcurrentRuntimeInstancesHaveIndependentMovementState()
{

View file

@ -12,8 +12,8 @@ namespace AcDream.UI.Abstractions.Tests.Input;
/// non-modifier chord is reported via the supplied callback and the
/// dispatcher does NOT fire normal action events for that chord. Esc
/// cancels capture (callback receives a sentinel <c>default</c> chord).
/// Modifier-only key transitions don't complete capture — the user can
/// dial in Shift / Ctrl / Alt before pressing the trigger key.
/// A modifier key is captured on release when used alone, or remains a
/// modifier prefix when another key is pressed while it is held.
/// </summary>
public class InputDispatcherCaptureTests
{
@ -93,6 +93,23 @@ public class InputDispatcherCaptureTests
Assert.Equal(new KeyChord(Key.A, ModifierMask.Shift | ModifierMask.Ctrl), captured!.Value);
}
[Fact]
public void BeginCapture_modifier_released_alone_becomes_bare_primary_key()
{
var (dispatcher, kb, _, _, fired) = Build();
KeyChord? captured = null;
dispatcher.BeginCapture(chord => captured = chord);
kb.EmitKeyDown(Key.ShiftLeft, ModifierMask.Shift);
Assert.Null(captured);
kb.EmitKeyUp(Key.ShiftLeft, ModifierMask.Shift);
Assert.Equal(
new KeyChord(Key.ShiftLeft, ModifierMask.None),
captured);
Assert.Empty(fired);
}
[Fact]
public void BeginCapture_completes_with_modifier_state()
{
@ -106,6 +123,26 @@ public class InputDispatcherCaptureTests
Assert.Equal(new KeyChord(Key.A, ModifierMask.Ctrl), captured!.Value);
}
[Fact]
public void BeginCapture_consumes_mouse_button_as_retail_qualified_control()
{
var (dispatcher, _, mouse, bindings, fired) = Build();
var left = new KeyChord(
InputDispatcher.MouseButtonToKey(MouseButton.Left),
ModifierMask.Ctrl,
Device: 1);
bindings.Add(new Binding(left, InputAction.ToggleInventoryPanel));
mouse.WantCaptureMouse = true;
KeyChord? captured = null;
dispatcher.BeginCapture(chord => captured = chord);
mouse.EmitMouseDown(MouseButton.Left, ModifierMask.Ctrl);
Assert.Equal(left, captured);
Assert.False(dispatcher.IsCapturing);
Assert.Empty(fired);
}
[Fact]
public void CancelCapture_invokes_callback_with_default_chord_and_clears_state()
{

View file

@ -101,6 +101,22 @@ public class InputDispatcherTests
fired);
}
[Fact]
public void Same_scope_retail_duplicate_chord_fires_every_distinct_action()
{
var (_, kb, _, bindings, fired) = Build();
var chord = new KeyChord(Key.Number1, ModifierMask.Alt);
bindings.Add(new Binding(chord, InputAction.ToggleFloatingChatWindow1));
bindings.Add(new Binding(chord, InputAction.UseQuickSlot_10));
kb.EmitKeyDown(Key.Number1, ModifierMask.Alt);
Assert.Equal(
[(InputAction.ToggleFloatingChatWindow1, ActivationType.Press),
(InputAction.UseQuickSlot_10, ActivationType.Press)],
fired);
}
[Fact]
public void Changing_combat_scope_releases_hold_resolved_in_previous_scope()
{
@ -188,6 +204,30 @@ public class InputDispatcherTests
Assert.Empty(fired); // no longer held
}
[Fact]
public void RetailBareLeftShiftBinding_NormalizesSilkSelfModifierBit()
{
var kb = new FakeKeyboardSource();
var mouse = new FakeMouseSource();
var dispatcher = InputDispatcher.CreateDetached(
kb,
mouse,
KeyBindings.RetailDefaults());
dispatcher.Attach();
var fired = new List<(InputAction, ActivationType)>();
dispatcher.Fired += (action, activation) => fired.Add((action, activation));
kb.EmitKeyDown(Key.ShiftLeft, ModifierMask.Shift);
kb.EmitKeyUp(Key.ShiftLeft, ModifierMask.Shift);
Assert.Contains(
(InputAction.MovementWalkMode, ActivationType.Press),
fired);
Assert.Contains(
(InputAction.MovementWalkMode, ActivationType.Release),
fired);
}
[Fact]
public void Hold_callback_scope_change_DoesNotDispatchStaleSnapshotChord()
{

View file

@ -104,10 +104,19 @@ public class KeyBindingsJsonTests
var path = TempFile();
try
{
// User customizes ONE action — replace MovementForward with Q.
var custom = new KeyBindings();
custom.Add(new(new KeyChord(Key.Q, ModifierMask.None), InputAction.MovementForward));
custom.SaveToFile(path);
// A pre-v7 partial file customizes ONE action. Missing actions in
// those schemas mean "not stored yet", so they default-merge.
const string legacyJson = """
{
"version": 6,
"actions": {
"MovementForward": [
{ "key": "Q" }
]
}
}
""";
File.WriteAllText(path, legacyJson);
var loaded = KeyBindings.LoadOrDefault(path);
@ -128,6 +137,32 @@ public class KeyBindingsJsonTests
}
}
[Fact]
public void Roundtrip_preserves_explicitly_unbound_retail_action()
{
var path = TempFile();
try
{
KeyBindings defaults = KeyBindings.RetailDefaults();
var customized = new KeyBindings();
foreach (Binding binding in defaults.All)
{
if (binding.Action != InputAction.ToggleHelp)
customized.Add(binding);
}
customized.SaveToFile(path);
KeyBindings loaded = KeyBindings.LoadOrDefault(path);
Assert.Empty(loaded.ForAction(InputAction.ToggleHelp));
Assert.NotEmpty(loaded.ForAction(InputAction.ToggleOptionsPanel));
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Fact]
public void LoadOrDefault_handles_version_zero_legacy_file()
{
@ -193,17 +228,16 @@ public class KeyBindingsJsonTests
}
[Fact]
public void LoadOrDefault_migratesV1CtrlNumberQuickSlotFromUseToSelect()
public void LoadOrDefault_migratesV5CtrlNumberQuickSlotFromSelectBackToRetailUse()
{
var path = TempFile();
try
{
const string json = """
{
"version": 1,
"version": 5,
"actions": {
"UseQuickSlot_5": [
{ "key": "Number5" },
"SelectQuickSlot_5": [
{ "key": "Number5", "mod": "Ctrl" }
]
}
@ -214,9 +248,8 @@ public class KeyBindingsJsonTests
var loaded = KeyBindings.LoadOrDefault(path);
Assert.Equal(InputAction.UseQuickSlot_5,
loaded.Find(new KeyChord(Key.Number5, ModifierMask.None), ActivationType.Press)?.Action);
Assert.Equal(InputAction.SelectQuickSlot_5,
loaded.Find(new KeyChord(Key.Number5, ModifierMask.Ctrl), ActivationType.Press)?.Action);
Assert.Empty(loaded.ForAction(InputAction.SelectQuickSlot_5));
}
finally
{

View file

@ -76,9 +76,11 @@ public class KeyBindingsRetailTests
{
var b = KeyBindings.RetailDefaults();
var binds = b.ForAction(InputAction.MovementWalkMode).ToList();
Assert.NotEmpty(binds);
Assert.All(binds, x => Assert.Equal(ActivationType.Hold, x.Activation));
Assert.Contains(binds, x => x.Chord.Key == Key.ShiftLeft);
Binding binding = Assert.Single(binds);
Assert.Equal(ActivationType.Hold, binding.Activation);
Assert.Equal(
new KeyChord(Key.ShiftLeft, ModifierMask.None),
binding.Chord);
}
[Fact]
@ -135,14 +137,15 @@ public class KeyBindingsRetailTests
}
[Fact]
public void QuickSlot_5_bareUsesAndCtrlSelects()
public void QuickSlot_5_BareAndCtrlBothUseRetailAction()
{
var b = KeyBindings.RetailDefaults();
var bare = b.Find(new KeyChord(Key.Number5, ModifierMask.None), ActivationType.Press);
var ctrl = b.Find(new KeyChord(Key.Number5, ModifierMask.Ctrl), ActivationType.Press);
Assert.Equal(InputAction.UseQuickSlot_5, bare?.Action);
Assert.Equal(InputAction.SelectQuickSlot_5, ctrl?.Action);
Assert.Equal(InputAction.UseQuickSlot_5, ctrl?.Action);
Assert.Empty(b.ForAction(InputAction.SelectQuickSlot_5));
}
[Fact]
@ -216,6 +219,18 @@ public class KeyBindingsRetailTests
var binds = b.ForAction(InputAction.CameraActivateAlternateMode).ToList();
Assert.Contains(binds, x => x.Chord == new KeyChord(Key.F2, ModifierMask.None));
Assert.Contains(binds, x => x.Chord == new KeyChord(Key.KeypadDivide, ModifierMask.None));
Assert.All(binds, x => Assert.Equal(ActivationType.Hold, x.Activation));
}
[Theory]
[InlineData(InputAction.CombatAimLow)]
[InlineData(InputAction.CombatAimMedium)]
[InlineData(InputAction.CombatAimHigh)]
public void Missile_aim_actions_use_retail_press_and_release_edges(InputAction action)
{
var binding = Assert.Single(KeyBindings.RetailDefaults().ForAction(action));
Assert.Equal(ActivationType.Hold, binding.Activation);
Assert.Equal(InputScope.MissileCombat, binding.Scope);
}
[Fact]