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:
parent
41430420b3
commit
6e0be4bd34
7 changed files with 154 additions and 1 deletions
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.UI.Layout;
|
||||
|
||||
|
|
@ -78,6 +79,15 @@ public sealed class ElementInfo
|
|||
/// </summary>
|
||||
public VJustify VJustify = VJustify.Center;
|
||||
|
||||
/// <summary>
|
||||
/// Font color from dat <c>Properties[0x1B]</c> (<c>ColorBaseProperty</c>, ARGB bytes).
|
||||
/// Null when the dat carries no color for this element; the factory then leaves the
|
||||
/// widget at its default white (<see cref="System.Numerics.Vector4.One"/>).
|
||||
/// Propagated in <see cref="ElementReader.Merge"/> with the same "non-null derived wins"
|
||||
/// rule used for <see cref="FontDid"/> and <see cref="HJustify"/>.
|
||||
/// </summary>
|
||||
public Vector4? FontColor;
|
||||
|
||||
/// <summary>
|
||||
/// Sprite per state: state name → (RenderSurface file id, DrawMode int).
|
||||
/// The <c>""</c> key represents the unnamed DirectState (<c>ElementDesc.StateDesc</c>).
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,19 @@ public sealed class UiText : UiElement
|
|||
/// the host from <see cref="UiHost.Keyboard"/>.</summary>
|
||||
public Silk.NET.Input.IKeyboard? Keyboard { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Default line color used by controllers when they do not supply a per-line
|
||||
/// <see cref="Vector4"/> color explicitly. Set by <c>DatWidgetFactory.BuildText</c>
|
||||
/// from <c>ElementInfo.FontColor</c> when the dat carries a 0x1B ColorBaseProperty;
|
||||
/// otherwise white (<see cref="Vector4.One"/>).
|
||||
///
|
||||
/// <para>Controllers that supply a per-line color via <see cref="LinesProvider"/>
|
||||
/// (e.g. <c>new UiText.Line(text, explicitColor)</c>) 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.</para>
|
||||
/// </summary>
|
||||
public Vector4 DefaultColor { get; set; } = Vector4.One;
|
||||
|
||||
/// <summary>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. <c>ChatWindowController</c>).</summary>
|
||||
|
|
|
|||
|
|
@ -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) ─────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// When ElementInfo.FontColor is set (dat carried 0x1B ColorBaseProperty),
|
||||
/// DatWidgetFactory.BuildText must copy it onto UiText.DefaultColor.
|
||||
/// </summary>
|
||||
[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<UiText>(DatWidgetFactory.Create(info, NoTex, null));
|
||||
Assert.Equal(gold, t.DefaultColor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When ElementInfo.FontColor is null (dat carried no 0x1B), DefaultColor must
|
||||
/// stay at white (Vector4.One) — the backward-compatible default.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void BuildText_FontColorAbsent_DefaultColorIsWhite()
|
||||
{
|
||||
var info = new ElementInfo { Type = 12, Width = 100, Height = 20, FontColor = null };
|
||||
var t = Assert.IsType<UiText>(DatWidgetFactory.Create(info, NoTex, null));
|
||||
Assert.Equal(Vector4.One, t.DefaultColor);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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<UiText>(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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) ────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When the derived element has no FontColor (null), the base's FontColor is inherited.
|
||||
/// A null-derived must NOT override a non-null base.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When neither base nor derived carries a FontColor, the merged result is null.
|
||||
/// </summary>
|
||||
[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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue