fix: complete retail parity stability pass
All checks were successful
CI / linux-portable (push) Successful in 3m41s
CI / windows-gate (push) Successful in 6m49s
CI / release (push) Successful in 3m22s

This commit is contained in:
Erik 2026-08-28 20:01:39 +02:00
parent d3df4cb20a
commit f7aa8e0eb7
131 changed files with 7765 additions and 1190 deletions

View file

@ -1,3 +1,4 @@
using System.Globalization;
using AcDream.App.UI;
using AcDream.Core.Chat;
using AcDream.Core.Physics;
@ -552,11 +553,355 @@ public sealed class ClientCommandControllerTests
Assert.Equal("my chat log.txt", match.Arguments);
}
// ── #361: retail's @day / @render ─────────────────────────────────
[Fact]
public void Day_TogglesThePersistentOptionAndPrintsRetailsExactLines()
{
bool persistentDaylight = false;
var values = new List<bool>();
var messages = new List<string>();
ClientCommandController controller = NewController(
messages: messages,
isPersistentDaylight: () => persistentDaylight,
setPersistentDaylight: value =>
{
persistentDaylight = value;
values.Add(value);
});
controller.Execute(new ExecuteClientCommandCmd(
ClientCommandId.TogglePersistentDaylight,
"ignored exactly like retail"));
controller.Execute(new ExecuteClientCommandCmd(
ClientCommandId.TogglePersistentDaylight,
string.Empty));
Assert.Equal([true, false], values);
Assert.Equal(
["Let there be light!", "Normality has been restored."],
messages);
}
[Theory]
[InlineData("radius 5", 5)]
[InlineData("RADIUS 25 extra ignored", 25)]
[InlineData("radius 12suffix", 12)]
public void RenderRadius_AcceptsRetailRangeAndAtoiPrefix(
string arguments,
int expected)
{
var radii = new List<int>();
var messages = new List<string>();
ClientCommandController controller = NewController(
messages: messages,
setLandscapeRadius: radii.Add);
controller.Execute(new ExecuteClientCommandCmd(
ClientCommandId.RenderOption,
arguments));
Assert.Equal([expected], radii);
Assert.Equal(["Landscape radius set"], messages);
}
[Theory]
[InlineData("fov 10", 10f)]
[InlineData("FOV 160 extra", 160f)]
[InlineData("fov 91degrees", 91f)]
public void RenderFov_AcceptsRetailRangeAndAtoiPrefix(
string arguments,
float expected)
{
var values = new List<float>();
var messages = new List<string>();
ClientCommandController controller = NewController(
messages: messages,
setFieldOfView: values.Add);
controller.Execute(new ExecuteClientCommandCmd(
ClientCommandId.RenderOption,
arguments));
Assert.Equal([expected], values);
Assert.Equal(["Field of view set"], messages);
}
[Theory]
[InlineData("radius", "Must specify a radius")]
[InlineData("radius 4", "Radius must be between 5 and 25")]
[InlineData("radius nope", "Radius must be between 5 and 25")]
[InlineData("fov", "Must specify a field of view")]
[InlineData("fov 161", "Field of view must be between 10 and 160")]
public void Render_InvalidValuesPrintRetailsExactReply(
string arguments,
string expected)
{
var calls = new List<string>();
var messages = new List<string>();
ClientCommandController controller = NewController(
calls,
messages: messages);
controller.Execute(new ExecuteClientCommandCmd(
ClientCommandId.RenderOption,
arguments));
Assert.DoesNotContain(calls, call =>
call.StartsWith("radius:", StringComparison.Ordinal)
|| call.StartsWith("fov:", StringComparison.Ordinal));
Assert.Equal([expected], messages);
}
[Fact]
public void Render_UsageAndUnknownOptionMatchRetail()
{
var messages = new List<string>();
ClientCommandController controller = NewController(messages: messages);
controller.Execute(new ExecuteClientCommandCmd(
ClientCommandId.RenderOption,
string.Empty));
controller.Execute(new ExecuteClientCommandCmd(
ClientCommandId.RenderOption,
"usage"));
controller.Execute(new ExecuteClientCommandCmd(
ClientCommandId.RenderOption,
"unknown 1"));
string usage = RetailCommandHelpTable.Render.TrimEnd('\n');
Assert.Equal([usage, usage], messages);
}
[Fact]
public void AllegianceAdministration_ExecutesEveryRetailDispatcherBranch()
{
var calls = new List<string>();
var system = new List<string>();
var clientLocal = new List<string>();
ClientCommandController controller = NewController(
calls: calls,
messages: system,
clientLocalMessages: clientLocal);
Execute(ClientCommandId.AllegianceInfo, "Lord Bob");
Execute(ClientCommandId.AllegianceBoot, "Lord Bob");
Execute(ClientCommandId.AllegianceBoot, "-account Account Bob");
// Retail validates non-empty BEFORE removing -account, so this odd
// form deliberately sends an empty name with accountBoot=true.
Execute(ClientCommandId.AllegianceBoot, "-account");
Execute(ClientCommandId.AllegianceBan, "list ignored");
Execute(ClientCommandId.AllegianceBan, "add Lord Bob");
Execute(ClientCommandId.AllegianceBan, "remove Lord Bob");
Execute(ClientCommandId.AllegianceChat, "on");
Execute(ClientCommandId.AllegianceChat, "off");
Execute(ClientCommandId.AllegianceChat, "kick Bob");
Execute(ClientCommandId.AllegianceChat, "kick Bob, Bad manners");
Execute(ClientCommandId.AllegianceChat, "kick ");
Execute(ClientCommandId.AllegianceChat, "gag Lord Bob");
Execute(ClientCommandId.AllegianceChat, "ungag Lord Bob");
Execute(ClientCommandId.AllegianceBroadcast, "Hear ye");
Execute(ClientCommandId.AllegianceOfficer, "");
Execute(ClientCommandId.AllegianceOfficer, "clear ignored");
Execute(ClientCommandId.AllegianceOfficer, "remove Lord Bob");
Execute(ClientCommandId.AllegianceOfficer, "add 0x2 Lord Bob");
Execute(ClientCommandId.AllegianceOfficer, "set 03 Aunt Alice");
Execute(ClientCommandId.AllegianceOfficerTitle, "");
Execute(ClientCommandId.AllegianceOfficerTitle, "clear ignored");
Execute(ClientCommandId.AllegianceOfficerTitle, "set 0x2 High Regent");
Execute(ClientCommandId.AllegianceOfficerTitle, "set 1");
Execute(ClientCommandId.AllegianceName, "");
Execute(ClientCommandId.AllegianceName, "set The Best Allegiance");
Execute(ClientCommandId.AllegianceName, "set");
Execute(ClientCommandId.AllegianceName, "clear ignored");
Execute(ClientCommandId.AllegianceLock, "");
Execute(ClientCommandId.AllegianceLock, "off");
Execute(ClientCommandId.AllegianceLock, "on");
Execute(ClientCommandId.AllegianceLock, "toggle");
Execute(ClientCommandId.AllegianceLock, "check");
Execute(ClientCommandId.AllegianceLock, "bypass");
Execute(ClientCommandId.AllegianceLock, "bypass clear");
Execute(ClientCommandId.AllegianceLock, "bypass Lord Bob");
Execute(ClientCommandId.AllegianceHouse, "");
Execute(ClientCommandId.AllegianceHouse, "guest open");
Execute(ClientCommandId.AllegianceHouse, "guest close");
Execute(ClientCommandId.AllegianceHouse, "storage open");
Execute(ClientCommandId.AllegianceHouse, "storage close");
Execute(ClientCommandId.AllegianceMotd, "");
Execute(ClientCommandId.AllegianceMotd, "set Welcome everyone");
Execute(ClientCommandId.AllegianceMotd, "set");
Execute(ClientCommandId.AllegianceMotd, "clear ignored");
Assert.Equal(
[
"alleginfo:Lord Bob",
"allegboot:Lord Bob:False",
"allegboot:Account Bob:True",
"allegboot::True",
"allegban:list",
"allegban:add:Lord Bob",
"allegban:remove:Lord Bob",
"charoption:27:True",
"charoption:27:False",
"allegchatboot:Bob:No reason given.",
"allegchatboot:Bob:Bad manners",
"allegchatboot::No reason given.",
"allegchatgag:Lord Bob:True",
"allegchatgag:Lord Bob:False",
"allegbroadcast:Hear ye",
"allegofficer:list",
"allegofficer:clear",
"allegofficer:remove:Lord Bob",
"allegofficer:set:2:Lord Bob",
"allegofficer:set:3:Aunt Alice",
"allegtitle:list",
"allegtitle:clear",
"allegtitle:set:2:High Regent",
"allegtitle:set:1:",
"allegname:query",
"allegname:set:The Best Allegiance",
"allegname:set:",
"allegname:clear",
"alleglock:4",
"alleglock:1",
"alleglock:2",
"alleglock:3",
"alleglock:4",
"alleglock:5",
"alleglock:6",
"alleglock:bypass:Lord Bob",
"alleghouse:1",
"alleghouse:2",
"alleghouse:3",
"alleghouse:4",
"alleghouse:5",
"motd:query",
"motd:set:Welcome everyone",
"motd:set:",
"motd:clear",
],
calls);
Assert.Equal(
[
"Attempting to boot Lord Bob...",
"Attempting to boot Account Bob (Account)...",
"Attempting to boot (Account)...",
],
system);
Assert.Empty(clientLocal);
void Execute(ClientCommandId command, string arguments) =>
controller.Execute(new ExecuteClientCommandCmd(command, arguments));
}
[Fact]
public void HouseAdministration_ExecutesEveryRetailDispatcherBranch()
{
var calls = new List<string>();
ClientCommandController controller = NewController(calls: calls);
Execute(ClientCommandId.HouseOpenStatus, "open");
Execute(ClientCommandId.HouseOpenStatus, "close");
Execute(ClientCommandId.HouseGuests, "add Lord Bob");
Execute(ClientCommandId.HouseGuests, "remove Lord Bob");
Execute(ClientCommandId.HouseGuests, "remove_all ignored");
Execute(ClientCommandId.HouseGuests, "list ignored");
Execute(ClientCommandId.HouseGuests, "show ignored");
Execute(ClientCommandId.HouseGuests, "add_allegiance ignored");
Execute(ClientCommandId.HouseGuests, "remove_allegiance ignored");
Execute(ClientCommandId.HouseStorage, "add Lord Bob");
Execute(ClientCommandId.HouseStorage, "remove Lord Bob");
Execute(ClientCommandId.HouseStorage, "add -all");
Execute(ClientCommandId.HouseStorage, "remove -all");
Execute(ClientCommandId.HouseStorage, "remove_all ignored");
Execute(ClientCommandId.HouseStorage, "list ignored");
Execute(ClientCommandId.HouseStorage, "show ignored");
Execute(ClientCommandId.HouseStorage, "add_allegiance ignored");
Execute(ClientCommandId.HouseStorage, "remove_allegiance ignored");
Execute(ClientCommandId.HouseBoot, "Lord Bob");
Execute(ClientCommandId.HouseBoot, "-all");
Execute(ClientCommandId.HouseBootAll, "ignored");
Execute(ClientCommandId.HouseHooks, "on ignored");
Execute(ClientCommandId.HouseHooks, "off ignored");
Assert.Equal(
[
"houseopen:True",
"houseopen:False",
"houseguest:add:Lord Bob",
"houseguest:remove:Lord Bob",
"houseguest:remove_all",
"houseguest:list",
"houseguest:list",
"houseguest:allegiance:True",
"houseguest:allegiance:False",
"housestorage:True:Lord Bob",
"housestorage:False:Lord Bob",
"housestorage:add_all",
"housestorage:remove_all",
"housestorage:remove_all",
"houseguest:list",
"houseguest:list",
"housestorage:allegiance:True",
"housestorage:allegiance:False",
"houseboot:Lord Bob",
"houseboot:all",
"houseboot:all",
"househooks:True",
"househooks:False",
],
calls);
void Execute(ClientCommandId command, string arguments) =>
controller.Execute(new ExecuteClientCommandCmd(command, arguments));
}
[Theory]
[InlineData(ClientCommandId.AllegianceInfo, "", "Please specify an actual name.")]
[InlineData(ClientCommandId.AllegianceBoot, "", "Please specify an actual name.")]
[InlineData(ClientCommandId.AllegianceBan, "add", "Please specify an actual name.")]
[InlineData(ClientCommandId.AllegianceChat, "gag", "Please specify an actual name.")]
[InlineData(ClientCommandId.AllegianceBroadcast, "", "Please see @help Allegiance for more information on how to use this command.")]
[InlineData(ClientCommandId.AllegianceOfficer, "remove", "Please specify the name of an allegiance member.")]
[InlineData(ClientCommandId.AllegianceOfficer, "add nope Bob", "Please specify a valid officer level as a number between 1 and 3. Check the game help files for more information on officer levels.")]
[InlineData(ClientCommandId.AllegianceOfficer, "add 2", "Please specify the name of an allegiance member.")]
[InlineData(ClientCommandId.AllegianceOfficerTitle, "set 4 Regent", "Please specify a valid officer level as a number between 1 and 3.")]
[InlineData(ClientCommandId.AllegianceName, "nope", "Please see @help Allegiance for more information on how to use this command.")]
[InlineData(ClientCommandId.AllegianceLock, "nope", "Please see @help Allegiance for more information on how to use this command.")]
[InlineData(ClientCommandId.AllegianceHouse, "guest nope", "Please see @help Allegiance for more information on how to use this command.")]
[InlineData(ClientCommandId.AllegianceMotd, "nope", "Please see @help Allegiance for more information on how to use this command.")]
[InlineData(ClientCommandId.AllegianceUnrecognizedSubcommand, "nope", "Please see @help Allegiance for more information on how to use this command.")]
[InlineData(ClientCommandId.HouseGuests, "add", "Please specify the guest's name.")]
[InlineData(ClientCommandId.HouseStorage, "add", "Please specify an actual name.")]
[InlineData(ClientCommandId.HouseBoot, "", "Please see @help House for more information on how to use this command.")]
[InlineData(ClientCommandId.HouseHooks, "maybe", "Please see @help House for more information on how to use this command.")]
[InlineData(ClientCommandId.HouseUnrecognizedSubcommand, "nope", "Please see @help House for more information on how to use this command.")]
public void AdministrationInvalidForms_EmitExactRetailClientLocalText(
ClientCommandId command,
string arguments,
string expected)
{
var calls = new List<string>();
var system = new List<string>();
var clientLocal = new List<string>();
ClientCommandController controller = NewController(
calls: calls,
messages: system,
clientLocalMessages: clientLocal);
controller.Execute(new ExecuteClientCommandCmd(command, arguments));
Assert.Empty(calls);
Assert.Empty(system);
Assert.Equal([expected], clientLocal);
}
private static ClientCommandController NewController(
List<string>? calls = null,
List<uint>? errors = null,
uint? playerBitfield = 0x02000028u,
List<string>? messages = null,
List<string>? clientLocalMessages = null,
bool isAway = false,
bool acceptsLootPermits = true,
FriendsState? friends = null,
@ -569,11 +914,16 @@ public sealed class ClientCommandControllerTests
// Defaults to "always accept" so every pre-existing single-stage
// test (Die, etc.) keeps its original behavior unchanged.
Queue<bool>? confirmationResponses = null,
Func<string, AcDream.Core.Chat.ChatLogResult>? chatLog = null)
Func<string, AcDream.Core.Chat.ChatLogResult>? chatLog = null,
Func<bool>? isPersistentDaylight = null,
Action<bool>? setPersistentDaylight = null,
Action<int>? setLandscapeRadius = null,
Action<float>? setFieldOfView = null)
{
calls ??= [];
errors ??= [];
messages ??= [];
clientLocalMessages ??= messages;
return new ClientCommandController(new ClientCommandController.Bindings(
() => calls.Add("ls"),
() => calls.Add("mp"),
@ -586,6 +936,7 @@ public sealed class ClientCommandControllerTests
() => calls.Add("fps"),
() => calls.Add("lock"),
messages.Add,
clientLocalMessages.Add,
errors.Add,
() => playerBitfield,
() => "1.2.3",
@ -653,6 +1004,61 @@ public sealed class ClientCommandControllerTests
channelId => calls.Add("off:" + channelId),
() => calls.Add("alh"),
name => calls.Add("alleginfo:" + name),
() => calls.Add("houseabandon")));
() => calls.Add("houseabandon"),
NewAdministrationBindings(calls),
isPersistentDaylight ?? (() => false),
setPersistentDaylight ?? (value => calls.Add("day:" + value)),
setLandscapeRadius ?? (value => calls.Add("radius:" + value)),
setFieldOfView ?? (value => calls.Add(
"fov:" + value.ToString(CultureInfo.InvariantCulture)))));
}
private static ClientCommandController.AdministrationBindings
NewAdministrationBindings(List<string> calls) => new(
BreakAllegianceBoot: (name, account) =>
calls.Add($"allegboot:{name}:{account}"),
AllegianceChatBoot: (name, reason) =>
calls.Add($"allegchatboot:{name}:{reason}"),
AllegianceChatGag: (name, enabled) =>
calls.Add($"allegchatgag:{name}:{enabled}"),
AllegianceBroadcast: text => calls.Add("allegbroadcast:" + text),
ListAllegianceBans: () => calls.Add("allegban:list"),
AddAllegianceBan: name => calls.Add("allegban:add:" + name),
RemoveAllegianceBan: name => calls.Add("allegban:remove:" + name),
ListAllegianceOfficers: () => calls.Add("allegofficer:list"),
ClearAllegianceOfficers: () => calls.Add("allegofficer:clear"),
SetAllegianceOfficer: (name, level) =>
calls.Add($"allegofficer:set:{level}:{name}"),
RemoveAllegianceOfficer: name =>
calls.Add("allegofficer:remove:" + name),
ListAllegianceOfficerTitles: () => calls.Add("allegtitle:list"),
ClearAllegianceOfficerTitles: () => calls.Add("allegtitle:clear"),
SetAllegianceOfficerTitle: (level, title) =>
calls.Add($"allegtitle:set:{level}:{title}"),
QueryAllegianceName: () => calls.Add("allegname:query"),
SetAllegianceName: name => calls.Add("allegname:set:" + name),
ClearAllegianceName: () => calls.Add("allegname:clear"),
AllegianceLockAction: action => calls.Add("alleglock:" + action),
SetAllegianceApprovedVassal: name =>
calls.Add("alleglock:bypass:" + name),
AllegianceHouseAction: action => calls.Add("alleghouse:" + action),
QueryMotd: () => calls.Add("motd:query"),
SetMotd: text => calls.Add("motd:set:" + text),
ClearMotd: () => calls.Add("motd:clear"),
SetOpenHouseStatus: open => calls.Add("houseopen:" + open),
AddPermanentGuest: name => calls.Add("houseguest:add:" + name),
RemovePermanentGuest: name => calls.Add("houseguest:remove:" + name),
RemoveAllPermanentGuests: () => calls.Add("houseguest:remove_all"),
ChangeStoragePermission: (name, enabled) =>
calls.Add($"housestorage:{enabled}:{name}"),
AddAllStoragePermission: () => calls.Add("housestorage:add_all"),
RemoveAllStoragePermission: () => calls.Add("housestorage:remove_all"),
RequestFullGuestList: () => calls.Add("houseguest:list"),
BootSpecificHouseGuest: name => calls.Add("houseboot:" + name),
BootEveryone: () => calls.Add("houseboot:all"),
SetHooksVisibility: visible => calls.Add("househooks:" + visible),
ModifyAllegianceGuestPermission: enabled =>
calls.Add("houseguest:allegiance:" + enabled),
ModifyAllegianceStoragePermission: enabled =>
calls.Add("housestorage:allegiance:" + enabled));
}

