Campaign CH round 4, user-gate items 1+2. Root cause: retail ships a second (background) glyph atlas per font, dilated 2px on every side, plus two border-pixel scalars (Font.NumHorizontalBorderPixels/ NumVerticalBorderPixels) that acdream's font reader never read — so even the pre-existing outline parameter drew almost nothing once enabled. Landed together (either half alone is a no-op or a regression): - UiDatFont carries BorderX/BorderY from the DAT font resource. - UiRenderContext.DrawStringDat inflates the background blit's source and destination rect by that margin and restructures into retail's exact two-pass whole-string outline-then-fill model (UIElement_Text::DrawSelf), plus the 8-neighbour +-1px fallback for fonts with no background atlas. Corrects the stale "property 0xd" comment to the real ids, 0x21 (Outline) / 0x22 (OutlineColor). - LayoutDesc property 0x21/0x22 import (ElementInfo.Outline/ OutlineColor, LayoutImporter.ReadState, ElementReader.Merge/ ApplyCanonicalLegacyProjection, DatWidgetFactory.BuildText) so every authored-outline element across the DAT set is correct at once. - SpewBox: RetailFontId corrected from a round-3 heuristic (0x40000025) to the actually-authored 0x40000001 (18px bold serif), Outline=true set on the controller's UiText. Fill colour stays the user-gate-round-1-pinned yellow — font atlases are alpha-only (PFID_A8), so there is no baked shading that could explain the screenshot's gold as anything other than the outline itself. - Chat transcript: default fill now seeds from its authored ARGB(255,204,204,204) instead of an unrelated color-table slot (ChatTranscriptRenderer.BuildLines takes the transcript's own DefaultColor as a parameter); the 34-entry LogTextType table is untouched, and every existing CH1 conformance test stays green unmodified. Regenerated the committed chat_2100006f.json fixture from the real installed DAT, confirming end to end (not by missing-field default) that the transcript carries no outline. Tests: font-reader border fields + inflation math pinned against the real DAT font, two-pass draw ordering/tint/inflation via a new TextRenderer.DebugSpriteSegmentVerts test seam, property 0x21/0x22 import at both the ElementReader.Merge and StateDesc-property layers, SpewBox font/outline, and the chat default-shade seed with the color table proven untouched. Full Release suite: 12,610 passed / 4 skipped / 0 failed (AcDream.slnx, complete solution). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
201 lines
9.3 KiB
C#
201 lines
9.3 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.Numerics;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.Content;
|
|
using DatReaderWriter;
|
|
using DatReaderWriter.DBObjs;
|
|
using DatReaderWriter.Types;
|
|
|
|
namespace AcDream.App.UI;
|
|
|
|
/// <summary>
|
|
/// 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, the border-pixel
|
|
/// inflation margin (<see cref="BorderX"/>/<see cref="BorderY"/>), and the line
|
|
/// metrics, so <see cref="UiRenderContext.DrawStringDat"/> can blit each glyph
|
|
/// as two textured quads exactly the way the retail client does.
|
|
///
|
|
/// <para>
|
|
/// Retail render model — <c>UIElement_Text::DrawSelf</c> (acclient 0x00467aa0)
|
|
/// runs the WHOLE glyph run TWICE when the element's outline flag (LayoutDesc
|
|
/// property 0x21, <c>m_bitField & 0x10</c>) is set: pass 0 draws every glyph's
|
|
/// outline (the BACKGROUND atlas sub-rect, inflated by <see cref="BorderX"/>/
|
|
/// <see cref="BorderY"/> on every side, tinted with the element's outline color —
|
|
/// ctor default black, property 0x22), then pass 1 draws every glyph's fill (the
|
|
/// FOREGROUND atlas sub-rect, tinted with the requested text color) on top. With
|
|
/// the outline flag clear, only pass 1 runs. <c>SurfaceWindow::DrawCharacter</c>
|
|
/// (acclient 0x00442bd0, Font::GetCharDesc + the two SurfaceWindow blits) is the
|
|
/// per-glyph blit each pass calls. The pen advances by
|
|
/// <c>HorizontalOffsetBefore + Width + HorizontalOffsetAfter</c> (the function's
|
|
/// return value, accumulated by the string loop at 0x00467ed4
|
|
/// <c>edi_3 += var_98</c>), and each glyph is drawn starting at
|
|
/// <c>penX + HorizontalOffsetBefore</c>.
|
|
/// </para>
|
|
///
|
|
/// <para>
|
|
/// Atlas format: the foreground atlas (0x06005EE5 for Font 0x40000000) is
|
|
/// PFID_A8 — alpha-only. Our <c>SurfaceDecoder</c> expands A8 to RGBA as
|
|
/// (255,255,255, alpha). The UI sprite shader path (ui_text.frag,
|
|
/// <c>uUseTexture==2</c>) MULTIPLIES the sampled texel by the per-vertex tint
|
|
/// (<c>texture(uTex,vUv) * vColor</c>), so tinting a white+alpha glyph by a
|
|
/// color gives that color with the glyph's alpha — the outline color for the
|
|
/// outline pass, text color for the fill pass. No shader change was needed.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class UiDatFont
|
|
{
|
|
/// <summary>Retail UI font id (Latin-1, 16x16 max, with outline atlas).</summary>
|
|
public const uint DefaultFontId = 0x40000000u;
|
|
|
|
/// <summary>Foreground (glyph pixels) GL texture handle + atlas pixel size.</summary>
|
|
public uint ForegroundTexture { get; }
|
|
public int ForegroundWidth { get; }
|
|
public int ForegroundHeight { get; }
|
|
|
|
/// <summary>Background (outline/shadow) GL texture handle + atlas pixel size.
|
|
/// 0 when the font has no background atlas (then the outline pass is skipped).</summary>
|
|
public uint BackgroundTexture { get; }
|
|
public int BackgroundWidth { get; }
|
|
public int BackgroundHeight { get; }
|
|
|
|
/// <summary>Vertical advance between lines (retail MaxCharHeight).</summary>
|
|
public float LineHeight { get; }
|
|
|
|
/// <summary>Distance from a line's top to its baseline (retail BaselineOffset).</summary>
|
|
public float BaselineOffset { get; }
|
|
|
|
/// <summary>
|
|
/// Retail <c>Font::m_NumHorizontalBorderPixels</c> / <c>m_NumVerticalBorderPixels</c>
|
|
/// (<c>Font::Serialize @0x00443650</c>; verified struct offsets <c>Font+0x40</c> /
|
|
/// <c>Font+0x44</c>). The background (outline) atlas glyph is the foreground glyph
|
|
/// dilated 2px on every side, sitting inside a margin this wide — measured: the FG
|
|
/// glyph always sits at exactly <c>(BorderX, BorderY)</c> inside its inflated window,
|
|
/// and the BG glyph's alpha bbox begins at <c>(BorderX-2, BorderY-2)</c>. Zero for
|
|
/// fonts with no background atlas (e.g. the CJK/unicode family, <see cref="HasBackground"/>
|
|
/// is false). <see cref="UiRenderContext.DrawStringDat"/> inflates the background blit's
|
|
/// source AND destination rect by exactly this much on every side
|
|
/// (<c>SurfaceWindow::DrawCharacter @0x00442d3a</c> + <c>CreateCharRectPair @0x00441480</c>)
|
|
/// so the dilation is actually captured instead of cropped away.
|
|
/// </summary>
|
|
public int BorderX { get; }
|
|
public int BorderY { get; }
|
|
|
|
private readonly Dictionary<char, FontCharDesc> _glyphs;
|
|
|
|
internal UiDatFont(
|
|
uint fgTex, int fgW, int fgH,
|
|
uint bgTex, int bgW, int bgH,
|
|
float lineHeight, float baselineOffset,
|
|
Dictionary<char, FontCharDesc> glyphs,
|
|
int borderX = 0, int borderY = 0)
|
|
{
|
|
ForegroundTexture = fgTex; ForegroundWidth = fgW; ForegroundHeight = fgH;
|
|
BackgroundTexture = bgTex; BackgroundWidth = bgW; BackgroundHeight = bgH;
|
|
LineHeight = lineHeight;
|
|
BaselineOffset = baselineOffset;
|
|
_glyphs = glyphs;
|
|
BorderX = borderX;
|
|
BorderY = borderY;
|
|
}
|
|
|
|
/// <summary>True if this font carries a separate outline/shadow atlas
|
|
/// (retail's <c>m_pBackgroundSurface</c>). When false the outline pass is
|
|
/// skipped and only the foreground (fill) glyphs are drawn.</summary>
|
|
public bool HasBackground => BackgroundTexture != 0;
|
|
|
|
/// <summary>Look up a glyph descriptor for a character. Returns false for
|
|
/// characters not present in the font's table (callers skip them).</summary>
|
|
public bool TryGetGlyph(char c, out FontCharDesc glyph) => _glyphs.TryGetValue(c, out glyph!);
|
|
|
|
/// <summary>
|
|
/// Load Font <paramref name="fontId"/> 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.
|
|
/// </summary>
|
|
public static UiDatFont? Load(IDatReaderWriter dats, TextureCache cache, uint fontId = DefaultFontId)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(dats);
|
|
ArgumentNullException.ThrowIfNull(cache);
|
|
|
|
if (!dats.TryGet<Font>(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;
|
|
|
|
// Point-sample the glyph atlases (nearest) so small UI text stays pixel-crisp;
|
|
// bilinear softens the dat font noticeably (the chat menu/button text "blur").
|
|
uint fgTex = cache.GetOrUploadRenderSurface(font.ForegroundSurfaceDataId, out int fgW, out int fgH, nearest: true);
|
|
|
|
uint bgTex = 0; int bgW = 0, bgH = 0;
|
|
if (font.BackgroundSurfaceDataId != 0)
|
|
bgTex = cache.GetOrUploadRenderSurface(font.BackgroundSurfaceDataId, out bgW, out bgH, nearest: true);
|
|
|
|
// 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<char, FontCharDesc>(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,
|
|
borderX: (int)font.NumHorizontalBorderPixels,
|
|
borderY: (int)font.NumVerticalBorderPixels);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Total pen advance (in pixels) for <paramref name="text"/>, summing each
|
|
/// glyph's retail advance. Characters not in the font contribute nothing.
|
|
/// </summary>
|
|
public float MeasureWidth(string text)
|
|
{
|
|
if (string.IsNullOrEmpty(text)) return 0f;
|
|
|
|
float width = 0f;
|
|
for (int index = 0; index < text.Length; index++)
|
|
{
|
|
if (_glyphs.TryGetValue(text[index], out FontCharDesc? glyph))
|
|
width += GlyphAdvance(glyph);
|
|
}
|
|
|
|
return width;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Pure pen-advance summation seam: total width of <paramref name="text"/>
|
|
/// given a <paramref name="lookup"/> 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.
|
|
/// </summary>
|
|
public static float MeasureWidth(string? text, Func<char, FontCharDesc?> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// The retail per-glyph horizontal advance:
|
|
/// <c>HorizontalOffsetBefore + Width + HorizontalOffsetAfter</c>. This is the
|
|
/// value <c>SurfaceWindow::DrawCharacter</c> 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.
|
|
/// </summary>
|
|
public static float GlyphAdvance(FontCharDesc g)
|
|
=> g.HorizontalOffsetBefore + g.Width + g.HorizontalOffsetAfter;
|
|
}
|