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

@ -115,6 +115,31 @@ public sealed class TextRenderer : IDisposable
}
}
/// <summary>
/// Test-only: same submission-ordered segmentation as
/// <see cref="DebugSpriteSegments"/>, but exposing the FULL per-vertex float
/// buffer (8 floats/vertex: x,y,u,v,r,g,b,a — see <see cref="AppendQuad"/>,
/// 6 vertices/quad) instead of just texture/count/alpha. Needed by the
/// <c>UiRenderContext.DrawStringDat</c> two-pass outline/fill tests — proving
/// draw ORDER (outline segment before fill segment), TINT (RGB, not just
/// alpha), and the background-plane INFLATION (dest quad size + UV span)
/// all requires more than <see cref="DebugSpriteSegments"/> exposes.
/// <c>AcDream.App.Tests</c>-only via <c>InternalsVisibleTo</c>.
/// </summary>
internal IReadOnlyList<(uint Texture, IReadOnlyList<float> Verts)> DebugSpriteSegmentVerts
{
get
{
var result = new List<(uint, IReadOnlyList<float>)>(_segUsed);
for (int i = 0; i < _segUsed; i++)
{
SpriteSeg seg = _spriteSegs[i];
result.Add((seg.Texture, seg.Verts.ToArray()));
}
return result;
}
}
/// <summary>
/// Test-only snapshot of the current frame's queued NORMAL-layer BITMAP FONT
/// text buffer (<see cref="DrawString"/>/<see cref="DrawStringClipped"/>,

View file

@ -45,22 +45,43 @@ internal static class ChatTranscriptRenderer
/// advancing for lines actually appended to THIS window's own scroll
/// (<c>AppendStringInfoWithFont</c> only runs for displayed lines).
/// </param>
/// <param name="defaultColor">
/// The transcript element's own base fill color — retail's
/// <c>m_curFontColor</c> BEFORE any <c>AppendTextWithFont</c> call ever
/// tints it, i.e. the value <c>DoFontReset</c> seeds from the element's
/// authored LayoutDesc property <c>0x1B</c> (style <c>0x10000372</c>:
/// <c>ARGB(255,204,204,204)</c> for the main chat transcript). Campaign
/// CH round 4 (<c>docs/research/2026-08-10-retail-ui-text-style.md</c>
/// §5.2 Fix 5): this used to be hardcoded to
/// <c>RetailChatColorTable.TryGetColor(0x00u, ...)</c> (colorGreen) —
/// the color table's OWN default-slot color, not the ELEMENT's authored
/// default. The two are unrelated: the table governs the per-message
/// <c>LogTextType</c> tint (untouched by this parameter — every message
/// with an in-range type still resolves its own table color exactly as
/// before), while <paramref name="defaultColor"/> is only the carried-
/// forward seed for a line whose type falls OUTSIDE the table's 34
/// entries (matching retail's out-of-range "leave <c>m_curFontColor</c>
/// unchanged" rule — see <see cref="RetailChatColorTable"/>'s own doc).
/// Callers pass their transcript's <see cref="UiText.DefaultColor"/>.
/// </param>
public static List<UiText.Line> BuildLines(
IReadOnlyList<FormattedLine> detailed,
float maxW,
Func<string, float> measure,
Func<uint, bool>? accept)
Func<uint, bool>? accept,
Vector4 defaultColor)
{
var result = new List<UiText.Line>(detailed.Count);
if (detailed.Count == 0)
return result;
// Retail's font-color state (m_curFontColor) persists across every
// line actually appended to this window — an out-of-range LogTextType
// leaves it unchanged rather than reverting to a default (color-table
// doc §3.2). Seed the carry with retail's own unfilled-slot default
// (colorGreen, index 0x00).
RetailChatColorTable.TryGetColor(0x00u, out Vector4 currentColor);
// Retail's font-color state (m_curFontColor) persists across every line
// actually appended to this window — an out-of-range LogTextType leaves it
// unchanged rather than reverting to a color-table default (color-table doc
// §3.2). Seed the carry with the ELEMENT's own authored default fill
// (defaultColor), matching retail's DoFontReset — not the color table's
// unrelated index-0x00 slot.
Vector4 currentColor = defaultColor;
foreach (FormattedLine d in detailed)
{
if (accept is not null && !accept(d.LogTextType))

View file

@ -697,7 +697,8 @@ public sealed class ChatWindowController : IRetainedWindowStateController, IReta
// no more accept:null "no user filter" placeholder.
bool Accept(uint logTextType) => _windowFilters.ShouldDisplay(
ChatWindowState.MainWindowId, ChatWindowState.BroadcastTargetWindow, logTextType);
var result = ChatTranscriptRenderer.BuildLines(detailed, maxW, measure, Accept);
var result = ChatTranscriptRenderer.BuildLines(
detailed, maxW, measure, Accept, Transcript.DefaultColor);
return StoreTranscriptLayout(result, revision, filter, maxW, datFont, debugFont);
}

View file

@ -612,6 +612,10 @@ public static class DatWidgetFactory
FontColorPalette = ElementReader.ReadEffectiveColorPalette(
info,
0x1Bu),
// Outline from dat property 0x21 (BoolBaseProperty). Default false — matches
// ElementInfo.Outline's own default, so this is a no-op for the ~99% of text
// elements that don't author it.
Outline = info.Outline,
};
t.ConfigureDatState(info);
@ -622,6 +626,12 @@ public static class DatWidgetFactory
if (info.FontColor.HasValue)
t.DefaultColor = info.FontColor.Value;
// Outline color from dat property 0x22 (ColorBaseProperty). Only 9 elements in the
// whole DAT set author a non-black value; when absent, UiText's own ctor default
// (black, matching retail's m_curOutlineColor default) already applies.
if (info.OutlineColor.HasValue)
t.OutlineColor = info.OutlineColor.Value;
if (ResolveAuthoredString(info, stringResolve) is { Length: > 0 } authored)
t.LinesProvider = () => [new UiText.Line(authored, t.DefaultColor)];

