fix(ui): restore retail vitals and window interactions
All checks were successful
CI / linux-portable (push) Successful in 3m16s
CI / windows-gate (push) Successful in 5m41s
CI / release (push) Successful in 2m5s

This commit is contained in:
Erik 2026-08-20 13:26:35 +02:00
parent 4d84456c21
commit 1bd2b30291
36 changed files with 1462 additions and 86 deletions

View file

@ -4,6 +4,7 @@ using AcDream.App.Combat;
using AcDream.App.Composition;
using AcDream.App.Diagnostics;
using AcDream.App.Rendering;
using AcDream.App.Settings;
using AcDream.App.Spells;
using AcDream.Content;
using AcDream.App.UI;
@ -35,6 +36,25 @@ public sealed class InteractionRetainedUiCompositionTests
InteractionRetainedUiCompositionPoint.InventoryContainerBound,
];
[Fact]
public void RadarLockBindingUsesAuthoritativeRequestInsteadOfPresentationOnlySetter()
{
MethodInfo compose = typeof(RetailInteractionRetainedUiCompositionFactory)
.GetMethod(nameof(
RetailInteractionRetainedUiCompositionFactory.CreateRetainedUi))!;
IReadOnlyList<CompiledCall> references =
CompiledCallGraph.ReadMethodReferences(compose);
Assert.Contains(
references,
call => call.Target.DeclaringType == typeof(RuntimeSettingsController)
&& call.Target.Name == nameof(RuntimeSettingsController.RequestUiLocked));
Assert.DoesNotContain(
references,
call => call.Target.DeclaringType == typeof(RuntimeSettingsController)
&& call.Target.Name == nameof(RuntimeSettingsController.SetUiLocked));
}
[Fact]
public void EnabledUiPublishesOneExactResultAfterFrozenConstructionOrder()
{

View file

@ -628,6 +628,43 @@ public sealed class LiveSessionCommandRouterTests
isOlthoiPlayer: false).Status);
}
[Fact]
public void SettingsRouteLockUi_UpdatesCanonicalBitSynchronouslyAndSendsAutosave()
{
var characterState = new RuntimeCharacterState();
characterState.Options.SetOptionBit(
(uint)CharacterOptionId.LockUI,
false);
var sent = new List<(uint OptionId, bool Value)>();
LiveSessionCommandRouter router = NewRouter(
characterState: characterState,
sendSingleCharacterOption: (id, value) =>
characterState.Options.TrySetOption(
id,
value,
sendAutoSave: (sentId, sentValue) =>
sent.Add((sentId, sentValue))));
router.Activate();
router.Publish(new SetSingleCharacterOptionRuntimeCmd(
(uint)CharacterOptionId.LockUI,
true));
Assert.True(characterState.Options.GetOptionBit(CharacterOptionId.LockUI));
Assert.Equal([((uint)CharacterOptionId.LockUI, true)], sent);
router.Publish(new SetSingleCharacterOptionRuntimeCmd(
(uint)CharacterOptionId.LockUI,
false));
Assert.False(characterState.Options.GetOptionBit(CharacterOptionId.LockUI));
Assert.Equal(
[
((uint)CharacterOptionId.LockUI, true),
((uint)CharacterOptionId.LockUI, false),
], sent);
}
[Fact]
public void ShowWeenieErrorFriendsFull_ResolvesThroughAddText_AndLandsInSpewBoxNotChat()
{

View file

@ -941,6 +941,85 @@ public sealed class RuntimeSettingsControllerTests
Assert.Equal(["target-quality"], events);
}
[Fact]
public void RequestUiLocked_PublishesAuthoritativeOptionBeforePresentation()
{
var events = new List<string>();
bool authoritativeLock = false;
var controller = new RuntimeSettingsController(
new FakeStorage(),
log: events.Add,
characterOptionValue: optionId =>
optionId == (uint)CharacterOptionId.LockUI && authoritativeLock);
var targets = new FakeRuntimeTargets(events);
targets.SingleOptionApplied = (optionId, value) =>
{
if (optionId == (uint)CharacterOptionId.LockUI)
authoritativeLock = value;
};
controller.BindRuntimeTargets(targets);
controller.RequestUiLocked(true);
Assert.Equal(
[
$"target-single-option:0x{(uint)CharacterOptionId.LockUI:X}:True",
"target-ui-lock:True",
], events);
Assert.Equal(
[((uint)CharacterOptionId.LockUI, true)],
targets.SingleOptionCalls);
Assert.Equal(1, targets.UiLockCalls);
events.Clear();
controller.RequestUiLocked(false);
Assert.Equal(
[
$"target-single-option:0x{(uint)CharacterOptionId.LockUI:X}:False",
"target-ui-lock:False",
], events);
Assert.Equal(
[
((uint)CharacterOptionId.LockUI, true),
((uint)CharacterOptionId.LockUI, false),
], targets.SingleOptionCalls);
Assert.Equal(2, targets.UiLockCalls);
}
[Fact]
public void RequestUiLocked_InactiveRouteDoesNotSplitPresentation_AndServerSeedDoesNotEcho()
{
var events = new List<string>();
bool authoritativeLock = false;
var controller = new RuntimeSettingsController(
new FakeStorage(),
log: events.Add,
characterOptionValue: optionId =>
optionId == (uint)CharacterOptionId.LockUI && authoritativeLock);
var targets = new FakeRuntimeTargets(events);
controller.BindRuntimeTargets(targets);
// The fake records the publish but deliberately does not mutate the
// canonical option, modeling an inactive/displaced live router.
controller.RequestUiLocked(true);
Assert.Equal(
[$"target-single-option:0x{(uint)CharacterOptionId.LockUI:X}:True"],
events);
Assert.Equal(0, targets.UiLockCalls);
// A later PlayerDescription has already replaced Runtime authority;
// its convergence call is presentation-only and must not echo 0x0005.
authoritativeLock = true;
events.Clear();
controller.SetUiLocked(true);
Assert.Equal(["target-ui-lock:True"], events);
Assert.Single(targets.SingleOptionCalls);
Assert.Equal(1, targets.UiLockCalls);
}
[Fact]
public void SetUiLocked_AppliesOnFirstCallThenNoOpsOnRepeatedSameValue()
{
@ -1121,10 +1200,13 @@ public sealed class RuntimeSettingsControllerTests
public List<(uint OptionId, bool Value)> SingleOptionCalls { get; } = [];
public Action<uint, bool>? SingleOptionApplied { get; set; }
public void SetSingleCharacterOption(uint optionId, bool value)
{
SingleOptionCalls.Add((optionId, value));
events.Add($"target-single-option:0x{optionId:X}:{value}");
SingleOptionApplied?.Invoke(optionId, value);
}
public List<(float DefaultOpacity, float ActiveOpacity)> ChatOpacityCalls { get; } = [];

View file

@ -1,4 +1,6 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.App.Tests.UI.Layout;
using AcDream.Core.Combat;
using AcDream.Core.Items;
@ -354,7 +356,7 @@ public sealed class CursorFeedbackControllerTests
}
[Fact]
public void UpdateFromRoot_worldProviderDrivesTargetCursor_whenUiNotHovered()
public void UpdateFromRoot_worldProviderContinuesBehindNonItemUi()
{
var objects = SeedTargetObjects();
var interaction = new ItemInteractionController(
@ -376,11 +378,38 @@ public sealed class CursorFeedbackControllerTests
// World hover (retail SmartBox found object) drives valid/invalid…
Assert.Equal(CursorFeedbackKind.TargetValid, c.Update(root).Kind);
// …but UI occludes the world: hovering a plain panel → PENDING even
// though the world provider would return a valid target.
// UIElement_SmartBoxWrapper::FindObject @0x004E5430 only returns
// early for UIElement_UIItem. Ordinary UI chrome falls through to
// SmartBox::find_object, so it does not occlude the world pick.
var panel = new UiPanel { Left = 390, Top = 290, Width = 40, Height = 40 };
root.AddChild(panel);
Assert.Equal(CursorFeedbackKind.TargetPending, c.Update(root).Kind);
Assert.Equal(CursorFeedbackKind.TargetValid, c.Update(root).Kind);
}
[Fact]
public void UpdateFromRoot_toolbarBackpackRepresentsSelfAndShowsFoundCursorWithoutWorldHit()
{
ImportedLayout toolbar = FixtureLoader.LoadToolbar();
using ToolbarController controller = ToolbarController.Bind(
toolbar,
new ClientObjectTable(),
new ShortcutStore(),
iconIds: static (_, _, _, _, _) => 0u,
useItem: static _ => { },
playerGuid: () => Player);
var root = new UiRoot { Width = 800, Height = 600 };
root.AddChild(toolbar.Root);
var backpack = Assert.IsType<UiButton>(toolbar.FindElement(0x100001B1u));
System.Numerics.Vector2 position = backpack.ScreenPosition;
root.OnMouseMove(
(int)(position.X + backpack.Width * 0.5f),
(int)(position.Y + backpack.Height * 0.5f));
var c = new CursorFeedbackController(worldTargetProvider: () => 0u);
CursorFeedback feedback = c.Update(root);
Assert.Equal(CursorFeedbackKind.Default, feedback.Kind);
Assert.Equal(RetailGlobalCursorKind.DefaultFound, feedback.GlobalKind);
}
[Fact]

View file

@ -200,11 +200,11 @@ public class ChatLayoutConformanceTests
[InlineData(0x10000698u)]
[InlineData(0x10000699u)]
[InlineData(0x1000069Au)]
public void MountedChatWindow_LockedTwinBorderArt_DefaultsHidden(uint lockedTwinId)
public void BoundChatWindow_LockedTwinBorderArt_SeedsUnlockedUntilRegistration(uint lockedTwinId)
{
// Register row AP-185: CH6a shows only the live (unlocked) grip/dragbar
// border-art set by default, matching UiRoot.UiLocked's own false
// default and avoiding a double-rendered border.
// Bind seeds the unlocked skin for standalone layouts. Once registered,
// RetailWindowLockPresentationController applies the canonical UiLocked
// state; its real-fixture swap is covered by that controller's tests.
var infos = FixtureLoader.LoadChatInfos();
var layout = LayoutImporter.Build(infos, NoTex, null);
var controller = ChatWindowController.Bind(

View file

@ -1483,6 +1483,80 @@ public class InventoryControllerTests
ctrl.OnDragOver(grid, grid.GetItem(0)!, Payload(0xFFFFu))); // grid → green
}
[Fact]
public void GroundPack_rejectsContentsGrid_butEmptyPackSlotAcceptsAndPicksUpAtThatSlot()
{
const uint droppedPack = 0x700000C0u;
var (layout, grid, containers, _, _, _, _, _) = BuildLayout();
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject
{
ObjectId = Player,
Type = ItemType.Creature,
ItemsCapacity = 102,
ContainersCapacity = 7,
});
SeedBag(objects, 0x500000C1u, slot: 0);
SeedBag(objects, 0x500000C2u, slot: 1);
objects.AddOrUpdate(new ClientObject
{
ObjectId = droppedPack,
Name = "Dropped Pack",
Type = ItemType.Container,
ItemsCapacity = 24,
});
var puts = new List<(uint Item, uint Container, int Placement)>();
using var interaction = new ItemInteractionController(
objects,
new AcDream.Runtime.Gameplay.RuntimeInteractionTransactionState(
new InventoryTransactionState(objects)),
new InteractionState(),
playerGuid: () => Player,
sendUse: null,
sendUseWithTarget: null,
sendWield: null,
sendDrop: null,
groundObjectId: () => droppedPack,
backpackContainerId: () => Player,
placeInBackpack: static (_, _, _) => { });
using var controller = InventoryController.Bind(
layout,
objects,
() => Player,
iconIds: static (_, _, _, _, _) => 0u,
strength: () => 100,
selection: new SelectionState(),
datFont: null,
sendPutItemInContainer: (item, container, placement) =>
puts.Add((item, container, placement)),
itemInteraction: interaction);
var source = new UiItemSlot { SourceKind = ItemDragSource.Ground };
source.SetItem(droppedPack, 0u);
var payload = new ItemDragPayload(
droppedPack,
ItemDragSource.Ground,
SourceSlot: 0,
SourceCell: source);
Assert.Equal(
ItemDragAcceptance.Reject,
controller.OnDragOver(grid, grid.GetItem(0)!, payload));
controller.HandleDropRelease(grid, grid.GetItem(0)!, payload);
Assert.Empty(puts);
UiItemSlot emptyPackSlot = containers.GetItem(2)!;
Assert.Equal(0u, emptyPackSlot.ItemId);
Assert.Equal(
ItemDragAcceptance.Accept,
controller.OnDragOver(containers, emptyPackSlot, payload));
controller.HandleDropRelease(containers, emptyPackSlot, payload);
Assert.Equal(new[] { (droppedPack, Player, 2) }, puts);
Assert.True(interaction.TryGetPendingBackpackPlacement(droppedPack, out var pending));
Assert.Equal(Player, pending.ContainerId);
Assert.Equal(2, pending.Placement);
}
[Fact]
public void OnDragLift_selectsItem_butKeepsItUntilServerConfirms()
{

View file

@ -1,6 +1,8 @@
using System.Numerics;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.App.Rendering;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.Core.Ui;
namespace AcDream.App.Tests.UI.Layout;
@ -101,6 +103,111 @@ public sealed class RadarControllerTests
Assert.False(layout.FindElement(RadarController.CoordinateContainerId)!.Visible);
}
[Fact]
public void Bind_RealFixture_RootPointerLockCycleRoundTripsAuthorityAndMedia()
{
var layout = LayoutImporter.Build(
FixtureLoader.LoadRadarInfos(),
static file => (file, 8, 8),
null);
var root = new UiRoot { Width = 800, Height = 600 };
root.AddChild(layout.Root);
var state = UiRadarSnapshot.Empty with { UiLocked = false };
var requestedLocks = new List<bool>();
using var controller = RadarController.Bind(
layout,
() => state,
setUiLocked: value =>
{
// Production's corrected contract: update the authoritative
// character option before pushing the retained-root message.
requestedLocks.Add(value);
state = state with { UiLocked = value };
root.UiLocked = value;
});
var radar = Assert.IsType<UiRadar>(layout.Root);
var lockButton = Assert.IsType<UiButton>(
layout.FindElement(RadarController.LockButtonId));
var dragButton = Assert.IsType<UiDatElement>(
layout.FindElement(RadarController.DragButtonId));
radar.Refresh();
Assert.Equal("UnlockedUI", lockButton.ActiveState);
Assert.Equal(0x060074B8u, DrawnFaceFile(lockButton));
Assert.True(lockButton.Visible);
Assert.False(lockButton.ClickThrough);
Assert.True(dragButton.Visible);
Assert.True(radar.Draggable);
ClickThroughRoot(root, lockButton);
radar.Refresh();
Assert.Equal([true], requestedLocks);
Assert.True(root.UiLocked);
Assert.Equal("LockedUI", lockButton.ActiveState);
Assert.Equal(0x060074B7u, DrawnFaceFile(lockButton));
Assert.True(lockButton.Visible);
Assert.False(lockButton.ClickThrough);
Assert.False(dragButton.Visible);
Assert.False(radar.Draggable);
MoveAwayAndBack(root, lockButton);
Assert.Equal("LockedUI", lockButton.ActiveState);
Assert.Equal(0x060074B7u, DrawnFaceFile(lockButton));
// UiLocked suppresses move/resize only. The same radar button remains
// hit-testable and must be able to publish the authoritative unlock.
ClickThroughRoot(root, lockButton);
radar.Refresh();
Assert.Equal([true, false], requestedLocks);
Assert.False(root.UiLocked);
Assert.Equal("UnlockedUI", lockButton.ActiveState);
Assert.Equal(0x060074B8u, DrawnFaceFile(lockButton));
Assert.True(lockButton.Visible);
Assert.False(lockButton.ClickThrough);
Assert.True(dragButton.Visible);
Assert.True(radar.Draggable);
MoveAwayAndBack(root, lockButton);
Assert.Equal("UnlockedUI", lockButton.ActiveState);
Assert.Equal(0x060074B8u, DrawnFaceFile(lockButton));
}
private static void ClickThroughRoot(UiRoot root, UiButton button)
{
Vector2 position = button.ScreenPosition;
int x = (int)(position.X + button.Width * 0.5f);
int y = (int)(position.Y + button.Height * 0.5f);
root.OnMouseMove(x, y);
root.OnMouseDown(UiMouseButton.Left, x, y);
root.OnMouseUp(UiMouseButton.Left, x, y);
}
private static void MoveAwayAndBack(UiRoot root, UiButton button)
{
root.OnMouseMove(700, 500);
Vector2 position = button.ScreenPosition;
root.OnMouseMove(
(int)(position.X + button.Width * 0.5f),
(int)(position.Y + button.Height * 0.5f));
}
private static uint DrawnFaceFile(UiButton button)
{
var renderer = new TextRenderer(
new RecordingGpuDevice(), new NullGpuFrameSource(), "unused");
renderer.Begin(new Vector2(200f, 200f));
button.DrawSelfAndChildren(new UiRenderContext(renderer, new Vector2(200f, 200f)));
return Assert.Single(renderer.DebugSpriteSegments).Texture;
}
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
{
public AcDream.App.Rendering.Gpu.IGpuFrame? CurrentFrame => null;
}
private static ImportedLayout BuildRadarLayout()
{
var root = new ElementInfo

View file

@ -465,6 +465,73 @@ public class ToolbarControllerTests
Assert.Equal("Normal", characterButton.ActiveState);
}
[Fact]
public void RetailFixture_inventoryButtonPressKeepsClosedFaceThenOpensDirectly()
{
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadToolbarInfos(),
static file => (file, 8, 8),
null);
var controller = ToolbarController.Bind(
layout,
new ClientObjectTable(),
new ShortcutStore(),
iconIds: (_, _, _, _, _) => 0u,
useItem: _ => { });
controller.BindPanelButtons(
_ => true,
panelId => controller.SetPanelOpen(panelId, open: true));
var inventory = Assert.IsType<UiButton>(layout.FindElement(InventoryButtonId));
Assert.Equal("Normal", inventory.ActiveState);
Assert.Equal(0x06004CF7u, DrawnFaceFile(inventory));
inventory.OnEvent(new UiEvent(
inventory.EventId,
inventory,
UiEventType.MouseDown,
Data1: 31,
Data2: 29));
Assert.Equal("Normal_pressed", inventory.ActiveState);
Assert.Equal(0x06004CF7u, DrawnFaceFile(inventory));
inventory.OnEvent(new UiEvent(
inventory.EventId,
inventory,
UiEventType.MouseUp,
Data1: 31,
Data2: 29));
Assert.Equal("Highlight", inventory.ActiveState);
Assert.Equal(0x06004CF8u, DrawnFaceFile(inventory));
}
[InstalledDatFact]
[Trait("Lane", "InstalledDat")]
public void InstalledDat_inventoryButtonPressStateKeepsTheCurrentFace()
{
string datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call");
using var dats = new AcDream.App.Tests.BoundedTestDatCollection(
datDirectory,
DatReaderWriter.Options.DatAccessType.Read);
ElementInfo root = Assert.IsType<ElementInfo>(
LayoutImporter.ImportInfos(dats, 0x21000016u));
ElementInfo inventory = FindInfo(root, InventoryButtonId);
Assert.Equal(1, inventory.States[UiButtonStateMachine.Normal].MediaCount);
Assert.Equal(1, inventory.States[UiButtonStateMachine.Normal].ImageMediaCount);
Assert.Equal(1, inventory.States[UiButtonStateMachine.NormalPressed].MediaCount);
Assert.Equal(0, inventory.States[UiButtonStateMachine.NormalPressed].ImageMediaCount);
Assert.Equal(1, inventory.States[UiButtonStateMachine.Highlight].MediaCount);
Assert.Equal(1, inventory.States[UiButtonStateMachine.Highlight].ImageMediaCount);
Assert.DoesNotContain("Normal_pressed", inventory.StateMedia.Keys);
}
[Fact]
public void RetailFixture_panelButtonsExposeExactDatPanelIds()
{
@ -1526,4 +1593,39 @@ public class ToolbarControllerTests
iconIds: (_,_,_,_,_) => 0u, useItem: _ => { });
Assert.Equal(0x060011FAu, slots[Row1[0]].Cell.DragAcceptSprite); // green cross, not the ring F9
}
private static uint DrawnFaceFile(UiButton button)
{
var renderer = new AcDream.App.Rendering.TextRenderer(
new AcDream.App.Tests.Rendering.Gpu.RecordingGpuDevice(),
new NullGpuFrameSource(),
"unused");
renderer.Begin(new System.Numerics.Vector2(200f, 200f));
button.DrawSelfAndChildren(new UiRenderContext(
renderer,
new System.Numerics.Vector2(200f, 200f)));
return Assert.Single(renderer.DebugSpriteSegments).Texture;
}
private sealed class NullGpuFrameSource
: AcDream.App.Rendering.ICurrentGpuFrameSource
{
public AcDream.App.Rendering.Gpu.IGpuFrame? CurrentFrame => null;
}
private static ElementInfo FindInfo(ElementInfo root, uint id)
=> TryFindInfo(root, id)
?? throw new InvalidOperationException($"Element 0x{id:X8} was not found.");
private static ElementInfo? TryFindInfo(ElementInfo root, uint id)
{
if (root.Id == id)
return root;
foreach (ElementInfo child in root.Children)
{
if (TryFindInfo(child, id) is { } found)
return found;
}
return null;
}
}