View file

@ -1059,8 +1059,8 @@ public sealed class CharacterCreationLiveDatTests
/// GF-13: the BEHAVIOR half — after the real controller mounts through
/// <see cref="CharacterCreationUiController.CreateDetached"/> (not a raw
/// <see cref="LayoutImporter.Build"/> call), the two authored-invisible
/// elements are not <see cref="UiElement.Visible"/>. Exercises
/// <c>HideAuthoredInvisibleElements</c>'s real chargen-scoped honor path,
/// elements are not <see cref="UiElement.Visible"/>. Exercises the shared
/// importer-wide #408 behavior through a real chargen controller mount,
/// not just the data plumbing the sibling test above pins.
/// </summary>
[InstalledDatFact]

View file

@ -446,7 +446,7 @@ public sealed class CharacterCreationUiControllerTests
/// Center default.
/// </summary>
[Fact]
public void SkillsPage_InfoBoxPanes_ForceTopVerticalJustify_ToAvoidTitleDescriptionOverlap()
public void SkillsPage_InfoBoxPanes_InheritRetailTopDefault_ToAvoidTitleDescriptionOverlap()
{
using var environment = new EnvironmentHarness();
environment.Controller.Open();

View file

@ -168,6 +168,31 @@ public sealed class CharacterManagementUiControllerTests
string.Join(" ", worldText.LinesProvider().Select(static line => line.Text)));
}
[Fact]
public void CreditsButton_QueuesTheCreditsMode_AndPresentationCanBeSuppressedForIt()
{
int openCalls = 0;
using var environment = new EnvironmentHarness(() => openCalls++);
CharacterManagementUiController controller = environment.Controller;
UiButton credits = environment.Button(
CharacterManagementUiController.CreditsElementId);
Assert.True(credits.Visible);
Assert.True(credits.Enabled);
Assert.NotNull(credits.OnClick);
credits.OnClick!();
Assert.Equal(1, openCalls);
controller.SetPresentationSuppressed(true);
Assert.False(controller.Root.Visible);
Assert.Null(environment.Host.FixedCanvasSize);
controller.SetPresentationSuppressed(false);
Assert.True(controller.Root.Visible);
Assert.Equal(new Vector2(800f, 600f), environment.Host.FixedCanvasSize);
Assert.Equal(3, controller.Rows.Count);
}
[Fact]
public void RowHeight_UsesAllowedSlotsAndClampsAtOneTenthForLargeRosters()
{
@ -882,7 +907,7 @@ public sealed class CharacterManagementUiControllerTests
private sealed class EnvironmentHarness : IDisposable
{
public EnvironmentHarness()
public EnvironmentHarness(Action? openCredits = null)
{
Host = new UiRoot { Width = 800f, Height = 600f };
Screen = BuildScreen();
@ -901,7 +926,8 @@ public sealed class CharacterManagementUiControllerTests
static (_, _) => BuildRow(),
Dialogs,
Runtime.Bindings,
TestStrings()));
TestStrings(),
openCredits));
}
public UiRoot Host { get; }

View file

@ -1115,7 +1115,7 @@ public sealed class ConfigOptionsPageControllerTests
2, // 19 Landscape Texture Detail
1, // 20 Environment Texture Detail
1, // 21 Texture Filtering
8, // 22 Landscape Draw Distance (opaque — AP-198 sub-note)
8, // 22 Landscape Draw Distance (retail radius payload)
true, // 23 Building Detail Textures
false, // 24 Multi-Pass Alpha
@ -1418,7 +1418,7 @@ public sealed class ConfigOptionsPageControllerTests
(23, RowKind.Menu, true, "Landscape Texture Detail"), // AP-198
(24, RowKind.Menu, true, "Environment Texture Detail"), // AP-198
(25, RowKind.Menu, true, "Texture Filtering"), // AP-198
(26, RowKind.Menu, true, "Landscape Draw Distance"), // AP-198
(26, RowKind.Menu, false, "Landscape Draw Distance"), // LIVE — #361
(27, RowKind.Toggle, false, "Building Detail Textures"), // LIVE — #226
(28, RowKind.Toggle, true, "Multi-Pass Alpha"), // AP-198
(31, RowKind.Slider, true, "Mouse Look Sensitivity"), // TS-74
@ -1485,6 +1485,27 @@ public sealed class ConfigOptionsPageControllerTests
}
}
[Fact]
public void LandscapeDrawDistance_UsesRetailRadiusPayloads_AndAppliesSelection()
{
(OptionsPanelController controller, FakeBindings bindings, bool bound) = BindReal();
Assert.True(bound);
var configSlot = UiElement.FindDescendant(controller.TabPanel, ConfigPageSlotId)!;
List<UiMenu> menus = CollectMenus(configSlot);
UiMenu drawDistance = menus[5];
Assert.Equal(8, drawDistance.Selected);
Assert.Equal(
[3, 5, 8, 11, 15, 25],
drawDistance.Items.Select(item => Assert.IsType<int>(item.Payload)).ToArray());
drawDistance.OnSelect!(25);
controller.ConfigPage.Apply();
Assert.Equal(25, bindings.Display.LandscapeDrawDistance);
}
// ── #412-class regression: Config tab content escaping the window frame ──
//
// 2026-08-16/17 overnight hover/UI round, Batch A bug 2. The user's

