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; /// /// Campaign CH round 4 (docs/research/2026-08-10-retail-ui-text-style.md): /// pins 's retail two-pass outline /// model — whole-string outline pass THEN whole-string fill pass /// (UIElement_Text::DrawSelf @0x00467aa0), the outline-color tint /// (property 0x22, default black), the background-plane INFLATION by the /// font's border-pixel margin (CreateCharRectPair @0x00441480), and the /// 8-neighbour ±1px fallback for fonts with no background atlas. /// /// /// Builds a real over the in-memory /// test double (no live GPU) and reads back /// — the full per-vertex /// float buffer, not just texture/count/alpha — so position, UV span, and RGB /// tint are all directly assertable. See /// for the sibling /// alpha-chokepoint coverage this file does not duplicate. /// /// 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, }; /// 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 /// 's known 6-vertex/8-float layout /// (V0 = top-left, V1 = bottom-right of the first triangle). 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 DecodeQuads(IReadOnlyList verts) { var quads = new List(); 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 { ['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 { ['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 { ['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 { ['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 { // 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 { ['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 { ['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 } }