View file

@ -1163,6 +1163,7 @@
"File": 100693175,
"DrawMode": 3
},
"MediaCount": 1,
"Cursor": null,
"Properties": {
"Values": {}
@ -1177,6 +1178,7 @@
"File": 100693176,
"DrawMode": 3
},
"MediaCount": 1,
"Cursor": null,
"Properties": {
"Values": {}
@ -1269,4 +1271,4 @@
"LedCheckedSprite": 0,
"LedUncheckedSprite": 0,
"ScrollbarElementId": 0
}
}

View file

@ -6569,6 +6569,8 @@
"Name": "Normal_pressed",
"PassToChildren": false,
"IncorporationFlags": 0,
"MediaCount": 1,
"ImageMediaCount": 0,
"Image": null,
"Cursor": null,
"Properties": {
@ -6580,6 +6582,8 @@
"Name": "Normal",
"PassToChildren": false,
"IncorporationFlags": 0,
"MediaCount": 1,
"ImageMediaCount": 1,
"Image": {
"File": 100682999,
"DrawMode": 3
@ -6594,6 +6598,8 @@
"Name": "Highlight",
"PassToChildren": false,
"IncorporationFlags": 0,
"MediaCount": 1,
"ImageMediaCount": 1,
"Image": {
"File": 100683000,
"DrawMode": 3
@ -12574,4 +12580,4 @@
"LedCheckedSprite": 0,
"LedUncheckedSprite": 0,
"ScrollbarElementId": 0
}
}

View file

@ -334,7 +334,7 @@ public sealed class RetailWindowLayoutPersistenceTests : IDisposable
}
[Fact]
public void RestoreAll_WithoutSaveBack_WritesNothingToTheStore()
public void RestoreAfterDisplayChange_WritesNothingToTheStore()
{
// The live display-change reload (#390) must not persist — retail
// saves layouts only via @saveui/@saveautoui, and a mid-drag reload
@ -346,19 +346,70 @@ public sealed class RetailWindowLayoutPersistenceTests : IDisposable
using var persistence = new RetailWindowLayoutPersistence(
root.WindowManager, store, () => "Alice", () => (800, 600));
persistence.RestoreAll(saveBack: false);
persistence.RestoreAfterDisplayChange();
Assert.Null(store.LoadWindowLayout(
"Alice", "800x600", WindowNames.Examination, default));
}
[Fact]
public void RestoreAfterDisplayChange_RestoresGeometryButPreservesLiveVisibility()
{
// Retail's global-message-0xE auto-layout file contains only X/Y/W/H.
// Our richer ambient settings schema also remembers visibility and
// collapsed/maximized state, but replaying visibility during a
// resolution change used to hide an open Options panel. Config's OnHidden reset
// then restored the previous resolution, creating the user-observed
// resize-out/resize-back blip.
var store = new SettingsStore(PathName);
store.SaveWindowLayout(
"Alice",
"1280x720",
WindowNames.Options,
new UiWindowLayout(
X: 700f,
Y: 200f,
Width: 310f,
Height: 400f,
Visible: false,
Collapsed: true,
Maximized: true));
var root = new UiRoot { Width = 1280, Height = 720 };
var state = new FakeWindowState();
var lifecycle = new FakePanelController();
RetailWindowHandle handle = Mount(
root,
WindowNames.Options,
state,
controller: lifecycle,
width: 300f,
height: 300f);
using var persistence = new RetailWindowLayoutPersistence(
root.WindowManager,
store,
() => "Alice",
() => (1280, 720));
persistence.RestoreAfterDisplayChange();
Assert.Equal((700f, 200f, 310f, 400f),
(handle.Left, handle.Top, handle.Width, handle.Height));
Assert.True(handle.IsVisible);
Assert.Equal(0, lifecycle.HiddenCount);
Assert.Equal(1, state.RestoreCount);
Assert.True(state.Restored.Collapsed);
Assert.True(state.Restored.Maximized);
}
private static RetailWindowHandle Mount(
UiRoot root,
string name,
IRetainedWindowStateController? state = null,
int authoredGeometryRevision = 0,
float width = 200f,
float height = 100f)
float height = 100f,
IRetainedPanelController? controller = null)
{
var frame = new UiPanel
{
@ -377,6 +428,7 @@ public sealed class RetailWindowLayoutPersistenceTests : IDisposable
return root.RegisterWindow(
name,
frame,
controller: controller,
stateController: state,
authoredGeometryRevision: authoredGeometryRevision);
}
@ -389,7 +441,23 @@ public sealed class RetailWindowLayoutPersistenceTests : IDisposable
private sealed class FakeWindowState : IRetainedWindowStateController
{
public RetainedWindowState Restored { get; private set; }
public int RestoreCount { get; private set; }
public RetainedWindowState CaptureWindowState() => Restored;
public void RestoreWindowState(RetainedWindowState state) => Restored = state;
public void RestoreWindowState(RetainedWindowState state)
{
Restored = state;
RestoreCount++;
}
}
private sealed class FakePanelController : IRetainedPanelController
{
public int HiddenCount { get; private set; }
public void OnShown() { }
public void OnHidden() => HiddenCount++;
public void Dispose() { }
}
}

