acdream/tools/LayoutDump/Program.cs
Erik 89db9a794c feat(ui): authored state media animates, so the unseen-text indicator blinks
The blink is not code. It is data, and we were throwing it away.

A retail UI state's media is a small program: images interleaved with timed
pauses, branches, and a terminal hand-off to another state. Our importer kept
the FIRST image per state and dropped the rest, so nothing authored could ever
animate — the indicator was correct in every other respect and simply sat
still.

Measured from the installed dats (LayoutDump --media 0x1000048C), the chat
unseen-text indicator's Normal state authors thirteen steps: two frames
alternating every half second, three times, then `State 13` — Ghosted, whose
authored 0x3B is Invisible.

So retail's indicator is a three-second attention FLASH that hides itself, not
a badge that stays lit until you scroll to the bottom. Nobody would guess that
from the code, because there is no blink code anywhere; the behaviour lives
entirely in the authored sequence. Our shipped version stayed lit, which is
the one thing the data says it must not do.

Sampling is a pure function of (steps, elapsed) rather than a playback object
holding a cursor, so an element only has to remember WHEN its state began and
the whole thing is testable without a clock, a GPU or a frame loop. One shared
UiMediaClock is advanced once per frame by RetailUiRuntime; a UI element has
no tick of its own.

The controller change is the other half: it starts the flash on the rising
edge ONLY. Re-setting Normal every frame would pin the sequence on frame zero
and it would never blink at all — which is the failure mode the second new
test exists to catch, and which no "is it visible?" assertion would notice.
When the sequence reaches its terminal step the controller follows it down
instead of re-lighting it.

Two guesses are refused rather than made, and both are registered: a Pause's
max duration (every sequence measured sets min == max, and what the range MEANS
is not in the decomp) and a sub-1 branch probability (falls through, the
direction where a malformed sequence stops rather than animates forever).
A jump-cycle with no elapsed time is bounded so a bad sequence cannot spin
inside a frame.

Kept `Other` steps in the list rather than filtering them, so a jump's authored
index still lands on the entry it names.

Register: CT-3, CT-4.

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

224 lines
8.8 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 mediaAt = Array.IndexOf(args, "--media");
if (mediaAt >= 0)
{
// The RESOLVED media sequence per state — inheritance already applied by
// ImportInfos, which is what the raw LayoutDesc walk could not do.
uint wanted = mediaAt + 1 < args.Length
? Convert.ToUInt32(args[mediaAt + 1], 16)
: 0u;
WalkMedia(root);
return 0;
void WalkMedia(ElementInfo e)
{
if (wanted == 0 || e.Id == wanted)
{
Console.WriteLine($"element 0x{e.Id:X8}");
foreach (var (stateId, st) in e.States.OrderBy(kv => kv.Key))
{
if (st.MediaSteps.Count == 0) continue;
Console.WriteLine(
$" state {stateId}{(st.Name.Length != 0 ? $" ({st.Name})" : "")}"
+ $": {st.MediaSteps.Count} steps");
for (int i = 0; i < st.MediaSteps.Count; i++)
{
UiMediaStep m = st.MediaSteps[i];
string detail = m.Kind switch
{
UiMediaStepKind.Image => $"file=0x{m.File:X8} draw={m.DrawMode}",
UiMediaStepKind.Pause => $"min={m.MinDuration} max={m.MaxDuration}",
UiMediaStepKind.Jump => $"to={m.JumpIndex} p={m.Probability}",
UiMediaStepKind.State => $"state={m.JumpIndex} p={m.Probability}",
_ => $"MediaType={(DatReaderWriter.Enums.MediaType)m.RawType}",
};
Console.WriteLine($" [{i,2}] {m.Kind,-6} {detail}");
}
}
}
foreach (ElementInfo child in e.Children)
WalkMedia(child);
}
}
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);
}