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

@ -230,6 +230,53 @@ public class ChatLayoutConformanceTests
Assert.Equal(0x10000016u, input.DatElementId);
}
/// <summary>
/// Campaign CH round 4 (<c>docs/research/2026-08-10-retail-ui-text-style.md</c>
/// §2.5/§2.6): the chat transcript's base style (<c>0x10000372</c> in layout
/// <c>0x2100003F</c>) authors NO property 0x21 (Outline) anywhere in its
/// inheritance chain — regenerated straight from the real installed DAT
/// (<c>ChatLayoutFixtureGenerator</c>), proving the property-0x21/0x22
/// importer (<see cref="ElementReader.ApplyCanonicalLegacyProjection"/>)
/// resolves this correctly end to end, not merely by a missing-field JSON
/// default.
/// </summary>
[Fact]
public void ChatFixture_TranscriptElementInfo_CarriesNoOutline()
{
var root = FixtureLoader.LoadChatInfos();
var transcript = Find(root, 0x10000011u)!;
Assert.False(transcript.Outline);
Assert.Null(transcript.OutlineColor);
}
/// <summary>Same fact at the built-widget level — <c>DatWidgetFactory.BuildText</c>
/// must not turn a false <c>ElementInfo.Outline</c> into a true <c>UiText.Outline</c>.</summary>
[Fact]
public void ChatFixture_TranscriptWidget_OutlineIsOff()
{
var layout = FixtureLoader.LoadChat();
var transcript = Assert.IsType<UiText>(layout.FindElement(0x10000011u));
Assert.False(transcript.Outline);
}
/// <summary>
/// The transcript's default fill resolves to the authored
/// <c>ARGB(255,204,204,204)</c> (style <c>0x10000372</c> property 0x1B) —
/// Fix 5's target. Pins the value at the built-widget level, on the SAME
/// regenerated fixture the outline test above uses.
/// </summary>
[Fact]
public void ChatFixture_TranscriptWidget_DefaultColorIsAuthoredOffWhite()
{
var layout = FixtureLoader.LoadChat();
var transcript = Assert.IsType<UiText>(layout.FindElement(0x10000011u));
var expected = new System.Numerics.Vector4(204f / 255f, 204f / 255f, 204f / 255f, 1f);
Assert.Equal(expected, transcript.DefaultColor);
}
[Fact]
public void ChatFixture_ScrollbarImportsInheritedMediaRoles()
{

View file

@ -1,6 +1,9 @@
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using AcDream.App.UI.Layout;
using AcDream.Core.Chat;
using AcDream.UI.Abstractions.Panels.Chat;
namespace AcDream.App.Tests.UI.Layout;
@ -86,4 +89,78 @@ public class ChatTranscriptRendererTests
Assert.Equal(new[] { "first", "", "third" }, lines);
}
// ── BuildLines: default-color seed (Campaign CH round 4, Fix 5) ─────────
// docs/research/2026-08-10-retail-ui-text-style.md §5.2 Fix 5: the seed for
// a line whose LogTextType falls OUTSIDE RetailChatColorTable's 34 entries
// must be the ELEMENT's own authored default fill (the transcript's
// UiText.DefaultColor), not the color table's own index-0x00 slot. Every
// line with an IN-RANGE type must still resolve its OWN exact table color —
// this parameter must never perturb that half.
private static float MeasureByWidth(string s) => s.Length;
private static readonly Vector4 OffWhite = new(0.8f, 0.8f, 0.8f, 1f); // ARGB(255,204,204,204)
[Fact]
public void BuildLines_InRangeLogTextType_AlwaysUsesItsOwnTableColor_RegardlessOfDefaultColor()
{
// The 34-entry table remains the per-message color authority — this is
// the regression guard the task explicitly calls for: an in-range type
// (0x02 Speech -> pure white) must resolve to the SAME color whether the
// caller passes the new authored off-white default or an unrelated color.
var lines = new[] { new FormattedLine("hello", ChatKind.LocalSpeech, null, LogTextType: 0x02u) };
var withOffWhiteDefault = ChatTranscriptRenderer.BuildLines(lines, 1000f, MeasureByWidth, null, OffWhite);
var withUnrelatedDefault = ChatTranscriptRenderer.BuildLines(
lines, 1000f, MeasureByWidth, null, new Vector4(0f, 1f, 0f, 1f));
Assert.True(RetailChatColorTable.TryGetColor(0x02u, out Vector4 expected));
Assert.Equal(expected, Assert.Single(withOffWhiteDefault).Color);
Assert.Equal(expected, Assert.Single(withUnrelatedDefault).Color);
}
[Fact]
public void BuildLines_OutOfRangeLogTextType_AsFirstLine_UsesTheSuppliedDefaultColor()
{
// A LogTextType >= 34 (RetailChatColorTable.Colors.Count) is the
// out-of-range carry-forward case (see RetailChatColorTable's own doc):
// as the very FIRST line, there is no prior color to carry, so the
// element's own authored default fill applies — not colorGreen (the
// color table's unrelated 0x00 "Default" slot, the pre-fix seed).
var lines = new[] { new FormattedLine("mystery", ChatKind.System, null, LogTextType: 0xFFu) };
var result = ChatTranscriptRenderer.BuildLines(lines, 1000f, MeasureByWidth, null, OffWhite);
Assert.Equal(OffWhite, Assert.Single(result).Color);
}
[Fact]
public void BuildLines_OutOfRangeLogTextType_AfterAnInRangeLine_CarriesForwardThePriorTableColor()
{
// Retail's carry-forward rule (color-table doc §3.2): an out-of-range
// type leaves m_curFontColor UNCHANGED — it inherits whatever the
// PREVIOUS line resolved to, not the seed again. This must hold
// regardless of what defaultColor is passed.
var lines = new[]
{
new FormattedLine("says hi", ChatKind.LocalSpeech, null, LogTextType: 0x03u), // Tell -> yellow
new FormattedLine("mystery", ChatKind.System, null, LogTextType: 0xFFu), // out of range
};
var result = ChatTranscriptRenderer.BuildLines(lines, 1000f, MeasureByWidth, null, OffWhite);
Assert.True(RetailChatColorTable.TryGetColor(0x03u, out Vector4 tellColor));
Assert.Equal(2, result.Count);
Assert.Equal(tellColor, result[0].Color);
Assert.Equal(tellColor, result[1].Color); // carried forward, NOT OffWhite
}
[Fact]
public void BuildLines_EmptyDetailed_ReturnsEmpty_RegardlessOfDefaultColor()
{
var result = ChatTranscriptRenderer.BuildLines(
System.Array.Empty<FormattedLine>(), 1000f, MeasureByWidth, null, OffWhite);
Assert.Empty(result);
}
}