View file

@ -0,0 +1,362 @@
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.Core.Chat;
using AcDream.UI.Abstractions.Panels.Chat;
using System.Numerics;
namespace AcDream.App.Tests.UI.Layout;
public sealed class RetailWindowLockPresentationControllerTests
{
private static readonly uint[] LockedChatChromeIds =
[
0x10000693u, 0x10000694u, 0x10000695u, 0x10000696u,
0x10000697u, 0x10000698u, 0x10000699u, 0x1000069Au,
];
private static readonly uint[] LiveChatChromeIds =
[
0x1000069Bu, 0x1000069Cu, 0x1000069Du, 0x1000069Eu,
0x1000069Fu, 0x100006A0u, 0x100006A1u, 0x100006A2u,
];
[Fact]
public void ExistingAndFutureNineSliceWindows_TrackCurrentLockPresentationExactly()
{
var root = new UiRoot { Width = 800, Height = 600 };
var existing = NewNineSlice();
root.AddChild(existing);
root.RegisterWindow("existing", existing);
using var presentation = new RetailWindowLockPresentationController(root.WindowManager);
Assert.True(existing.DrawResizeAffordances);
Assert.True(existing.DrawCenterFill);
root.UiLocked = true;
Assert.False(existing.DrawResizeAffordances);
Assert.True(existing.DrawCenterFill);
var registeredWhileLocked = NewNineSlice();
registeredWhileLocked.DrawCenterFill = false;
root.AddChild(registeredWhileLocked);
root.RegisterWindow("future", registeredWhileLocked);
Assert.False(registeredWhileLocked.DrawResizeAffordances);
Assert.False(registeredWhileLocked.DrawCenterFill);
root.UiLocked = true;
Assert.False(existing.DrawResizeAffordances);
Assert.False(registeredWhileLocked.DrawResizeAffordances);
root.UiLocked = false;
Assert.True(existing.DrawResizeAffordances);
Assert.True(registeredWhileLocked.DrawResizeAffordances);
Assert.True(existing.DrawCenterFill);
Assert.False(registeredWhileLocked.DrawCenterFill);
}
[Fact]
public void LockedNineSlice_DrawsCenterAndEightBevelPieces_ButNoEightGripOverlays()
{
var root = new UiRoot { Width = 800, Height = 600 };
var frame = NewNineSlice();
root.AddChild(frame);
root.RegisterWindow("frame", frame);
using var presentation = new RetailWindowLockPresentationController(root.WindowManager);
TextRenderer unlocked = Draw(frame);
Assert.Equal(17, SpriteCallCount(unlocked));
Assert.Equal(8, GripCallCount(unlocked));
root.UiLocked = true;
TextRenderer locked = Draw(frame);
Assert.Equal(9, SpriteCallCount(locked));
Assert.Equal(0, GripCallCount(locked));
Assert.Equal(1, CallsFor(locked, RetailChromeSprites.CenterFill));
Assert.Equal(1, CallsFor(locked, RetailChromeSprites.TopEdge));
Assert.Equal(1, CallsFor(locked, RetailChromeSprites.BottomEdge));
Assert.Equal(1, CallsFor(locked, RetailChromeSprites.LeftEdge));
Assert.Equal(1, CallsFor(locked, RetailChromeSprites.RightEdge));
Assert.Equal(1, CallsFor(locked, RetailChromeSprites.CornerTL));
Assert.Equal(1, CallsFor(locked, RetailChromeSprites.CornerTR));
Assert.Equal(1, CallsFor(locked, RetailChromeSprites.CornerBL));
Assert.Equal(1, CallsFor(locked, RetailChromeSprites.CornerBR));
}
[Fact]
public void AuthoredChatChrome_SwapsLiveAndLockedSets_AndUnlockRestoresExactly()
{
var infos = FixtureLoader.LoadChatInfos();
var layout = LayoutImporter.Build(infos, NoTex, null);
_ = ChatWindowController.Bind(
infos,
layout,
new ChatVM(new ChatLog()),
() => NullCommandBus.Instance,
new ChatWindowState(),
null,
null,
NoTex);
var root = new UiRoot { Width = 800, Height = 600 };
root.AddChild(layout.Root);
root.RegisterWindow("chat", layout.Root, layout.Root);
using var presentation = new RetailWindowLockPresentationController(root.WindowManager);
AssertChrome(layout, liveVisible: true);
root.UiLocked = true;
AssertChrome(layout, liveVisible: false);
root.UiLocked = true;
AssertChrome(layout, liveVisible: false);
root.UiLocked = false;
AssertChrome(layout, liveVisible: true);
root.UiLocked = false;
AssertChrome(layout, liveVisible: true);
}
[Fact]
public void AuthoredWindowRegisteredWhileLocked_StartsWithLockedChrome()
{
var root = new UiRoot { Width = 800, Height = 600, UiLocked = true };
using var presentation = new RetailWindowLockPresentationController(root.WindowManager);
var infos = FixtureLoader.LoadChatInfos();
var layout = LayoutImporter.Build(infos, NoTex, null);
_ = ChatWindowController.Bind(
infos,
layout,
new ChatVM(new ChatLog()),
() => NullCommandBus.Instance,
new ChatWindowState(),
null,
null,
NoTex);
root.AddChild(layout.Root);
root.RegisterWindow("late-chat", layout.Root, layout.Root);
AssertChrome(layout, liveVisible: false);
}
[Fact]
public void FloatingChat_LockHidesAllResizeGrips_ButLeavesTitleDragElementVisible()
{
var layout = FixtureLoader.LoadFloatyChat();
var root = new UiRoot { Width = 800, Height = 600 };
root.AddChild(layout.Root);
root.RegisterWindow("floaty", layout.Root, layout.Root);
using var presentation = new RetailWindowLockPresentationController(root.WindowManager);
UiElement? titleDrag = layout.FindElement(0x10000529u);
Assert.NotNull(titleDrag);
UiResizeGrip[] grips = DescendantsAndSelf(layout.Root).OfType<UiResizeGrip>().ToArray();
Assert.Equal(8, grips.Length);
Assert.All(grips, grip => Assert.True(grip.Visible));
Assert.True(titleDrag.Visible);
root.UiLocked = true;
Assert.All(grips, grip => Assert.False(grip.Visible));
Assert.True(titleDrag.Visible);
root.UiLocked = false;
Assert.All(grips, grip => Assert.True(grip.Visible));
Assert.True(titleDrag.Visible);
}
[Fact]
public void AuthoredChrome_MissingPairMembersAndUnrelatedDecoration_TransitionSafely()
{
var root = new UiRoot { Width = 800, Height = 600 };
var frame = new UiPanel { Width = 200, Height = 100 };
var lockedOnly = new UiPanel { DatElementId = 0x10000633u, Visible = false };
var liveOnly = new UiPanel { DatElementId = 0x1000063Cu, Visible = true };
var unrelated = new UiPanel { DatElementId = 0x10000632u, Visible = true };
var initiallyHiddenLive = new UiPanel { DatElementId = 0x1000063Du, Visible = false };
frame.AddChild(lockedOnly);
frame.AddChild(liveOnly);
frame.AddChild(unrelated);
frame.AddChild(initiallyHiddenLive);
root.AddChild(frame);
root.RegisterWindow("partial", frame);
using var presentation = new RetailWindowLockPresentationController(root.WindowManager);
root.UiLocked = true;
Assert.True(lockedOnly.Visible);
Assert.False(liveOnly.Visible);
Assert.True(unrelated.Visible);
Assert.False(initiallyHiddenLive.Visible);
root.UiLocked = false;
Assert.False(lockedOnly.Visible);
Assert.True(liveOnly.Visible);
Assert.True(unrelated.Visible);
Assert.False(initiallyHiddenLive.Visible);
}
[Theory]
[InlineData(0x10000633u, 0x1000063Bu)]
[InlineData(0x10000643u, 0x1000064Bu)]
[InlineData(0x10000653u, 0x1000065Bu)]
[InlineData(0x10000663u, 0x1000066Bu)]
[InlineData(0x10000673u, 0x1000067Bu)]
[InlineData(0x10000683u, 0x1000068Bu)]
[InlineData(0x10000693u, 0x1000069Bu)]
[InlineData(0x100006A5u, 0x100006ADu)]
public void EveryRetailAuthoredChromeBlock_SwapsAllEightMembers(
uint lockedStart,
uint liveStart)
{
var root = new UiRoot { Width = 800, Height = 600 };
var frame = new UiPanel { Width = 200, Height = 100 };
UiElement[] lockedChrome = Enumerable.Range(0, 8)
.Select(i => new UiPanel { DatElementId = lockedStart + (uint)i })
.Cast<UiElement>()
.ToArray();
UiElement[] liveChrome = Enumerable.Range(0, 8)
.Select(i => new UiPanel { DatElementId = liveStart + (uint)i })
.Cast<UiElement>()
.ToArray();
foreach (UiElement element in lockedChrome.Concat(liveChrome))
frame.AddChild(element);
root.AddChild(frame);
root.RegisterWindow("authored", frame);
using var presentation = new RetailWindowLockPresentationController(root.WindowManager);
Assert.All(lockedChrome, element => Assert.False(element.Visible));
Assert.All(liveChrome, element => Assert.True(element.Visible));
root.UiLocked = true;
Assert.All(lockedChrome, element => Assert.True(element.Visible));
Assert.All(liveChrome, element => Assert.False(element.Visible));
root.UiLocked = false;
Assert.All(lockedChrome, element => Assert.False(element.Visible));
Assert.All(liveChrome, element => Assert.True(element.Visible));
}
[Fact]
public void SmartBoxLiveOnlyChrome_HidesAndRestores_WithoutTouchingOtherType2OrType3Decoration()
{
var root = new UiRoot { Width = 800, Height = 600 };
var frame = new UiPanel { Width = 200, Height = 100 };
UiElement[] smartBoxChrome = Enumerable.Range(0, 8)
.Select(i => new UiPanel { DatElementId = 0x100006CAu + (uint)i })
.Cast<UiElement>()
.ToArray();
var unrelatedType2 = new UiPanel { DatElementId = 0x10000529u };
var unrelatedType3 = new UiPanel { DatElementId = 0x100006D2u };
foreach (UiElement chrome in smartBoxChrome)
frame.AddChild(chrome);
frame.AddChild(unrelatedType2);
frame.AddChild(unrelatedType3);
root.AddChild(frame);
root.RegisterWindow("smartbox", frame);
using var presentation = new RetailWindowLockPresentationController(root.WindowManager);
root.UiLocked = true;
Assert.All(smartBoxChrome, chrome => Assert.False(chrome.Visible));
Assert.True(unrelatedType2.Visible);
Assert.True(unrelatedType3.Visible);
root.UiLocked = false;
Assert.All(smartBoxChrome, chrome => Assert.True(chrome.Visible));
Assert.True(unrelatedType2.Visible);
Assert.True(unrelatedType3.Visible);
}
[Fact]
public void LockedLateRegistration_AppliesChromeBeforeControllerOnShown()
{
var root = new UiRoot { Width = 800, Height = 600, UiLocked = true };
using var presentation = new RetailWindowLockPresentationController(root.WindowManager);
var frame = NewNineSlice();
root.AddChild(frame);
var observer = new OnShownObserver(() => !frame.DrawResizeAffordances);
root.RegisterWindow("late", frame, frame, observer);
Assert.True(observer.ObservedLockedChrome);
Assert.Equal(1, observer.ShownCount);
}
private static UiNineSlicePanel NewNineSlice() =>
new(static id => (id, 5, 5)) { Width = 200, Height = 100 };
private static TextRenderer Draw(UiElement element)
{
var renderer = new TextRenderer(new RecordingGpuDevice(), new NullGpuFrameSource(), "unused");
renderer.Begin(new Vector2(800f, 600f));
element.DrawSelfAndChildren(new UiRenderContext(renderer, new Vector2(800f, 600f)));
return renderer;
}
private static int SpriteCallCount(TextRenderer renderer) =>
renderer.DebugSpriteSegments.Sum(segment => segment.VertexCount / 6);
private static int CallsFor(TextRenderer renderer, uint texture) =>
renderer.DebugSpriteSegments
.Where(segment => segment.Texture == texture)
.Sum(segment => segment.VertexCount / 6);
private static int GripCallCount(TextRenderer renderer) =>
CallsFor(renderer, RetailChromeSprites.GripTop)
+ CallsFor(renderer, RetailChromeSprites.GripBottom)
+ CallsFor(renderer, RetailChromeSprites.GripLeft)
+ CallsFor(renderer, RetailChromeSprites.GripRight)
+ CallsFor(renderer, RetailChromeSprites.GripCorner);
private static IEnumerable<UiElement> DescendantsAndSelf(UiElement root)
{
yield return root;
foreach (UiElement child in root.Children)
foreach (UiElement descendant in DescendantsAndSelf(child))
yield return descendant;
}
private static void AssertChrome(ImportedLayout layout, bool liveVisible)
{
foreach (uint id in LiveChatChromeIds)
{
UiElement? element = layout.FindElement(id);
Assert.NotNull(element);
Assert.Equal(liveVisible, element.Visible);
}
foreach (uint id in LockedChatChromeIds)
{
UiElement? element = layout.FindElement(id);
Assert.NotNull(element);
Assert.Equal(!liveVisible, element.Visible);
}
}
private static (uint tex, int width, int height) NoTex(uint _) => (0u, 0, 0);
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
{
public IGpuFrame? CurrentFrame => null;
}
private sealed class OnShownObserver(Func<bool> observe) : IRetainedPanelController
{
public int ShownCount { get; private set; }
public bool ObservedLockedChrome { get; private set; }
public void OnShown()
{
ShownCount++;
ObservedLockedChrome = observe();
}
public void Dispose()
{
}
}
}

