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