View file

@ -272,4 +272,82 @@ public class ElementReaderTests
var merged = ElementReader.Merge(base_, derived);
Assert.Null(merged.FontColor);
}
// ── Outline / OutlineColor — property 0x21/0x22 (Campaign CH round 4) ──────
/// <summary>
/// A derived element that authors 0x21=true wins over an un-outlined base —
/// same "derived wins" convention as FontDid. This is the SpewBox line
/// template's own shape: its base style (0x10000377) authors no outline; the
/// line template's own state does.
/// </summary>
[Fact]
public void Merge_DerivedOutlineTrue_OverridesBaseFalse()
{
var base_ = new ElementInfo { Outline = false };
var derived = new ElementInfo { Outline = true };
var merged = ElementReader.Merge(base_, derived);
Assert.True(merged.Outline);
}
/// <summary>
/// A derived element that does NOT author 0x21 (stays at the false default)
/// inherits an outlined base rather than clobbering it — the same "false/zero
/// derived never overrides a set base" rule HJustify/FontDid already use.
/// </summary>
[Fact]
public void Merge_DerivedOutlineFalse_InheritsOutlinedBase()
{
var base_ = new ElementInfo { Outline = true };
var derived = new ElementInfo { Outline = false };
var merged = ElementReader.Merge(base_, derived);
Assert.True(merged.Outline);
}
/// <summary>
/// Neither base nor derived authors 0x21 — the chat transcript's own shape
/// (style 0x10000372 authors no outline anywhere in its chain).
/// </summary>
[Fact]
public void Merge_NeitherOutlines_MergedStaysFalse()
{
var base_ = new ElementInfo { Outline = false };
var derived = new ElementInfo { Outline = false };
var merged = ElementReader.Merge(base_, derived);
Assert.False(merged.Outline);
}
/// <summary>
/// OutlineColor follows the exact same "non-null derived wins" rule as
/// FontColor — property 0x22 is authored on only 9 elements in the whole DAT
/// set, so most outlined elements inherit null here and fall back to the
/// widget's own black default.
/// </summary>
[Fact]
public void Merge_DerivedOutlineColor_OverridesBaseNull()
{
var base_ = new ElementInfo { OutlineColor = null };
var derived = new ElementInfo { OutlineColor = new Vector4(0f, 0f, 0.4f, 1f) };
var merged = ElementReader.Merge(base_, derived);
Assert.Equal(new Vector4(0f, 0f, 0.4f, 1f), merged.OutlineColor);
}
[Fact]
public void Merge_DerivedOutlineColorNull_InheritsBaseColor()
{
var authored = new Vector4(17f / 255f, 15f / 255f, 7f / 255f, 1f);
var base_ = new ElementInfo { OutlineColor = authored };
var derived = new ElementInfo { OutlineColor = null };
var merged = ElementReader.Merge(base_, derived);
Assert.Equal(authored, merged.OutlineColor);
}
[Fact]
public void Merge_BothOutlineColorNull_MergedIsNull()
{
var base_ = new ElementInfo { OutlineColor = null };
var derived = new ElementInfo { OutlineColor = null };
var merged = ElementReader.Merge(base_, derived);
Assert.Null(merged.OutlineColor);
}
}

