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 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-26 10:15:55 +02:00
parent 41430420b3
commit 6e0be4bd34
7 changed files with 154 additions and 1 deletions

View file

@ -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"));