From 6e0be4bd34922cc6167c7d4b84c5648862015bc7 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 26 Jun 2026 10:15:55 +0200 Subject: [PATCH] feat(ui): importer carries dat FontColor (0x1B) onto text widgets; character colors from dat where present LayoutImporter.ReadState now reads Properties 0x1B (ColorBaseProperty, ARGB bytes) and stores the normalized Vector4 in ElementInfo.FontColor (nullable). ElementReader.Merge propagates it with the same non-null-derived-wins rule as FontDid and HJustify. DatWidgetFactory.BuildText seeds UiText.DefaultColor from FontColor when present. Diagnosis for LayoutDesc 0x2100002E: ALL 12 header and footer text elements carry NO dat color. Every color is runtime set by CharacterStatController. Comments added at each callsite. No hardcoded colors deleted. Tests added: 3 ElementReader FontColor Merge + 3 DatWidgetFactory DefaultColor. 702 passed, 0 failed. Screenshots: character window, vitals, toolbar all unchanged. Co-Authored-By: Claude Sonnet 4.6 --- .../UI/Layout/CharacterStatController.cs | 11 ++++ src/AcDream.App/UI/Layout/DatWidgetFactory.cs | 11 +++- src/AcDream.App/UI/Layout/ElementReader.cs | 13 +++++ src/AcDream.App/UI/Layout/LayoutImporter.cs | 13 +++++ src/AcDream.App/UI/UiText.cs | 13 +++++ .../UI/Layout/DatWidgetFactoryTests.cs | 52 +++++++++++++++++++ .../UI/Layout/ElementReaderTests.cs | 42 +++++++++++++++ 7 files changed, 154 insertions(+), 1 deletion(-) diff --git a/src/AcDream.App/UI/Layout/CharacterStatController.cs b/src/AcDream.App/UI/Layout/CharacterStatController.cs index 7d28c711..a2508cbf 100644 --- a/src/AcDream.App/UI/Layout/CharacterStatController.cs +++ b/src/AcDream.App/UI/Layout/CharacterStatController.cs @@ -182,26 +182,33 @@ public static class CharacterStatController // Falls back to datFont when null (tests, or dat missing). rowDatFont ??= datFont; // Name: retail "Horan" is WHITE, not gold. Heritage and PK status are also white. + // runtime color, dat carries none (LayoutDesc 0x2100002E property 0x1B absent — FIX-B diagnosis 2026-06-26). Label(layout, NameId, datFont, Vector4.One, () => data().Name); + // runtime color, dat carries none. Label(layout, HeritageId, datFont, Body, () => data().Heritage ?? data().Race ?? string.Empty); + // runtime color, dat carries none. Label(layout, PkStatusId, datFont, Body, () => data().PkStatus ?? string.Empty); // ── Header captions (new — retail labels above/left of each number) ────── // 0x1000023A = "Character Level" caption area above the level value (235,0,65×35). // Retail shows 2 lines: "Character" (top) / "Level" (bottom) so neither truncates. // Source: retail spec (2026-06-26-character-window-retail-reference.md §State 1). + // runtime color, dat carries none. LabelTwoLine(layout, LevelCaptionId, datFont, Body, "Character", "Level"); // Level number: retail renders this as large gold centered text in the 65×50 element. // Use the larger dat font (18px) for the level value so it fills more of the vertical // space in the 50px element and approaches the retail "large gold digit" look. // Source: spec §Level area (65,50) + decomp gmStatManagementUI::UpdateCharacterInfo 0x004f0770. + // runtime color, dat carries none. Label(layout, LevelId, rowDatFont, Gold, () => data().Level.ToString()); // 0x10000234 = "Total Experience (XP):" caption, left of the XP value. // Source: dump idx=22; spec §Header element map. + // runtime color, dat carries none. LabelLeft(layout, TotalXpLabelId, datFont, Body, static () => "Total Experience (XP):"); + // runtime color, dat carries none. Label(layout, TotalXpId, datFont, Body, () => data().TotalXp.ToString("N0")); // XP-to-level meter fill (gmStatManagementUI::UpdateExperience 0x004f0a70). @@ -776,12 +783,14 @@ public static class CharacterStatController } // Line-1 label (Top=20, Left=5): State A = "Skill Credits Available:"; State B = "Experience To Raise:" + // runtime color, dat carries none. var l1L = ByPos(20f, 5f, FooterLine1Label); LabelProvider(l1L, datFont, Body, () => sel[0] < 0 ? "Skill Credits Available:" : "Experience To Raise:"); // Line-1 value (Top=20, Left=200): State A = SkillCredits; State B = raise cost // When attribute is maxed (cost == 0), retail shows "Infinity!" (confirmed 2026-06-26 ref). + // runtime color, dat carries none. var l1V = ByPos(20f, 200f, FooterLine1Value); LabelProvider(l1V, datFont, Body, () => { @@ -791,11 +800,13 @@ public static class CharacterStatController }); // Line-2 label (Top=37, Left=5): "Unassigned Experience:" in both states + // runtime color, dat carries none. var l2L = ByPos(37f, 5f, FooterLine2Label); LabelProvider(l2L, datFont, Body, static () => "Unassigned Experience:"); // Line-2 value (Top=37, Left=200): UnassignedXp in both states + // runtime color, dat carries none. var l2V = ByPos(37f, 200f, FooterLine2Value); LabelProvider(l2V, datFont, Body, () => data().UnassignedXp.ToString("N0")); diff --git a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs index ebc19b0b..c08d948b 100644 --- a/src/AcDream.App/UI/Layout/DatWidgetFactory.cs +++ b/src/AcDream.App/UI/Layout/DatWidgetFactory.cs @@ -269,7 +269,7 @@ public static class DatWidgetFactory _ => VJustify.Center, }; - return new UiText + var t = new UiText { BackgroundSprite = bg, SpriteResolve = resolve, @@ -277,5 +277,14 @@ public static class DatWidgetFactory RightAligned = rightAligned, VerticalJustify = vJustify, }; + + // Font color from dat property 0x1B (ColorBaseProperty). + // When present, seed DefaultColor so controllers that read it don't have to hard-code colors. + // Controllers that supply explicit per-line colors via LinesProvider still win — this is only + // the build-time default. + if (info.FontColor.HasValue) + t.DefaultColor = info.FontColor.Value; + + return t; } } diff --git a/src/AcDream.App/UI/Layout/ElementReader.cs b/src/AcDream.App/UI/Layout/ElementReader.cs index 27883dfd..cb4b1552 100644 --- a/src/AcDream.App/UI/Layout/ElementReader.cs +++ b/src/AcDream.App/UI/Layout/ElementReader.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Numerics; namespace AcDream.App.UI.Layout; @@ -78,6 +79,15 @@ public sealed class ElementInfo /// public VJustify VJustify = VJustify.Center; + /// + /// Font color from dat Properties[0x1B] (ColorBaseProperty, ARGB bytes). + /// Null when the dat carries no color for this element; the factory then leaves the + /// widget at its default white (). + /// Propagated in with the same "non-null derived wins" + /// rule used for and . + /// + public Vector4? FontColor; + /// /// Sprite per state: state name → (RenderSurface file id, DrawMode int). /// The "" key represents the unnamed DirectState (ElementDesc.StateDesc). @@ -193,6 +203,9 @@ public static class ElementReader // a non-Center base — matching the FontDid "non-zero wins" convention. HJustify = derived.HJustify != HJustify.Center ? derived.HJustify : base_.HJustify, VJustify = derived.VJustify != VJustify.Center ? derived.VJustify : base_.VJustify, + // FontColor: derived wins when it has an explicit (non-null) color; otherwise inherit the base. + // Null means "dat carried no 0x1B property" — so null-derived does NOT override a non-null base. + FontColor = derived.FontColor ?? base_.FontColor, // DefaultStateName: derived wins if set; otherwise inherit the base's default. DefaultStateName = !string.IsNullOrEmpty(derived.DefaultStateName) ? derived.DefaultStateName : base_.DefaultStateName, // Children come from the derived element's own tree, not the base prototype's. diff --git a/src/AcDream.App/UI/Layout/LayoutImporter.cs b/src/AcDream.App/UI/Layout/LayoutImporter.cs index 799470f2..4f63d83a 100644 --- a/src/AcDream.App/UI/Layout/LayoutImporter.cs +++ b/src/AcDream.App/UI/Layout/LayoutImporter.cs @@ -395,6 +395,19 @@ public static class LayoutImporter _ => VJustify.Center, }; } + + // ColorBaseProperty (0x1B): ARGB bytes → normalized [0,1] Vector4 (R,G,B,A). + // Only read when not already set (first dat state wins; Merge propagates from base). + if (info.FontColor is null + && sd.Properties.TryGetValue(0x1Bu, out var cRaw) + && cRaw is ColorBaseProperty cProp) + { + var c = cProp.Value; + // ColorARGB stores components as bytes (0–255); normalize to [0,1] for Vector4. + // Alpha=0 in the dat typically means fully opaque (retail convention: 0 → 255). + float a = c.Alpha == 0 ? 1f : c.Alpha / 255f; + info.FontColor = new System.Numerics.Vector4(c.Red / 255f, c.Green / 255f, c.Blue / 255f, a); + } } } diff --git a/src/AcDream.App/UI/UiText.cs b/src/AcDream.App/UI/UiText.cs index e6bc45f0..0d9f2b15 100644 --- a/src/AcDream.App/UI/UiText.cs +++ b/src/AcDream.App/UI/UiText.cs @@ -45,6 +45,19 @@ public sealed class UiText : UiElement /// the host from . public Silk.NET.Input.IKeyboard? Keyboard { get; set; } + /// + /// Default line color used by controllers when they do not supply a per-line + /// color explicitly. Set by DatWidgetFactory.BuildText + /// from ElementInfo.FontColor when the dat carries a 0x1B ColorBaseProperty; + /// otherwise white (). + /// + /// Controllers that supply a per-line color via + /// (e.g. new UiText.Line(text, explicitColor)) are unaffected — they always + /// win over this default. This property is only a convenience starting point for + /// controllers that want to read the dat color rather than hard-code it. + /// + public Vector4 DefaultColor { get; set; } = Vector4.One; + /// Backing fill behind the text. Defaults to transparent so an unbound /// UiText (no controller) draws nothing. Set to the retail translucent value by /// the controller (e.g. ChatWindowController). diff --git a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs index 0adb9eee..12871f40 100644 --- a/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/DatWidgetFactoryTests.cs @@ -1,3 +1,4 @@ +using System.Numerics; using AcDream.App.UI; using AcDream.App.UI.Layout; namespace AcDream.App.Tests.UI.Layout; @@ -299,4 +300,55 @@ public class DatWidgetFactoryTests t.Centered = false; // controller override Assert.False(t.Centered); // override wins } + + // ── DefaultColor from dat FontColor (importer Fix B) ───────────────────── + + /// + /// When ElementInfo.FontColor is set (dat carried 0x1B ColorBaseProperty), + /// DatWidgetFactory.BuildText must copy it onto UiText.DefaultColor. + /// + [Fact] + public void BuildText_FontColorPresent_SetsDefaultColor() + { + var gold = new Vector4(1f, 0.82f, 0.36f, 1f); + var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontColor = gold }; + var t = Assert.IsType(DatWidgetFactory.Create(info, NoTex, null)); + Assert.Equal(gold, t.DefaultColor); + } + + /// + /// When ElementInfo.FontColor is null (dat carried no 0x1B), DefaultColor must + /// stay at white (Vector4.One) — the backward-compatible default. + /// + [Fact] + public void BuildText_FontColorAbsent_DefaultColorIsWhite() + { + var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontColor = null }; + var t = Assert.IsType(DatWidgetFactory.Create(info, NoTex, null)); + Assert.Equal(Vector4.One, t.DefaultColor); + } + + /// + /// Backward-compat guarantee: a controller that sets LinesProvider with an + /// explicit per-line color AFTER build is unaffected by DefaultColor. + /// The controller's per-line color is stored in the Line record, not DefaultColor, + /// so DefaultColor changing from dat does not touch existing controller bindings. + /// + [Fact] + public void BuildText_ControllerExplicitLineColor_UnaffectedByDefaultColor() + { + // Dat sets a parchment color. + var parchment = new Vector4(0.92f, 0.90f, 0.82f, 1f); + var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontColor = parchment }; + var t = Assert.IsType(DatWidgetFactory.Create(info, NoTex, null)); + + // Controller overrides with an explicit gold color in the Line record. + var gold = new Vector4(1f, 0.82f, 0.36f, 1f); + t.LinesProvider = () => new[] { new UiText.Line("text", gold) }; + + // The line's color is gold (controller wins), NOT the dat parchment. + Assert.Equal(gold, t.LinesProvider()[0].Color); + // DefaultColor still holds the dat parchment (unmodified by the controller binding). + Assert.Equal(parchment, t.DefaultColor); + } } diff --git a/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs b/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs index 2745590d..387767bb 100644 --- a/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs +++ b/tests/AcDream.App.Tests/UI/Layout/ElementReaderTests.cs @@ -1,3 +1,4 @@ +using System.Numerics; using AcDream.App.UI; using AcDream.App.UI.Layout; namespace AcDream.App.Tests.UI.Layout; @@ -214,4 +215,45 @@ public class ElementReaderTests var merged = ElementReader.Merge(base_, derived); Assert.Equal(VJustify.Bottom, merged.VJustify); } + + // ── FontColor — Merge propagation (Fix B) ──────────────────────────────── + + /// + /// When the derived element has an explicit (non-null) FontColor, the derived value + /// wins in Merge — same "non-null derived wins" rule used for FontDid and HJustify. + /// + [Fact] + public void Merge_DerivedFontColor_OverridesBaseNull() + { + var base_ = new ElementInfo { FontColor = null }; + var derived = new ElementInfo { FontColor = new Vector4(1f, 0f, 0f, 1f) }; + var merged = ElementReader.Merge(base_, derived); + Assert.Equal(new Vector4(1f, 0f, 0f, 1f), merged.FontColor); + } + + /// + /// When the derived element has no FontColor (null), the base's FontColor is inherited. + /// A null-derived must NOT override a non-null base. + /// + [Fact] + public void Merge_DerivedFontColorNull_InheritsBaseColor() + { + var gold = new Vector4(1f, 0.82f, 0.36f, 1f); + var base_ = new ElementInfo { FontColor = gold }; + var derived = new ElementInfo { FontColor = null }; // default — no dat property + var merged = ElementReader.Merge(base_, derived); + Assert.Equal(gold, merged.FontColor); + } + + /// + /// When neither base nor derived carries a FontColor, the merged result is null. + /// + [Fact] + public void Merge_BothFontColorNull_MergedIsNull() + { + var base_ = new ElementInfo { FontColor = null }; + var derived = new ElementInfo { FontColor = null }; + var merged = ElementReader.Merge(base_, derived); + Assert.Null(merged.FontColor); + } }