View file

@ -74,6 +74,71 @@ public sealed class UiPropertyBagTests
Assert.Equal(HJustify.Left, merged.HJustify);
}
// ── Outline (0x21) / OutlineColor (0x22) — Campaign CH round 4 ─────────────
// Exercises ApplyCanonicalLegacyProjection (invoked by ElementReader.Merge)
// reading raw StateDesc-shaped properties, the same layer HJustify/FontColor
// are already pinned at above — one level below the flattened ElementInfo
// fields ElementReaderTests.cs covers.
/// <summary>
/// The SpewBox line template's own shape: state 0x10000002 authors property
/// 0x21 (Outline) = true with no 0x22 (OutlineColor) — retail leaves
/// m_curOutlineColor at its ctor black default in that case.
/// </summary>
[Fact]
public void ElementMerge_Property0x21True_SetsOutline_WithNoAuthoredColor()
{
var info = new ElementInfo { DefaultStateId = 1u };
info.States[1u] = State(1u, "0x10000002", (0x21u, Bool(true)));
var merged = ElementReader.Merge(new ElementInfo(), info);
Assert.True(merged.Outline);
Assert.Null(merged.OutlineColor);
}
/// <summary>
/// The chat transcript base style's own shape: DirectState authors neither
/// 0x21 nor 0x22 anywhere in its chain — Outline must stay false, matching
/// "the chat transcript is NOT outlined in retail" (research doc §2.5).
/// </summary>
[Fact]
public void ElementMerge_NoOutlineProperty_StaysFalse()
{
var info = new ElementInfo();
info.States[UiStateInfo.DirectStateId] = State(
UiStateInfo.DirectStateId,
"",
(0x1Bu, Color(204, 204, 204, 255)));
var merged = ElementReader.Merge(new ElementInfo(), info);
Assert.False(merged.Outline);
Assert.Null(merged.OutlineColor);
}
/// <summary>
/// Property 0x22 (OutlineColor) reads a ColorBaseProperty into a normalized
/// Vector4 exactly like 0x1B (FontColor) does — one of the 9 elements in the
/// whole DAT set that authors a non-default outline colour (values observed:
/// ARGB(255,17,15,7) and ARGB(255,0,0,102)).
/// </summary>
[Fact]
public void ElementMerge_Property0x22_SetsOutlineColorFromAuthoredArgb()
{
var info = new ElementInfo();
info.States[UiStateInfo.DirectStateId] = State(
UiStateInfo.DirectStateId,
"",
(0x21u, Bool(true)),
(0x22u, Color(17, 15, 7, 255)));
var merged = ElementReader.Merge(new ElementInfo(), info);
Assert.True(merged.Outline);
Assert.Equal(new Vector4(17f / 255f, 15f / 255f, 7f / 255f, 1f), merged.OutlineColor);
}
[Fact]
public void ConvertProperty_PreservesNestedRetailValues()
{
@ -139,6 +204,9 @@ public sealed class UiPropertyBagTests
private static UiPropertyValue Enum(uint value)
=> new() { Kind = UiPropertyKind.Enum, UnsignedValue = value };
private static UiPropertyValue Color(byte red, byte green, byte blue, byte alpha)
=> new() { Kind = UiPropertyKind.Color, ColorValue = new UiColorValue(blue, green, red, alpha) };
private static UiStateInfo State(
uint id,
string name,

View file

@ -200,6 +200,8 @@
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"Outline": false,
"OutlineColor": null,
"StateMedia": {},
"StateCursors": {},
"DefaultStateName": "",
@ -604,6 +606,8 @@
"Z": 1,
"W": 1
},
"Outline": false,
"OutlineColor": null,
"StateMedia": {
"Normal": {
"Item1": 100688408,
@ -685,6 +689,8 @@
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"Outline": false,
"OutlineColor": null,
"StateMedia": {
"": {
"Item1": 100693183,
@ -1095,6 +1101,8 @@
"Z": 1,
"W": 1
},
"Outline": false,
"OutlineColor": null,
"StateMedia": {
"Normal": {
"Item1": 100688408,
@ -1176,6 +1184,8 @@
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"Outline": false,
"OutlineColor": null,
"StateMedia": {
"": {
"Item1": 100693188,
@ -1253,6 +1263,8 @@
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"Outline": false,
"OutlineColor": null,
"StateMedia": {
"": {
"Item1": 100693184,
@ -1336,6 +1348,8 @@
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"Outline": false,
"OutlineColor": null,
"StateMedia": {
"Maximized": {
"Item1": 100687460,
@ -1417,6 +1431,8 @@
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"Outline": false,
"OutlineColor": null,
"StateMedia": {
"": {
"Item1": 100693189,
@ -1494,6 +1510,8 @@
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"Outline": false,
"OutlineColor": null,
"StateMedia": {
"": {
"Item1": 100693185,
@ -1571,6 +1589,8 @@
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"Outline": false,
"OutlineColor": null,
"StateMedia": {
"": {
"Item1": 100693190,
@ -1648,6 +1668,8 @@
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"Outline": false,
"OutlineColor": null,
"StateMedia": {
"": {
"Item1": 100693186,
@ -1759,6 +1781,8 @@
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"Outline": false,
"OutlineColor": null,
"StateMedia": {
"": {
"Item1": 100688169,
@ -1818,6 +1842,8 @@
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"Outline": false,
"OutlineColor": null,
"StateMedia": {
"": {
"Item1": 100688170,
@ -1936,6 +1962,8 @@
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"Outline": false,
"OutlineColor": null,
"StateMedia": {
"": {
"Item1": 100688169,
@ -2025,6 +2053,8 @@
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"Outline": false,
"OutlineColor": null,
"StateMedia": {
"": {
"Item1": 100688171,
@ -2079,6 +2109,8 @@
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"Outline": false,
"OutlineColor": null,
"StateMedia": {
"": {
"Item1": 100682946,
@ -3051,6 +3083,8 @@
"Z": 0.8,
"W": 1
},
"Outline": false,
"OutlineColor": null,
"StateMedia": {},
"StateCursors": {},
"DefaultStateName": "",
@ -3218,6 +3252,8 @@
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"Outline": false,
"OutlineColor": null,
"StateMedia": {
"Normal": {
"Item1": 100687630,
@ -3446,6 +3482,8 @@
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"Outline": false,
"OutlineColor": null,
"StateMedia": {
"": {
"Item1": 100682847,
@ -3552,6 +3590,8 @@
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"Outline": false,
"OutlineColor": null,
"StateMedia": {},
"StateCursors": {},
"DefaultStateName": "Normal",
@ -3632,6 +3672,8 @@
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"Outline": false,
"OutlineColor": null,
"StateMedia": {
"Normal": {
"Item1": 100682848,
@ -3726,6 +3768,8 @@
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"Outline": false,
"OutlineColor": null,
"StateMedia": {
"Normal": {
"Item1": 100682851,
@ -3820,6 +3864,8 @@
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"Outline": false,
"OutlineColor": null,
"StateMedia": {
"Normal": {
"Item1": 100682854,
@ -4033,6 +4079,8 @@
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"Outline": false,
"OutlineColor": null,
"StateMedia": {
"Normal": {
"Item1": 100682857,
@ -4244,6 +4292,8 @@
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"Outline": false,
"OutlineColor": null,
"StateMedia": {
"Normal": {
"Item1": 100682860,
@ -4367,6 +4417,8 @@
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"Outline": false,
"OutlineColor": null,
"StateMedia": {
"": {
"Item1": 100688169,
@ -4456,6 +4508,8 @@
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"Outline": false,
"OutlineColor": null,
"StateMedia": {
"": {
"Item1": 100688172,
@ -4574,6 +4628,8 @@
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"Outline": false,
"OutlineColor": null,
"StateMedia": {
"": {
"Item1": 100688169,
@ -4658,6 +4714,8 @@
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"Outline": false,
"OutlineColor": null,
"StateMedia": {
"": {
"Item1": 100667706,
@ -4933,6 +4991,8 @@
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"Outline": false,
"OutlineColor": null,
"StateMedia": {
"Normal": {
"Item1": 100683109,
@ -5320,6 +5380,8 @@
"Z": 1,
"W": 1
},
"Outline": false,
"OutlineColor": null,
"StateMedia": {},
"StateCursors": {},
"DefaultStateName": "",
@ -6304,6 +6366,8 @@
"Z": 1,
"W": 1
},
"Outline": false,
"OutlineColor": null,
"StateMedia": {
"Normal_focussed": {
"Item1": 100667819,
@ -6361,6 +6425,8 @@
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"Outline": false,
"OutlineColor": null,
"StateMedia": {
"Normal_focussed": {
"Item1": 100683111,
@ -6419,6 +6485,8 @@
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"Outline": false,
"OutlineColor": null,
"StateMedia": {
"Normal_focussed": {
"Item1": 100683111,
@ -6776,6 +6844,8 @@
"Z": 1,
"W": 1
},
"Outline": false,
"OutlineColor": null,
"StateMedia": {
"Normal": {
"Item1": 100669717,
@ -6868,6 +6938,8 @@
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"Outline": false,
"OutlineColor": null,
"StateMedia": {
"": {
"Item1": 100688173,
@ -7285,6 +7357,8 @@
"Z": 1,
"W": 1
},
"Outline": false,
"OutlineColor": null,
"StateMedia": {
"Normal": {
"Item1": 100688408,
@ -7699,6 +7773,8 @@
"Z": 1,
"W": 1
},
"Outline": false,
"OutlineColor": null,
"StateMedia": {
"Normal": {
"Item1": 100688408,
@ -7780,6 +7856,8 @@
"HJustify": 1,
"VJustify": 1,
"FontColor": null,
"Outline": false,
"OutlineColor": null,
"StateMedia": {
"": {
"Item1": 100693187,

View file

@ -252,4 +252,53 @@ public sealed class SpewBoxControllerTests
Assert.Null(text.DatFont);
Assert.Null(text.Font);
}
// ── Campaign CH user-gate round 4 (2026-08-10) — font/outline AUTHORED ──
[Fact]
public void RetailFontId_IsTheAuthoredEighteenPixelFace_NotTheRound3Heuristic()
{
// docs/research/2026-08-10-retail-ui-text-style.md §4.2: base style
// 0x10000377 (the line template's own BaseElement) authors FontDID
// [0x40000001] directly — three independent cross-checks (FontDID,
// the 18px authored line height, and 4x18=72 = the authored box
// height) all agree. Font 0x40000025 (the round-3 "smallest
// confirmed-used font" heuristic) is retired.
Assert.Equal(0x40000001u, SpewBoxController.RetailFontId);
}
[Fact]
public void Construction_SetsOutlineTrue_MatchingTheAuthoredLineTemplate()
{
// The line template's state 0x10000002 authors property 0x21
// (Outline) = true with no authored 0x22 (OutlineColor), so the
// ctor black default applies — the heavy black border in the
// user's retail screenshot that earlier rounds never reproduced.
var root = new UiRoot { Width = 1280f, Height = 720f };
using var controller = new SpewBoxController(root, new SpewBoxVM(new SpewBoxState()));
UiText text = Assert.IsType<UiText>(root.Children.OfType<UiText>().Single());
Assert.True(text.Outline);
Assert.Equal(UiRenderContext.DefaultOutlineColor, text.OutlineColor);
}
[Fact]
public void Construction_WithTheAuthoredFont_LineHeightMatchesTheAuthoredEighteenPixelBox()
{
// Three-way cross-check from the class remarks: the resolved font's
// MaxCharHeight (18) matches the authored per-line box height (18),
// and 4 concurrent items x 18px = the authored 72px box height.
var root = new UiRoot { Width = 1280f, Height = 720f };
var font = new UiDatFont(
fgTex: 1, fgW: 64, fgH: 64,
bgTex: 2, bgW: 64, bgH: 64, // background atlas present, like the real 0x40000001
lineHeight: 18f, baselineOffset: 14f,
glyphs: new Dictionary<char, FontCharDesc>());
using var controller = new SpewBoxController(
root, new SpewBoxVM(new SpewBoxState()), font, debugFont: null);
UiText text = Assert.IsType<UiText>(root.Children.OfType<UiText>().Single());
Assert.Equal(18f, text.DatFont!.LineHeight);
}
}

View file

@ -0,0 +1,131 @@
using System;
using System.Collections.Generic;
using System.IO;
using AcDream.App.UI;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
using DatReaderWriter.Types;
using SysEnv = System.Environment;
namespace AcDream.App.Tests.UI;
/// <summary>
/// Pins <c>Font.NumHorizontalBorderPixels</c>/<c>NumVerticalBorderPixels</c>
/// (<c>Font::Serialize @0x00443650</c>) against the real installed DAT, and the
/// <see cref="UiDatFont"/> plumbing that carries them from
/// <see cref="UiDatFont.Load"/> onto <see cref="UiDatFont.BorderX"/>/
/// <see cref="UiDatFont.BorderY"/> — the field this project's font reader
/// dropped entirely before Campaign CH round 4
/// (<c>docs/research/2026-08-10-retail-ui-text-style.md</c> §1.4). A repo-wide
/// grep for <c>BorderPixel</c> returned zero hits before this fix; these tests
/// are the regression guard against that gap reappearing.
///
/// <para>
/// The live-dat tests follow <see cref="AcDream.App.Tests.UI.RetailCursorCatalogTests"/>'s
/// pattern: skip (not fail) when the real dats aren't present, so the suite stays
/// green in environments without the installed game.
/// </para>
/// </summary>
public sealed class UiDatFontBorderPixelTests
{
// Measured values — docs/research/2026-08-10-retail-ui-text-style.md §1.3.
[Theory]
[InlineData(0x40000000u, 4u, 4u)] // 16px bold serif — chat transcript face
[InlineData(0x40000001u, 4u, 4u)] // 18px bold serif — SpewBox face (round 4)
[InlineData(0x40000002u, 3u, 3u)] // 14px bold serif
[InlineData(0x40000025u, 3u, 3u)] // 11px — the pre-round-4 SpewBox placeholder face
public void RealDatFont_HasExpectedBorderPixels(uint fontId, uint expectedHorizontal, uint expectedVertical)
{
string? datDir = ResolveDatDir();
if (datDir is null)
return;
using var dats = new DatCollection(datDir, DatAccessType.Read);
Assert.True(dats.TryGet<Font>(fontId, out Font? font), $"Font 0x{fontId:X8} not found");
Assert.NotNull(font);
Assert.Equal(expectedHorizontal, font!.NumHorizontalBorderPixels);
Assert.Equal(expectedVertical, font.NumVerticalBorderPixels);
}
[Fact]
public void RealDatFont_EveryFontWithABackgroundAtlas_HasANonZeroBorder()
{
// docs/research/2026-08-10-retail-ui-text-style.md §1.2: "every font that
// has a background atlas has border >= 3, and every font without one has
// border == 0" — the data-driven dispatch DrawStringDat's outline pass
// relies on (background plane vs 8-neighbour fallback) is exactly this
// correlation. Sweep the documented populated range (0x40000000-0x40000032).
string? datDir = ResolveDatDir();
if (datDir is null)
return;
using var dats = new DatCollection(datDir, DatAccessType.Read);
int checkedCount = 0;
for (uint id = 0x40000000u; id <= 0x40000032u; id++)
{
if (!dats.TryGet<Font>(id, out Font? font) || font is null)
continue;
checkedCount++;
bool hasBackground = font.BackgroundSurfaceDataId != 0;
bool hasBorder = font.NumHorizontalBorderPixels > 0 || font.NumVerticalBorderPixels > 0;
Assert.True(
hasBackground == hasBorder,
$"Font 0x{id:X8}: hasBackground={hasBackground} but hasBorder={hasBorder}");
}
Assert.True(checkedCount > 10, "expected the documented font sweep to find multiple populated fonts");
}
/// <summary>
/// Pure plumbing check — no dat, no GL: the <see cref="UiDatFont"/> ctor
/// stores <paramref name="borderX"/>/<paramref name="borderY"/> verbatim onto
/// <see cref="UiDatFont.BorderX"/>/<see cref="UiDatFont.BorderY"/>, and existing
/// callers that omit them (pre-round-4 test fixtures) still default to zero.
/// </summary>
[Theory]
[InlineData(4, 4)]
[InlineData(3, 3)]
[InlineData(0, 0)]
public void Ctor_StoresBorderPixelsVerbatim(int borderX, int borderY)
{
var font = new UiDatFont(
fgTex: 1, fgW: 64, fgH: 64,
bgTex: 2, bgW: 64, bgH: 64,
lineHeight: 16f, baselineOffset: 12f,
glyphs: new Dictionary<char, FontCharDesc>(),
borderX: borderX, borderY: borderY);
Assert.Equal(borderX, font.BorderX);
Assert.Equal(borderY, font.BorderY);
}
[Fact]
public void Ctor_OmittedBorderPixels_DefaultToZero()
{
// Pins backward compatibility for the existing UiDatFontTests/SpewBoxControllerTests
// call sites that construct UiDatFont without borderX/borderY.
var font = new UiDatFont(
fgTex: 0, fgW: 0, fgH: 0,
bgTex: 0, bgW: 0, bgH: 0,
lineHeight: 16f, baselineOffset: 12f,
glyphs: new Dictionary<char, FontCharDesc>());
Assert.Equal(0, font.BorderX);
Assert.Equal(0, font.BorderY);
}
private static string? ResolveDatDir()
{
string? fromEnv = SysEnv.GetEnvironmentVariable("ACDREAM_DAT_DIR");
if (!string.IsNullOrWhiteSpace(fromEnv) && Directory.Exists(fromEnv))
return fromEnv;
string defaultDir = Path.Combine(
SysEnv.GetFolderPath(SysEnv.SpecialFolder.UserProfile),
"Documents",
"Asheron's Call");
return Directory.Exists(defaultDir) ? defaultDir : null;
}
}

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