View file

@ -1,5 +1,6 @@
using System.Collections.Generic;
using System.Numerics;
using AcDream.App.UI;
namespace AcDream.App.UI.Layout;
@ -108,6 +109,26 @@ public sealed class ElementInfo
/// </summary>
public Vector4? FontColor;
/// <summary>
/// Outline flag from dat <c>Properties[0x21]</c> (<c>BoolBaseProperty</c>). Retail
/// <c>UIElement_Text::SetOutline @0x0046a81c</c> / <c>m_bitField &amp; 0x10</c>.
/// Default false (ctor <c>m_bitField=0x300</c> clears the outline bit) — outlining is
/// opt-in per element. Propagated in <see cref="ElementReader.Merge"/> with the same
/// "derived wins when true" rule used for <see cref="FontDid"/>.
/// </summary>
public bool Outline;
/// <summary>
/// Outline color from dat <c>Properties[0x22]</c> (<c>ColorBaseProperty</c>). Retail
/// <c>m_curOutlineColor</c>, ctor default <c>RGBAColor_Black</c>
/// (<c>UIElement_Text::UIElement_Text @0x004686cb</c>). Null means "not authored" —
/// the factory then leaves the widget at its own black default
/// (<see cref="UiRenderContext.DefaultOutlineColor"/>). Propagated in
/// <see cref="ElementReader.Merge"/> with the same "non-null derived wins" rule used
/// for <see cref="FontColor"/>.
/// </summary>
public Vector4? OutlineColor;
/// <summary>
/// Sprite per state: state name → (RenderSurface file id, DrawMode int).
/// The <c>""</c> key represents the unnamed DirectState (<c>ElementDesc.StateDesc</c>).
@ -307,6 +328,12 @@ public static class ElementReader
// FontColor: derived wins when it has an explicit (non-null) color; otherwise inherit the base.
// Null means "dat carried no 0x1B property" — so null-derived does NOT override a non-null base.
FontColor = derived.FontColor ?? base_.FontColor,
// Outline: derived wins when true (the dat property 0x21 was present and read as
// true); otherwise inherit the base. False-derived never overrides a true base —
// matching the FontDid/HJustify "non-default wins" convention.
Outline = derived.Outline || base_.Outline,
// OutlineColor: same "non-null derived wins" rule as FontColor.
OutlineColor = derived.OutlineColor ?? base_.OutlineColor,
// DefaultStateName: derived wins if set; otherwise inherit the base's default.
DefaultStateName = !string.IsNullOrEmpty(derived.DefaultStateName) ? derived.DefaultStateName : base_.DefaultStateName,
// This helper merges one element snapshot only. LayoutImporter separately
@ -384,6 +411,33 @@ public static class ElementReader
info.FontColor = new Vector4(c.Red / 255f, c.Green / 255f, c.Blue / 255f, alpha);
}
}
// Outline (0x21): BoolBaseProperty. Retail SetOutline @0x0046a81c / m_bitField & 0x10.
if (info.TryGetEffectiveProperty(0x21u, out var outline)
&& outline.Kind == UiPropertyKind.Bool)
{
info.Outline = outline.BoolValue;
}
// OutlineColor (0x22): ColorBaseProperty (matches FontColor's Array-tolerant read —
// no authored 0x22 is currently array-wrapped, but the fallback costs nothing).
// Retail m_curOutlineColor, ctor default RGBAColor_Black.
if (info.TryGetEffectiveProperty(0x22u, out var outlineColor))
{
UiPropertyValue? outlineColorValue = outlineColor.Kind == UiPropertyKind.Color
? outlineColor
: outlineColor.Kind == UiPropertyKind.Array
&& outlineColor.ArrayValue.Count > 0
&& outlineColor.ArrayValue[0].Kind == UiPropertyKind.Color
? outlineColor.ArrayValue[0]
: null;
if (outlineColorValue is not null)
{
var c = outlineColorValue.ColorValue;
float alpha = c.Alpha == 0 ? 1f : c.Alpha / 255f;
info.OutlineColor = new Vector4(c.Red / 255f, c.Green / 255f, c.Blue / 255f, alpha);
}
}
}
/// <summary>