View file

@ -1,5 +1,6 @@
using AcDream.Core.Items;
using AcDream.Core.Player;
using AcDream.Core.Properties;
using AcDream.Core.Spells;
namespace AcDream.Core.Tests.Player;
@ -170,6 +171,123 @@ public sealed class LocalPlayerStateTests
Assert.Equal(1f, s.StaminaPercent!.Value);
}
[Fact]
public void GetMaxApprox_PrimaryAttributeModifierWithCollidingKeyDoesNotAffectHealth()
{
// Regression: PropertyAttribute.Strength and
// PropertyAttribute2nd.MaxHealth both use numeric key 1. The HUD's
// GetVitalMod path previously omitted retail's SecondAtt domain
// filter, so a near-identity Strength modifier reproduced the live
// report exactly: 99999 current / 99998 max.
var book = new Spellbook(SpellTable.Create([TestSpell(1u)]));
book.OnEnchantmentAdded(new ActiveEnchantmentRecord(
SpellId: 1u,
LayerId: 1u,
Duration: 60d,
CasterGuid: 0u,
StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.Attribute,
StatModKey: 1u,
StatModValue: 0.99999f,
Bucket: 1u));
var s = new LocalPlayerState(book);
s.OnVitalUpdate(
vitalId: 7u,
ranks: 99_999u,
start: 0u,
xp: 0u,
current: 99_999u);
Assert.Equal(99_999u, s.GetBaseMaxApprox(LocalPlayerState.VitalKind.Health));
Assert.Equal(99_999u, s.GetMaxApprox(LocalPlayerState.VitalKind.Health));
Assert.Equal(1f, s.HealthPercent);
}
[Fact]
public void GetMaxApprox_SecondaryAttributeModifierTruncatesLikeRetail()
{
// CEnchantmentRegistry::EnchantAttribute2nd ends in _ftol2. A
// fractional secondary-attribute result is truncated, not rounded.
var book = new Spellbook(SpellTable.Create([TestSpell(1u)]));
book.OnEnchantmentAdded(new ActiveEnchantmentRecord(
SpellId: 1u,
LayerId: 1u,
Duration: 60d,
CasterGuid: 0u,
StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.SecondAtt,
StatModKey: EnchantmentMath.StatKey.MaxHealth,
StatModValue: 0.75f,
Bucket: 2u));
var s = new LocalPlayerState(book);
s.OnVitalUpdate(
vitalId: 7u,
ranks: 100u,
start: 0u,
xp: 0u,
current: 100u);
Assert.Equal(100u, s.GetMaxApprox(LocalPlayerState.VitalKind.Health));
}
[Fact]
public void GetMaxApprox_PrimaryAttributeBuffsFeedVitalFormula_ExactLiveRegression()
{
// Live screenshot regression: the server-authoritative currents were
// 38/75/25 while the HUD showed the raw-formula maxima 30/60/10.
// Retail InqAttribute2nd evaluates Endurance/Self through enchanted
// InqAttribute before applying the separate secondary-attribute mod.
var book = new Spellbook(SpellTable.Create([TestSpell(1u), TestSpell(2u)]));
book.OnEnchantmentAdded(new ActiveEnchantmentRecord(
SpellId: 1u,
LayerId: 1u,
Duration: 60d,
CasterGuid: 0u,
StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.Attribute,
StatModKey: 2u, // Endurance
StatModValue: 15f,
Bucket: 2u));
book.OnEnchantmentAdded(new ActiveEnchantmentRecord(
SpellId: 2u,
LayerId: 2u,
Duration: 60d,
CasterGuid: 0u,
StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.Attribute,
StatModKey: 6u, // Self
StatModValue: 15f,
Bucket: 2u));
var s = new LocalPlayerState(book);
s.OnAttributeUpdate(atType: 2u, ranks: 0u, start: 30u, xp: 0u);
s.OnAttributeUpdate(atType: 6u, ranks: 0u, start: 10u, xp: 0u);
s.OnVitalUpdate(vitalId: 7u, ranks: 0u, start: 15u, xp: 0u, current: 38u);
s.OnVitalUpdate(vitalId: 8u, ranks: 0u, start: 30u, xp: 0u, current: 75u);
s.OnVitalUpdate(vitalId: 9u, ranks: 0u, start: 0u, xp: 0u, current: 25u);
Assert.Equal(30u, s.GetBaseMaxApprox(LocalPlayerState.VitalKind.Health));
Assert.Equal(60u, s.GetBaseMaxApprox(LocalPlayerState.VitalKind.Stamina));
Assert.Equal(10u, s.GetBaseMaxApprox(LocalPlayerState.VitalKind.Mana));
Assert.Equal(38u, s.GetMaxApprox(LocalPlayerState.VitalKind.Health));
Assert.Equal(75u, s.GetMaxApprox(LocalPlayerState.VitalKind.Stamina));
Assert.Equal(25u, s.GetMaxApprox(LocalPlayerState.VitalKind.Mana));
Assert.Equal(1f, s.HealthPercent);
Assert.Equal(1f, s.StaminaPercent);
Assert.Equal(1f, s.ManaPercent);
}
[Fact]
public void GetMaxApprox_HealthRoundsHalfEnduranceAndIncludesGearMaxHealth()
{
// SkillFormula::Calculate @ 0x00591960 rounds 45/2 to 23, and
// InqAttribute2nd @ 0x00592020 adds property 379 before enchantment.
var s = new LocalPlayerState();
var properties = new PropertyBundle();
properties.Ints[(uint)PropertyInt.GearMaxHealth] = 7;
s.OnProperties(properties);
s.OnAttributeUpdate(atType: 2u, ranks: 0u, start: 45u, xp: 0u);
s.OnVitalUpdate(vitalId: 7u, ranks: 0u, start: 10u, xp: 0u, current: 40u);
Assert.Equal(40u, s.GetBaseMaxApprox(LocalPlayerState.VitalKind.Health));
Assert.Equal(40u, s.GetMaxApprox(LocalPlayerState.VitalKind.Health));
}
[Fact]
public void OnVitalCurrent_UpdatesOnlyCurrent_LeavesRanksStartXpAlone()
{

View file

@ -103,6 +103,7 @@ public sealed class SpellbookTests
LayerId: 7u,
Duration: 300f,
CasterGuid: 0u,
StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.SecondAtt,
StatModKey: EnchantmentMath.StatKey.MaxHealth,
StatModValue: 1.5f,
Bucket: 1u));
@ -134,6 +135,7 @@ public sealed class SpellbookTests
LayerId: 7u,
Duration: 300f,
CasterGuid: 0u,
StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.SecondAtt,
StatModKey: EnchantmentMath.StatKey.MaxHealth,
StatModValue: 1.25f,
Bucket: 1u));

View file

@ -94,7 +94,7 @@ public sealed class RuntimeCharacterStateTests
Duration: 60f,
CasterGuid: 2u,
Bucket: 2u,
StatModType: 0u,
StatModType: (uint)EnchantmentMath.EnchantmentTypeFlag.SecondAtt,
StatModKey: EnchantmentMath.StatKey.MaxHealth,
StatModValue: 25f));
state.Spellbook.SetDesiredComponent(0x68000001u, 12u);