diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 5a7c7b05..045f4a49 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -55,7 +55,7 @@ accepted-divergence entries (#96, #49, #50).
| IA-12 | UI toolkit mirrors retail behavior from research docs, not a byte-port — keystone.dll is outside decomp coverage; observed constants embedded (drag 3 px, tooltip 1000 ms) | `src/AcDream.App/UI/README.md:3` | keystone.dll has no PDB/decomp; semantics reconstructed from the six `docs/research/retail-ui/` deep-dives, keeping retail's event-type constants so panel switch-cases transplant cleanly | Edge-case input semantics the research under-specified (drag threshold, tooltip timing, focus hand-off, capture corners) differ silently with no oracle to diff against | keystone.dll Device DAT_00837ff4; docs/research/retail-ui/04-input-events.md |
| IA-13 | GameEventType registry deliberately omits event types retail ignores; unknown events fall through unhandled | `src/AcDream.Core.Net/Messages/GameEventType.cs:11` | Retail also ignores them — dropping matches retail by construction | If the "retail ignores X" judgment is wrong for any opcode (or a server mod uses one), the event is silently dropped with no diagnostic pointing at the omission | retail GameEvent dispatch (ignored-event set) |
| IA-14 | Rendering + dat-handling base is WorldBuilder's tested port, not a fresh retail-decomp port (Phase N.4/O design stance) | `docs/architecture/worldbuilder-inventory.md` (code at `src/AcDream.{Core,App}/Rendering/Wb/`) | WB visually verified on the AC world, MIT, same stack; known WB↔retail deltas resolved case-by-case — terrain split kept retail `FSplitNESW` (**#51**, pinned by `SplitFormulaDivergenceTest`), scenery drift accepted (AP-31) | A WB-upstream divergence not yet caught ships silently as "our" behavior; guard = the inventory doc's 🟢/🔴 split + per-formula divergence tests | retail decomp per algorithm; `tests/.../SplitFormulaDivergenceTest.cs` |
-| IA-15 | D.2b retail UI is our own UiHost/UiElement retained-mode tree drawing an 8-piece dat-sprite window frame (later: XML markup + controls.ini stylesheet), not a byte-port of keystone.dll's LayoutDesc binary tree | `src/AcDream.App/UI/UiNineSlicePanel.cs` + `RetailChromeSprites.cs` | keystone.dll has no PDB/decomp so a byte-port is impossible by definition; we mirror retail's ElementDesc field model + controls.ini tokens, and the chrome sprites ARE the real dat RenderSurfaces (Step-0 prove-out 2026-06-14 confirmed 0x06004CC2 center + 0x060074BF..C6 bevel) | The 8-piece edge/corner→position mapping is a guess until the LayoutDesc 0x21000040 parse; anchor resolution at non-800x600 + controls.ini cascade corners differ silently with no oracle | LayoutDesc 0x21000040; controls.ini tokens; keystone.dll layout eval (no PDB) |
+| IA-15 | D.2b retail UI is our own UiHost/UiElement retained-mode tree drawing an 8-piece dat-sprite window frame (later: XML markup + controls.ini stylesheet), not a byte-port of keystone.dll's LayoutDesc binary tree | `src/AcDream.App/UI/UiNineSlicePanel.cs` + `RetailChromeSprites.cs` + `src/AcDream.App/UI/Layout/LayoutImporter.cs` | keystone.dll has no PDB/decomp so a byte-port is impossible by definition; we mirror retail's ElementDesc field model + controls.ini tokens, and the chrome sprites ARE the real dat RenderSurfaces (Step-0 prove-out 2026-06-14 confirmed 0x06004CC2 center + 0x060074BF..C6 bevel). The 8-piece edge/corner→position mapping is NOW DATA-DRIVEN from the dat: the `LayoutImporter` (gated `ACDREAM_RETAIL_UI_IMPORTER`) reads the real `LayoutDesc` for `0x2100006C` and resolves chrome element positions + sprite ids directly from parsed dat fields; locked by the conformance fixture `tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json` | Remaining residual risk: anchor resolution at non-800×600 and the controls.ini cascade still lack an oracle — layout scaling at non-reference resolution and stylesheet token inheritance differ silently | `LayoutDesc 0x2100006C` (SHIPPED); `docs/research/2026-06-15-layoutdesc-format.md`; controls.ini tokens; keystone.dll layout eval (no PDB) |
---
@@ -93,7 +93,7 @@ accepted-divergence entries (#96, #49, #50).
---
-## 3. Documented approximation (AP) — 31 rows
+## 3. Documented approximation (AP) — 32 rows
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@@ -128,6 +128,7 @@ accepted-divergence entries (#96, #49, #50).
| AP-29 | Target-indicator fallback for entities with no baked selection sphere: invented 1.5 m × scale box + 16/12 px screen floors (primary path is a faithful `GetObjectBoundingBox` port) | `src/AcDream.App/UI/TargetIndicatorPanel.cs:86` | Fallback only fires when the Setup didn't bake a selection sphere — rare in practice | Sphere-less entities get a non-retail indicator size/placement; the pixel floors prevent retail's far-distance collapse | `SmartBox::GetObjectBoundingBox` 0x00452e20; `GetSelectionSphere` |
| AP-30 | AutonomousPosition diff cadence compares with epsilons (1 mm pos, 1e-4 normal, 1 mm dist); retail's `Frame::is_equal` is an exact float compare | `src/AcDream.App/Input/PlayerMovementController.cs:1541` | Sub-millimeter epsilon is well below any movement worth suppressing; comparisons are against last-SENT state so drift accumulates past the epsilon | Sub-epsilon drift suppresses an AP send retail would have made — negligible today; a consumer expecting retail's exact send-on-any-change cadence sees fewer packets | `Frame::is_equal` pc:700263 |
| AP-31 | Scenery placement drift + the 0xA9B1 road-edge tree — WB-upstream divergences from retail, ACCEPTED (**#49/#50**, 2026-05-11) | `src/AcDream.Core/World/SceneryGenerator.cs` (via `WbSceneryAdapter`) | Piecemeal patching against WB upstream is net-negative (the `e279c46` road-check attempt over-suppressed scenery elsewhere, reverted `677a726`); visible impact = a handful of trees a few meters off | The same WB-upstream class could hide a *larger* placement divergence elsewhere; revisit only via a coherent ACME-style per-vertex filter port | `CLandBlock::get_land_scenes`; ACME GameScene.cs:1074 per-vertex road filter |
+| AP-32 | LayoutDesc importer collapses the dat's nested meter structure (Type-7 meter → two Type-3 container children → three Type-3 image-slice grandchildren each) into `UiMeter`'s programmatic 3-slice fields (`BackLeft..FrontRight`) + reuses `UiMeter.DrawHBar`'s scissor-fill, instead of building those child nodes generically and porting `UIElement_Meter::DrawChildren`. Standalone Type-0 text elements are also skipped (vitals numbers render via `UiMeter.Label` bound by the controller; a dedicated dat-text widget is Plan 2). The inheritance `Merge` treats Width/Height==0 as "inherit from base", diverging from format-doc §12 rule 2 (documented inline in `ElementReader.cs`) | `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (`BuildMeter`/`SliceIds`) + `src/AcDream.App/UI/Layout/LayoutImporter.cs` (`BuildWidget` meter-child skip) | Reuses the tested `UiMeter` render that already visually matches retail's stacked vitals bars; the full nested-element + `DrawChildren` scissor port and a dat-text widget are deferred to Plan 2. Gated opt-in (`ACDREAM_RETAIL_UI_IMPORTER`) and locked by the conformance fixture (`tests/AcDream.App.Tests/UI/Layout/fixtures/vitals_2100006C.json`) | A LayoutDesc whose meter structure differs from the vitals 2-container/3-slice shape, or a window needing standalone dat text, renders an empty/wrong meter or drops text — no oracle diff until the Plan-2 widgets land | `UIElement_Meter::DrawChildren` @0x46fbd0; `UIElement_Text::DrawSelf` @0x467aa0; `docs/research/2026-06-15-layoutdesc-format.md` |
---
diff --git a/docs/research/2026-06-15-layoutdesc-format.md b/docs/research/2026-06-15-layoutdesc-format.md
index 867fd0a8..10e66e8f 100644
--- a/docs/research/2026-06-15-layoutdesc-format.md
+++ b/docs/research/2026-06-15-layoutdesc-format.md
@@ -322,7 +322,7 @@ derived (Type=0, no StateDesc media, no font prop itself)
The derived text element overrides `Width/Height/X/Y` (from the dat element's fields) but inherits the font DID and color from the base element's `Properties`.
-**There is no `StateDesc.Media` on the text elements** — the text is rendered by the `UIElement_Text::DrawSelf` algorithm using the font DID from properties, not a sprite. In Plan 1, the text elements render as `UiDatElement` (generic fallback) until a dedicated text widget is implemented in Plan 2.
+**There is no `StateDesc.Media` on the text elements** — the text is rendered by the `UIElement_Text::DrawSelf` algorithm using the font DID from properties, not a sprite. In Plan 1, the text elements are **skipped entirely**: `Type = 0` (derived) inherits `Type = 12` from the base prototype `0x10000376` via `ElementReader.Merge` (zero-wins-nothing rule — the derived Type 0 inherits the base's Type 12), and `DatWidgetFactory` returns null for Type 12. This means no `UiDatElement` is created for them. For the vitals window this is correct: the numbers render via `UiMeter.Label` bound by the `VitalsController`, not a dat text node. A dedicated dat-text widget (Type 0) is Plan 2.
---
diff --git a/src/AcDream.App/UI/Layout/ElementReader.cs b/src/AcDream.App/UI/Layout/ElementReader.cs
index c5087b99..31a402b3 100644
--- a/src/AcDream.App/UI/Layout/ElementReader.cs
+++ b/src/AcDream.App/UI/Layout/ElementReader.cs
@@ -142,6 +142,12 @@ public static class ElementReader
var m = new ElementInfo
{
Id = derived.Id != 0 ? derived.Id : base_.Id,
+ // Type: derived wins if non-zero; Type 0 (text element per format §8) inherits the base's Type.
+ // For a text element whose base prototype is Type 12 (style prototype), this yields Type 12 —
+ // which DatWidgetFactory skips (returns null). That is intentional for Plan 1: vitals text
+ // numbers render via UiMeter.Label bound by VitalsController, not a dat text node.
+ // A Plan-2 standalone text element would need a type-preserving path (e.g. float? nullable
+ // Width/Height, or explicit handling of Type 0 before the merge).
Type = derived.Type != 0 ? derived.Type : base_.Type,
X = derived.X,
Y = derived.Y,
diff --git a/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs b/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs
index 7f0f5eca..724a0e89 100644
--- a/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/FixtureLoader.cs
@@ -24,6 +24,19 @@ public static class FixtureLoader
/// dat font — sufficient for conformance checks on tree structure and slice ids.
///
public static ImportedLayout LoadVitals()
+ {
+ var root = LoadVitalsInfos();
+ return LayoutImporter.Build(root, _ => (0u, 0, 0), null);
+ }
+
+ ///
+ /// Deserializes the committed vitals_2100006C.json fixture into a raw
+ /// tree WITHOUT calling .
+ /// Use this when the test needs to inspect the resolved
+ /// tree directly (e.g. inheritance-resolution checks) without exercising the
+ /// widget factory.
+ ///
+ public static AcDream.App.UI.Layout.ElementInfo LoadVitalsInfos()
{
var fixturePath = Path.Combine(AppContext.BaseDirectory, "UI", "Layout", "fixtures", "vitals_2100006C.json");
if (!File.Exists(fixturePath))
@@ -35,9 +48,7 @@ public static class FixtureLoader
ReadOnlySpan span = bytes;
if (span.Length >= 3 && span[0] == 0xEF && span[1] == 0xBB && span[2] == 0xBF)
span = span[3..];
- var root = JsonSerializer.Deserialize(span, _opts)
+ return JsonSerializer.Deserialize(span, _opts)
?? throw new InvalidOperationException($"fixture deserialized to null: {fixturePath}");
-
- return LayoutImporter.Build(root, _ => (0u, 0, 0), null);
}
}
diff --git a/tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs b/tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs
index b50862bc..a2bcfe08 100644
--- a/tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/LayoutConformanceTests.cs
@@ -93,4 +93,49 @@ public class LayoutConformanceTests
var (file, _) = datElem.ActiveMedia();
Assert.Equal(0x060074C3u, file);
}
+
+ // ── Test 4 (N4): Inheritance resolution — FontDid propagated from base ───
+
+ ///
+ /// Proves that Resolve()'s inheritance merge fired against real dat data:
+ /// at least one element in the fixture tree must have FontDid == 0x40000000
+ /// (the vitals font), inherited from the base-layout prototype 0x10000376
+ /// in 0x2100003F via the BaseElement / BaseLayoutId chain.
+ ///
+ ///
+ /// The three text labels (0x100000EB health, 0x100000ED stamina,
+ /// 0x100000EF mana) are Type=0 derived elements with no own font property.
+ /// The base element 0x10000376 carries Properties[0x1A] →
+ /// ArrayBaseProperty[ DataIdBaseProperty{Value=0x40000000} ].
+ /// propagates this via the "FontDid: derived wins
+ /// if non-zero, otherwise inherit" rule.
+ ///
+ ///
+ ///
+ /// This test verifies end-to-end inheritance resolution against the committed fixture
+ /// (format doc §10, docs/research/2026-06-15-layoutdesc-format.md).
+ /// It operates on the raw tree, NOT the widget tree,
+ /// so the factory dispatch (Type 12 → skip) does not interfere.
+ ///
+ ///
+ [Fact]
+ public void VitalsTree_TextLabel_InheritsFontDidFromBaseLayout()
+ {
+ var root = FixtureLoader.LoadVitalsInfos();
+
+ // Walk the full ElementInfo tree and collect all FontDid values.
+ var fontDids = new System.Collections.Generic.List();
+ CollectFontDids(root, fontDids);
+
+ // At least one element must carry FontDid == 0x40000000 (the vitals font).
+ // In practice, the three text labels (health/stamina/mana) all inherit it.
+ Assert.Contains(0x40000000u, fontDids);
+ }
+
+ private static void CollectFontDids(ElementInfo node, System.Collections.Generic.List acc)
+ {
+ if (node.FontDid != 0) acc.Add(node.FontDid);
+ foreach (var child in node.Children)
+ CollectFontDids(child, acc);
+ }
}