acdream/tools/LayoutDump/Program.cs
Erik a330d50df9 fix(chat): the unseen-text indicator follows its authored per-state visibility
Regression from cbab79d7, which I introduced: the indicator stopped showing at
all. Switching it from Visible to state-driven was half a correction — right
about retail's mechanism, wrong about what makes this element appear.

Measured, rather than reasoned about (LayoutDump gained --props for it):

    0x1000048C  state 13 Ghosted  0x3B = True    -> hidden
                state 1  Normal   0x3B = False   -> shown
                state 3  pressed  0x3B = False

Dat property 0x3B is "Invisible", authored PER STATE, and it is what puts this
element on screen. UiDatElement applies 0x3B on a state change; UiButton does
not, and this element builds as a button — so driving the state alone left it
hidden forever. The original Visible toggle was, by coincidence, exactly what
the authored data prescribes.

So the property is applied here rather than left unhonoured. That is the
authored data, not a visibility hack layered over the state machinery.

The state is still set, for the media it selects, but only on the way IN:
TrySetRetailState(Ghosted) means Enabled = false, and disabling the button
would also refuse the click that scrolls to the newest text — a second bug
waiting behind the first.

The test now pins VISIBILITY across the transitions instead of ActiveState.
The previous test passed while the feature was broken because the fixture
element carried no 0x3B, so the assertion could never see the property that
actually decides this. It fails now if the state is driven without the
visibility.

Proper fix noted for later: UiButton should honour per-state 0x3B the way
UiDatElement already does. That is a wider change than this regression wants.

Solution builds clean; full hermetic gate green.

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

182 lines
7.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");
bool showColors = args.Contains("--colors");
bool showProps = args.Contains("--props");
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}");
// Colour-array properties. 0x1B is the ordinary font-colour array and 0x1D
// the TAG font-colour array (UIElement_Text::SetFontColorHelper); both are
// indexed by the caller, so the tagged-name colour is a row in 0x1D rather
// than anything the runtime builds.
if (showColors)
{
foreach (UiStateInfo state in e.States.Values)
{
foreach (uint prop in new[] { 0x1Bu, 0x1Du })
{
if (!state.Properties.TryGetValue(prop, out UiPropertyValue? v) || v is null)
continue;
Console.WriteLine($"{pad} P0x{prop:X2} ({v.ArrayValue.Count} entries):");
for (int i = 0; i < v.ArrayValue.Count; i++)
{
UiColorValue c = v.ArrayValue[i].ColorValue;
Console.WriteLine(
$"{pad} [0x{i:X2}] R={c.Red,3} G={c.Green,3} B={c.Blue,3} A={c.Alpha,3}");
}
}
}
}
// Raw property ids per state — ToggleBehavior (0x0B) and RolloverEnabled
// (0x13) change how a button interprets a state change, so "which state did
// I set" is not the whole story.
if (showProps)
{
foreach (var (stateId, state) in e.States)
{
if (state.Properties.Values.Count == 0)
continue;
string ids = string.Join(", ", state.Properties.Values
.OrderBy(kv => kv.Key)
.Select(kv => $"0x{kv.Key:X2}={Describe(kv.Value)}"));
static string Describe(UiPropertyValue v) => v.Kind switch
{
UiPropertyKind.Bool => v.BoolValue.ToString(),
UiPropertyKind.Integer => v.IntegerValue.ToString(),
UiPropertyKind.Enum => $"0x{v.UnsignedValue:X}",
_ => v.Kind.ToString(),
};
Console.WriteLine($"{pad} state {stateId}: props {ids}");
}
}
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);
}