feat(chat): retail text style — two-plane glyph outlines, authored SpewBox/chat styles

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>
This commit is contained in:
Erik 2026-08-10 19:28:34 +02:00
parent 5b54387b8e
commit bcc34ee301
22 changed files with 1995 additions and 105 deletions

View file

@ -12,16 +12,22 @@ 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, and the line
/// 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>SurfaceWindow::DrawCharacter</c>
/// (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
/// 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 &amp; 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
@ -34,8 +40,8 @@ namespace AcDream.App.UI;
/// (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 — black for the outline pass,
/// text color for the fill pass. No shader change was needed.
/// 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
@ -60,19 +66,38 @@ public sealed class UiDatFont
/// <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)
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
@ -123,7 +148,9 @@ public sealed class UiDatFont
bgTex, bgW, bgH,
lineHeight: font.MaxCharHeight,
baselineOffset: font.BaselineOffset,
glyphs);
glyphs,
borderX: (int)font.NumHorizontalBorderPixels,
borderY: (int)font.NumVerticalBorderPixels);
}
/// <summary>