View file

@ -277,7 +277,8 @@ public sealed class FloatingChatWindowController : IRetainedPanelController
bool Accept(uint logTextType) => windowFilters.ShouldDisplay(
WindowId, ChatWindowState.BroadcastTargetWindow, logTextType);
var result = ChatTranscriptRenderer.BuildLines(detailed, maxW, measure, Accept);
var result = ChatTranscriptRenderer.BuildLines(
detailed, maxW, measure, Accept, Transcript.DefaultColor);
_cachedTranscriptRevision = revision;
_cachedFilter = filter;

View file

@ -559,6 +559,27 @@ public static class LayoutImporter
float a = c.Alpha == 0 ? 1f : c.Alpha / 255f;
info.FontColor = new System.Numerics.Vector4(c.Red / 255f, c.Green / 255f, c.Blue / 255f, a);
}
// Outline (0x21): BoolBaseProperty. Retail SetOutline @0x0046a81c / m_bitField &
// 0x10. Only update while still at the default (false); derived-wins handled in
// ElementReader.Merge — same "only update if still at default" pattern as HJustify.
if (!info.Outline
&& sd.Properties.TryGetValue(0x21u, out var outlineRaw)
&& outlineRaw is BoolBaseProperty outlineBool)
{
info.Outline = outlineBool.Value;
}
// OutlineColor (0x22): ColorBaseProperty. Retail m_curOutlineColor, ctor default
// RGBAColor_Black. Only read when not already set — same pattern as FontColor.
if (info.OutlineColor is null
&& sd.Properties.TryGetValue(0x22u, out var outlineColorRaw)
&& outlineColorRaw is ColorBaseProperty outlineColorProp)
{
var oc = outlineColorProp.Value;
float oa = oc.Alpha == 0 ? 1f : oc.Alpha / 255f;
info.OutlineColor = new System.Numerics.Vector4(oc.Red / 255f, oc.Green / 255f, oc.Blue / 255f, oa);
}
}
}

View file

