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);
+ }
}