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:
Erik 2026-06-25 16:25:23 +02:00
parent 9ed9d8dbd9
commit e3de7f0dab
6 changed files with 117572 additions and 10 deletions

View 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;
}
}