acdream/tools/LayoutDump/Program.cs
Erik 7e75be23d1
Some checks failed
CI / linux-portable (push) Failing after 3m15s
CI / windows-gate (push) Successful in 6m19s
CI / release (push) Has been skipped
fix(ui): effects list stayed pinned at its authored height in a taller window
The "Beneficial Spells in Effect" window rendered its rows only in the top
249px and painted the rest of the list as empty black background, with a
scrollbar thumb sized for a viewport far smaller than the visible one. It did
not depend on window size, and the last visible row was sliced mid-height --
a clip boundary, not a missing row.

Root cause is the #412 class again. The authored list element (0x10000123) is
a UiTemplateListBox, not a UiItemList, so EffectsUiController creates the item
list itself and attaches it as a child with fill anchors. That baseline is
captured lazily on the child's first ApplyAnchor -- which lands AFTER the host
has already been resized to the restored window height in the same frame. The
capture then measures a bottom margin of (hostH - 249) and ComputeAnchoredRect
preserves it forever: h = hostH - (hostH - 249) = 249, at every subsequent
size. Rows past 249px fail LayoutCells' cull test and never draw.

Capturing the baseline at creation, while the list's extent still exactly
equals the host's, makes the margins (0,0,0,0) so it tracks the host from then
on. Identical fix and reason to UiTemplateListBox's own viewport seed. The
spellbook's component list is built by the same pattern and had the same
latent defect; it is fixed alongside.

Why it shipped: every existing test in EffectsUiControllerTests supplies a
synthetic UiItemList as the list element, so `host is UiItemList` is true and
the controller uses it directly -- the create-and-attach branch that actually
runs against real dat was never exercised. The new test binds the real
fixture, which builds the real UiTemplateListBox. Neutralising the fix makes
it fail with the exact production numbers (expected 547, actual 249).

Measured, not guessed. tools/LayoutDump grew --resize, which reproduces
retail's raw-edge policy (UIElement::UpdateForParentSizeChange @ 0x00462640)
offline, and it ruled out the authored geometry, the import, the layout policy
and the window frame in turn -- all four are faithful. The 4px gap between the
scrollbar and the window's inner edge is likewise authored: the user confirmed
retail shows the same gap, so it is deliberately left alone.

Solution builds clean; 14,465 tests pass on the standard hermetic lane filter,
0 failures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 21:00:16 +02:00

133 lines
5.2 KiB
C#

