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
188
tests/AcDream.App.Tests/Studio/DumpLayoutTests.cs
Normal file
188
tests/AcDream.App.Tests/Studio/DumpLayoutTests.cs
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
using AcDream.App.Studio;
|
||||
using AcDream.App.UI;
|
||||
|
||||
namespace AcDream.App.Tests.Studio;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="DumpLayout"/> — parsing the retail UI dump JSON and
|
||||
/// building a <see cref="UiElement"/> tree from it.
|
||||
///
|
||||
/// These tests load the real dump file from the source tree
|
||||
/// (<c>docs/research/2026-06-25-retail-ui-layout-dump.json</c>). The test
|
||||
/// skips cleanly when the file is absent (should not happen in a normal dev
|
||||
/// checkout, but guards against stripped CI machines).
|
||||
/// </summary>
|
||||
public class DumpLayoutTests
|
||||
{
|
||||
private static string DumpPath()
|
||||
{
|
||||
// Walk up from the test output directory to the solution root,
|
||||
// mirroring ConformanceDats.SolutionRoot().
|
||||
var dir = AppContext.BaseDirectory;
|
||||
while (!string.IsNullOrEmpty(dir))
|
||||
{
|
||||
if (File.Exists(Path.Combine(dir, "AcDream.slnx")))
|
||||
return Path.Combine(dir, "docs", "research",
|
||||
"2026-06-25-retail-ui-layout-dump.json");
|
||||
dir = Path.GetDirectoryName(dir);
|
||||
}
|
||||
// Fallback: try a relative path (won't find it but skip rather than throw)
|
||||
return Path.Combine(AppContext.BaseDirectory,
|
||||
"docs", "research", "2026-06-25-retail-ui-layout-dump.json");
|
||||
}
|
||||
|
||||
private static (uint, int, int) NoTex(uint _) => (1u, 1, 1);
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Depth-first search for an element with the given EventId.</summary>
|
||||
private static UiElement? FindById(UiElement root, uint id)
|
||||
{
|
||||
if (root.EventId == id) return root;
|
||||
foreach (var c in root.Children)
|
||||
{
|
||||
var found = FindById(c, id);
|
||||
if (found is not null) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Count the total elements in the tree (self + all descendants).</summary>
|
||||
private static int CountAll(UiElement root)
|
||||
{
|
||||
int n = 1;
|
||||
foreach (var c in root.Children) n += CountAll(c);
|
||||
return n;
|
||||
}
|
||||
|
||||
// ── Tests ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Loading the "inventory" slug should succeed and the returned tree should
|
||||
/// contain an element with EventId == 0x100001D5 (the doll viewport node)
|
||||
/// and at least 40 elements in total.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Load_Inventory_ReturnsTreeWithDollViewport()
|
||||
{
|
||||
var path = DumpPath();
|
||||
if (!File.Exists(path))
|
||||
return; // Skip: dump not available.
|
||||
|
||||
var root = DumpLayout.Load(path, "inventory", NoTex, out var err);
|
||||
|
||||
Assert.NotNull(root);
|
||||
Assert.Null(err);
|
||||
|
||||
// The doll viewport element must appear somewhere in the tree.
|
||||
const uint dollViewportId = 0x100001D5u;
|
||||
var found = FindById(root!, dollViewportId);
|
||||
Assert.NotNull(found);
|
||||
|
||||
// The full tree must be reasonably deep — 59 dump nodes → >= 40 elements.
|
||||
int total = CountAll(root!);
|
||||
Assert.True(total >= 40,
|
||||
$"Expected >= 40 elements in inventory tree; got {total}");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loading an unknown slug must return null and a non-empty error string.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Load_UnknownSlug_ReturnsNullWithError()
|
||||
{
|
||||
var path = DumpPath();
|
||||
if (!File.Exists(path))
|
||||
return; // Skip.
|
||||
|
||||
var root = DumpLayout.Load(path, "this_slug_does_not_exist", NoTex, out var err);
|
||||
|
||||
Assert.Null(root);
|
||||
Assert.NotNull(err);
|
||||
Assert.NotEmpty(err!);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The root element's Left/Top should be (0,0) (the panel's rect offset has
|
||||
/// been stripped so the tree sits at the window origin), and its Width/Height
|
||||
/// should match the dump panel dimensions.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Load_Inventory_RootAtOrigin()
|
||||
{
|
||||
var path = DumpPath();
|
||||
if (!File.Exists(path))
|
||||
return; // Skip.
|
||||
|
||||
var root = DumpLayout.Load(path, "inventory", NoTex, out _);
|
||||
Assert.NotNull(root);
|
||||
|
||||
// Root always placed at (0,0) by DumpLayout (origin of the UiHost).
|
||||
Assert.Equal(0f, root!.Left);
|
||||
Assert.Equal(0f, root.Top);
|
||||
// Width/Height come from the panel record in the dump.
|
||||
Assert.True(root.Width > 0, "Root width must be > 0");
|
||||
Assert.True(root.Height > 0, "Root height must be > 0");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Children must use parent-relative coordinates (the dump rects are absolute;
|
||||
/// DumpLayout subtracts the parent rect to produce parent-local offsets).
|
||||
/// Verify that at least the direct children of the root have Left/Top values
|
||||
/// that are NOT equal to the absolute rect they had in the dump (since the root
|
||||
/// was at x>0 in screen space but we place it at 0,0).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Load_Inventory_ChildrenAreParentRelative()
|
||||
{
|
||||
var path = DumpPath();
|
||||
if (!File.Exists(path))
|
||||
return; // Skip.
|
||||
|
||||
var root = DumpLayout.Load(path, "inventory", NoTex, out _);
|
||||
Assert.NotNull(root);
|
||||
|
||||
// If the dump has children at absolute x>=500 but the root is at 0,
|
||||
// a correct parent-relative placement will give children x < 500.
|
||||
// (The inventory panel root is at absolute x=500; children in the dump
|
||||
// also start at x=500 — after subtraction they should land near x=0.)
|
||||
bool anyChildAtAbsoluteX = false;
|
||||
foreach (var child in root!.Children)
|
||||
{
|
||||
if (child.Left >= 490f) // would indicate absolute not relative
|
||||
{
|
||||
anyChildAtAbsoluteX = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
Assert.False(anyChildAtAbsoluteX,
|
||||
"Children appear to have absolute coords (Left >= 490) — " +
|
||||
"DumpLayout must subtract the parent rect.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every panel slug known in the dump must load without error.
|
||||
/// This is a smoke test that the JSON parse + tree build does not
|
||||
/// crash on any of the 26 panels.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Load_AllSlugs_Succeed()
|
||||
{
|
||||
var path = DumpPath();
|
||||
if (!File.Exists(path))
|
||||
return; // Skip.
|
||||
|
||||
var slugs = UiDumpModel.ListSlugs(path);
|
||||
Assert.True(slugs.Count >= 20,
|
||||
$"Expected >= 20 panel slugs in dump; got {slugs.Count}");
|
||||
|
||||
foreach (var slug in slugs)
|
||||
{
|
||||
var root = DumpLayout.Load(path, slug, NoTex, out var err);
|
||||
Assert.True(root is not null || err is not null,
|
||||
$"Load('{slug}') returned both null root AND null error — one must be set.");
|
||||
if (root is null)
|
||||
Assert.Fail($"Slug '{slug}' failed with error: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue