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

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