// Print the AUTHORED geometry and state set of a retail LayoutDesc element
// tree, straight from the installed DATs.
//
// Layout questions -- "is this scrollbar where retail put it?", "does this
// button even have a pressed state?" -- were being answered by reading our own
// importer and inferring. This reads the authored truth instead, which is the
// only thing either question is actually about.
//
// dotnet run --project tools/LayoutDump -- 0x21000071
// dotnet run --project tools/LayoutDump -- 0x2100002F 0x1000018E --states
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Content;
using DatReaderWriter;
using DatReaderWriter.Options;
using SysEnv = System.Environment;
if (args.Length == 0)
{
Console.WriteLine("usage: LayoutDump <layoutId> [rootElementId] [--states]");
return 1;
}
bool showStates = args.Contains("--states");
uint[] ids = args.Where(a => !a.StartsWith("--"))
.Select(a => Convert.ToUInt32(a, a.StartsWith("0x") ? 16 : 10))
.ToArray();
string datDir = SysEnv.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(SysEnv.GetFolderPath(SysEnv.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
using var dats = new DatCollection(datDir, DatAccessType.Read);
using var adapter = new DatCollectionAdapter(dats);
ElementInfo? root = ids.Length > 1
? LayoutImporter.ImportInfos(adapter, ids[0], ids[1])
: LayoutImporter.ImportInfos(adapter, ids[0]);
if (root is null)
{
Console.WriteLine($"layout 0x{ids[0]:X8} not found (or root 0x{(ids.Length > 1 ? ids[1] : 0):X8} missing)");
return 2;
}
Console.WriteLine($"layout 0x{ids[0]:X8}");
Print(root, 0);
int resizeAt = Array.IndexOf(args, "--resize");
if (resizeAt >= 0 && resizeAt + 2 < args.Length)
{
// Reproduce a window resize exactly, without a running client: retail's
// raw-edge policy (UIElement::UpdateForParentSizeChange @ 0x00462640) is a
// pure function of the authored rects and the new parent size, which is
// what UiElement.ApplyAnchor feeds it every frame.
int rw = int.Parse(args[resizeAt + 1]);
int rh = int.Parse(args[resizeAt + 2]);
Console.WriteLine();
Console.WriteLine($"resized to {rw}x{rh}:");
PrintResized(root, UiPixelRect.FromPositionAndSize(0, 0, rw, rh), 0);
}
if (args.Contains("--built"))
{
// What the importer actually PRODUCES, next to what the dat authored.
// A difference between the two is the whole question for any "this
// control is in the wrong place" report.
Console.WriteLine();
Console.WriteLine("built widget tree:");
ImportedLayout built = LayoutImporter.Build(root, _ => (0u, 0, 0), null, _ => null);
PrintBuilt(built.Root, 0);
}
return 0;
void PrintResized(ElementInfo e, UiPixelRect parentRect, int depth)
{
string pad = new(' ', depth * 2);
Console.WriteLine(
$"{pad}0x{e.Id:X8} type={e.Type,-10} "
+ $"x={parentRect.X0,6} y={parentRect.Y0,6} "
+ $"w={parentRect.Width,6} h={parentRect.Height,6}");
foreach (ElementInfo child in e.Children)
{
var authored = UiPixelRect.FromPositionAndSize(
(int)child.X, (int)child.Y, (int)child.Width, (int)child.Height);
var originalParent = child.HasOriginalParentSize
? UiPixelRect.FromPositionAndSize(
0, 0, (int)child.OriginalParentWidth, (int)child.OriginalParentHeight)
: UiPixelRect.FromPositionAndSize(0, 0, parentRect.Width, parentRect.Height);
UiPixelRect next = UiLayoutPolicy.Apply(
child.Left, child.Top, child.Right, child.Bottom,
authored, originalParent, authored,
UiPixelRect.FromPositionAndSize(0, 0, parentRect.Width, parentRect.Height));
PrintResized(child, next, depth + 1);
}
}
void PrintBuilt(UiElement e, int depth)
{
string pad = new(' ', depth * 2);
Console.WriteLine(
$"{pad}{e.GetType().Name,-20} id=0x{e.EventId:X8} "
+ $"L={e.Left,6:0.#} T={e.Top,6:0.#} W={e.Width,6:0.#} H={e.Height,6:0.#} "
+ $"vis={e.Visible}");
foreach (UiElement child in e.Children)
PrintBuilt(child, depth + 1);
}
void Print(ElementInfo e, int depth)
{
string pad = new(' ', depth * 2);
Console.WriteLine(
$"{pad}0x{e.Id:X8} type={e.Type,-10} "
+ $"x={e.X,6:0.#} y={e.Y,6:0.#} w={e.Width,6:0.#} h={e.Height,6:0.#} "
+ $"edges=L{e.Left}/T{e.Top}/R{e.Right}/B{e.Bottom} "
+ $"parent={(e.HasOriginalParentSize ? $"{e.OriginalParentWidth:0.#}x{e.OriginalParentHeight:0.#}" : "-")} "
+ $"z={e.ZLevel} order={e.ReadOrder}");
if (showStates && e.States.Count != 0)
{
string names = string.Join(", ", e.States
.OrderBy(kv => kv.Key)
.Select(kv => $"{kv.Key}{(kv.Value.Name.Length != 0 ? $":{kv.Value.Name}" : "")}"
+ $"[pass={kv.Value.PassToChildren}"
+ $" img={(kv.Value.Image is { } m ? $"0x{m.File:X8}" : "-")}"
+ $" media={kv.Value.MediaCount}/{kv.Value.ImageMediaCount}]"));
Console.WriteLine($"{pad} states(default={e.DefaultStateId}): {names}");
}
foreach (ElementInfo child in e.Children)
Print(child, depth + 1);
}