feat(ui): importer Fix C — per-element dat FontDid resolver (studio path); character level uses its retail font

DatWidgetFactory.Create now accepts an optional fontResolve: Func<uint,UiDatFont?>
parameter. When supplied and the element has a non-zero FontDid, the element
receives its own dat font instead of the shared global datFont fallback.
Null = original single-font behavior (the live GameWindow path passes null —
provably unchanged). LayoutImporter.Build/BuildFromInfos/Import all thread
the optional resolver down to the factory.

RenderStack gains a lazy font cache (ConcurrentDictionary, pre-seeded with
VitalsDatFont + LargeDatFont) and a ResolveDatFont(uint) method. StudioWindow
wires stack.ResolveDatFont into LayoutSource so every studio import gets
per-element fonts. GameWindow import calls left passing null (follow-up todo).

CharacterStatController font-hack cleanup (diagnosed via one-shot console dump
then removed):
- Name (0x10000231): dat FontDid = 18px font — remove datFont override (null)
- Heritage/PkStatus: dat FontDid = 14px fonts — remove override
- LevelCaption: dat FontDid = 16px — remove override (same font, no visual change)
- Level (0x1000023B): dat FontDid = 36px (the big retail gold font) — was forced
  to rowDatFont/LargeDatFont (18px); now drops to null so the dat 36px font drives
- TotalXpLabel/TotalXp: dat FontDid = 16px — remove override
- FooterTitle (0x1000024E): dat FontDid = 20px — remove datFont override
- KEEP: synthesized elements (XP meter overlays, 9 attribute rows, tab sprites)
  still use datFont directly since they have no dat origin

All Label/LabelTwoLine/LabelLeft/LabelProvider helpers updated: null = keep
build-time dat font; non-null = controller explicit override (backward-compat).

8 new tests in DatWidgetFactoryFontResolveTests:
- null resolver → DatFont == global datFont
- FontDid=0 → resolver not called
- resolver returns null → fallback to global datFont
- resolver called with element's FontDid
- controller DatFont override wins after build
- LayoutImporter.Build threads fontResolve to factory
- meter element fires resolver for non-zero FontDid
- BuildFromInfos without fontResolve param = original behavior

Build + all 710 App tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-26 10:30:13 +02:00
parent 6e0be4bd34
commit a0d33956f6
7 changed files with 360 additions and 66 deletions

View file

@ -68,25 +68,33 @@ public static class LayoutImporter
ElementInfo rootInfo,
IEnumerable<ElementInfo> children,
Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont)
UiDatFont? datFont,
Func<uint, UiDatFont?>? fontResolve = null)
{
rootInfo.Children = new List<ElementInfo>(children);
return Build(rootInfo, resolve, datFont);
return Build(rootInfo, resolve, datFont, fontResolve);
}
/// <summary>
/// Pure builder: produce the widget tree from a fully resolved
/// <see cref="ElementInfo"/> tree (children already attached).
/// </summary>
/// <param name="fontResolve">Optional per-element font resolver — FontDid →
/// <see cref="UiDatFont"/> (or null if the font can't be loaded). When supplied,
/// elements with a non-zero <see cref="ElementInfo.FontDid"/> get their own dat
/// font at build time instead of the shared <paramref name="datFont"/> fallback.
/// Null preserves the original single-font behavior for all callers that don't
/// pass it — no behavior change for the live game path.</param>
public static ImportedLayout Build(
ElementInfo rootInfo,
Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont)
UiDatFont? datFont,
Func<uint, UiDatFont?>? fontResolve = null)
{
var byId = new Dictionary<uint, UiElement>();
// Root is never a Type-12 prototype in practice; fall back to a generic
// container if the factory returns null for an exotic root type.
var root = BuildWidget(rootInfo, resolve, datFont, byId);
var root = BuildWidget(rootInfo, resolve, datFont, fontResolve, byId);
if (root is null)
{
Console.WriteLine($"[D.2b] LayoutImporter: root element 0x{rootInfo.Id:X8} (type {rootInfo.Type}) produced no widget — using empty container fallback.");
@ -99,9 +107,10 @@ public static class LayoutImporter
ElementInfo info,
Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont,
Func<uint, UiDatFont?>? fontResolve,
Dictionary<uint, UiElement> byId)
{
var w = DatWidgetFactory.Create(info, resolve, datFont);
var w = DatWidgetFactory.Create(info, resolve, datFont, fontResolve);
if (w is null) return null; // Type-12 style prototype — skip
if (info.Id != 0) byId[info.Id] = w;
@ -117,7 +126,7 @@ public static class LayoutImporter
{
foreach (var child in info.Children)
{
var cw = BuildWidget(child, resolve, datFont, byId);
var cw = BuildWidget(child, resolve, datFont, fontResolve, byId);
if (cw is not null) w.AddChild(cw);
}
}
@ -178,15 +187,18 @@ public static class LayoutImporter
/// element, and build the widget tree. Returns null if the layout is absent
/// from the dats.
/// </summary>
/// <param name="fontResolve">Optional per-element font resolver (see
/// <see cref="Build"/> for details). Null = original single-font behavior.</param>
public static ImportedLayout? Import(
DatCollection dats,
uint layoutId,
Func<uint, (uint, int, int)> resolve,
UiDatFont? datFont)
UiDatFont? datFont,
Func<uint, UiDatFont?>? fontResolve = null)
{
var rootInfo = ImportInfos(dats, layoutId);
if (rootInfo is null) return null;
return Build(rootInfo, resolve, datFont);
return Build(rootInfo, resolve, datFont, fontResolve);
}
// ── Inheritance resolution ────────────────────────────────────────────────