acdream/tests/AcDream.App.Tests/UI/UiRenderContextDrawStringDatOutlineTests.cs
Erik bcc34ee301 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>
2026-08-10 19:28:34 +02:00

334 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}
}