From 4dcc90cb51c88da95175d737d148e38fb045d390 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 15 Jun 2026 14:55:01 +0200 Subject: [PATCH] docs(D.2b): register AP-32 + IA-15 amend for importer; doc/test review fixes (N1/N4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Process/quality items from the LayoutDesc-importer final review — no runtime behavior change. I1a — amend IA-15: the 8-piece chrome edge/corner→position mapping is no longer a guess. The LayoutImporter (ACDREAM_RETAIL_UI_IMPORTER) reads real LayoutDesc dat data and resolves positions + sprite ids directly; locked by the conformance fixture vitals_2100006C.json. Residual risk trimmed to anchor resolution at non-800×600 + controls.ini cascade. Pointers added to LayoutImporter.cs and the format-doc. I1b — add AP-32: the importer collapses the dat's nested meter structure (Type-7 → two Type-3 containers → three image-slice grandchildren each) into UiMeter's programmatic 3-slice fields instead of building those nodes generically and porting UIElement_Meter::DrawChildren. Standalone Type-0 text elements are also skipped (Plan 2). Retail oracles: UIElement_Meter::DrawChildren @0x46fbd0, UIElement_Text::DrawSelf @0x467aa0. I1c — AP section header 31 → 32. N1 — ElementReader.cs: comment at the Type-merge line explaining that a derived Type 0 (text element) inherits the base's Type 12 (style prototype), which DatWidgetFactory skips; safe for Plan 1 because vitals numbers render via UiMeter.Label. Format-doc §10: correct the "render as UiDatElement" sentence to "skipped entirely" (Type-0 → inherits Type-12 via Merge → factory returns null). N4 — new conformance test VitalsTree_TextLabel_InheritsFontDidFromBaseLayout: walks the raw ElementInfo tree from the fixture and asserts at least one element carries FontDid==0x40000000, proving Resolve()'s inheritance merge fired against real dat data. FixtureLoader gains LoadVitalsInfos() that returns the raw tree without calling Build. Tests: 36 pass (was 35), 0 errors, 0 warnings. Co-Authored-By: Claude Sonnet 4.6 --- .../retail-divergence-register.md | 5 ++- docs/research/2026-06-15-layoutdesc-format.md | 2 +- src/AcDream.App/UI/Layout/ElementReader.cs | 6 +++ .../UI/Layout/FixtureLoader.cs | 17 +++++-- .../UI/Layout/LayoutConformanceTests.cs | 45 +++++++++++++++++++ 5 files changed, 69 insertions(+), 6 deletions(-) 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); + } }