@ -107,29 +107,74 @@ namespace AcDream.App.UI;
/// </list>
/// Register row AP-178 updated to record both dispositions.
/// </remarks>
/// <remarks>
/// <b>Campaign CH user-gate round 4 (2026-08-10),
/// <c>docs/research/2026-08-10-retail-ui-text-style.md</c>:</b> the earlier
/// "absent from both dats" finding for the SpewBox's own line template
/// (element <c>0x1000004A</c>, base style <c>0x10000377</c> in layout
/// <c>0x2100003F</c>) was WRONG — it was missed because the element is a
/// ROOT of its layout (a children-only walk skips it) and its font/color
/// live in a BaseElement in a DIFFERENT LayoutDesc plus a NAMED state, not
/// its own DirectState. Extending the sweep to walk roots, BaseElement/
/// BaseLayoutId inheritance, and named-state properties found it, resolving
/// three of AP-178's four remaining open sub-claims:
/// <list type="bullet">
/// <item><b>Font:</b> <see cref="RetailFontId"/> is now
/// <b>0x40000001</b> (18px bold serif), not the round-3 smallest-font
/// heuristic <c>0x40000025</c>. Three independent cross-checks: (1) base
/// style <c>0x10000377</c> authors FontDID <c>[0x40000001]</c> directly;
/// (2) the line template's authored height is 18px, exactly
/// <c>0x40000001</c>'s <c>MaxCharHeight</c>; (3) 4 items × 18px = 72px,
/// the authored box height (<see cref="SpewBoxHeight"/>).</item>
/// <item><b>Outline:</b> the line template's state <c>0x10000002</c>
/// authors property <c>0x21</c> (Outline) = <c>true</c>, outline color
/// NOT authored → ctor default black
/// (<see cref="UiRenderContext.DefaultOutlineColor"/>). This is the
/// "heavy black border" the user's screenshot showed and the earlier
/// rounds never reproduced — <see cref="UiText.Outline"/> is now set on
/// construction.</item>
/// <item><b>Position/extent:</b> confirmed AUTHORED, not merely
/// user-matched by luck. <c>0x10000048</c> (the gmSpewBoxUI root) is a
/// ROOT element of its own layout, so its parent is the viewport:
/// <c>pos(0,0)</c>, edge codes <c>L3/R3</c> ("centered" per
/// <c>ElementReader.ToAnchors</c>'s doc comment) and <c>T1</c>
/// (top-anchored) resolve to a 450×72 block horizontally centered, flush
/// to the viewport top — exactly what <see cref="TopOffset"/>=0 and the
/// per-frame centered <c>Left</c> recompute already do. AP-178's position
/// sub-claim retires.</item>
/// </list>
/// Only fill COLOR (see <see cref="SpewBoxColor"/> — the atlas is PFID_A8,
/// alpha-only, so it cannot carry baked shading; the user-pinned yellow
/// stands pending an exact retail measurement) and the AP-177 line-lifetime
/// timeout remain open.
/// </remarks>
internal sealed class SpewBoxController : IDisposable
{
/// <summary>
/// Retail dat Font id this controller resolves for its text
/// (Campaign CH user-gate round 3 — see the class remarks). Not
/// retail's own measured SpewBox font (unmeasurable — no FontDid
/// property was found on the authored element); the smallest DAT font
/// confirmed in use by any currently-imported retail LayoutDesc,
/// chosen so the rendered text is visibly smaller than the prior debug
/// fallback, per the user's report.
/// Retail dat Font id this controller resolves for its text. Campaign CH
/// user-gate round 4 (2026-08-10): AUTHORED, not a heuristic — base
/// style <c>0x10000377</c> (layout <c>0x2100003F</c>), which the
/// SpewBox's own line template (<c>0x1000004A</c> in layout
/// <c>0x21000011</c>) inherits, authors FontDID <c>[0x40000001]</c>
/// directly. <c>0x40000001</c> is the classic AC bold-serif UI face at
/// 18px (<c>MaxCharHeight=18</c>, matching the line template's own
/// authored 18px height and the 4×18=72px authored box height). See the
/// class remarks for the three-way cross-check and
/// <c>docs/research/2026-08-10-retail-ui-text-style.md</c> §4.2.
/// </summary>
internal const uint RetailFontId = 0x40000025u;
internal const uint RetailFontId = 0x40000001u;
/// <summary>
/// Register row AP-178 (screen position): retail's authored ABSOLUTE
/// screen position is still unknown — the LayoutDesc dump (see class
/// remarks) recovered the element's position as <c>(0,0)</c> relative
/// to a PARENT this sweep could not identify. Campaign CH user-gate
/// round 3 (2026-08-10): the user reported the box was not flush to
/// the very top of the screen; mounted at <c>0</c> now, per explicit
/// user direction — still acdream's own placement choice pending the
/// true retail parent/offset, but now matching the user's live report
/// instead of an arbitrary 60px placeholder. (The SIBLING row AP-177 —
/// Register row AP-178 (screen position): CONFIRMED authored, not a
/// placeholder that happens to match. Campaign CH user-gate round 4
/// (2026-08-10): the SpewBox root (<c>0x10000048</c>) is a ROOT element
/// of its own layout (<c>0x21000011</c>), so its parent is the
/// viewport — <c>pos(0,0)</c> plus edge codes <c>L3/R3</c> (centered)
/// and <c>T1</c> (top-anchored) resolve to exactly this: a fixed-width
/// block horizontally centered and flush to the viewport top. The
/// round-3 "user-directed approximation pending the true retail
/// parent/offset" framing is retired — the parent (the viewport root)
/// and the offset (0) are now both resolved. (The SIBLING row AP-177 —
/// the invented line-lifetime timeout — lives in
/// <see cref="SpewBoxState.DefaultLifetime"/>'s own doc comment, not
/// here; this controller does not own that concern.)
@ -173,13 +218,22 @@ internal sealed class SpewBoxController : IDisposable
/// (<c>colorBrightRed</c>) is still explicitly NOT this — retail's own
/// <c>BuildChatColorLookupTable</c> writes to <c>ChatInterface::m_chatLog</c>,
/// a completely different element tree the SpewBox never touches
/// (research doc §3.2.3); the LayoutDesc dump (see class remarks) also
/// never surfaced a colour property for this element. The exact retail
/// value simply happens to coincide with the Tell colour, per the user's
/// live observation. POSITION and FONT were re-addressed at Campaign CH
/// user-gate round 3 (2026-08-10) — see the class remarks and the
/// <see cref="TopOffset"/>/<see cref="RetailFontId"/> comments; both
/// remain acdream-directed approximations, not resolved retail values.
/// (research doc §3.2.3). POSITION and FONT were resolved as AUTHORED at
/// Campaign CH user-gate round 4 (2026-08-10) — see the class remarks and
/// the <see cref="TopOffset"/>/<see cref="RetailFontId"/> comments. The
/// AUTHORED value for THIS element's state <c>0x10000002</c> is actually
/// pure red <c>ARGB(255,255,0,0)</c> — but the user's live retail
/// screenshot shows gold/amber, not red, so the user's own eye remains
/// the axiom here (per <c>feedback_retail_oracle_no_whack_a_mole</c>).
/// Round 4 checked whether font <c>0x40000001</c>'s FILL-plane atlas
/// could explain the gold as baked shading over the pinned yellow: it
/// cannot — every dat font atlas (this one included) is <c>PFID_A8</c>,
/// alpha-only coverage with no per-pixel colour channel, so there is no
/// baked tint to compose with. The gold reading is therefore the OUTLINE
/// itself (a black border around a bright yellow glyph reads warmer/
/// richer than the same colour drawn flat) rather than atlas shading.
/// The user-pinned yellow stands; only an exact cdb capture of live
/// retail's <c>m_curFontColor</c> would resolve the remaining gap.
/// </summary>
private static readonly Vector4 SpewBoxColor = new(1f, 1f, 0.247f, 1f);
@ -245,6 +299,12 @@ internal sealed class SpewBoxController : IDisposable
ClickThrough = true,
ZOrder = int.MaxValue,
DefaultColor = SpewBoxColor,
// Campaign CH user-gate round 4: the line template's authored state
// 0x10000002 sets property 0x21 (Outline) = true with no authored
// outline colour, i.e. the ctor black default
// (UiRenderContext.DefaultOutlineColor) — the heavy black border in
// the user's screenshot. See the class remarks.
Outline = true,
Visible = false,
};
_text.LinesProvider = () => _lines;

