Reported symptom: Abandon, New, Record, Start, First and Last all unclickable.
That Abandon was in the list is what identified it — Abandon is deliberately
unwired, so if it behaved the same as the others the cause could not be wiring.
UiButton read authored property 0x0D as "starts disabled" (Enabled = !0x0D).
It was the one property read in that file with no citation, and it was wrong.
Every button on the Journal panel authors 0x0D, so every one built disabled:
visible, because drawing never consults Enabled, and unclickable, because
UiElement.HitTest skips disabled elements. Exactly the reported shape.
The evidence is a sweep of every installed layout (LayoutDump gained --ghosted
for it): 85 elements author 0x0D and ALL 85 author it TRUE — not one False
anywhere in the client — and no panel ever clears it, the only four
SetAttribute_Bool(.., 0xd, ..) sites in the binary being chargen appearance,
the keymap option and the barber. A flag that is only ever true, never cleared,
and sits on New, Record, Start, Delete and Reset cannot mean "dead button";
under the old reading 85 elements were permanently dead in a shipping game.
It is not a pure ghosted LOOK either, which is why this ignores it rather than
moving it to appearance: the same 85 mix live buttons with inert column headers
("Contract", "Status", "Title", "Timer", "Label", "#"), and one appearance
cannot be right for both. Registered as QJ-2 with the measurement, so the open
question is recorded rather than quietly decided.
The test that asserted the old behaviour carried no citation either — it
encoded the same assumption. It now asserts the evidenced behaviour, with a
companion test proving the state machine's own Ghosted transition still
suppresses a click: that mechanism is separate and did not change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
410 lines
16 KiB
C#
410 lines
16 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]");
|
|
Console.WriteLine(" LayoutDump --find <elementIdOrType>");
|
|
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);
|
|
|
|
if (args.Contains("--ghosted"))
|
|
{
|
|
// Which authored elements carry property 0x0D (retail's ghost flag)?
|
|
// Sizing the blast radius of how acdream interprets it.
|
|
int hits = 0;
|
|
foreach (uint layoutId in dats.GetAllIdsOfType<DatReaderWriter.DBObjs.LayoutDesc>()
|
|
.OrderBy(i => i))
|
|
{
|
|
ElementInfo? candidate;
|
|
try { candidate = LayoutImporter.ImportInfos(adapter, layoutId); }
|
|
catch { continue; }
|
|
if (candidate is null) continue;
|
|
Walk(candidate, layoutId);
|
|
}
|
|
Console.WriteLine($"elements authoring 0x0D: {hits}");
|
|
return 0;
|
|
|
|
void Walk(ElementInfo e, uint layoutId)
|
|
{
|
|
foreach (var (_, state) in e.States)
|
|
{
|
|
if (state.Properties.Values.TryGetValue(0x0Du, out UiPropertyValue? v))
|
|
{
|
|
hits++;
|
|
Console.WriteLine(
|
|
$"layout 0x{layoutId:X8} element 0x{e.Id:X8} type={e.Type} 0x0D={v.BoolValue}");
|
|
break;
|
|
}
|
|
}
|
|
foreach (ElementInfo child in e.Children) Walk(child, layoutId);
|
|
}
|
|
}
|
|
|
|
if (args.Contains("--contracts"))
|
|
{
|
|
// Campaign QT slice QT2: what does the installed ContractTable actually
|
|
// hold, and does the reader decode it at all?
|
|
var table = dats.Get<DatReaderWriter.DBObjs.ContractTable>(0x0E00001Du);
|
|
if (table is null)
|
|
{
|
|
Console.WriteLine("ContractTable 0x0E00001D not found");
|
|
return 2;
|
|
}
|
|
|
|
Console.WriteLine($"ContractTable 0x{table.Id:X8}: {table.Contracts.Count} contracts");
|
|
|
|
// Which printf specifiers does the authored DescriptionProgress actually
|
|
// use? FillProgressString passes exactly ONE integer, so anything else
|
|
// would be reading past the argument in retail too.
|
|
var specs = new SortedDictionary<string, int>(StringComparer.Ordinal);
|
|
int withProgress = 0;
|
|
foreach (var c in table.Contracts.Values)
|
|
{
|
|
string f = c.DescriptionProgress ?? "";
|
|
if (f.Length == 0) continue;
|
|
withProgress++;
|
|
for (int i = 0; i < f.Length - 1; i++)
|
|
{
|
|
if (f[i] != '%') continue;
|
|
string spec = f.Substring(i, 2);
|
|
specs[spec] = specs.TryGetValue(spec, out int n) ? n + 1 : 1;
|
|
}
|
|
}
|
|
Console.WriteLine($" {withProgress} have a DescriptionProgress; specifiers:");
|
|
foreach (var (spec, n) in specs)
|
|
Console.WriteLine($" {spec} x{n}");
|
|
|
|
int shown = 0;
|
|
foreach (var (key, contract) in table.Contracts.OrderBy(kv => kv.Key))
|
|
{
|
|
if (shown++ >= 5) break;
|
|
Console.WriteLine($" 0x{key:X8} v{contract.Version} \"{contract.ContractName}\"");
|
|
Console.WriteLine($" desc: {contract.Description}");
|
|
Console.WriteLine($" progress: {contract.DescriptionProgress}");
|
|
Console.WriteLine($" npc: {contract.NameNPCStart} -> {contract.NameNPCEnd}");
|
|
Console.WriteLine($" flags: started={contract.QuestflagStarted} "
|
|
+ $"finished={contract.QuestflagFinished} progress={contract.QuestflagProgress} "
|
|
+ $"repeat={contract.QuestflagRepeatTime}");
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
int findAt = Array.IndexOf(args, "--find");
|
|
if (findAt >= 0)
|
|
{
|
|
// "Which layout owns this element?" -- the question every panel port
|
|
// starts with, and the one this tool could not answer. Retail registers a
|
|
// panel class against an ELEMENT id (UIElement::RegisterElementClass), so
|
|
// the decomp hands you an id with no layout attached to it; without a scan
|
|
// the only way across that gap is guessing at 0x21xxxxxx ids.
|
|
uint wantedElement = findAt + 1 < args.Length
|
|
? Convert.ToUInt32(args[findAt + 1], 16)
|
|
: 0u;
|
|
if (wantedElement == 0)
|
|
{
|
|
Console.WriteLine("--find needs an element id");
|
|
return 1;
|
|
}
|
|
|
|
int scanned = 0;
|
|
int hits = 0;
|
|
foreach (uint layoutId in dats.GetAllIdsOfType<DatReaderWriter.DBObjs.LayoutDesc>().OrderBy(i => i))
|
|
{
|
|
scanned++;
|
|
ElementInfo? candidate;
|
|
try
|
|
{
|
|
candidate = LayoutImporter.ImportInfos(adapter, layoutId);
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
// A layout this importer cannot read is a finding, not a stop --
|
|
// the whole point is to sweep every one of them.
|
|
Console.WriteLine($" layout 0x{layoutId:X8}: FAILED TO IMPORT ({e.GetType().Name})");
|
|
continue;
|
|
}
|
|
|
|
if (candidate is null)
|
|
continue;
|
|
|
|
if (FindElement(candidate, wantedElement, out string path))
|
|
{
|
|
hits++;
|
|
Console.WriteLine($"layout 0x{layoutId:X8} {path}");
|
|
}
|
|
}
|
|
|
|
Console.WriteLine();
|
|
Console.WriteLine($"element 0x{wantedElement:X8}: {hits} hit(s) across {scanned} layouts");
|
|
return hits > 0 ? 0 : 2;
|
|
|
|
// Matches an element's ID or its TYPE. Retail's
|
|
// UIElement::RegisterElementClass keys a panel class on the TYPE field
|
|
// (0xC = Text, 0x19 = WaitDialog, 0x1000004B = gmContractsUI), so a class
|
|
// id out of the decomp is a type; an id out of a layout dump is an id.
|
|
// Searching only one of them silently finds the wrong element, because
|
|
// the two share a number space.
|
|
static bool FindElement(ElementInfo e, uint wanted, out string path)
|
|
{
|
|
if (e.Id == wanted || (uint)e.Type == wanted)
|
|
{
|
|
string how = e.Id == wanted ? "id" : "TYPE";
|
|
path = $"0x{e.Id:X8} (match on {how}; type 0x{e.Type:X}, "
|
|
+ $"{e.Width}x{e.Height} at {e.X},{e.Y})";
|
|
return true;
|
|
}
|
|
|
|
foreach (ElementInfo child in e.Children)
|
|
{
|
|
if (FindElement(child, wanted, out path))
|
|
{
|
|
path = $"0x{e.Id:X8} > {path}";
|
|
return true;
|
|
}
|
|
}
|
|
|
|
path = string.Empty;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
var stringResolver = new DatStringResolver(adapter);
|
|
|
|
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)}"));
|
|
|
|
string Describe(UiPropertyValue v) => v.Kind switch
|
|
{
|
|
UiPropertyKind.Bool => v.BoolValue.ToString(),
|
|
UiPropertyKind.Integer => v.IntegerValue.ToString(),
|
|
UiPropertyKind.Enum => $"0x{v.UnsignedValue:X}",
|
|
UiPropertyKind.DataId => $"did:0x{v.UnsignedValue:X8}",
|
|
// An authored StringInfo is a table id + string id, which says
|
|
// nothing on its own -- resolve it, because "what does this
|
|
// label SAY?" is the whole reason to dump properties.
|
|
UiPropertyKind.StringInfo => DescribeString(v.StringInfoValue),
|
|
// A tab table (0x2E) is an array of structs pairing a button
|
|
// id with its page id. Printing "Array" hides the one thing it
|
|
// is for -- and inferring the pairing from x-order instead is
|
|
// exactly the mistake Campaign FA had to correct.
|
|
UiPropertyKind.Array => "[" + string.Join(
|
|
", ", v.ArrayValue.Select(Describe)) + "]",
|
|
UiPropertyKind.Struct => "{" + string.Join(
|
|
", ", v.StructValue.OrderBy(kv => kv.Key)
|
|
.Select(kv => $"0x{kv.Key:X2}={Describe(kv.Value)}")) + "}",
|
|
_ => v.Kind.ToString(),
|
|
};
|
|
|
|
string DescribeString(UiStringInfoValue info)
|
|
{
|
|
string? resolved = stringResolver.Resolve(info.TableId, info.StringId);
|
|
return !string.IsNullOrEmpty(resolved)
|
|
? $"\"{resolved}\""
|
|
: $"StringInfo(table=0x{info.TableId:X8}, id={info.StringId})";
|
|
}
|
|
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);
|
|
}
|