feat(studio): dump LayoutSource — preview any retail window from the UI dump
Adds Task 4b: a second load path for the UI Studio that reads the committed retail UI layout dump (docs/research/2026-06-25-retail-ui-layout-dump.json) and renders any of the 26 retail windows as a static sprite hierarchy. New files: - src/AcDream.App/Studio/UiDumpModel.cs — POCOs + System.Text.Json parse of the dump (UiDump, DumpPanel, DumpNode, DumpRect, DumpStateSet, DumpImage, DumpState + UiDumpModel static helpers: Parse, ListSlugs, PickImageId). - src/AcDream.App/Studio/DumpLayout.cs — DumpLayout.Load(path, slug, resolve, out err): parses the dump, finds the panel by slug, builds a UiElement tree. Internal DumpSpriteElement draws its sprite via DrawSprite (not reusing UiDatElement — avoids the ElementInfo/StateMedia dat-import dependency for this static mockup). DumpGroupElement is a transparent container for Group nodes. Rect basis is ABSOLUTE in the dump (verified: inventory root at absolute x=500 and its children also start at x≈500 — child offset from parent is 0–50px, not 500px); DumpLayout subtracts parent rect to produce parent-relative Left/Top for each child. - tests/AcDream.App.Tests/Studio/DumpLayoutTests.cs — 5 tests covering: inventory load with 0x100001D5 check + >= 40 nodes, unknown slug → null+err, root at origin, children are parent-relative, all 26 slugs smoke-load. Modified files: - StudioOptions: adds DumpSlug + DumpFile fields; --dump <slug> and --dump-file <path> args; ResolveDumpFile() walks up to the solution root to find the default dump JSON (mirrors ConformanceDats.SolutionRoot()). --dump suppresses the default vitals layout so the two modes are exclusive. - StudioWindow.OnLoad: when DumpSlug is set, loads via DumpLayout (no FixtureProvider, no controllers — static structure only); else falls through to the existing LayoutSource + FixtureProvider path. Results: DumpLayoutTests 5/5 passed; full AcDream.App.Tests 609 passed, 2 skipped (same as before); dotnet build green, 0 warnings. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
9ed9d8dbd9
commit
e3de7f0dab
6 changed files with 117572 additions and 10 deletions
235
src/AcDream.App/Studio/DumpLayout.cs
Normal file
235
src/AcDream.App/Studio/DumpLayout.cs
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.UI;
|
||||
|
||||
namespace AcDream.App.Studio;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// DumpLayout — load a panel from the retail UI layout dump
|
||||
//
|
||||
// The dump stores every node's rect in ABSOLUTE screen coordinates (the
|
||||
// panel's design position in the retail UI, not relative to its parent).
|
||||
// Evidence: for the "inventory" panel, the root node is at x=500,y=138 and
|
||||
// its direct children are also at x=500,y=161 — the child y=161 is only
|
||||
// 23 pixels below the parent y=138, which makes sense as a child offset
|
||||
// (the header row), not as the raw rect. If the rects were parent-relative,
|
||||
// (500,161) would place the child way off the window.
|
||||
//
|
||||
// DumpLayout converts absolute → parent-relative by computing:
|
||||
// child.Left = child.Rect.X - parent.Rect.X
|
||||
// child.Top = child.Rect.Y - parent.Rect.Y
|
||||
//
|
||||
// The root node (ParentTraversalIndex == null) is placed at (0,0) so the
|
||||
// whole tree sits at the UiHost origin rather than at the panel's retail
|
||||
// screen position.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Builds a static <see cref="UiElement"/> tree from the retail UI layout dump
|
||||
/// JSON. The tree is a hierarchy of <see cref="DumpSpriteElement"/> (draws its
|
||||
/// sprite) or plain <see cref="UiElement"/> containers (Group nodes), with each
|
||||
/// node's <see cref="UiElement.EventId"/> set to the dump's element_id and
|
||||
/// <see cref="UiElement.Name"/> set to the widget_kind string.
|
||||
///
|
||||
/// <para>This source is STATIC — no controllers, no FixtureProvider, no live
|
||||
/// game data. It is a build reference for the UI Studio showing any of the 26
|
||||
/// retail windows without needing the production panel wired up.</para>
|
||||
/// </summary>
|
||||
public static class DumpLayout
|
||||
{
|
||||
/// <summary>
|
||||
/// Parse the dump at <paramref name="dumpPath"/>, find the panel whose slug
|
||||
/// matches <paramref name="slug"/>, and build a <see cref="UiElement"/> tree.
|
||||
///
|
||||
/// <para>
|
||||
/// <paramref name="resolve"/> maps a RenderSurface id (0x06xxxxxx) to a
|
||||
/// (GL texture handle, native width, native height) triple — pass
|
||||
/// <c>RenderStack.ResolveChrome</c> from the studio, or a stub returning
|
||||
/// (1,1,1) for tests.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>Returns null and sets <paramref name="error"/> on failure.</para>
|
||||
/// </summary>
|
||||
public static UiElement? Load(
|
||||
string dumpPath,
|
||||
string slug,
|
||||
Func<uint, (uint, int, int)> resolve,
|
||||
out string? error)
|
||||
{
|
||||
// ── 1. Parse the dump JSON ────────────────────────────────────────
|
||||
var dump = UiDumpModel.Parse(dumpPath);
|
||||
if (dump is null)
|
||||
{
|
||||
error = $"[dump] Failed to parse '{dumpPath}'.";
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── 2. Find the requested panel ───────────────────────────────────
|
||||
var panel = dump.Panels.FirstOrDefault(
|
||||
p => string.Equals(p.Slug, slug, StringComparison.OrdinalIgnoreCase));
|
||||
if (panel is null)
|
||||
{
|
||||
error = $"[dump] Panel slug '{slug}' not found. " +
|
||||
$"Available: {string.Join(", ", dump.Panels.Select(p => p.Slug))}";
|
||||
return null;
|
||||
}
|
||||
|
||||
if (panel.Nodes.Count == 0)
|
||||
{
|
||||
error = $"[dump] Panel '{slug}' has no nodes.";
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── 3. Build a traversal-index → node lookup ──────────────────────
|
||||
var byIndex = new Dictionary<int, DumpNode>(panel.Nodes.Count);
|
||||
foreach (var n in panel.Nodes)
|
||||
byIndex[n.TraversalIndex] = n;
|
||||
|
||||
// ── 4. Create UiElement objects for every node ────────────────────
|
||||
var elements = new Dictionary<int, UiElement>(panel.Nodes.Count);
|
||||
foreach (var node in panel.Nodes)
|
||||
{
|
||||
var el = BuildElement(node, resolve);
|
||||
elements[node.TraversalIndex] = el;
|
||||
}
|
||||
|
||||
// ── 5. Wire parent–child relationships + set parent-relative coords ─
|
||||
UiElement? root = null;
|
||||
foreach (var node in panel.Nodes)
|
||||
{
|
||||
var el = elements[node.TraversalIndex];
|
||||
|
||||
if (node.ParentTraversalIndex is null)
|
||||
{
|
||||
// Root node — place at (0,0) so the tree sits at the UiHost origin.
|
||||
// The panel's absolute rect offset is discarded here (it was the
|
||||
// retail design position inside the retail screen, which we don't need).
|
||||
el.Left = 0f;
|
||||
el.Top = 0f;
|
||||
root = el;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Non-root: convert absolute → parent-relative by subtracting parent rect.
|
||||
// child.Left = child.Rect.X - parent.Rect.X
|
||||
// child.Top = child.Rect.Y - parent.Rect.Y
|
||||
// This preserves the visual layout inside each group without placing the
|
||||
// entire panel at its retail screen origin.
|
||||
var parentNode = byIndex[node.ParentTraversalIndex.Value];
|
||||
el.Left = node.Rect.X - parentNode.Rect.X;
|
||||
el.Top = node.Rect.Y - parentNode.Rect.Y;
|
||||
|
||||
var parentEl = elements[node.ParentTraversalIndex.Value];
|
||||
parentEl.AddChild(el);
|
||||
}
|
||||
}
|
||||
|
||||
if (root is null)
|
||||
{
|
||||
error = $"[dump] Panel '{slug}': no root node found (all nodes have a parent).";
|
||||
return null;
|
||||
}
|
||||
|
||||
// Give the root the full panel dimensions (from the dump's width/height record).
|
||||
root.Width = panel.Width;
|
||||
root.Height = panel.Height;
|
||||
|
||||
error = null;
|
||||
return root;
|
||||
}
|
||||
|
||||
// ── Private helpers ───────────────────────────────────────────────────────
|
||||
|
||||
private static UiElement BuildElement(
|
||||
DumpNode node,
|
||||
Func<uint, (uint, int, int)> resolve)
|
||||
{
|
||||
uint imageId = UiDumpModel.PickImageId(node);
|
||||
var kind = node.WidgetKind ?? "Group";
|
||||
|
||||
UiElement el;
|
||||
if (imageId != 0 && !string.Equals(kind, "Group", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Sprite/Button/Scrollbar/Slider — create a sprite-drawing element.
|
||||
el = new DumpSpriteElement(imageId, resolve)
|
||||
{
|
||||
Name = kind,
|
||||
ClickThrough = true, // static mockup; no behavior
|
||||
Anchors = AnchorEdges.None,
|
||||
};
|
||||
}
|
||||
else
|
||||
{
|
||||
// Group (or sprite without an image) — plain container, no own draw.
|
||||
el = new DumpGroupElement()
|
||||
{
|
||||
Name = kind,
|
||||
ClickThrough = true,
|
||||
Anchors = AnchorEdges.None,
|
||||
};
|
||||
}
|
||||
|
||||
// EventId is set from the dump's element_id (cast to uint — the decimal
|
||||
// values in the JSON represent the same dat handle used at runtime).
|
||||
el.EventId = (uint)node.ElementId;
|
||||
el.Left = node.Rect.X; // overwritten by caller per root/child logic
|
||||
el.Top = node.Rect.Y;
|
||||
el.Width = node.Rect.Width;
|
||||
el.Height = node.Rect.Height;
|
||||
|
||||
return el;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// DumpSpriteElement — minimal element that draws a single sprite
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Draws a single sprite at its native size tiled to fill <see cref="UiElement.Width"/>
|
||||
/// × <see cref="UiElement.Height"/>. Used for Sprite/Button/Scrollbar/Slider nodes from
|
||||
/// the retail UI dump.
|
||||
///
|
||||
/// <para>We do NOT reuse <see cref="AcDream.App.UI.Layout.UiDatElement"/> here because
|
||||
/// that class requires an <c>ElementInfo</c> with a populated <c>StateMedia</c>
|
||||
/// dictionary — the dat-import plumbing — which is not needed for a static dump
|
||||
/// preview. A minimal subclass keeps the code simpler and the dependency surface
|
||||
/// smaller.</para>
|
||||
/// </summary>
|
||||
internal sealed class DumpSpriteElement : UiElement
|
||||
{
|
||||
private readonly uint _imageId;
|
||||
private readonly Func<uint, (uint tex, int w, int h)> _resolve;
|
||||
|
||||
public DumpSpriteElement(uint imageId, Func<uint, (uint tex, int w, int h)> resolve)
|
||||
{
|
||||
_imageId = imageId;
|
||||
_resolve = resolve;
|
||||
}
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
if (_imageId == 0) return;
|
||||
|
||||
var (tex, tw, th) = _resolve(_imageId);
|
||||
if (tex == 0 || tw == 0 || th == 0) return;
|
||||
|
||||
// Tile at native resolution (same as UiDatElement.OnDraw — UV-repeat on both
|
||||
// axes via GL_REPEAT, Width/tw and Height/th tile the texture).
|
||||
ctx.DrawSprite(tex, 0, 0, Width, Height,
|
||||
0, 0, Width / tw, Height / th, Vector4.One);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// DumpGroupElement — pure container (Group nodes from the dump)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Container element for dump Group nodes — no own draw, just hosts children.
|
||||
/// Extending UiElement directly (no OnDraw override) gives transparent groups,
|
||||
/// which matches Group nodes in the retail layout that have no background sprite.
|
||||
/// </summary>
|
||||
internal sealed class DumpGroupElement : UiElement
|
||||
{
|
||||
// No OnDraw — completely transparent container.
|
||||
}
|
||||
|
|
@ -5,7 +5,12 @@ namespace AcDream.App.Studio;
|
|||
/// Constructed by <see cref="Parse"/> from the command-line tokens that follow
|
||||
/// the <c>ui-studio</c> dispatch token.
|
||||
/// </summary>
|
||||
public sealed record StudioOptions(string DatDir, uint? LayoutId, string? MarkupPath)
|
||||
public sealed record StudioOptions(
|
||||
string DatDir,
|
||||
uint? LayoutId,
|
||||
string? MarkupPath,
|
||||
string? DumpSlug = null,
|
||||
string? DumpFile = null)
|
||||
{
|
||||
/// <summary>
|
||||
/// Parse studio options from the args that come AFTER the <c>ui-studio</c> token.
|
||||
|
|
@ -14,14 +19,21 @@ public sealed record StudioOptions(string DatDir, uint? LayoutId, string? Markup
|
|||
/// <c>ACDREAM_DAT_DIR</c> when omitted.</para>
|
||||
/// <para><c>--layout 0xNNNN</c>: hex LayoutDesc dat id to preview.</para>
|
||||
/// <para><c>--markup <path></c>: path to a KSML markup file (Task 6, unsupported for now).</para>
|
||||
/// <para>When neither <c>--layout</c> nor <c>--markup</c> is given the default layout
|
||||
/// <c>0x2100006C</c> (vitals) is used.</para>
|
||||
/// <para><c>--dump <slug></c>: load a panel from the retail UI dump JSON by slug
|
||||
/// (e.g. <c>inventory</c>, <c>radar</c>, <c>toolbar</c>). Static mockup — no controllers.</para>
|
||||
/// <para><c>--dump-file <path></c>: override the default dump file path
|
||||
/// (<c>docs/research/2026-06-25-retail-ui-layout-dump.json</c> from the solution root).
|
||||
/// Only meaningful when <c>--dump</c> is also given.</para>
|
||||
/// <para>When neither <c>--layout</c>, <c>--markup</c>, nor <c>--dump</c> is given the
|
||||
/// default layout <c>0x2100006C</c> (vitals) is used.</para>
|
||||
/// </summary>
|
||||
public static StudioOptions Parse(string[] args)
|
||||
{
|
||||
string? datDir = null;
|
||||
uint? layoutId = null;
|
||||
string? markupPath = null;
|
||||
string? dumpSlug = null;
|
||||
string? dumpFile = null;
|
||||
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
|
|
@ -38,6 +50,14 @@ public sealed record StudioOptions(string DatDir, uint? LayoutId, string? Markup
|
|||
{
|
||||
markupPath = args[++i];
|
||||
}
|
||||
else if (args[i] == "--dump" && i + 1 < args.Length)
|
||||
{
|
||||
dumpSlug = args[++i];
|
||||
}
|
||||
else if (args[i] == "--dump-file" && i + 1 < args.Length)
|
||||
{
|
||||
dumpFile = args[++i];
|
||||
}
|
||||
else if (!args[i].StartsWith('-'))
|
||||
{
|
||||
datDir ??= args[i];
|
||||
|
|
@ -51,10 +71,37 @@ public sealed record StudioOptions(string DatDir, uint? LayoutId, string? Markup
|
|||
throw new InvalidOperationException(
|
||||
"ui-studio: dat directory required — pass as first arg or set ACDREAM_DAT_DIR.");
|
||||
|
||||
// Default layout: vitals (0x2100006C).
|
||||
if (layoutId is null && markupPath is null)
|
||||
// Default layout: vitals (0x2100006C), unless a dump slug or markup is requested.
|
||||
if (layoutId is null && markupPath is null && dumpSlug is null)
|
||||
layoutId = 0x2100006Cu;
|
||||
|
||||
return new StudioOptions(datDir, layoutId, markupPath);
|
||||
return new StudioOptions(datDir, layoutId, markupPath, dumpSlug, dumpFile);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the dump file path for this session:
|
||||
/// <list type="bullet">
|
||||
/// <item><see cref="DumpFile"/> if explicitly set.</item>
|
||||
/// <item>Otherwise <c><solutionRoot>/docs/research/2026-06-25-retail-ui-layout-dump.json</c>.</item>
|
||||
/// </list>
|
||||
/// Returns null when neither the override nor the default file exists.
|
||||
/// </summary>
|
||||
public string? ResolveDumpFile()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(DumpFile))
|
||||
return DumpFile;
|
||||
|
||||
// Walk up from the App binary to the solution root (same approach as
|
||||
// ConformanceDats.SolutionRoot in the test project).
|
||||
var dir = AppContext.BaseDirectory;
|
||||
while (!string.IsNullOrEmpty(dir))
|
||||
{
|
||||
var candidate = Path.Combine(dir, "docs", "research",
|
||||
"2026-06-25-retail-ui-layout-dump.json");
|
||||
if (File.Exists(candidate))
|
||||
return candidate;
|
||||
dir = Path.GetDirectoryName(dir);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,8 +117,34 @@ public sealed class StudioWindow : IDisposable
|
|||
_stack.UiHost.WireKeyboard(kb);
|
||||
|
||||
// Load the panel described by options and add it to the UI tree.
|
||||
// Task 4b: --dump <slug> uses DumpLayout (static retail mockup, no controllers).
|
||||
// All other modes use LayoutSource + FixtureProvider (production path).
|
||||
_source = new LayoutSource(_dats, _stack.ResolveChrome, _stack.VitalsDatFont);
|
||||
var root = _source.Load(_opts);
|
||||
UiElement? root;
|
||||
if (_opts.DumpSlug is not null)
|
||||
{
|
||||
var dumpFile = _opts.ResolveDumpFile();
|
||||
if (dumpFile is null)
|
||||
{
|
||||
Console.Error.WriteLine("[studio] --dump: retail UI dump file not found. " +
|
||||
"Expected docs/research/2026-06-25-retail-ui-layout-dump.json in the source tree, " +
|
||||
"or pass --dump-file <path>.");
|
||||
root = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
root = DumpLayout.Load(dumpFile, _opts.DumpSlug, _stack.ResolveChrome, out var dumpErr);
|
||||
if (root is null)
|
||||
Console.Error.WriteLine($"[studio] dump load failed: {dumpErr}");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
root = _source.Load(_opts);
|
||||
if (root is null)
|
||||
Console.Error.WriteLine($"[studio] panel load failed: {_source.LastError}");
|
||||
}
|
||||
|
||||
_panelRoot = root;
|
||||
if (root is not null)
|
||||
{
|
||||
|
|
@ -126,15 +152,14 @@ public sealed class StudioWindow : IDisposable
|
|||
|
||||
// Task 4: populate the panel with sample data via production controllers,
|
||||
// so inventory / vitals / toolbar panels render with plausible content.
|
||||
if (_source.CurrentLayout is not null)
|
||||
// Dump source is static — no FixtureProvider needed.
|
||||
if (_opts.DumpSlug is null && _source.CurrentLayout is not null)
|
||||
{
|
||||
uint layoutId = _opts.LayoutId ?? 0x2100006Cu;
|
||||
_objects = SampleData.BuildObjectTable();
|
||||
FixtureProvider.Populate(layoutId, _source.CurrentLayout, _stack, _objects, _dats);
|
||||
}
|
||||
}
|
||||
else
|
||||
Console.Error.WriteLine($"[studio] panel load failed: {_source.LastError}");
|
||||
|
||||
// Task 3: ImGui IDE. The studio is always "devtools" — no gate flag needed.
|
||||
_imgui = new AcDream.UI.ImGui.ImGuiBootstrapper(gl, _window!, input);
|
||||
|
|
|
|||
198
src/AcDream.App/Studio/UiDumpModel.cs
Normal file
198
src/AcDream.App/Studio/UiDumpModel.cs
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AcDream.App.Studio;
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// UiDumpModel — POCOs for docs/research/2026-06-25-retail-ui-layout-dump.json
|
||||
//
|
||||
// Schema (v1):
|
||||
// { "version":1, "panels":[ { "id":int, "slug":string, "title":string,
|
||||
// "bucket":string, "parent_slug":string|null,
|
||||
// "width":int, "height":int,
|
||||
// "nodes":[ { "traversal_index":int, "element_id":int,
|
||||
// "layout_id":int, "parent_layout_id":int|null,
|
||||
// "parent_traversal_index":int|null, "base_layout_id":int,
|
||||
// "rect":{x,y,width,height},
|
||||
// "widget_kind":"Group"|"Sprite"|"Button"|"Scrollbar"|"Slider",
|
||||
// "state_set":{ "default_image":{image_id,alpha_image_id}|null,
|
||||
// "states":[{state_id,image:{...}}] }
|
||||
// } ] } ] }
|
||||
//
|
||||
// All ids in the dump are DECIMAL ints (e.g. element_id=268435925 = 0x100001D5,
|
||||
// image_id=100693194 = 0x060074CA). Cast to uint before use in dat/GL APIs.
|
||||
//
|
||||
// Rect coordinates are ABSOLUTE (screen-space origin = panel's design position
|
||||
// in retail layout, NOT relative to the parent). DumpLayout.Load converts them
|
||||
// to parent-relative when building the UiElement tree.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Top-level container for the retail UI layout dump.</summary>
|
||||
public sealed class UiDump
|
||||
{
|
||||
[JsonPropertyName("version")]
|
||||
public int Version { get; set; }
|
||||
|
||||
[JsonPropertyName("panels")]
|
||||
public List<DumpPanel> Panels { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>One panel (window) exported from the retail UI.</summary>
|
||||
public sealed class DumpPanel
|
||||
{
|
||||
[JsonPropertyName("id")]
|
||||
public long Id { get; set; }
|
||||
|
||||
[JsonPropertyName("slug")]
|
||||
public string Slug { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("title")]
|
||||
public string Title { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("bucket")]
|
||||
public string Bucket { get; set; } = "";
|
||||
|
||||
[JsonPropertyName("parent_slug")]
|
||||
public string? ParentSlug { get; set; }
|
||||
|
||||
[JsonPropertyName("width")]
|
||||
public float Width { get; set; }
|
||||
|
||||
[JsonPropertyName("height")]
|
||||
public float Height { get; set; }
|
||||
|
||||
[JsonPropertyName("nodes")]
|
||||
public List<DumpNode> Nodes { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>One element node within a panel's traversal list.</summary>
|
||||
public sealed class DumpNode
|
||||
{
|
||||
[JsonPropertyName("traversal_index")]
|
||||
public int TraversalIndex { get; set; }
|
||||
|
||||
[JsonPropertyName("element_id")]
|
||||
public long ElementId { get; set; }
|
||||
|
||||
[JsonPropertyName("layout_id")]
|
||||
public long LayoutId { get; set; }
|
||||
|
||||
[JsonPropertyName("parent_layout_id")]
|
||||
public long? ParentLayoutId { get; set; }
|
||||
|
||||
[JsonPropertyName("parent_traversal_index")]
|
||||
public int? ParentTraversalIndex { get; set; }
|
||||
|
||||
[JsonPropertyName("base_layout_id")]
|
||||
public long BaseLayoutId { get; set; }
|
||||
|
||||
[JsonPropertyName("rect")]
|
||||
public DumpRect Rect { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("widget_kind")]
|
||||
public string WidgetKind { get; set; } = "Group";
|
||||
|
||||
[JsonPropertyName("state_set")]
|
||||
public DumpStateSet StateSet { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Absolute screen-space rect (see comment above — must subtract parent rect for UiElement).</summary>
|
||||
public sealed class DumpRect
|
||||
{
|
||||
[JsonPropertyName("x")]
|
||||
public float X { get; set; }
|
||||
|
||||
[JsonPropertyName("y")]
|
||||
public float Y { get; set; }
|
||||
|
||||
[JsonPropertyName("width")]
|
||||
public float Width { get; set; }
|
||||
|
||||
[JsonPropertyName("height")]
|
||||
public float Height { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>State set for a node — default image plus per-state overrides.</summary>
|
||||
public sealed class DumpStateSet
|
||||
{
|
||||
[JsonPropertyName("default_image")]
|
||||
public DumpImage? DefaultImage { get; set; }
|
||||
|
||||
[JsonPropertyName("states")]
|
||||
public List<DumpState> States { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>Image reference (RenderSurface dat id + optional separate alpha surface).</summary>
|
||||
public sealed class DumpImage
|
||||
{
|
||||
[JsonPropertyName("image_id")]
|
||||
public long ImageId { get; set; }
|
||||
|
||||
[JsonPropertyName("alpha_image_id")]
|
||||
public long? AlphaImageId { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>A named state override.</summary>
|
||||
public sealed class DumpState
|
||||
{
|
||||
[JsonPropertyName("state_id")]
|
||||
public int StateId { get; set; }
|
||||
|
||||
[JsonPropertyName("image")]
|
||||
public DumpImage Image { get; set; } = new();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Helper statics
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Parsing helpers for the retail UI dump JSON.
|
||||
/// </summary>
|
||||
public static class UiDumpModel
|
||||
{
|
||||
private static readonly JsonSerializerOptions _opts = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
AllowTrailingCommas = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
};
|
||||
|
||||
/// <summary>Parse the full dump from a file path. Returns null on failure.</summary>
|
||||
public static UiDump? Parse(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var stream = File.OpenRead(path);
|
||||
return JsonSerializer.Deserialize<UiDump>(stream, _opts);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Return the list of slugs in the dump (for smoke-testing every panel).
|
||||
/// Returns an empty list if the file cannot be parsed.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string> ListSlugs(string path)
|
||||
{
|
||||
var dump = Parse(path);
|
||||
if (dump is null) return Array.Empty<string>();
|
||||
return dump.Panels.Select(p => p.Slug).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pick the sprite id to use for a node: prefer default_image.image_id;
|
||||
/// fall back to states[0].image.image_id; return 0 if neither exists.
|
||||
/// </summary>
|
||||
public static uint PickImageId(DumpNode node)
|
||||
{
|
||||
if (node.StateSet.DefaultImage is { ImageId: > 0 } di)
|
||||
return (uint)di.ImageId;
|
||||
if (node.StateSet.States.Count > 0 && node.StateSet.States[0].Image.ImageId > 0)
|
||||
return (uint)node.StateSet.States[0].Image.ImageId;
|
||||
return 0u;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue