feat(D.2b): ElementReader — layout inheritance merge + edge-flag anchors
Implements Task 2 of the LayoutDesc Importer (Plan 1 — vitals conformance). - ElementInfo POCO: GL-free/dat-free snapshot of a resolved layout element. Shape matches the plan spec exactly (Id, Type as uint, X/Y/Width/Height as float, raw Left/Top/Right/Bottom uint edge flags, ReadOrder, FontDid, StateMedia dict, Children list). Tasks 3–6 depend on this shape. - ElementReader.ToAnchors(uint,uint,uint,uint): maps dat edge-flag values (0=none, 1=near-pin, 2=far-pin, 3=floating-center, 4=stretch) to AnchorEdges bit flags. Corrects the plan's stale assumption that value 4 was the only anchor trigger; the verified format doc §4 shows 1→Left/Top, 2→Right/Bottom, 4→both. All-zero falls back to Left|Top (default pin top-left). - ElementReader.Merge(base_, derived): inheritance merge mirroring BaseElement/ BaseLayoutId. Derived scalars win when non-zero; position/edge-flags/ReadOrder always from derived; StateMedia merged (base defaults, derived overrides); Children from derived only. TDD: tests written first (9 tests covering ToAnchors near-pin/far-pin/stretch/ zero/value-3, Merge scalar override/font inheritance/StateMedia merge/children). All 9 pass; dotnet build 0 errors 0 warnings. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
67819f35a4
commit
f73422a79a
2 changed files with 304 additions and 0 deletions
165
src/AcDream.App/UI/Layout/ElementReader.cs
Normal file
165
src/AcDream.App/UI/Layout/ElementReader.cs
Normal file
|
|
@ -0,0 +1,165 @@
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace AcDream.App.UI.Layout;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// GL-free, dat-free snapshot of a resolved layout element.
|
||||||
|
/// Populated by the LayoutDesc importer from <c>DatReaderWriter.ElementDesc</c>
|
||||||
|
/// after inheritance is applied. The pure transforms on <see cref="ElementReader"/>
|
||||||
|
/// operate on this type so they can be unit-tested without the dats or OpenGL.
|
||||||
|
///
|
||||||
|
/// IMPORTANT: Tasks 3–6 depend on this shape exactly. Do not add members without
|
||||||
|
/// updating the plan spec and downstream consumers.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ElementInfo
|
||||||
|
{
|
||||||
|
/// <summary>Dat element id (e.g. <c>0x100000E6</c>).</summary>
|
||||||
|
public uint Id;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raw element class id as a uint.
|
||||||
|
/// Game-specific ids like <c>0x1000004D</c> (gmVitalsUI root) and <c>0x10000009</c>
|
||||||
|
/// overflow <c>int</c> when treated as signed, so this stays <c>uint</c>.
|
||||||
|
/// Known values: 0=text, 2=dragbar, 3=container/chrome, 7=meter,
|
||||||
|
/// 9=resize-grip, 12=style-prototype (skip), 0x10000009/0x1000004D=window root.
|
||||||
|
/// </summary>
|
||||||
|
public uint Type;
|
||||||
|
|
||||||
|
/// <summary>Position and size within the parent, in pixels (cast from dat uint fields).</summary>
|
||||||
|
public float X, Y, Width, Height;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raw edge-anchor flag values from the dat (<c>LeftEdge</c>, <c>TopEdge</c>,
|
||||||
|
/// <c>RightEdge</c>, <c>BottomEdge</c> fields of <c>ElementDesc</c>).
|
||||||
|
/// Values 0–4; map to <see cref="AnchorEdges"/> bit-flags via
|
||||||
|
/// <see cref="ElementReader.ToAnchors"/>.
|
||||||
|
/// </summary>
|
||||||
|
public uint Left, Top, Right, Bottom;
|
||||||
|
|
||||||
|
/// <summary>Draw order within the parent (lower = drawn first / behind).</summary>
|
||||||
|
public uint ReadOrder;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Font dat object id inherited from the base element's <c>Properties[0x1A]</c>
|
||||||
|
/// (<c>ArrayBaseProperty → DataIdBaseProperty</c>). 0 = none / not inherited.
|
||||||
|
/// </summary>
|
||||||
|
public uint FontDid;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sprite per state: state name → (RenderSurface file id, DrawMode int).
|
||||||
|
/// The <c>""</c> key represents the unnamed DirectState (<c>ElementDesc.StateDesc</c>).
|
||||||
|
/// Named states use the <c>UIStateId.ToString()</c> value as the key
|
||||||
|
/// (e.g. <c>"HideDetail"</c>, <c>"ShowDetail"</c>).
|
||||||
|
/// </summary>
|
||||||
|
public Dictionary<string, (uint File, int DrawMode)> StateMedia = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolved child elements (populated by the importer in Task 5).
|
||||||
|
/// Children come from the derived element's own tree, not the base element's.
|
||||||
|
/// </summary>
|
||||||
|
public List<ElementInfo> Children = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure, GL-free, dat-free transforms for the LayoutDesc importer.
|
||||||
|
/// All methods are static and operate on <see cref="ElementInfo"/> POCOs.
|
||||||
|
/// No OpenGL, no DatReaderWriter types, no rendering dependencies beyond
|
||||||
|
/// the <see cref="AnchorEdges"/> bit-flag enum from <c>AcDream.App.UI</c>.
|
||||||
|
/// </summary>
|
||||||
|
public static class ElementReader
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Maps the four raw edge-anchor flag values from <c>ElementDesc</c> to the
|
||||||
|
/// <see cref="AnchorEdges"/> bit-flag used by the UI layout engine.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// The dat stores one <c>uint</c> per edge with these semantics (§4 of the
|
||||||
|
/// LayoutDesc format reference, 2026-06-15):
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description>0 = no anchor (prototype-only elements — zero-size style stores)</description></item>
|
||||||
|
/// <item><description>1 = pinned to the <em>near</em> edge (left for LeftEdge, top for TopEdge)</description></item>
|
||||||
|
/// <item><description>2 = pinned to the <em>far</em> edge (right for RightEdge, bottom for BottomEdge)</description></item>
|
||||||
|
/// <item><description>3 = floating / centered between both far edges (maps to neither Left nor Right)</description></item>
|
||||||
|
/// <item><description>4 = stretch: pinned to BOTH near AND far edges simultaneously (element stretches with parent)</description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </para>
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Default when no flags resolve: <c>Left | Top</c> (pin top-left, fixed size).
|
||||||
|
/// This matches elements whose all-zero edge flags indicate a no-reflow prototype.
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="left">LeftEdge dat field value (0–4).</param>
|
||||||
|
/// <param name="top">TopEdge dat field value (0–4).</param>
|
||||||
|
/// <param name="right">RightEdge dat field value (0–4).</param>
|
||||||
|
/// <param name="bottom">BottomEdge dat field value (0–4).</param>
|
||||||
|
public static AnchorEdges ToAnchors(uint left, uint top, uint right, uint bottom)
|
||||||
|
{
|
||||||
|
// 1 = near-pin, 2 = far-pin, 3 = both-far (floating center), 4 = stretch (both sides).
|
||||||
|
// Only 1 and 4 contribute the NEAR (Left/Top) anchor.
|
||||||
|
// Only 2 and 4 contribute the FAR (Right/Bottom) anchor.
|
||||||
|
// Value 3 contributes neither (floating center is handled by the UI engine differently).
|
||||||
|
var a = AnchorEdges.None;
|
||||||
|
if (left == 1 || left == 4) a |= AnchorEdges.Left;
|
||||||
|
if (top == 1 || top == 4) a |= AnchorEdges.Top;
|
||||||
|
if (right == 2 || right == 4) a |= AnchorEdges.Right;
|
||||||
|
if (bottom == 2 || bottom == 4) a |= AnchorEdges.Bottom;
|
||||||
|
if (a == AnchorEdges.None) a = AnchorEdges.Left | AnchorEdges.Top; // default: pin top-left
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Merges a base element snapshot with a derived element snapshot, mirroring
|
||||||
|
/// the <c>BaseElement</c> / <c>BaseLayoutId</c> inheritance chain in the dat.
|
||||||
|
///
|
||||||
|
/// <para>
|
||||||
|
/// Rules:
|
||||||
|
/// <list type="bullet">
|
||||||
|
/// <item><description>
|
||||||
|
/// Scalar fields (<see cref="ElementInfo.Id"/>, <see cref="ElementInfo.Type"/>,
|
||||||
|
/// <see cref="ElementInfo.Width"/>, <see cref="ElementInfo.Height"/>,
|
||||||
|
/// <see cref="ElementInfo.FontDid"/>): derived wins if non-zero; otherwise
|
||||||
|
/// inherited from base.
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// Position (<see cref="ElementInfo.X"/>, <see cref="ElementInfo.Y"/>) and
|
||||||
|
/// edge flags (<see cref="ElementInfo.Left"/> etc.) and
|
||||||
|
/// <see cref="ElementInfo.ReadOrder"/>: always taken from the derived element
|
||||||
|
/// (derived placement, not the base prototype's geometry).
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// <see cref="ElementInfo.StateMedia"/>: base entries are the default; derived
|
||||||
|
/// entries override (or add) per state name key.
|
||||||
|
/// </description></item>
|
||||||
|
/// <item><description>
|
||||||
|
/// <see cref="ElementInfo.Children"/>: come from the derived element's own tree only.
|
||||||
|
/// </description></item>
|
||||||
|
/// </list>
|
||||||
|
/// </para>
|
||||||
|
/// </summary>
|
||||||
|
public static ElementInfo Merge(ElementInfo base_, ElementInfo derived)
|
||||||
|
{
|
||||||
|
var m = new ElementInfo
|
||||||
|
{
|
||||||
|
Id = derived.Id != 0 ? derived.Id : base_.Id,
|
||||||
|
Type = derived.Type != 0 ? derived.Type : base_.Type,
|
||||||
|
X = derived.X,
|
||||||
|
Y = derived.Y,
|
||||||
|
Width = derived.Width != 0 ? derived.Width : base_.Width,
|
||||||
|
Height = derived.Height != 0 ? derived.Height : base_.Height,
|
||||||
|
Left = derived.Left,
|
||||||
|
Top = derived.Top,
|
||||||
|
Right = derived.Right,
|
||||||
|
Bottom = derived.Bottom,
|
||||||
|
ReadOrder = derived.ReadOrder,
|
||||||
|
FontDid = derived.FontDid != 0 ? derived.FontDid : base_.FontDid,
|
||||||
|
// Children come from the derived element's own tree, not the base prototype's.
|
||||||
|
Children = derived.Children,
|
||||||
|
};
|
||||||
|
// Start with base StateMedia as defaults, then let derived entries override.
|
||||||
|
m.StateMedia = new Dictionary<string, (uint, int)>(base_.StateMedia);
|
||||||
|
foreach (var kv in derived.StateMedia)
|
||||||
|
m.StateMedia[kv.Key] = kv.Value;
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
}
|
||||||
139
tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs
Normal file
139
tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
using AcDream.App.UI;
|
||||||
|
using AcDream.App.UI.Layout;
|
||||||
|
namespace AcDream.App.Tests.UI.Layout;
|
||||||
|
|
||||||
|
public class ElementReaderTests
|
||||||
|
{
|
||||||
|
// ── ToAnchors ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Edge value 4 = stretch (pinned to BOTH near AND far sides simultaneously).
|
||||||
|
/// LeftEdge=4 → Left anchor; RightEdge=4 → Right anchor.
|
||||||
|
/// TopEdge=1 → Top only (near-pin); BottomEdge=1 → near-pin (left/top axis), NOT Bottom.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void EdgeFlagsToAnchors_LeftRight_Stretches()
|
||||||
|
{
|
||||||
|
// left=4 (stretch ⇒ Left), top=1 (near-pin ⇒ Top), right=4 (stretch ⇒ Right), bottom=1 (near-pin of bottom axis ⇒ not Bottom)
|
||||||
|
var a = ElementReader.ToAnchors(left: 4, top: 1, right: 4, bottom: 1);
|
||||||
|
Assert.True(a.HasFlag(AnchorEdges.Left));
|
||||||
|
Assert.True(a.HasFlag(AnchorEdges.Right));
|
||||||
|
Assert.False(a.HasFlag(AnchorEdges.Bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Edge value 1 = pinned to the NEAR edge of that axis.
|
||||||
|
/// For LeftEdge: near = Left. For TopEdge: near = Top.
|
||||||
|
/// For RightEdge: value 1 means near-pin of the right axis → does NOT map to Right anchor.
|
||||||
|
/// For BottomEdge: value 1 means near-pin of the bottom axis → does NOT map to Bottom anchor.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void EdgeFlagsToAnchors_AllOnes_PinsTopLeftOnly()
|
||||||
|
{
|
||||||
|
// 1 everywhere: only Left and Top anchors set (near-pins). Right/Bottom are far edges and value 1 is near-pin.
|
||||||
|
var a = ElementReader.ToAnchors(left: 1, top: 1, right: 1, bottom: 1);
|
||||||
|
Assert.True(a.HasFlag(AnchorEdges.Left));
|
||||||
|
Assert.True(a.HasFlag(AnchorEdges.Top));
|
||||||
|
Assert.False(a.HasFlag(AnchorEdges.Right));
|
||||||
|
Assert.False(a.HasFlag(AnchorEdges.Bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Edge value 2 = pinned to the FAR edge of that axis.
|
||||||
|
/// For RightEdge: far = Right anchor. For BottomEdge: far = Bottom anchor.
|
||||||
|
/// For LeftEdge: value 2 means far-pin of the left axis → does NOT map to Left anchor.
|
||||||
|
/// For TopEdge: value 2 means far-pin of the top axis → does NOT map to Top anchor.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void EdgeFlagsToAnchors_AllTwos_PinsRightBottomOnly()
|
||||||
|
{
|
||||||
|
// 2 everywhere: only Right and Bottom anchors set (far-pins).
|
||||||
|
var a = ElementReader.ToAnchors(left: 2, top: 2, right: 2, bottom: 2);
|
||||||
|
Assert.False(a.HasFlag(AnchorEdges.Left));
|
||||||
|
Assert.False(a.HasFlag(AnchorEdges.Top));
|
||||||
|
Assert.True(a.HasFlag(AnchorEdges.Right));
|
||||||
|
Assert.True(a.HasFlag(AnchorEdges.Bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// All-zero edge flags (prototype-only elements) fall back to Left|Top default.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void EdgeFlagsToAnchors_AllZero_DefaultsToTopLeft()
|
||||||
|
{
|
||||||
|
var a = ElementReader.ToAnchors(left: 0, top: 0, right: 0, bottom: 0);
|
||||||
|
Assert.Equal(AnchorEdges.Left | AnchorEdges.Top, a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Value 3 = floating/centered between both far edges on that axis.
|
||||||
|
/// Both LeftEdge=3 and RightEdge=3 → neither Left nor Right are set by the
|
||||||
|
/// near/stretch rules. The result is only Right+Bottom (the "far" semantics).
|
||||||
|
/// Specifically: left=3 → not Left (3 is not 1 or 4); right=3 → Right (3 is not 2 or 4, skip).
|
||||||
|
/// Wait — value 3 means "pinned to BOTH far edges" per format doc §4. Re-check the
|
||||||
|
/// mapping rule: Right anchor fires on right==2 || right==4, NOT on right==3.
|
||||||
|
/// So value 3 on LeftEdge, TopEdge, RightEdge, BottomEdge → no flags set → default Left|Top.
|
||||||
|
/// This test covers that corner case (element 0x100004A9 — expand-detail overlay).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public void EdgeFlagsToAnchors_ValueThree_FallsBackToTopLeft()
|
||||||
|
{
|
||||||
|
// value 3 doesn't match any anchor rule; falls back to Left|Top default.
|
||||||
|
var a = ElementReader.ToAnchors(left: 3, top: 3, right: 3, bottom: 3);
|
||||||
|
Assert.Equal(AnchorEdges.Left | AnchorEdges.Top, a);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Merge ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Merge_BaseThenOverride_DerivedWins()
|
||||||
|
{
|
||||||
|
var base_ = new ElementInfo { Type = 0, FontDid = 0x40000000, Width = 150, Height = 16 };
|
||||||
|
var derived = new ElementInfo { Type = 0, Width = 200 }; // overrides width, inherits font + height
|
||||||
|
var merged = ElementReader.Merge(base_, derived);
|
||||||
|
Assert.Equal(200, merged.Width); // override
|
||||||
|
Assert.Equal(16, merged.Height); // inherited
|
||||||
|
Assert.Equal(0x40000000u, merged.FontDid);// inherited
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Merge_DerivedHasFontDid_OverridesBase()
|
||||||
|
{
|
||||||
|
var base_ = new ElementInfo { FontDid = 0x40000000, Width = 100, Height = 10 };
|
||||||
|
var derived = new ElementInfo { FontDid = 0x40000001, Width = 100 };
|
||||||
|
var merged = ElementReader.Merge(base_, derived);
|
||||||
|
Assert.Equal(0x40000001u, merged.FontDid);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Merge_DerivedStateMediaOverridesBase()
|
||||||
|
{
|
||||||
|
var base_ = new ElementInfo();
|
||||||
|
base_.StateMedia[""] = (0x06001000u, 1);
|
||||||
|
base_.StateMedia["HideDetail"] = (0x06001001u, 1);
|
||||||
|
|
||||||
|
var derived = new ElementInfo();
|
||||||
|
derived.StateMedia[""] = (0x06002000u, 3); // overrides base default state
|
||||||
|
|
||||||
|
var merged = ElementReader.Merge(base_, derived);
|
||||||
|
// derived's "" overrides base's ""
|
||||||
|
Assert.Equal((0x06002000u, 3), merged.StateMedia[""]);
|
||||||
|
// base's "HideDetail" is kept (derived didn't provide it)
|
||||||
|
Assert.Equal((0x06001001u, 1), merged.StateMedia["HideDetail"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Merge_ChildrenComeFromDerived()
|
||||||
|
{
|
||||||
|
var base_ = new ElementInfo();
|
||||||
|
base_.Children.Add(new ElementInfo { Id = 0x1u });
|
||||||
|
|
||||||
|
var derived = new ElementInfo();
|
||||||
|
derived.Children.Add(new ElementInfo { Id = 0x2u });
|
||||||
|
|
||||||
|
var merged = ElementReader.Merge(base_, derived);
|
||||||
|
// children must come from derived only
|
||||||
|
Assert.Single(merged.Children);
|
||||||
|
Assert.Equal(0x2u, merged.Children[0].Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue