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

@ -0,0 +1,334 @@
using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.Rendering;
using AcDream.App.Rendering.Gpu;
using AcDream.App.Tests.Rendering.Gpu;
using AcDream.App.UI;
using DatReaderWriter.Types;
namespace AcDream.App.Tests.UI;
/// <summary>
/// Campaign CH round 4 (<c>docs/research/2026-08-10-retail-ui-text-style.md</c>):
/// pins <see cref="UiRenderContext.DrawStringDat"/>'s retail two-pass outline
/// model — whole-string outline pass THEN whole-string fill pass
/// (<c>UIElement_Text::DrawSelf @0x00467aa0</c>), the outline-color tint
/// (property 0x22, default black), the background-plane INFLATION by the
/// font's border-pixel margin (<c>CreateCharRectPair @0x00441480</c>), and the
/// 8-neighbour ±1px fallback for fonts with no background atlas.
///
/// <para>
/// Builds a real <see cref="TextRenderer"/> over the in-memory
/// <see cref="RecordingGpuDevice"/> test double (no live GPU) and reads back
/// <see cref="TextRenderer.DebugSpriteSegmentVerts"/> — the full per-vertex
/// float buffer, not just texture/count/alpha — so position, UV span, and RGB
/// tint are all directly assertable. See
/// <see cref="AcDream.App.Tests.UI.UiRenderContextAlphaTests"/> for the sibling
/// alpha-chokepoint coverage this file does not duplicate.
/// </para>
/// </summary>
public sealed class UiRenderContextDrawStringDatOutlineTests
{
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
{
public IGpuFrame? CurrentFrame => null;
}
private const uint ForegroundTex = 1u;
private const uint BackgroundTex = 2u;
private static (TextRenderer renderer, UiRenderContext ctx) Build()
{
var device = new RecordingGpuDevice();
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
renderer.Begin(new Vector2(800f, 600f));
var ctx = new UiRenderContext(renderer, new Vector2(800f, 600f));
return (renderer, ctx);
}
private static FontCharDesc Glyph(
char c, ushort offsetX, ushort offsetY, byte width, byte height,
sbyte before = 0, sbyte after = 0, sbyte vBefore = 0)
=> new()
{
Unicode = c,
Width = width,
Height = height,
OffsetX = offsetX,
OffsetY = offsetY,
HorizontalOffsetBefore = before,
HorizontalOffsetAfter = after,
VerticalOffsetBefore = vBefore,
};
/// <summary>One quad's decoded first-two-vertex geometry: (x,y,w,h) in dest
/// pixel space, (u0,v0,u1,v1) atlas UVs, (r,g,b,a) tint — reconstructed from
/// <see cref="TextRenderer.AppendQuad"/>'s known 6-vertex/8-float layout
/// (V0 = top-left, V1 = bottom-right of the first triangle).</summary>
private readonly record struct Quad(
float X, float Y, float W, float H,
float U0, float V0, float U1, float V1,
float R, float G, float B, float A);
private static List<Quad> DecodeQuads(IReadOnlyList<float> verts)
{
var quads = new List<Quad>();
const int floatsPerVertex = 8;
const int floatsPerQuad = floatsPerVertex * 6;
for (int i = 0; i + floatsPerQuad <= verts.Count; i += floatsPerQuad)
{
float x0 = verts[i + 0], y0 = verts[i + 1], u0 = verts[i + 2], v0 = verts[i + 3];
float r = verts[i + 4], g = verts[i + 5], b = verts[i + 6], a = verts[i + 7];
// Second vertex (index 1) is (x+w, y+h, u1, v1) per AppendQuad's V0..V5 order.
float x1 = verts[i + 8 + 0], y1 = verts[i + 8 + 1], u1 = verts[i + 8 + 2], v1 = verts[i + 8 + 3];
quads.Add(new Quad(x0, y0, x1 - x0, y1 - y0, u0, v0, u1, v1, r, g, b, a));
}
return quads;
}
// ── Two-pass ordering ────────────────────────────────────────────────────
[Fact]
public void Outline_TwoGlyphString_EmitsOneWholeStringOutlineSegmentThenOneWholeStringFillSegment()
{
// Retail runs the ENTIRE glyph run's outline pass first, then the
// ENTIRE fill pass (UIElement_Text::DrawSelf 0x00467aa0) — not
// interleaved per glyph. Two glyphs on the SAME (background) texture
// batch into ONE segment; the fill pass on the SAME (foreground)
// texture batches into a SECOND segment. An interleaved
// outline-A/fill-A/outline-B/fill-B implementation would instead
// alternate textures and produce FOUR segments.
(TextRenderer renderer, UiRenderContext ctx) = Build();
var font = new UiDatFont(
fgTex: ForegroundTex, fgW: 64, fgH: 64,
bgTex: BackgroundTex, bgW: 64, bgH: 64,
lineHeight: 16f, baselineOffset: 12f,
glyphs: new Dictionary<char, FontCharDesc>
{
['A'] = Glyph('A', offsetX: 4, offsetY: 4, width: 8, height: 8),
['B'] = Glyph('B', offsetX: 4, offsetY: 4, width: 8, height: 8, before: 1),
},
borderX: 4, borderY: 4);
ctx.DrawStringDat(font, "AB", 0, 0, new Vector4(1f, 1f, 1f, 1f), outline: true);
var segs = renderer.DebugSpriteSegments;
Assert.Equal(2, segs.Count);
Assert.Equal(BackgroundTex, segs[0].Texture);
Assert.Equal(12, segs[0].VertexCount); // 2 glyphs × 6 verts, ONE segment
Assert.Equal(ForegroundTex, segs[1].Texture);
Assert.Equal(12, segs[1].VertexCount);
}
[Fact]
public void NoOutline_OnlyOneFillPassRuns()
{
(TextRenderer renderer, UiRenderContext ctx) = Build();
var font = new UiDatFont(
fgTex: ForegroundTex, fgW: 64, fgH: 64,
bgTex: BackgroundTex, bgW: 64, bgH: 64,
lineHeight: 16f, baselineOffset: 12f,
glyphs: new Dictionary<char, FontCharDesc>
{
['A'] = Glyph('A', offsetX: 4, offsetY: 4, width: 8, height: 8),
},
borderX: 4, borderY: 4);
ctx.DrawStringDat(font, "A", 0, 0, new Vector4(1f, 1f, 1f, 1f), outline: false);
var seg = Assert.Single(renderer.DebugSpriteSegments);
Assert.Equal(ForegroundTex, seg.Texture);
}
// ── Tint ─────────────────────────────────────────────────────────────────
[Fact]
public void Outline_UsesOutlineColor_NotFillColor()
{
(TextRenderer renderer, UiRenderContext ctx) = Build();
var font = new UiDatFont(
fgTex: ForegroundTex, fgW: 64, fgH: 64,
bgTex: BackgroundTex, bgW: 64, bgH: 64,
lineHeight: 16f, baselineOffset: 12f,
glyphs: new Dictionary<char, FontCharDesc>
{
['A'] = Glyph('A', offsetX: 4, offsetY: 4, width: 8, height: 8),
},
borderX: 4, borderY: 4);
var fill = new Vector4(1f, 1f, 0.247f, 1f); // SpewBox-style gold/yellow fill
var outlineColor = new Vector4(0.2f, 0.4f, 0.6f, 1f); // an arbitrary non-black outline
ctx.DrawStringDat(font, "A", 0, 0, fill, outline: true, outlineColor: outlineColor);
var segVerts = renderer.DebugSpriteSegmentVerts;
Assert.Equal(2, segVerts.Count);
Quad outlineQuad = Assert.Single(DecodeQuads(segVerts[0].Verts));
Assert.Equal(outlineColor.X, outlineQuad.R, 5);
Assert.Equal(outlineColor.Y, outlineQuad.G, 5);
Assert.Equal(outlineColor.Z, outlineQuad.B, 5);
Quad fillQuad = Assert.Single(DecodeQuads(segVerts[1].Verts));
Assert.Equal(fill.X, fillQuad.R, 5);
Assert.Equal(fill.Y, fillQuad.G, 5);
Assert.Equal(fill.Z, fillQuad.B, 5);
}
[Fact]
public void Outline_DefaultsToBlack_WhenNoOutlineColorSupplied()
{
// Retail ctor default (RGBAColor_Black, UIElement_Text::UIElement_Text
// @0x004686cb) — only 9 elements in the whole DAT set author property
// 0x22, so the overwhelming majority of outlined text uses this default.
(TextRenderer renderer, UiRenderContext ctx) = Build();
var font = new UiDatFont(
fgTex: ForegroundTex, fgW: 64, fgH: 64,
bgTex: BackgroundTex, bgW: 64, bgH: 64,
lineHeight: 16f, baselineOffset: 12f,
glyphs: new Dictionary<char, FontCharDesc>
{
['A'] = Glyph('A', offsetX: 4, offsetY: 4, width: 8, height: 8),
},
borderX: 4, borderY: 4);
ctx.DrawStringDat(font, "A", 0, 0, new Vector4(1f, 1f, 0.247f, 1f), outline: true);
var segVerts = renderer.DebugSpriteSegmentVerts;
Quad outlineQuad = Assert.Single(DecodeQuads(segVerts[0].Verts));
Assert.Equal(0f, outlineQuad.R);
Assert.Equal(0f, outlineQuad.G);
Assert.Equal(0f, outlineQuad.B);
}
// ── Background-plane inflation (Fix 1+2) ────────────────────────────────
[Fact]
public void Outline_BackgroundPass_InflatesDestAndSourceRectByBorderPixels()
{
// §3.3 of the research doc: CreateCharRectPair inflates BOTH the source
// sub-rect and the destination rect by (BorderX, BorderY) on every side.
// Un-inflated (acdream's prior behavior) crops the dilated background
// glyph away almost entirely — this is Fix 1+2's whole point.
(TextRenderer renderer, UiRenderContext ctx) = Build();
const int borderX = 4, borderY = 3;
const int atlasW = 100, atlasH = 100;
var font = new UiDatFont(
fgTex: ForegroundTex, fgW: atlasW, fgH: atlasH,
bgTex: BackgroundTex, bgW: atlasW, bgH: atlasH,
lineHeight: 16f, baselineOffset: 12f,
glyphs: new Dictionary<char, FontCharDesc>
{
// OffsetX/Y >= border, matching every measured real font (§1.2:
// "the FG glyph always sits at exactly (hB, vB) inside the
// inflated window").
['A'] = Glyph('A', offsetX: 10, offsetY: 8, width: 9, height: 8),
},
borderX: borderX, borderY: borderY);
ctx.DrawStringDat(font, "A", 20f, 5f, new Vector4(1f, 1f, 1f, 1f), outline: true);
var segVerts = renderer.DebugSpriteSegmentVerts;
Quad bg = Assert.Single(DecodeQuads(segVerts[0].Verts));
Quad fg = Assert.Single(DecodeQuads(segVerts[1].Verts));
// Foreground (fill) quad is UN-inflated — the glyph's own box.
Assert.Equal(9f, fg.W, 3);
Assert.Equal(8f, fg.H, 3);
Assert.Equal(10f / atlasW, fg.U0, 5);
Assert.Equal(8f / atlasH, fg.V0, 5);
Assert.Equal(19f / atlasW, fg.U1, 5); // (offsetX + width) / atlasW
Assert.Equal(16f / atlasH, fg.V1, 5); // (offsetY + height) / atlasH
// Background (outline) quad is inflated by (borderX, borderY) on every side:
// dest position moves up/left by the border, size grows by 2×border, and the
// SOURCE UV rect shifts by the same amount in the SAME direction (not cropped
// to the foreground glyph's own box).
Assert.Equal(fg.X - borderX, bg.X, 3);
Assert.Equal(fg.Y - borderY, bg.Y, 3);
Assert.Equal(fg.W + 2 * borderX, bg.W, 3);
Assert.Equal(fg.H + 2 * borderY, bg.H, 3);
Assert.Equal((10 - borderX) / (float)atlasW, bg.U0, 5);
Assert.Equal((8 - borderY) / (float)atlasH, bg.V0, 5);
Assert.Equal((10 - borderX + 9 + 2 * borderX) / (float)atlasW, bg.U1, 5);
Assert.Equal((8 - borderY + 8 + 2 * borderY) / (float)atlasH, bg.V1, 5);
}
[Fact]
public void Outline_ZeroBorderFont_BackgroundPassMatchesForegroundRectExactly()
{
// A font with a background atlas but border == 0 (not currently observed
// in the shipped DATs — every bordered font measures border >= 3 — but
// the inflation math must degrade to "no-op" rather than misbehave).
(TextRenderer renderer, UiRenderContext ctx) = Build();
var font = new UiDatFont(
fgTex: ForegroundTex, fgW: 64, fgH: 64,
bgTex: BackgroundTex, bgW: 64, bgH: 64,
lineHeight: 16f, baselineOffset: 12f,
glyphs: new Dictionary<char, FontCharDesc>
{
['A'] = Glyph('A', offsetX: 4, offsetY: 4, width: 8, height: 8),
},
borderX: 0, borderY: 0);
ctx.DrawStringDat(font, "A", 0, 0, new Vector4(1f, 1f, 1f, 1f), outline: true);
var segVerts = renderer.DebugSpriteSegmentVerts;
Quad bg = Assert.Single(DecodeQuads(segVerts[0].Verts));
Quad fg = Assert.Single(DecodeQuads(segVerts[1].Verts));
Assert.Equal(fg.X, bg.X, 3);
Assert.Equal(fg.Y, bg.Y, 3);
Assert.Equal(fg.W, bg.W, 3);
Assert.Equal(fg.H, bg.H, 3);
Assert.Equal(fg.U0, bg.U0, 5);
Assert.Equal(fg.U1, bg.U1, 5);
}
// ── 8-neighbour fallback (no background atlas) ──────────────────────────
[Fact]
public void Outline_FontWithNoBackgroundAtlas_FallsBackToEightNeighbourForegroundBlits()
{
// Retail's fallback for 0-border fonts (the CJK/unicode family):
// UIElement_Text::DrawSelf 0x00467d7e-0x00467e14 blits the FOREGROUND
// glyph 8 times at ±1px around the fill position, tinted with the
// outline color. Total: 8 outline quads + 1 fill quad, all on the same
// (foreground) texture, so they batch into ONE segment.
(TextRenderer renderer, UiRenderContext ctx) = Build();
var font = new UiDatFont(
fgTex: ForegroundTex, fgW: 64, fgH: 64,
bgTex: 0, bgW: 0, bgH: 0,
lineHeight: 16f, baselineOffset: 12f,
glyphs: new Dictionary<char, FontCharDesc>
{
['A'] = Glyph('A', offsetX: 4, offsetY: 4, width: 8, height: 8),
},
borderX: 0, borderY: 0);
ctx.DrawStringDat(font, "A", 20f, 10f, new Vector4(1f, 1f, 1f, 1f), outline: true);
var seg = Assert.Single(renderer.DebugSpriteSegments);
Assert.Equal(ForegroundTex, seg.Texture);
Assert.Equal(9, seg.VertexCount / 6); // 8 neighbour outline blits + 1 fill blit
var quads = DecodeQuads(renderer.DebugSpriteSegmentVerts[0].Verts);
Assert.Equal(9, quads.Count);
// The fill position (gx, gy) is present exactly once — the fill pass —
// and every one of the 8 immediate neighbours is present exactly once —
// the outline pass — covering the full 3x3 block around it with no gaps
// and no duplicates.
var seen = new HashSet<(int dx, int dy)>();
foreach (Quad q in quads)
{
int dx = (int)MathF.Round(q.X - 20f);
int dy = (int)MathF.Round(q.Y - 10f);
Assert.InRange(dx, -1, 1);
Assert.InRange(dy, -1, 1);
Assert.True(seen.Add((dx, dy)), $"duplicate blit at offset ({dx},{dy})");
}
Assert.Equal(9, seen.Count); // all 9 positions in the 3x3 block, each exactly once
}
}