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>
235 lines
10 KiB
C#
235 lines
10 KiB
C#
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.
|
||
}
|