View file

@ -0,0 +1,110 @@
using AcDream.App.UI.Layout;
using AcDream.App.UI;
using AcDream.Content;
using DatReaderWriter;
using DatReaderWriter.Options;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// Installed-retail-DAT oracle for issue #400's <c>gmCreditsUI</c> port.
/// Retail creates two independent enum-table-5 roots: the picture strip
/// and the scrolling text field.
/// </summary>
[Trait("Lane", "InstalledDat")]
public sealed class CreditsLiveDatTests
{
[InstalledDatFact]
public void Category4_ResolvesAuthoredPictureAndTextRoots()
{
string datDirectory = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call");
using var dats = new DatCollection(datDirectory, DatAccessType.Read);
// CreateAndAddRootElement(layoutEnum=0x10000004, rootElementId)
// uses the ordinary table-5 layout map, exactly like character
// management's (0x10000005, 0x1000039A) pair.
uint pictureLayout = RetailDataIdResolver.Resolve(dats, 0x10000004u, 5u);
Assert.Equal(0x21000003u, pictureLayout);
ElementInfo picture = Assert.IsType<ElementInfo>(
LayoutImporter.ImportInfos(dats, pictureLayout, 0x10000413u));
ElementInfo text = Assert.IsType<ElementInfo>(
LayoutImporter.ImportInfos(dats, pictureLayout, 0x10000410u));
Assert.Equal(CreditsUiController.PictureRootElementId, picture.Id);
Assert.Equal(3u, picture.Type);
Assert.Equal((0f, 0f, 400f, 600f),
(picture.X, picture.Y, picture.Width, picture.Height));
Assert.Equal(CreditsUiController.TextRootElementId, text.Id);
Assert.Equal(3u, text.Type);
Assert.Equal((400f, 0f, 400f, 600f),
(text.X, text.Y, text.Width, text.Height));
Assert.True(TryDataId(text, 0x10000002u, out uint textAreaId));
Assert.Equal(CreditsUiController.TextAreaElementId, textAreaId);
Assert.True(TryDataId(text, 0x10000003u, out uint stringTableId));
Assert.Equal(0x23000008u, stringTableId);
Assert.True(text.TryGetEffectiveFloat(0x10000004u, out float seconds));
Assert.Equal(20f, seconds);
ElementInfo textArea = Assert.Single(text.Children);
Assert.Equal(CreditsUiController.TextAreaElementId, textArea.Id);
Assert.Equal(12u, textArea.Type);
Assert.Equal(0x40000000u, textArea.FontDid);
Assert.Equal(HJustify.Center, textArea.HJustify);
Assert.Equal(VJustify.Center, textArea.VJustify);
Assert.True(picture.TryGetEffectiveProperty(
0x10000005u,
out UiPropertyValue pictureArray));
Assert.Equal(UiPropertyKind.Array, pictureArray.Kind);
Assert.Equal(
Enumerable.Range(0, 7).Select(index => 0x06005F14u + (uint)index),
pictureArray.ArrayValue.Select(static value => (uint)value.UnsignedValue));
ImportedLayout builtText = Assert.IsType<ImportedLayout>(
LayoutImporter.Import(
dats,
pictureLayout,
CreditsUiController.TextRootElementId,
static id => (id, 1, 1),
null));
Assert.IsType<UiText>(builtText.FindElement(
CreditsUiController.TextAreaElementId));
var strings = new DatStringResolver(dats);
int creditsCount = 0;
for (int i = 1; i <= 4096; i++)
{
string? value = strings.Resolve(
stringTableId,
DatStringResolver.ComputeHash($"ID_Credits{i}"));
if (value is null)
break;
creditsCount++;
}
Assert.Equal(2345, creditsCount);
Assert.NotNull(strings.Resolve(
0x23000001u,
DatStringResolver.ComputeHash("ID_Wait_PleaseWait")));
}
private static bool TryDataId(
ElementInfo info,
uint propertyId,
out uint value)
{
if (info.TryGetEffectiveProperty(propertyId, out UiPropertyValue property)
&& property.Kind is UiPropertyKind.DataId or UiPropertyKind.Enum)
{
value = checked((uint)property.UnsignedValue);
return true;
}
value = 0u;
return false;
}
}

View file

