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
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