// 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 [rootElementId] [--states]"); Console.WriteLine(" LayoutDump --find "); 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(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(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().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}", // 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), _ => 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); }