View file

@ -12,16 +12,22 @@ namespace AcDream.App.UI;
/// <summary>
/// A retail dat-font (DB_TYPE_FONT, id range 0x40000000-0x40000FFF) ready for
/// 2D drawing. Holds the two GL atlas textures (foreground glyph pixels +
/// background outline/shadow), the per-glyph descriptor table, and the line
/// background outline/shadow), the per-glyph descriptor table, the border-pixel
/// inflation margin (<see cref="BorderX"/>/<see cref="BorderY"/>), and the line
/// metrics, so <see cref="UiRenderContext.DrawStringDat"/> can blit each glyph
/// as two textured quads exactly the way the retail client does.
///
/// <para>
/// Retail render model — <c>SurfaceWindow::DrawCharacter</c>
/// (acclient 0x00442bd0, Font::GetCharDesc + the two SurfaceWindow blits): for
/// each glyph it copies the BACKGROUND atlas sub-rect first, tinted with the
/// outline color (black), then the FOREGROUND atlas sub-rect, tinted with the
/// requested text color. The pen advances by
/// Retail render model — <c>UIElement_Text::DrawSelf</c> (acclient 0x00467aa0)
/// runs the WHOLE glyph run TWICE when the element's outline flag (LayoutDesc
/// property 0x21, <c>m_bitField &amp; 0x10</c>) is set: pass 0 draws every glyph's
/// outline (the BACKGROUND atlas sub-rect, inflated by <see cref="BorderX"/>/
/// <see cref="BorderY"/> on every side, tinted with the element's outline color —
/// ctor default black, property 0x22), then pass 1 draws every glyph's fill (the
/// FOREGROUND atlas sub-rect, tinted with the requested text color) on top. With
/// the outline flag clear, only pass 1 runs. <c>SurfaceWindow::DrawCharacter</c>
/// (acclient 0x00442bd0, Font::GetCharDesc + the two SurfaceWindow blits) is the
/// per-glyph blit each pass calls. The pen advances by
/// <c>HorizontalOffsetBefore + Width + HorizontalOffsetAfter</c> (the function's
/// return value, accumulated by the string loop at 0x00467ed4
/// <c>edi_3 += var_98</c>), and each glyph is drawn starting at
@ -34,8 +40,8 @@ namespace AcDream.App.UI;
/// (255,255,255, alpha). The UI sprite shader path (ui_text.frag,
/// <c>uUseTexture==2</c>) MULTIPLIES the sampled texel by the per-vertex tint
/// (<c>texture(uTex,vUv) * vColor</c>), so tinting a white+alpha glyph by a
/// color gives that color with the glyph's alpha — black for the outline pass,
/// text color for the fill pass. No shader change was needed.
/// color gives that color with the glyph's alpha — the outline color for the
/// outline pass, text color for the fill pass. No shader change was needed.
/// </para>
/// </summary>
public sealed class UiDatFont
@ -60,19 +66,38 @@ public sealed class UiDatFont
/// <summary>Distance from a line's top to its baseline (retail BaselineOffset).</summary>
public float BaselineOffset { get; }
/// <summary>
/// Retail <c>Font::m_NumHorizontalBorderPixels</c> / <c>m_NumVerticalBorderPixels</c>
/// (<c>Font::Serialize @0x00443650</c>; verified struct offsets <c>Font+0x40</c> /
/// <c>Font+0x44</c>). The background (outline) atlas glyph is the foreground glyph
/// dilated 2px on every side, sitting inside a margin this wide — measured: the FG
/// glyph always sits at exactly <c>(BorderX, BorderY)</c> inside its inflated window,
/// and the BG glyph's alpha bbox begins at <c>(BorderX-2, BorderY-2)</c>. Zero for
/// fonts with no background atlas (e.g. the CJK/unicode family, <see cref="HasBackground"/>
/// is false). <see cref="UiRenderContext.DrawStringDat"/> inflates the background blit's
/// source AND destination rect by exactly this much on every side
/// (<c>SurfaceWindow::DrawCharacter @0x00442d3a</c> + <c>CreateCharRectPair @0x00441480</c>)
/// so the dilation is actually captured instead of cropped away.
/// </summary>
public int BorderX { get; }
public int BorderY { get; }
private readonly Dictionary<char, FontCharDesc> _glyphs;
internal UiDatFont(
uint fgTex, int fgW, int fgH,
uint bgTex, int bgW, int bgH,
float lineHeight, float baselineOffset,
Dictionary<char, FontCharDesc> glyphs)
Dictionary<char, FontCharDesc> glyphs,
int borderX = 0, int borderY = 0)
{
ForegroundTexture = fgTex; ForegroundWidth = fgW; ForegroundHeight = fgH;
BackgroundTexture = bgTex; BackgroundWidth = bgW; BackgroundHeight = bgH;
LineHeight = lineHeight;
BaselineOffset = baselineOffset;
_glyphs = glyphs;
BorderX = borderX;
BorderY = borderY;
}
/// <summary>True if this font carries a separate outline/shadow atlas
@ -123,7 +148,9 @@ public sealed class UiDatFont
bgTex, bgW, bgH,
lineHeight: font.MaxCharHeight,
baselineOffset: font.BaselineOffset,
glyphs);
glyphs,
borderX: (int)font.NumHorizontalBorderPixels,
borderY: (int)font.NumVerticalBorderPixels);
}
/// <summary>

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>

