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

@ -202,28 +202,64 @@ public sealed class UiRenderContext
TextRenderer.DrawString(f, text, screenX, screenY, alphaColor);
}
/// <summary>Retail <c>UIElement_Text</c>'s constructor outline color default
/// (<c>RGBAColor_Black</c>, <c>UIElement_Text::UIElement_Text @0x004686cb</c>).
/// Used by <see cref="DrawStringDat"/> when the caller doesn't supply an
/// explicit <c>outlineColor</c> (LayoutDesc property 0x22 is authored on only
/// 9 elements in the whole DAT set — every other outlined element uses this).</summary>
public static readonly Vector4 DefaultOutlineColor = new(0f, 0f, 0f, 1f);
/// <summary>
/// Draw a single line of text with a retail dat font (<see cref="UiDatFont"/>),
/// at <paramref name="x"/>,<paramref name="y"/> = the top-left of the
/// typographic block (in this element's local space). Mirrors retail's
/// <c>SurfaceWindow::DrawCharacter</c> (acclient 0x00442bd0): for each glyph
/// the BACKGROUND atlas sub-rect is blitted first tinted black (the outline),
/// then the FOREGROUND atlas sub-rect tinted <paramref name="color"/> (the
/// fill). The pen advances by
/// typographic block (in this element's local space). The pen advances by
/// <c>HorizontalOffsetBefore + Width + HorizontalOffsetAfter</c> and each
/// glyph is positioned at <c>pen + HorizontalOffsetBefore</c> on the X axis
/// and at <c>baseline + VerticalOffsetBefore - (BaselineOffset)</c> via the
/// glyph's OffsetY into the atlas.
/// and at <c>baseline + VerticalOffsetBefore</c> via the glyph's OffsetY into
/// the atlas.
///
/// <para><paramref name="outline"/> gates the black outline pass. Retail decides
/// this PER text element: <c>UIElement_Text::DrawSelf</c> (acclient 0x00467aa0)
/// runs the outline pass only when <c>m_bitField &amp; 0x10</c> is set — i.e. the
/// element called <c>SetOutline(true)</c> (LayoutDesc property 0xd). The DEFAULT
/// is OFF (one fill-only pass): the talk-focus menu items set no outline, so an
/// always-on outline shows as a grey halo over the solid menu panel. Pass
/// <c>outline:true</c> only for elements retail outlines.</para>
/// <para>
/// <b>Two-pass outline model</b> — <c>UIElement_Text::DrawSelf</c>
/// (acclient 0x00467aa0): <c>var_b0 = (m_bitField &amp; 0x10) ? 0 : 1;</c> — when
/// the outline bit is CLEAR the loop runs once (fill only, <c>var_b0==1</c>);
/// when SET it runs twice, pass 0 (outline) for the WHOLE STRING first, then
/// pass 1 (fill) for the whole string. Fills therefore always paint over every
/// neighbour's outline — this method reproduces that by iterating the string
/// twice rather than interleaving outline+fill per glyph, so on tight kerning
/// glyph N+1's outline never covers glyph N's fill (nor vice versa).
/// </para>
///
/// <para>
/// <paramref name="outline"/> gates the outline passes. Retail decides this PER
/// text element: the bit is <c>m_bitField &amp; 0x10</c>, set by LayoutDesc
/// property <b>0x21</b> (<c>SetOutline @0x0046a81c</c>) — NOT property 0xd (an
/// earlier comment here named the wrong id: 0xd is the *switch-case index* inside
/// <c>UIElement_Text::OnSetAttribute</c>, not the authored property). The DEFAULT
/// is OFF (one fill-only pass, ctor <c>m_bitField=0x300</c> clears bit 0x10):
/// outlining is opt-in per element (~100 authored rows across 15 layouts).
/// <paramref name="outlineColor"/> is the element's <c>m_curOutlineColor</c>
/// (LayoutDesc property 0x22, ctor default <see cref="DefaultOutlineColor"/> —
/// black; only 9 elements in the whole DAT set author a non-black value).
/// </para>
///
/// <para>
/// <b>Outline mechanism</b> — retail picks by data, per font
/// (<c>feedback_retail_dispatch_is_data_driven</c> shape): when
/// <see cref="UiDatFont.HasBackground"/> is true, the outline pass blits the
/// BACKGROUND (dilated) atlas sub-rect, inflated by
/// <see cref="UiDatFont.BorderX"/>/<see cref="UiDatFont.BorderY"/> on every
/// side of both the source AND destination rect
/// (<c>SurfaceWindow::DrawCharacter @0x00442d3a</c> +
/// <c>CreateCharRectPair @0x00441480</c>) — the un-inflated rect crops the
/// dilation away, making the outline invisible even with the flag on. When the
/// font has NO background atlas (the CJK/unicode family), retail falls back to
/// 8 neighbour blits of the FOREGROUND glyph at ±1px
/// (<c>UIElement_Text::DrawSelf</c> 0x00467d7e-0x00467e14).
/// </para>
/// </summary>
public void DrawStringDat(UiDatFont font, string text, float x, float y, Vector4 color, bool outline = false)
public void DrawStringDat(
UiDatFont font, string text, float x, float y, Vector4 color,
bool outline = false, Vector4? outlineColor = null)
{
if (font is null || string.IsNullOrEmpty(text)) return;
@ -232,7 +268,6 @@ public sealed class UiRenderContext
// 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;
// Snap the LINE baseline to a whole pixel ONCE. Retail's
// SurfaceWindow::DrawCharacter (acclient 0x00442bd0) takes an int32 pen Y
@ -246,8 +281,27 @@ public sealed class UiRenderContext
// line on one row and pixel-aligned.
float baseY = System.MathF.Round(originY);
var outlineTint = new Vector4(0f, 0f, 0f, color.W);
if (outline)
{
Vector4 oc = outlineColor ?? DefaultOutlineColor;
var outlineTint = new Vector4(oc.X, oc.Y, oc.Z, color.W);
// PASS 0 — outline, whole string (acclient 0x00467aa0, var_b0 starts at 0).
DrawStringDatPass(font, text, originX, baseY, outlineTint, isOutlinePass: true);
}
// PASS 1 (or the only pass, when outline is off) — fill, whole string.
DrawStringDatPass(font, text, originX, baseY, color, isOutlinePass: false);
}
/// <summary>One whole-string pass of <see cref="DrawStringDat"/>'s two-pass
/// model: either every glyph's outline (<paramref name="isOutlinePass"/> true)
/// or every glyph's fill. Recomputes the pen from scratch — a pure function of
/// <paramref name="font"/>/<paramref name="text"/>, so both passes advance
/// identically and stay in lock-step without sharing mutable state.</summary>
private void DrawStringDatPass(
UiDatFont font, string text, float originX, float baseY, Vector4 tint, bool isOutlinePass)
{
float pen = originX;
for (int i = 0; i < text.Length; i++)
{
if (!font.TryGetGlyph(text[i], out var g))
@ -255,7 +309,7 @@ public sealed class UiRenderContext
// Horizontal: snap each glyph's dest X to a whole pixel (the pen keeps its
// true fractional advance). Vertical: integer baseline + integer per-glyph
// offset — never an independent per-glyph round (see baseY note above).
// offset — never an independent per-glyph round (see DrawStringDat's baseY note).
float gx = System.MathF.Round(pen + g.HorizontalOffsetBefore);
float gy = baseY + g.VerticalOffsetBefore;
float gw = g.Width;
@ -263,35 +317,76 @@ public sealed class UiRenderContext
if (gw > 0f && gh > 0f)
{
// Background (outline) atlas pass, tinted black — drawn behind. Gated by
// `outline` (retail's per-element m_bitField & 0x10); off by default so UI
// text is crisp fill-only and free of the grey halo over solid panels.
// Both passes route through ApplyAlpha (applyAlpha: true) so a window's
// opacity fades glyphs exactly like its chrome/background sprites — retail's
// ChatInterface::SetOpacity (0x004F3120) fades the whole composited surface.
if (outline && font.BackgroundTexture != 0)
{
var (bu0, bv0, bu1, bv1) = AtlasUv(
g.OffsetX, g.OffsetY, g.Width, g.Height,
font.BackgroundWidth, font.BackgroundHeight);
DrawSpriteAbsolute(
font.BackgroundTexture, gx, gy, gw, gh,
bu0, bv0, bu1, bv1, outlineTint, applyAlpha: true);
}
// 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);
DrawSpriteAbsolute(
font.ForegroundTexture, gx, gy, gw, gh,
fu0, fv0, fu1, fv1, color, applyAlpha: true);
if (isOutlinePass)
DrawOutlineGlyph(font, g, gx, gy, gw, gh, tint);
else
DrawFillGlyph(font, g, gx, gy, gw, gh, tint);
}
pen += UiDatFont.GlyphAdvance(g);
}
}
/// <summary>Foreground (fill) atlas blit, tinted with the requested text color.
/// Both passes route through <c>DrawSpriteAbsolute(..., applyAlpha: true)</c> so a
/// window's opacity fades glyphs exactly like its chrome/background sprites —
/// retail's <c>ChatInterface::SetOpacity</c> (0x004F3120) fades the whole
/// composited surface.</summary>
private void DrawFillGlyph(
UiDatFont font, DatReaderWriter.Types.FontCharDesc g,
float gx, float gy, float gw, float gh, Vector4 tint)
{
var (fu0, fv0, fu1, fv1) = AtlasUv(
g.OffsetX, g.OffsetY, g.Width, g.Height,
font.ForegroundWidth, font.ForegroundHeight);
DrawSpriteAbsolute(font.ForegroundTexture, gx, gy, gw, gh, fu0, fv0, fu1, fv1, tint, applyAlpha: true);
}
/// <summary>Outline-pass blit for one glyph: the background (dilated) atlas
/// sub-rect inflated by the font's border-pixel margin when the font carries
/// one, else retail's 8-neighbour ±1px foreground-glyph fallback.</summary>
private void DrawOutlineGlyph(
UiDatFont font, DatReaderWriter.Types.FontCharDesc g,
float gx, float gy, float gw, float gh, Vector4 tint)
{
if (font.BackgroundTexture != 0)
{
// Background (dilated) plane, inflated by (BorderX, BorderY) on every side of
// BOTH the source sub-rect and the destination rect — CreateCharRectPair's
// background call (0x00442d3a) inflates both symmetrically before the atlas
// blit. The un-inflated rect (acdream's prior behavior) crops the dilation
// away entirely, leaving only a single stray pixel where a descender pokes out.
int bx = font.BorderX, by = font.BorderY;
float ix = gx - bx;
float iy = gy - by;
float iw = gw + 2f * bx;
float ih = gh + 2f * by;
var (bu0, bv0, bu1, bv1) = AtlasUv(
g.OffsetX - bx, g.OffsetY - by, (int)iw, (int)ih,
font.BackgroundWidth, font.BackgroundHeight);
DrawSpriteAbsolute(font.BackgroundTexture, ix, iy, iw, ih, bu0, bv0, bu1, bv1, tint, applyAlpha: true);
}
else
{
// No background plane (retail's 0-border fonts — the CJK/unicode family):
// fall back to retail's 8-neighbour ±1px foreground-glyph blit
// (UIElement_Text::DrawSelf 0x00467d7e-0x00467e14).
var (fu0, fv0, fu1, fv1) = AtlasUv(
g.OffsetX, g.OffsetY, g.Width, g.Height,
font.ForegroundWidth, font.ForegroundHeight);
for (int dy = -1; dy <= 1; dy++)
{
for (int dx = -1; dx <= 1; dx++)
{
if (dx == 0 && dy == 0) continue;
DrawSpriteAbsolute(
font.ForegroundTexture, gx + dx, gy + dy, gw, gh,
fu0, fv0, fu1, fv1, tint, applyAlpha: true);
}
}
}
}
/// <summary>Convert an (OffsetX,OffsetY,Width,Height) atlas pixel sub-rect to
/// normalized UVs for an atlas of <paramref name="atlasW"/> x
/// <paramref name="atlasH"/>. Guards against a zero-sized atlas.</summary>