@ -0,0 +1,166 @@
using System.Numerics;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
namespace AcDream.App.Tests.UI.Layout;
public sealed class CreditsUiControllerTests
{
[Fact]
public void Activate_UsesAuthoredCanvas_TextTiming_AndCyclicPictureStrip()
{
using var environment = new EnvironmentHarness();
CreditsUiController controller = environment.Controller;
controller.Activate();
Assert.True(controller.IsActive);
Assert.Equal(new Vector2(800f, 600f), environment.Host.FixedCanvasSize);
Assert.True(controller.PictureRoot.Visible);
Assert.True(controller.TextRoot.Visible);
Assert.Equal(600f, controller.TextArea.Top);
Assert.Equal(48f, controller.TextArea.Height);
Assert.Single(controller.Pictures);
Assert.Equal(601f, controller.Pictures[0].Top);
Assert.Equal(0x06000001u, controller.Pictures[0].BackgroundSprite);
Assert.Equal(
20d * (600d + 48d) / (600d + 48d / 3d),
controller.DurationSeconds,
precision: 5);
environment.Now += controller.DurationSeconds * 0.5d;
controller.Tick();
Assert.Equal(276f, controller.TextArea.Top);
Assert.True(controller.Pictures[0].Top < 600f);
Assert.Equal(2, controller.Pictures.Count);
Assert.Equal(0x06000002u, controller.Pictures[1].BackgroundSprite);
Assert.Equal(
controller.Pictures[0].Top + controller.Pictures[0].Height + 1f,
controller.Pictures[1].Top);
}
[Fact]
public void AnyKey_ShowsWaitForAFrame_ThenReturnsToCharacterManagement()
{
using var environment = new EnvironmentHarness();
CreditsUiController controller = environment.Controller;
controller.Activate();
environment.Host.OnKeyDown(123);
Assert.Equal(1, environment.Dialogs.ActiveCount);
controller.Tick();
Assert.True(controller.IsActive);
Assert.Equal(0, environment.ReturnCalls);
controller.Tick();
Assert.False(controller.IsActive);
Assert.Equal(1, environment.ReturnCalls);
Assert.Equal(0, environment.Dialogs.ActiveCount);
Assert.Null(environment.Host.FixedCanvasSize);
Assert.False(controller.PictureRoot.Visible);
Assert.False(controller.TextRoot.Visible);
}
[Fact]
public void NaturalCompletion_UsesTheSameWaitAndReturnPath()
{
using var environment = new EnvironmentHarness();
CreditsUiController controller = environment.Controller;
controller.Activate();
environment.Now += controller.DurationSeconds;
controller.Tick();
Assert.Equal(1, environment.Dialogs.ActiveCount);
controller.Tick();
Assert.True(controller.IsActive);
controller.Tick();
Assert.False(controller.IsActive);
Assert.Equal(1, environment.ReturnCalls);
Assert.Equal(0, environment.Dialogs.ActiveCount);
}
private sealed class EnvironmentHarness : IDisposable
{
public EnvironmentHarness()
{
Host = new UiRoot { Width = 1280f, Height = 720f };
Dialogs = new RetailDialogFactory(
Host,
RetailDialogFactoryTests.BuildDialogLayout);
CreditsUiResources resources = BuildResources();
Controller = Assert.IsType<CreditsUiController>(
CreditsUiController.CreateDetached(
Host,
resources,
Dialogs,
() => Now,
ResolveSprite,
() => ReturnCalls++));
}
public UiRoot Host { get; }
public RetailDialogFactory Dialogs { get; }
public CreditsUiController Controller { get; }
public double Now { get; set; } = 100d;
public int ReturnCalls { get; private set; }
public void Dispose()
{
Controller.Dispose();
Dialogs.Dispose();
}
private static (uint tex, int w, int h) ResolveSprite(uint id)
=> (id, 400, 300);
private static CreditsUiResources BuildResources()
{
ImportedLayout picture = LayoutImporter.Build(
new ElementInfo
{
Id = CreditsUiController.PictureRootElementId,
Type = 3u,
X = 0f,
Y = 0f,
Width = 400f,
Height = 600f,
},
ResolveSprite,
null);
var textRoot = new ElementInfo
{
Id = CreditsUiController.TextRootElementId,
Type = 3u,
X = 400f,
Y = 0f,
Width = 400f,
Height = 600f,
};
textRoot.Children.Add(new ElementInfo
{
Id = CreditsUiController.TextAreaElementId,
Type = 12u,
Width = 0f,
Height = 0f,
HJustify = HJustify.Center,
VJustify = VJustify.Center,
});
ImportedLayout text = LayoutImporter.Build(
textRoot,
ResolveSprite,
null);
return new CreditsUiResources(
0x21000003u,
picture,
text,
["One\n", "Two\n"],
[0x06000001u, 0x06000002u],
20f,
"Please Wait");
}
}
}

View file

@ -1111,7 +1111,7 @@ public class DatWidgetFactoryTests
// ── Justification build-time application (new for importer Fix A) ────────
/// <summary>
/// A Type-12 text element with HJustify=Center (the default) must produce a
/// A Type-12 text element with authored HJustify=Center must produce a
/// UiText with Centered=true and RightAligned=false at build time.
/// This proves BuildText applies the dat's HJustify at construction without a
/// controller binding step.
@ -1123,7 +1123,18 @@ public class DatWidgetFactoryTests
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
Assert.True(t.Centered);
Assert.False(t.RightAligned);
Assert.Equal(VJustify.Center, t.VerticalJustify);
Assert.Equal(VJustify.Top, t.VerticalJustify);
}
[Fact]
public void BuildText_UnauthoredJustification_UsesRetailNearEdges()
{
var info = new ElementInfo { Type = 12, Width = 100, Height = 20 };
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
Assert.False(t.Centered);
Assert.False(t.RightAligned);
Assert.Equal(VJustify.Top, t.VerticalJustify);
}
/// <summary>

View file

