acdream/tools/LayoutDump/Program.cs
Erik f44f7641b1 tools(LayoutDump): --props and --media, for questions the decomp cannot answer
Two dump modes, both added because a chat bug turned on authored data rather
than code.

--props prints each state's property ids WITH VALUES. It is what settled the
unseen-text indicator regression: element 0x1000048C authors dat property 0x3B
("Invisible") per state —

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

— which is what actually puts that element on screen. No amount of reading the
decomp would have produced those two booleans.

--media prints the RAW media sequence per state off the LayoutDesc, because
ElementInfo keeps only the FIRST image of a state (LayoutImporter.cs, the
`!imageRead` guard) and an animation is therefore invisible above that level.
It reports what it CANNOT do rather than implying an element has no media: raw
descriptors only carry what an element overrides, and states usually come from
a base element — for the indicator, base 0x10000527, which lives in a different
layout entirely. Following that chain means reimplementing LayoutImporter's
Resolve, so the mode says so instead of printing a misleading empty list.

What the two modes established between them, for whoever picks up the blink:
the media vocabulary includes MediaDescImage, MediaDescPause and MediaDescJump,
so an authored animation is a SEQUENCE with waits and a loop — a small program,
not a frame array. Supporting it means keeping the sequence through import and
running a media player, not just widening StateMedia to a list.

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

231 lines
9 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 RAW media sequence per state, straight off the LayoutDesc. ElementInfo
// keeps only the first image, so an animation is invisible above that level.
uint wanted = mediaAt + 1 < args.Length
? Convert.ToUInt32(args[mediaAt + 1], 16)
: 0u;
// Layouts are not necessarily in Portal — go through the adapter, the same
// way LayoutImporter does.
var ld = adapter.Get<DatReaderWriter.DBObjs.LayoutDesc>(ids[0]);
if (ld is null)
{
Console.WriteLine($"layout 0x{ids[0]:X8} not found");
return 2;
}
foreach (var top in ld.Elements)
Walk(top.Value);
return 0;
void Walk(DatReaderWriter.Types.ElementDesc d)
{
if (wanted == 0 || d.ElementId == wanted)
{
Console.WriteLine($"element 0x{d.ElementId:X8}");
if (d.States.Count == 0)
{
// Raw descriptors only carry what THIS element overrides; states
// and their media usually come from the base element, and
// LayoutDesc::InqFullDesc @0x0069A520 resolves that chain.
// Following it here would mean reimplementing LayoutImporter's
// Resolve, so say so rather than imply the element has none.
Console.WriteLine(
$" (no states of its own — inherited from base 0x{d.BaseElement:X8};"
+ " raw media not resolved here)");
}
foreach (var st in d.States)
{
Console.WriteLine($" state {st.Key}: {st.Value.Media.Count} media");
foreach (var m in st.Value.Media)
Console.WriteLine($" {m.GetType().Name}");
}
}
foreach (var child in d.Children)
Walk(child.Value);
}
}
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);
}