View file

@ -90,6 +90,27 @@ public sealed class UiText : UiElement, IUiDatStateful
/// the controller (e.g. <c>ChatWindowController</c>).</summary>
public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0f);
/// <summary>
/// Retail LayoutDesc property <c>0x21</c> (<c>UIElement_Text::SetOutline
/// @0x0046a81c</c>, <c>m_bitField &amp; 0x10</c>). When true, every dat-font draw
/// on this element runs retail's two-pass outline+fill model
/// (<see cref="UiRenderContext.DrawStringDat"/>). Default false, matching the
/// ctor bitfield (<c>0x300</c>) which clears the outline bit — outlining is
/// opt-in per element. Set by <see cref="AcDream.App.UI.Layout.DatWidgetFactory"/>
/// from <see cref="AcDream.App.UI.Layout.ElementInfo.Outline"/> for DAT-imported
/// text, or directly by a synthesized controller (e.g. the SpewBox).
/// </summary>
public bool Outline { get; set; }
/// <summary>
/// Retail LayoutDesc property <c>0x22</c> (<c>m_curOutlineColor</c>). Only
/// meaningful when <see cref="Outline"/> is true. Default black, matching the
/// ctor default (<c>RGBAColor_Black</c>,
/// <c>UIElement_Text::UIElement_Text @0x004686cb</c>) — only 9 elements in the
/// whole DAT set author a non-black outline color.
/// </summary>
public Vector4 OutlineColor { get; set; } = UiRenderContext.DefaultOutlineColor;
/// <summary>Optional dat state-sprite background (the element's own media), drawn
/// UNDER the text. Set by DatWidgetFactory.BuildText from the ElementInfo. 0 = none.</summary>
public uint BackgroundSprite { get; set; }
@ -425,7 +446,7 @@ public sealed class UiText : UiElement, IUiDatStateful
{
float cx = (Width - cdf.MeasureWidth(line0.Text)) * 0.5f;
float cy = VOffset(Height, cdf.LineHeight, Padding, VerticalJustify);
ctx.DrawStringDat(cdf, line0.Text, cx, cy, line0.Color);
ctx.DrawStringDat(cdf, line0.Text, cx, cy, line0.Color, Outline, OutlineColor);
}
else if ((Font ?? ctx.DefaultFont) is { } cbf)
{
@ -447,7 +468,7 @@ public sealed class UiText : UiElement, IUiDatStateful
{
float rx = Width - rdf.MeasureWidth(line0.Text) - Padding;
float ry = VOffset(Height, rdf.LineHeight, Padding, VerticalJustify);
ctx.DrawStringDat(rdf, line0.Text, rx, ry, line0.Color);
ctx.DrawStringDat(rdf, line0.Text, rx, ry, line0.Color, Outline, OutlineColor);
}
else if ((Font ?? ctx.DefaultFont) is { } rbf)
{
@ -468,7 +489,7 @@ public sealed class UiText : UiElement, IUiDatStateful
if (DatFont is { } datSingle)
{
float y = VOffset(Height, datSingle.LineHeight, Padding, VerticalJustify);
ctx.DrawStringDat(datSingle, line0.Text, Padding, y, line0.Color);
ctx.DrawStringDat(datSingle, line0.Text, Padding, y, line0.Color, Outline, OutlineColor);
}
else if ((Font ?? ctx.DefaultFont) is { } bitmapSingle)
{
@ -559,7 +580,7 @@ public sealed class UiText : UiElement, IUiDatStateful
}
if (datFont is not null)
ctx.DrawStringDat(datFont, text, lineX, y, lines[i].Color);
ctx.DrawStringDat(datFont, text, lineX, y, lines[i].Color, Outline, OutlineColor);
else
ctx.DrawString(text, lineX, y, lines[i].Color, bitmapFont);
}
@ -602,7 +623,7 @@ public sealed class UiText : UiElement, IUiDatStateful
if (run.Text.Length == 0) continue;
if (datFont is not null)
{
ctx.DrawStringDat(datFont, run.Text, x, y, run.Color);
ctx.DrawStringDat(datFont, run.Text, x, y, run.Color, Outline, OutlineColor);
x += datFont.MeasureWidth(run.Text);
}
else