@ -182,40 +182,40 @@ public class ElementReaderTests
// ── HJustify / VJustify — Merge propagation ─────────────────────────────
/// <summary>
/// When the derived element has a non-Center HJustify, the derived value wins
/// (same "non-default wins" rule as FontDid).
/// Authored justification is presence-based: raw 3 on the derived element
/// overrides raw 1 on its base.
/// </summary>
[Fact]
public void Merge_DerivedHJustifyRight_OverridesBaseCenter()
{
var base_ = new ElementInfo { HJustify = HJustify.Center };
var derived = new ElementInfo { HJustify = HJustify.Right };
ElementInfo base_ = WithJustification(0x14u, 1u);
ElementInfo derived = WithJustification(0x14u, 3u);
var merged = ElementReader.Merge(base_, derived);
Assert.Equal(HJustify.Right, merged.HJustify);
}
/// <summary>
/// When the derived element has the default HJustify (Center), the base value
/// is inherited — Center from the derived does NOT override a Left base.
/// Center is a real authored raw value, not an unset sentinel, and must
/// therefore override a Left base.
/// </summary>
[Fact]
public void Merge_DerivedHJustifyCenter_InheritsBaseLeft()
public void Merge_DerivedAuthoredHJustifyCenter_OverridesBaseLeft()
{
var base_ = new ElementInfo { HJustify = HJustify.Left };
var derived = new ElementInfo { HJustify = HJustify.Center }; // default — no explicit dat property
ElementInfo base_ = WithJustification(0x14u, 2u);
ElementInfo derived = WithJustification(0x14u, 1u);
var merged = ElementReader.Merge(base_, derived);
Assert.Equal(HJustify.Left, merged.HJustify);
Assert.Equal(HJustify.Center, merged.HJustify);
}
/// <summary>
/// VJustify=Top from the base propagates when the derived element has no explicit
/// (Center) vertical justification.
/// VJustify=Top from the base propagates when the derived element authors
/// no vertical justification.
/// </summary>
[Fact]
public void Merge_BaseVJustifyTop_InheritedWhenDerivedIsCenter()
public void Merge_BaseVJustifyTop_InheritedWhenDerivedIsUnauthored()
{
var base_ = new ElementInfo { VJustify = VJustify.Top };
var derived = new ElementInfo { VJustify = VJustify.Center }; // default
ElementInfo base_ = WithJustification(0x15u, 4u);
var derived = new ElementInfo();
var merged = ElementReader.Merge(base_, derived);
Assert.Equal(VJustify.Top, merged.VJustify);
}
@ -226,8 +226,8 @@ public class ElementReaderTests
[Fact]
public void Merge_DerivedVJustifyBottom_OverridesBaseCenter()
{
var base_ = new ElementInfo { VJustify = VJustify.Center };
var derived = new ElementInfo { VJustify = VJustify.Bottom };
ElementInfo base_ = WithJustification(0x15u, 1u);
ElementInfo derived = WithJustification(0x15u, 3u);
var merged = ElementReader.Merge(base_, derived);
Assert.Equal(VJustify.Bottom, merged.VJustify);
}
@ -380,6 +380,54 @@ public class ElementReaderTests
return info;
}
private static ElementInfo WithJustification(uint propertyId, uint raw)
{
ElementInfo info = WithDirectProperty(propertyId, EnumProp(raw));
ElementReader.ApplyCanonicalLegacyProjection(info);
return info;
}
[Fact]
public void Justification_UnauthoredDefaultsMatchRetailConstructors()
{
var info = new ElementInfo();
Assert.Equal(HJustify.Left, info.HJustify);
Assert.Equal(VJustify.Top, info.VJustify);
}
[Theory]
[InlineData(0u, HJustify.Left)]
[InlineData(1u, HJustify.Center)]
[InlineData(2u, HJustify.Left)]
[InlineData(3u, HJustify.Right)]
[InlineData(4u, HJustify.Left)]
[InlineData(5u, HJustify.Right)]
public void HorizontalJustification_AllRetailRawValues(
uint raw,
HJustify expected)
{
ElementInfo info = WithJustification(0x14u, raw);
Assert.Equal(expected, info.HJustify);
}
[Theory]
[InlineData(0u, VJustify.Top)]
[InlineData(1u, VJustify.Center)]
[InlineData(2u, VJustify.Top)]
[InlineData(3u, VJustify.Bottom)]
[InlineData(4u, VJustify.Top)]
[InlineData(5u, VJustify.Bottom)]
public void VerticalJustification_AllRetailRawValues(
uint raw,
VJustify expected)
{
ElementInfo info = WithJustification(0x15u, raw);
Assert.Equal(expected, info.VJustify);
}
[Fact]
public void ReadTabTable_DecodesButtonPageDefaultInAuthoredOrder()
{

View file

@ -0,0 +1,106 @@
using System.IO;
using System.Linq;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Content;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
namespace AcDream.App.Tests.UI.Layout;
/// <summary>
/// #408 client-wide blast-radius gate for DAT property 0x3B (Invisible).
/// Retail applies the property in UIElement::OnSetAttribute for every imported
/// element. Enumerate every installed LayoutDesc, then prove every corresponding
/// widget which survives importer child-consumption starts hidden.
/// </summary>
[Trait("Lane", "InstalledDat")]
public sealed class LayoutImporterInvisibleSweepTests
{
private static string DatDirectory =>
System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(
System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call");
private readonly record struct Finding(uint LayoutId, uint ElementId);
[InstalledDatFact]
public void EveryAuthoredInvisibleWidget_StartsHiddenAcrossAllLayouts()
{
using var dats = new DatCollection(DatDirectory, DatAccessType.Read);
var authored = new List<Finding>();
var built = new List<Finding>();
var incorrectlyVisible = new List<Finding>();
foreach (uint layoutId in dats.GetAllIdsOfType<LayoutDesc>().OrderBy(static id => id))
{
ElementInfo? tree = LayoutImporter.ImportInfos(dats, layoutId);
if (tree is null) continue;
CollectAuthored(layoutId, tree, authored);
ImportedLayout layout = LayoutImporter.Build(
tree, _ => (0u, 0, 0), datFont: null, sourceLayoutDid: layoutId);
CollectBuilt(layoutId, layout.Root, built, incorrectlyVisible);
}
foreach (IGrouping<uint, Finding> group in authored
.GroupBy(static f => f.LayoutId)
.OrderBy(static g => g.Key))
{
Console.WriteLine(
$"[INVISIBLE] layout=0x{group.Key:X8} count={group.Count()} ids=["
+ string.Join(",", group.Select(static f => $"0x{f.ElementId:X8}"))
+ "]");
}
Console.WriteLine(
$"[INVISIBLE] authored={authored.Count} layouts="
+ $"{authored.Select(static f => f.LayoutId).Distinct().Count()} "
+ $"built={built.Count} incorrectlyVisible={incorrectlyVisible.Count}");
// Keep the global threshold resilient to an installed DAT revision while
// pinning landmarks from independent screens in both data and widgets.
Assert.True(authored.Count >= 1_000,
$"Expected the known client-wide 0x3B population, found {authored.Count}.");
Assert.Contains(authored, static f => f.ElementId == 0x10000403u); // chargen GM label
Assert.Contains(authored, static f => f.ElementId == 0x10000494u); // chargen envoy label
Assert.Contains(authored, static f => f.ElementId == 0x100006A4u); // combat root
Assert.Contains(authored, static f => f.ElementId == 0x1000048Cu); // chat new-text indicator
Assert.Contains(built, static f => f.ElementId == 0x100006A4u);
Assert.Contains(built, static f => f.ElementId == 0x1000048Cu);
Assert.Empty(incorrectlyVisible);
}
private static void CollectAuthored(uint layoutId, ElementInfo node, List<Finding> findings)
{
if (node.Invisible)
findings.Add(new Finding(layoutId, node.Id));
foreach (ElementInfo child in node.Children)
CollectAuthored(layoutId, child, findings);
}
private static void CollectBuilt(
uint layoutId,
UiElement node,
List<Finding> built,
List<Finding> incorrectlyVisible)
{
if (node.AuthoredInvisible)
{
var finding = new Finding(layoutId, node.DatElementId);
built.Add(finding);
if (node.Visible)
incorrectlyVisible.Add(finding);
}
foreach (UiElement child in node.Children)
CollectBuilt(layoutId, child, built, incorrectlyVisible);
}
}

View file

@ -363,6 +363,29 @@ public class LayoutImporterTests
Assert.Null(found.AuthoredTooltipDelaySeconds);
}
[Fact]
public void BuildWidget_AuthoredInvisible_HidesInitiallyButDoesNotLatchVisibility()
{
var root = new ElementInfo { Id = 0x1, Type = 3, Width = 100, Height = 40 };
var hidden = new ElementInfo
{
Id = 0x2,
Type = 3,
Width = 20,
Height = 20,
Invisible = true,
};
ImportedLayout tree = LayoutImporter.BuildFromInfos(root, [hidden], NoTex, null);
UiElement found = tree.FindElement(0x2)!;
Assert.True(found.AuthoredInvisible);
Assert.False(found.Visible);
found.Visible = true;
Assert.True(found.Visible);
}
// ── Helpers ───────────────────────────────────────────────────────────────
private static ElementInfo BuildSliceContainer(uint id, uint ReadOrder, uint l, uint t, uint r)

View file

@ -1,8 +1,10 @@
using System.Linq;
using System.Numerics;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Net.Messages;
using AcDream.Core.World;
using AcDream.Runtime.Gameplay;
namespace AcDream.App.Tests.UI.Layout;
@ -124,7 +126,18 @@ public sealed class MapHousePanelControllerTests
/// UiButton hotspots above — a fresh instance per call, matching
/// production's real resolver.</summary>
private static UiElement? FakeHouseRowTemplate(uint layoutId, uint elementId)
=> new UiText { Width = 280f, Height = 28f };
=> new UiText
{
Width = 280f,
Height = 28f,
DefaultColor = new Vector4(0.1f, 0.1f, 0.1f, 1f),
FontColorPalette =
[
new Vector4(1f, 1f, 1f, 1f),
new Vector4(0f, 1f, 0f, 1f),
new Vector4(1f, 0f, 0f, 1f),
],
};
private static MapHousePanelController.Callbacks MakeCallbacks(
List<string>? calls = null,
@ -132,7 +145,8 @@ public sealed class MapHousePanelControllerTests
Func<uint>? playerCellId = null,
Func<CreateObject.ServerPosition?>? housePosition = null,
Func<IReadOnlyList<string>>? houseLines = null,
Func<uint, uint, ElementInfo?>? templateInfoResolver = null)
Func<uint, uint, ElementInfo?>? templateInfoResolver = null,
Func<IReadOnlyList<HousePanelLine>>? housePanelLines = null)
{
calls ??= new List<string>();
return new MapHousePanelController.Callbacks(
@ -147,7 +161,8 @@ public sealed class MapHousePanelControllerTests
House: new HousePageController.Bindings(
Lines: houseLines ?? (static () => Array.Empty<string>()),
OnShown: () => calls.Add("house-shown"),
TemplateResolver: FakeHouseRowTemplate));
TemplateResolver: FakeHouseRowTemplate,
PanelLines: housePanelLines));
}
[Fact]
@ -377,4 +392,35 @@ public sealed class MapHousePanelControllerTests
"You may buy another house immediately.",
Assert.Single(row.LinesProvider()).Text);
}
[Fact]
public void Tick_UsesRetailHousePanelColorAsAuthoredPaletteIndex()
{
ElementInfo rootInfo = FixtureLoader.LoadMapHouseHostInfos();
ImportedLayout layout = FixtureLoader.LoadMapHouseHost();
HousePanelLine[] lines =
[
new("paid", HousePanelTextColor.RentPaid),
new("unpaid", HousePanelTextColor.RentNotPaid),
];
MapHousePanelController? controller = MapHousePanelController.Bind(
rootInfo,
layout,
MakeCallbacks(housePanelLines: () => lines));
Assert.NotNull(controller);
controller!.Tick(0.016);
var listBox = Assert.IsType<UiTemplateListBox>(
UiElement.FindDescendant(controller.Root, HousePageController.TextBoxId));
UiScrollablePanel viewport = Assert.IsType<UiScrollablePanel>(
listBox.ViewportForTest);
Assert.Equal(2, viewport.Children.Count);
Assert.Equal(
new Vector4(0f, 1f, 0f, 1f),
Assert.Single(Assert.IsType<UiText>(viewport.Children[0]).LinesProvider()).Color);
Assert.Equal(
new Vector4(1f, 0f, 0f, 1f),
Assert.Single(Assert.IsType<UiText>(viewport.Children[1]).LinesProvider()).Color);
}
}

