The quest log is on screen. Rows come from the live tracker joined to the authored catalog, the Status column runs QT4's port of FillProgressString, and the detail pane shows contact, locations, description and the other timer. Two things measured rather than assumed, each now pinned by an installed-DAT test rather than left to the commit message: The tab pairing is read from the authored 0x2E table, not inferred from x-order — the FA campaign had to correct exactly that mistake, and Contracts turns out to be the authored DEFAULT tab (0x32 = True), so opening on the wrong one would have looked like an empty panel. The open path needed no keybind at all. Toolbar button 0x1000055A authors 0x10000029 = 0x19 and has been sitting in ToolbarController.PanelButtonIds since the toolbar was ported — it just had no panel behind it, so clicking it did nothing. Registering slot 25 finished a wiring that was already three-quarters present. The list rebuild is revision-gated while the repeat countdown is not: nothing on the wire changes as a cooldown runs down, so a rebuild-gated timer would freeze on screen, and a per-frame rebuild would reset the player's scroll under them. Both directions have a test. Deliberately inert: the Abandon button (retail's abandon path is a contract-registry command this campaign did not port — authored and visible, but wiring a no-op handler would look responsive and lie), and the Journal notes and Page List tabs, which are their own feature. Campaign QT slices 5 and 6 of 6 — code-complete, connected gate owed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
377 lines
15 KiB
C#
377 lines
15 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("--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);
|
|
}
|