diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 64289724..ce0989f8 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -1770,6 +1770,27 @@ public sealed class GameWindow : IDisposable
string vitalsXml = System.IO.File.ReadAllText(
System.IO.Path.Combine(AppContext.BaseDirectory, "UI", "assets", "vitals.xml"));
var panel = AcDream.App.UI.MarkupDocument.Build(vitalsXml, _vitalsVm!, ResolveChrome, controls);
+
+ // Phase D.2b — retail dat-font for the vitals numbers. Font 0x40000000
+ // (Latin-1, 16px, outline atlas). The consola TTF debug font is wrong
+ // for retail look; the meter falls back to it only if the dat font fails
+ // to load. Loaded under _datLock for consistency with other dat reads
+ // (no streaming worker is active during OnLoad, but the lock is cheap).
+ AcDream.App.UI.UiDatFont? vitalsDatFont;
+ lock (_datLock)
+ vitalsDatFont = AcDream.App.UI.UiDatFont.Load(_dats!, _textureCache!);
+ if (vitalsDatFont is not null)
+ {
+ foreach (var child in panel.Children)
+ if (child is AcDream.App.UI.UiMeter meter)
+ meter.DatFont = vitalsDatFont;
+ Console.WriteLine("[D.2b] vitals dat-font 0x40000000 loaded for numeric overlay.");
+ }
+ else
+ {
+ Console.WriteLine("[D.2b] vitals dat-font 0x40000000 unavailable — falling back to debug font.");
+ }
+
_uiHost.Root.AddChild(panel);
Console.WriteLine("[D.2b] retail UI active — vitals panel from vitals.xml markup.");
diff --git a/src/AcDream.App/UI/UiDatFont.cs b/src/AcDream.App/UI/UiDatFont.cs
new file mode 100644
index 00000000..c08e20de
--- /dev/null
+++ b/src/AcDream.App/UI/UiDatFont.cs
@@ -0,0 +1,160 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.App.Rendering;
+using DatReaderWriter;
+using DatReaderWriter.DBObjs;
+using DatReaderWriter.Types;
+
+namespace AcDream.App.UI;
+
+///
+/// A retail dat-font (DB_TYPE_FONT, id range 0x40000000-0x40000FFF) ready for
+/// 2D drawing. Holds the two GL atlas textures (foreground glyph pixels +
+/// background outline/shadow), the per-glyph descriptor table, and the line
+/// metrics, so can blit each glyph
+/// as two textured quads exactly the way the retail client does.
+///
+///
+/// Retail render model — SurfaceWindow::DrawCharacter
+/// (acclient 0x00442bd0, Font::GetCharDesc + the two SurfaceWindow blits): for
+/// each glyph it copies the BACKGROUND atlas sub-rect first, tinted with the
+/// outline color (black), then the FOREGROUND atlas sub-rect, tinted with the
+/// requested text color. The pen advances by
+/// HorizontalOffsetBefore + Width + HorizontalOffsetAfter (the function's
+/// return value, accumulated by the string loop at 0x00467ed4
+/// edi_3 += var_98), and each glyph is drawn starting at
+/// penX + HorizontalOffsetBefore.
+///
+///
+///
+/// Atlas format: the foreground atlas (0x06005EE5 for Font 0x40000000) is
+/// PFID_A8 — alpha-only. Our SurfaceDecoder expands A8 to RGBA as
+/// (255,255,255, alpha). The UI sprite shader path (ui_text.frag,
+/// uUseTexture==2) MULTIPLIES the sampled texel by the per-vertex tint
+/// (texture(uTex,vUv) * vColor), so tinting a white+alpha glyph by a
+/// color gives that color with the glyph's alpha — black for the outline pass,
+/// text color for the fill pass. No shader change was needed.
+///
+///
+public sealed class UiDatFont
+{
+ /// Retail UI font id (Latin-1, 16x16 max, with outline atlas).
+ public const uint DefaultFontId = 0x40000000u;
+
+ /// Foreground (glyph pixels) GL texture handle + atlas pixel size.
+ public uint ForegroundTexture { get; }
+ public int ForegroundWidth { get; }
+ public int ForegroundHeight { get; }
+
+ /// Background (outline/shadow) GL texture handle + atlas pixel size.
+ /// 0 when the font has no background atlas (then the outline pass is skipped).
+ public uint BackgroundTexture { get; }
+ public int BackgroundWidth { get; }
+ public int BackgroundHeight { get; }
+
+ /// Vertical advance between lines (retail MaxCharHeight).
+ public float LineHeight { get; }
+
+ /// Distance from a line's top to its baseline (retail BaselineOffset).
+ public float BaselineOffset { get; }
+
+ private readonly Dictionary _glyphs;
+
+ private UiDatFont(
+ uint fgTex, int fgW, int fgH,
+ uint bgTex, int bgW, int bgH,
+ float lineHeight, float baselineOffset,
+ Dictionary glyphs)
+ {
+ ForegroundTexture = fgTex; ForegroundWidth = fgW; ForegroundHeight = fgH;
+ BackgroundTexture = bgTex; BackgroundWidth = bgW; BackgroundHeight = bgH;
+ LineHeight = lineHeight;
+ BaselineOffset = baselineOffset;
+ _glyphs = glyphs;
+ }
+
+ /// True if this font carries a separate outline/shadow atlas
+ /// (retail's m_pBackgroundSurface). When false the outline pass is
+ /// skipped and only the foreground (fill) glyphs are drawn.
+ public bool HasBackground => BackgroundTexture != 0;
+
+ /// Look up a glyph descriptor for a character. Returns false for
+ /// characters not present in the font's table (callers skip them).
+ public bool TryGetGlyph(char c, out FontCharDesc glyph) => _glyphs.TryGetValue(c, out glyph!);
+
+ ///
+ /// Load Font from the dat collection and upload
+ /// both atlases through the texture cache (the same direct-RenderSurface
+ /// path the D.2b chrome sprites use). Returns null if the Font DBObj is
+ /// missing — callers fall back to the debug bitmap font.
+ ///
+ public static UiDatFont? Load(DatCollection dats, TextureCache cache, uint fontId = DefaultFontId)
+ {
+ ArgumentNullException.ThrowIfNull(dats);
+ ArgumentNullException.ThrowIfNull(cache);
+
+ if (!dats.TryGet(fontId, out var font) || font is null)
+ return null;
+
+ // Foreground atlas is required; without it there are no glyph pixels.
+ if (font.ForegroundSurfaceDataId == 0)
+ return null;
+
+ uint fgTex = cache.GetOrUploadRenderSurface(font.ForegroundSurfaceDataId, out int fgW, out int fgH);
+
+ uint bgTex = 0; int bgW = 0, bgH = 0;
+ if (font.BackgroundSurfaceDataId != 0)
+ bgTex = cache.GetOrUploadRenderSurface(font.BackgroundSurfaceDataId, out bgW, out bgH);
+
+ // Build the char->descriptor lookup. FontCharDesc.Unicode is the code
+ // point; for Latin-1 fonts this is a direct char cast. Last write wins
+ // on the rare duplicate (retail's Font::GetCharDesc does a linear scan
+ // and returns the first match, but the dat tables have no duplicates).
+ var glyphs = new Dictionary(font.CharDescs.Count);
+ foreach (var cd in font.CharDescs)
+ glyphs[(char)cd.Unicode] = cd;
+
+ return new UiDatFont(
+ fgTex, fgW, fgH,
+ bgTex, bgW, bgH,
+ lineHeight: font.MaxCharHeight,
+ baselineOffset: font.BaselineOffset,
+ glyphs);
+ }
+
+ ///
+ /// Total pen advance (in pixels) for , summing each
+ /// glyph's retail advance. Characters not in the font contribute nothing.
+ ///
+ public float MeasureWidth(string text)
+ => MeasureWidth(text, c => _glyphs.TryGetValue(c, out var g) ? g : null);
+
+ ///
+ /// Pure pen-advance summation seam: total width of
+ /// given a that maps each char to its descriptor
+ /// (null = not in the font → contributes nothing). Lets the advance math be
+ /// unit-tested with synthetic glyphs, with no GL or dat dependency.
+ ///
+ public static float MeasureWidth(string? text, Func lookup)
+ {
+ ArgumentNullException.ThrowIfNull(lookup);
+ if (string.IsNullOrEmpty(text)) return 0f;
+ float w = 0f;
+ for (int i = 0; i < text.Length; i++)
+ if (lookup(text[i]) is { } g)
+ w += GlyphAdvance(g);
+ return w;
+ }
+
+ ///
+ /// The retail per-glyph horizontal advance:
+ /// HorizontalOffsetBefore + Width + HorizontalOffsetAfter. This is the
+ /// value SurfaceWindow::DrawCharacter returns for proportional text
+ /// (flag bit 0x10 set, acclient 0x00442c3a) and the string loop accumulates
+ /// into the pen. Pulled out as a pure static so the math is unit-testable
+ /// without GL or the dat.
+ ///
+ public static float GlyphAdvance(FontCharDesc g)
+ => g.HorizontalOffsetBefore + g.Width + g.HorizontalOffsetAfter;
+}
diff --git a/src/AcDream.App/UI/UiMeter.cs b/src/AcDream.App/UI/UiMeter.cs
index 5baec4a7..f2b44f50 100644
--- a/src/AcDream.App/UI/UiMeter.cs
+++ b/src/AcDream.App/UI/UiMeter.cs
@@ -24,6 +24,12 @@ public sealed class UiMeter : UiElement
public Vector4 BgColor { get; set; } = new(0f, 0f, 0f, 0.5f);
public Vector4 LabelColor { get; set; } = new(1f, 1f, 1f, 1f);
+ /// Retail dat font (Font 0x40000000) for the "cur/max" overlay. When
+ /// set, the label renders through the dat-font two-pass blit (outline + fill);
+ /// when null, the debug bitmap font
+ /// is used instead. Set by the host when the retail UI is active.
+ public UiDatFont? DatFont { get; set; }
+
/// Resolver from a RenderSurface DataId to (GL handle, w, h). When set
/// with the 9-slice ids below, the bar draws the retail sprites instead of solid color.
public Func? SpriteResolve { get; set; }
@@ -87,12 +93,24 @@ public sealed class UiMeter : UiElement
}
string? label = Label();
- if (!string.IsNullOrEmpty(label) && ctx.DefaultFont is { } font)
+ if (!string.IsNullOrEmpty(label))
{
- float tw = font.MeasureWidth(label);
- float tx = (Width - tw) * 0.5f;
- float ty = (Height - font.LineHeight) * 0.5f;
- ctx.DrawString(label, tx, ty, LabelColor);
+ if (DatFont is { } datFont)
+ {
+ // Retail path: centered cur/max via the dat font's two-pass blit.
+ float tw = datFont.MeasureWidth(label);
+ float tx = (Width - tw) * 0.5f;
+ float ty = (Height - datFont.LineHeight) * 0.5f;
+ ctx.DrawStringDat(datFont, label, tx, ty, LabelColor);
+ }
+ else if (ctx.DefaultFont is { } font)
+ {
+ // Fallback: debug bitmap font (no dat font available).
+ float tw = font.MeasureWidth(label);
+ float tx = (Width - tw) * 0.5f;
+ float ty = (Height - font.LineHeight) * 0.5f;
+ ctx.DrawString(label, tx, ty, LabelColor);
+ }
}
}
diff --git a/src/AcDream.App/UI/UiRenderContext.cs b/src/AcDream.App/UI/UiRenderContext.cs
index 01d81277..39727a0d 100644
--- a/src/AcDream.App/UI/UiRenderContext.cs
+++ b/src/AcDream.App/UI/UiRenderContext.cs
@@ -64,4 +64,77 @@ public sealed class UiRenderContext
if (f is null) return;
TextRenderer.DrawString(f, text, _current.X + x, _current.Y + y, color);
}
+
+ ///
+ /// Draw a single line of text with a retail dat font (),
+ /// at , = the top-left of the
+ /// typographic block (in this element's local space). Mirrors retail's
+ /// SurfaceWindow::DrawCharacter (acclient 0x00442bd0): for each glyph
+ /// the BACKGROUND atlas sub-rect is blitted first tinted black (the outline),
+ /// then the FOREGROUND atlas sub-rect tinted (the
+ /// fill). The pen advances by
+ /// HorizontalOffsetBefore + Width + HorizontalOffsetAfter and each
+ /// glyph is positioned at pen + HorizontalOffsetBefore on the X axis
+ /// and at baseline + VerticalOffsetBefore - (BaselineOffset) via the
+ /// glyph's OffsetY into the atlas. If the font has no background atlas the
+ /// outline pass is skipped.
+ ///
+ public void DrawStringDat(UiDatFont font, string text, float x, float y, Vector4 color)
+ {
+ if (font is null || string.IsNullOrEmpty(text)) return;
+
+ // Baseline of this line in local space; retail draws glyphs whose
+ // descriptor OffsetY already places them relative to the line top, so we
+ // anchor each glyph's quad at the line top (y) plus its VerticalOffsetBefore.
+ float originX = _current.X + x;
+ float originY = _current.Y + y;
+ float pen = originX;
+
+ var outline = new Vector4(0f, 0f, 0f, color.W);
+
+ for (int i = 0; i < text.Length; i++)
+ {
+ if (!font.TryGetGlyph(text[i], out var g))
+ continue;
+
+ float gx = pen + g.HorizontalOffsetBefore;
+ float gy = originY + g.VerticalOffsetBefore;
+ float gw = g.Width;
+ float gh = g.Height;
+
+ if (gw > 0f && gh > 0f)
+ {
+ // Background (outline) atlas pass, tinted black — drawn behind.
+ if (font.BackgroundTexture != 0)
+ {
+ var (bu0, bv0, bu1, bv1) = AtlasUv(
+ g.OffsetX, g.OffsetY, g.Width, g.Height,
+ font.BackgroundWidth, font.BackgroundHeight);
+ TextRenderer.DrawSprite(font.BackgroundTexture, gx, gy, gw, gh, bu0, bv0, bu1, bv1, outline);
+ }
+
+ // Foreground (fill) atlas pass, tinted with the requested color.
+ var (fu0, fv0, fu1, fv1) = AtlasUv(
+ g.OffsetX, g.OffsetY, g.Width, g.Height,
+ font.ForegroundWidth, font.ForegroundHeight);
+ TextRenderer.DrawSprite(font.ForegroundTexture, gx, gy, gw, gh, fu0, fv0, fu1, fv1, color);
+ }
+
+ pen += UiDatFont.GlyphAdvance(g);
+ }
+ }
+
+ /// Convert an (OffsetX,OffsetY,Width,Height) atlas pixel sub-rect to
+ /// normalized UVs for an atlas of x
+ /// . Guards against a zero-sized atlas.
+ private static (float u0, float v0, float u1, float v1) AtlasUv(
+ int offsetX, int offsetY, int width, int height, int atlasW, int atlasH)
+ {
+ if (atlasW <= 0 || atlasH <= 0) return (0f, 0f, 0f, 0f);
+ float u0 = offsetX / (float)atlasW;
+ float v0 = offsetY / (float)atlasH;
+ float u1 = (offsetX + width) / (float)atlasW;
+ float v1 = (offsetY + height) / (float)atlasH;
+ return (u0, v0, u1, v1);
+ }
}
diff --git a/tests/AcDream.App.Tests/UI/UiDatFontTests.cs b/tests/AcDream.App.Tests/UI/UiDatFontTests.cs
new file mode 100644
index 00000000..55a6457a
--- /dev/null
+++ b/tests/AcDream.App.Tests/UI/UiDatFontTests.cs
@@ -0,0 +1,84 @@
+using System.Collections.Generic;
+using AcDream.App.UI;
+using DatReaderWriter.Types;
+
+namespace AcDream.App.Tests.UI;
+
+///
+/// Pure pen-advance / MeasureWidth math for the retail dat font (no GL, no dat).
+/// The advance per glyph is the retail
+/// HorizontalOffsetBefore + Width + HorizontalOffsetAfter
+/// (SurfaceWindow::DrawCharacter, acclient 0x00442c3a), accumulated across the
+/// string the way the retail string loop does (0x00467ed4 edi_3 += var_98).
+///
+public class UiDatFontTests
+{
+ private static FontCharDesc Glyph(
+ ushort unicode, byte width,
+ sbyte before = 0, sbyte after = 0,
+ ushort offsetX = 0, ushort offsetY = 0, byte height = 16, sbyte vBefore = 0)
+ => new()
+ {
+ Unicode = unicode,
+ Width = width,
+ Height = height,
+ OffsetX = offsetX,
+ OffsetY = offsetY,
+ HorizontalOffsetBefore = before,
+ HorizontalOffsetAfter = after,
+ VerticalOffsetBefore = vBefore,
+ };
+
+ [Fact]
+ public void GlyphAdvance_SumsBeforeWidthAfter()
+ {
+ var g = Glyph('A', width: 8, before: 1, after: 2);
+ Assert.Equal(11f, UiDatFont.GlyphAdvance(g));
+ }
+
+ [Fact]
+ public void GlyphAdvance_HandlesNegativeBearings()
+ {
+ // Kerned glyph: a negative left-bearing pulls it leftward; the advance
+ // still nets out to before + width + after.
+ var g = Glyph('j', width: 4, before: -1, after: 0);
+ Assert.Equal(3f, UiDatFont.GlyphAdvance(g));
+ }
+
+ [Fact]
+ public void MeasureWidth_SumsEachGlyphAdvance()
+ {
+ var table = new Dictionary
+ {
+ ['2'] = Glyph('2', width: 7, before: 1, after: 1), // advance 9
+ ['9'] = Glyph('9', width: 7, before: 1, after: 1), // advance 9
+ ['1'] = Glyph('1', width: 3, before: 2, after: 1), // advance 6
+ ['/'] = Glyph('/', width: 4, before: 0, after: 1), // advance 5
+ };
+ FontCharDesc? Lookup(char c) => table.TryGetValue(c, out var g) ? g : null;
+
+ // "291/291" = 9 + 9 + 6 + 5 + 9 + 9 + 6 = 53
+ Assert.Equal(53f, UiDatFont.MeasureWidth("291/291", Lookup));
+ }
+
+ [Fact]
+ public void MeasureWidth_SkipsCharactersNotInFont()
+ {
+ var table = new Dictionary
+ {
+ ['5'] = Glyph('5', width: 6, before: 1, after: 1), // advance 8
+ };
+ FontCharDesc? Lookup(char c) => table.TryGetValue(c, out var g) ? g : null;
+
+ // 'X' has no glyph → contributes nothing; only the two '5's count.
+ Assert.Equal(16f, UiDatFont.MeasureWidth("5X5", Lookup));
+ }
+
+ [Fact]
+ public void MeasureWidth_EmptyOrNullIsZero()
+ {
+ FontCharDesc? Lookup(char c) => null;
+ Assert.Equal(0f, UiDatFont.MeasureWidth("", Lookup));
+ Assert.Equal(0f, UiDatFont.MeasureWidth(null, Lookup));
+ }
+}