View file

@ -313,9 +313,11 @@ public sealed class OptionsPanelLiveMountProbeTests
/// LayoutDesc, and <c>Open @0x0046cc30</c> centers the POPUP over the
/// button when bool attr 3 is authored. This probe measures all of that
/// authored data for the Config option-menu chain (catalog 0x21000043,
/// base 0x10000353) with vendor's dropdown (0x1000034F chain — visibly a
/// FIXED 6-row + scrollbar popup in retail, user-gated during the vendor
/// campaign) as the contrast control.</summary>
/// base 0x10000353) and the vendor dropdown's sibling chain
/// (0x1000034F). The latter was once treated as a fixed-six-row contrast;
/// #386's named-retail trace corrected that reading: it follows the same
/// docked size-to-content message route and authors scrollbar property
/// 0x79 (hide when disabled).</summary>
[Fact]
[Trait("Purpose", "Diagnostic")]
public void ProbeMenuPopupSizingAndTextStyle()
@ -371,7 +373,7 @@ public sealed class OptionsPanelLiveMountProbeTests
Console.WriteLine("[menuprobe3] row template 0x1000035A FAILED to import");
}
Console.WriteLine("[menuprobe3] === CONTROL: vendor chain (fixed 6-row + scrollbar in retail) ===");
Console.WriteLine("[menuprobe3] === CONTROL: vendor chain (content-sized; scrollbar 0x79 hides when disabled) ===");
DumpDockAndSize(dats, 0x21000043u, 0x1000034Fu, "Vendor popup root");
DumpDockAndSize(dats, 0x21000043u, 0x10000350u, "Vendor popup ListBox");
ElementInfo? vendorBase = LayoutImporter.ImportInfos(dats, 0x21000043u, 0x1000034Bu);

View file

@ -18,6 +18,11 @@ public sealed class SpellcastingUiControllerTests
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
RetailCombatLayout.FitFavoriteSlots(layout);
// The authored combat root starts Invisible=true. Production's
// RetailWindowFrame/CombatUiController show it when combat mode opens
// the window; this standalone pointer fixture must model that mount
// edge explicitly now that #408 honors the DAT flag client-wide.
layout.Root.Visible = true;
var spellbook = new Spellbook();
spellbook.OnSpellLearned(42u, 1f);
spellbook.SetFavorite(0, 0, 42u);
@ -199,6 +204,7 @@ public sealed class SpellcastingUiControllerTests
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
RetailCombatLayout.FitFavoriteSlots(layout);
layout.Root.Visible = true;
var spellbook = new Spellbook();
spellbook.OnSpellLearned(1u, 1f);
spellbook.OnSpellLearned(2u, 1f);
@ -468,6 +474,7 @@ public sealed class SpellcastingUiControllerTests
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
RetailCombatLayout.FitFavoriteSlots(layout);
layout.Root.Visible = true;
var spellbook = new Spellbook();
spellbook.OnSpellLearned(1u, 1f);
spellbook.OnSpellLearned(2u, 1f);

View file

@ -247,13 +247,12 @@ public class UiDatElementTests
}
/// <summary>
/// The state-0 fallback must NOT honor a base-DirectState Invisible
/// (dat 0x3B) — that is the #408 construction-time class, gated
/// separately; retail's per-state Invisible honor applies to NAMED
/// authored states only.
/// #408: retail's unauthored-state fallback commits state 0 and applies
/// its properties, including Invisible, through the same OnSetAttribute
/// path as a named state.
/// </summary>
[Fact]
public void TrySetRetailState_UnauthoredStateFallback_DoesNotHonorBaseInvisible()
public void TrySetRetailState_UnauthoredStateFallback_RestoresBaseInvisible()
{
var info = new ElementInfo();
var baseState = new UiStateInfo { Id = UiStateInfo.DirectStateId };
@ -265,12 +264,12 @@ public class UiDatElementTests
info.States[UiStateInfo.DirectStateId] = baseState;
info.StateMedia["Normal_rollover"] = (0x06005EB6u, 1);
var element = new UiDatElement(info, _ => (0u, 0, 0));
Assert.True(element.Visible);
element.Visible = true;
Assert.True(element.TrySetRetailState(UiButtonStateMachine.Normal));
Assert.Equal("", element.ActiveState);
Assert.True(element.Visible);
Assert.False(element.Visible);
}
/// <summary>

View file

@ -1005,6 +1005,10 @@ public sealed class VendorUiControllerTests
Assert.Equal(6, h.TypeMenu.RowsPerColumn);
Assert.Equal(18f, h.TypeMenu.RowHeight);
Assert.Equal(100f, h.TypeMenu.ColumnWidth);
Assert.True(h.TypeMenu.PopupSizeToContent);
Assert.True(h.TypeMenu.PopupScrollbarHideWhenDisabled);
Assert.Equal(2 * h.TypeMenu.RowHeight + 2 * 5f, h.TypeMenu.PopupOuterHeight);
Assert.Equal(h.TypeMenu.ColumnWidth + 2 * 5f, h.TypeMenu.PopupOuterWidth);
// Open via the real widget event path.
Assert.True(h.TypeMenu.OnEvent(new UiEvent(0, h.TypeMenu, UiEventType.MouseDown, 0, 10, 5)));
@ -1054,7 +1058,7 @@ public sealed class VendorUiControllerTests
// where nothing lives; UiMenu treats it as an ordinary button click
// and just re-closes the still-open menu instead of picking a row.
const int border = 5;
float outerH = h.TypeMenu.RowsPerColumn * h.TypeMenu.RowHeight + 2 * border;
float outerH = h.TypeMenu.PopupOuterHeight;
const int targetRow = 1;
float iy = targetRow * h.TypeMenu.RowHeight + h.TypeMenu.RowHeight / 2f;
float oldUpwardLy = iy - outerH + border;

View file

@ -732,6 +732,65 @@ public class UiButtonTests
Assert.True(kid.Visible);
}
[Fact]
public void TrySetRetailState_AppliesInvisibleToButtonNamedAndDirectStates()
{
var info = ButtonInfo("Normal", "Ghosted");
info.StateMedia[""] = (99u, 1);
info.States[UiStateInfo.DirectStateId] = StateWithInvisible(
UiStateInfo.DirectStateId, "", invisible: true);
info.States[UiButtonStateMachine.Normal] = StateWithInvisible(
UiButtonStateMachine.Normal, "Normal", invisible: false);
info.States[UiButtonStateMachine.Ghosted] = StateWithInvisible(
UiButtonStateMachine.Ghosted, "Ghosted", invisible: true);
var button = CreateButton(info);
button.Visible = false;
Assert.True(button.TrySetRetailState(UiButtonStateMachine.Normal));
Assert.True(button.Visible);
Assert.True(button.TrySetRetailState(UiButtonStateMachine.Ghosted));
Assert.False(button.Visible);
button.Visible = true;
Assert.True(button.TrySetRetailState(UiStateInfo.DirectStateId));
Assert.False(button.Visible);
}
[Fact]
public void UiText_ReturningToDirectState_RestoresItsInvisibleProperty()
{
var info = new ElementInfo { Type = 12, Width = 20, Height = 20 };
info.States[UiStateInfo.DirectStateId] = StateWithInvisible(
UiStateInfo.DirectStateId, "", invisible: true);
info.States[UiButtonStateMachine.Normal] = StateWithInvisible(
UiButtonStateMachine.Normal, "Normal", invisible: false);
var text = new UiText();
text.ConfigureDatState(info);
text.Visible = true;
Assert.True(text.TrySetRetailState(UiStateInfo.DirectStateId));
Assert.False(text.Visible);
Assert.True(text.TrySetRetailState(UiButtonStateMachine.Normal));
Assert.True(text.Visible);
Assert.True(text.TrySetRetailState(UiStateInfo.DirectStateId));
Assert.False(text.Visible);
}
private static UiStateInfo StateWithInvisible(
uint id,
string name,
bool invisible)
{
var state = new UiStateInfo { Id = id, Name = name };
state.Properties.Values[0x3Bu] = new UiPropertyValue
{
Kind = UiPropertyKind.Bool,
BoolValue = invisible,
};
return state;
}
private static ElementInfo ButtonInfo(params string[] states)
{
var info = new ElementInfo { Type = 1, Width = 20, Height = 20 };

View file

@ -177,9 +177,11 @@ public class UiMenuTests
}
// ── G5 (vendor gate finding): Scrollable single-column popup ──────────────
// Retail's vendor category dropdown (LayoutDesc 0x21000043) is a SCROLLABLE
// single column with a docked scrollbar, not a column-major grid — see
// VendorUiController's "G5 correction" class-doc paragraph. Chat's own popup
// Retail's vendor category dropdown (LayoutDesc 0x21000043) is a scrollable
// single-column ListBox with a docked scrollbar, not a column-major grid.
// Its four-edge docking subsequently sizes the popup to content and property
// 0x79 hides that scrollbar when disabled; the fixed-viewport tests below
// retain coverage of UiMenu's actual overflow path. Chat's own popup
// (exercised by every test above, Scrollable left at its false default) is
// completely unaffected: it's a structurally different code path
// (DrawGridPopup / the grid branch of OnEvent's MouseDown handling).
@ -541,6 +543,45 @@ public class UiMenuTests
Assert.False(menu.IsOpen); // picking closes, same as every other path
}
[Fact]
public void SizeToContent_WithAuthoredHideWhenDisabled_HidesTheScrollbarAndItsInput()
{
UiMenu menu = MakeScrollableMenu(3, sizeToContent: true);
menu.PopupScrollbarHideWhenDisabled = true;
Assert.Equal(menu.ColumnWidth + 2 * 5f, menu.PopupOuterWidth);
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
// Configure the exact content/view extents used by both drawing and
// pointer dispatch. Size-to-content makes them equal, so retail's
// authored scrollbar property 0x79 hides the entire sibling widget.
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.Scroll, Data0: 0)));
Assert.False(menu.PopupScroll.HasOverflow);
Assert.False(menu.IsPopupScrollbarPresentationVisible);
// The old 16px sibling column is outside the popup now. A direct event
// at that old coordinate must not page/line scroll or leave an invisible
// control holding the popup open (UiRoot hit testing would not target the
// menu there at all because PopupOuterWidth has already collapsed).
float lx = 5f + menu.ColumnWidth + menu.ScrollbarWidth / 2f;
float ly = menu.Height + 5f + menu.RowHeight / 2f;
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, (int)lx, (int)ly)));
Assert.Equal(0, menu.PopupScroll.ScrollY);
Assert.False(menu.IsOpen);
}
[Fact]
public void FixedViewport_WithOverflow_KeepsHideWhenDisabledScrollbarVisible()
{
UiMenu menu = MakeScrollableMenu(18, sizeToContent: false);
menu.PopupScrollbarHideWhenDisabled = true;
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.MouseDown, 0, 10, 5))); // open
Assert.True(menu.OnEvent(new UiEvent(0, menu, UiEventType.Scroll, Data0: 0)));
Assert.True(menu.PopupScroll.HasOverflow);
Assert.True(menu.IsPopupScrollbarPresentationVisible);
Assert.Equal(menu.ColumnWidth + menu.ScrollbarWidth + 2 * 5f, menu.PopupOuterWidth);
}
[Fact]
public void EmptyItems_ButtonClickDoesNotOpen()
{
@ -552,14 +593,16 @@ public class UiMenuTests
}
[Fact]
public void TextStyleDefaults_PreserveChatAndVendorBehavior()
public void MenuRetailBehaviorFlags_DefaultOff_ForMenusThatDoNotAuthorThem()
{
// The three 2026-08-13 additions are opt-in: chat's gold left-aligned
// caption and vendor's fixed 6-row window are untouched by default.
// These are per-menu authored behaviors. Chat's grid popup keeps the
// generic left-aligned, non-resizing defaults; vendor opts into both
// content sizing and property-0x79 scrollbar hiding in its controller.
var menu = new UiMenu();
Assert.False(menu.ButtonTextCentered);
Assert.False(menu.ItemTextCentered);
Assert.False(menu.PopupSizeToContent);
Assert.False(menu.PopupScrollbarHideWhenDisabled);
}
/// <summary>