acdream/docs/research/2026-08-10-retail-ui-text-style.md
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

35 KiB
Raw Blame History

Retail UI text style — font resource, style model, outline pass, SpewBox, acdream gap list

Date: 2026-08-10 Status: RESEARCH ONLY — no production code changed. Trigger: Campaign CH user-gate round 4 (2026-08-10). The user reports that BOTH the chat-window transcript text and the on-screen SpewBox text differ from retail in face, size, colour shade, and "shading" — retail's text carries a heavy black border that ours does not. User-supplied retail screenshot of the SpewBox showing "You can't jump while in the air": golden/amber fill, strong full black outline around every glyph (a border, not an offset drop-shadow), bold serif face, horizontally centred at the very top of the viewport.

Primary sources

  • docs/research/named-retail/acclient_2013_pseudo_c.txt (Sept 2013 EoR build, PDB-named)
  • docs/research/named-retail/acclient.h (verbatim retail struct definitions)
  • The installed DATs at %USERPROFILE%\Documents\Asheron's Call\ read through Chorizite.DatReaderWriter 2.1.7 — the same reader DatCollection uses in production. All measurements below were taken with a throwaway scratchpad probe (not committed).

Headline result. Retail's outline is not a shadow and not a shader effect. Every retail Font DAT resource ships two glyph atlases — a foreground (fill) plane and a background (outline) plane whose glyphs are the fill glyphs dilated by 2 px on every side — plus the per-font NumHorizontalBorderPixels / NumVerticalBorderPixels that say how far to inflate the blit rectangle to capture that dilation. acdream loads both atlases but (a) never enables the outline pass at any production call site and (b) blits the background plane with the un-inflated rect, so even switching the flag on would draw almost nothing. Both must be fixed together.


1. THE FONT RESOURCE (DB_TYPE_FONT, 0x40xxxxxx)

1.1 Wire layout — Font::Serialize @ 0x00443650

Fields in serialization order (all Archive::CheckAlignment(4) then GetBytes):

Offset in stream Type Field Notes
DBObj::Serialize the DID header
+0 uint32 maxCharHeight line height
+4 uint32 maxCharWidth used by the fixed-width advance mode
+8 uint32 numCharacters
+12 numCharacters × 11 bytes charDescs esi_1 = numCharacters * 0xb @ 0x004436df
uint32 m_NumHorizontalBorderPixels @ 0x00443750
uint32 m_NumVerticalBorderPixels @ 0x00443778
int32 m_BaselineOffset @ 0x004437a0
uint32 m_ForegroundSurfaceDataID @ 0x004437c8
uint32 m_BackgroundSurfaceDataID @ 0x004437f3

FontCharDesc (acclient.h:4129, __unaligned align(1), exactly 11 bytes): u16 m_Unicode; u16 m_OffsetX; u16 m_OffsetY; s8 m_Width; s8 m_Height; s8 m_HorizontalOffsetBefore; s8 m_HorizontalOffsetAfter; s8 m_VerticalOffsetBefore;

Font : DBObj (acclient.h:4130) also holds runtime-only members (m_pForegroundSurface, m_pBackgroundSurface, characterMap) that are not serialized.

Verified struct offsets (from DrawCharacter's raw field reads, which cross-check the header): Font+0x40 = m_NumHorizontalBorderPixels, Font+0x44 = m_NumVerticalBorderPixels, Font+0x60 = m_pBackgroundSurface. That fixes sizeof(DBObj) == 0x30 and confirms the field order above.

1.2 So: YES, the resource carries outline data — as a second glyph PLANE

m_BackgroundSurfaceDataID is a full second RenderSurface of the same dimensions as the foreground, containing the same glyphs at the same (OffsetX, OffsetY) dilated outward.

Measured (scratchpad probe against the installed client_portal.dat; alpha bounding box of each glyph inside its inflated window, i.e. (OffsetXhB, OffsetYvB, W+2hB, H+2vB)):

Font border glyph FG bbox in window BG bbox in window
0x40000000 4,4 A (9×8) (4,4)-(12,11) 9×8 (2,2)-(14,13) 13×12
0x40000000 4,4 o (5×5) (4,4)-(8,8) 5×5 (2,2)-(10,10) 9×9
0x40000001 4,4 W (13×9) (4,4)-(16,12) 13×9 (2,2)-(18,14) 17×13
0x40000025 3,3 A (6×6) (3,3)-(8,8) 6×6 (1,1)-(10,10) 10×10

The FG glyph always sits at exactly (hB, vB) inside the inflated window — i.e. the source rect for the background plane is offset by exactly (hB, vB) — and the BG glyph is the FG glyph grown by +2 px on every side, uniformly, for every font measured. The border-pixel value (36) is the margin the atlas reserves; the actual dilation is 2 px. Inflating the blit by the full border is therefore correct and harmless (the ring beyond +2 is zero alpha).

Correlating evidence from the full font sweep (0x400000000x40000032, 49 fonts): every font that has a background atlas has border >= 3, and every font without one has border == 0. The first glyph row also begins at OffsetY == vBorder in bordered fonts (0x40000000 VB=4 → glyphs at OffsetY=4; 0x40000025 VB=3 → OffsetY=3) and at OffsetY == 0 in unbordered fonts (0x40000021). The margin exists precisely so the inflated source rect stays in bounds.

All font atlases are PFID_A8 (alpha-only, value 28 = 0x1C — see §3.2).

1.3 Font inventory relevant to this report

Font MaxCharHeight MaxCharWidth Baseline hB,vB glyphs fg / bg surface atlas
0x40000000 16 16 12 4,4 1050 0x06005EE5 / 0x06005EE6 1024×312 A8
0x40000001 18 19 14 4,4 1050 0x06005EE7 / 0x06005EE8 1024×361 A8
0x40000002 14 15 11 3,3 1050 0x06005ECF / 0x06005ED0 1024×214 A8
0x40000025 11 13 9 3,3 1050 0x06005F08 / 0x06005F09 1024×184 A8
0x4000000E0x40000014 24…48 46 210 the decorative uncial "title" family
0x40000017/18, 0x40000021/22/23 0,0 20609 fg only CJK/unicode, no outline plane

0x40000000/0x40000001/0x40000002 are the same bold serif face at 16/18/14 px — the classic AC UI face. Rendered proof (probe, font 0x40000001, gold fill, black outline, inflated bg pass) reproduces the user's screenshot exactly. The 0x4000000E+ family is the decorative celtic/uncial display face and is not what the user photographed.

1.4 How acdream reads it today — and what it drops

src/AcDream.App/UI/UiDatFont.cs:93 UiDatFont.Load:

  • reads MaxCharHeightLineHeight
  • reads BaselineOffsetBaselineOffset (stored but never used — see §3.4)
  • uploads ForegroundSurfaceDataId and BackgroundSurfaceDataId
  • builds the char → FontCharDesc map
  • never reads NumHorizontalBorderPixels / NumVerticalBorderPixels

The reader exposes them: DatReaderWriter.DBObjs.Font has public fields NumHorizontalBorderPixels and NumVerticalBorderPixels (verified by reflection over DatReaderWriter.dll 2.1.7). A repo-wide grep for BorderPixel returns zero hits.

Verdict for Q1: the resource carries a dedicated outline plane plus the two border-pixel scalars that make it usable. acdream loads the plane but drops the scalars, and therefore cannot address the plane correctly.


2. THE TEXT STYLE MODEL

2.1 The element property table — UIElement_Text::OnSetAttribute @ 0x0046a640

The switch is biased: switch (BaseProperty::GetPropertyName(arg2) - 0x14) at 0x0046a674. Adding 0x14 back gives the real LayoutDesc property ids:

Property id Meaning Handler
0x14 HorizontalJustification (enum) SetHorizontalJustification
0x15 VerticalJustification (enum) SetVerticalJustification
0x16 Editable (bool) SetEditable
0x17 StringInfo — the text SetStringInfo
0x1A FontDID array DoFontReset
0x1B FontColor array DoFontReset
0x1C (second DID array — no OnSetAttribute case) pull-based; see §2.3
0x1D TagFontColor array DoFontReset
0x1E MaxCharacters (int) m_glyphList.m_nMaxCharacters
0x1F NoIme (bool) SetNoIme
0x20 OneLine (bool) SetOneLine
0x21 Outline (bool) SetOutline @ 0x0046a81c
0x22 OutlineColor (color) m_curOutlineColor @ 0x0046a6bf, then DoFontReset
0x23/0x24/0x25/0x26 margL / margR / margU / margD (int) m_margLm_margD
0x27 Selectable (bool) SetSelectable
0x28 TrimFromTop (bool) m_glyphList.m_bTrimFromTop
0x29 FitToText (bool) SetFitToText
0xC7 TruncateTextToFit
0xCB / 0xCC LoseFocusOnEscape / LoseFocusOnAcceptInput

Correction to an existing acdream comment. src/AcDream.App/UI/UiRenderContext.cs:221 says the outline flag is "LayoutDesc property 0xd". That is the switch case index, not the property id. The authored property id is 0x21; the outline colour is 0x22.

2.2 Defaults — UIElement_Text::UIElement_Text @ 0x00468570

  • m_bitField = 0x300 (0x00468 5eb) → bit 0x10 (outline) starts CLEAR. Outline is opt-in per element.
  • m_curFontColor = RGBAColor_White
  • m_curTagFontColor = RGBAColor_White
  • m_curOutlineColor = RGBAColor_Black (0x004686cb, packed from RGBAColor_Black)
  • m_eHorizontalJustification = 2, m_eVerticalJustification = 4
  • default font from DBObj::GetByEnum(0x2e /*Font DBObj type*/, 9, 0x18) with fallback (0x2e, 9, 3).

m_curOutlineColor has exactly two writers in the whole binary: the ctor (black) and property 0x22 (0x0046a6bf). UNVERIFIED-adjacent but well-supported: because no element in the shipped DATs authors 0x22 on any text that matters here (see §2.5), the effective outline colour everywhere is pure black — matching the user's screenshot.

2.3 Font/colour are ARRAYS with a runtime slot index

UIElement_Text::SetFontDIDHelper @ 0x00466960 does vtable->InqProperty(0x1A) → array → element[arg4] → InqDataID → FontLocal::GetGlobalOrLocalFont(did). SetFontColorHelper @ 0x00466ac0 is the same for a colour array.

  • SetFontDIDNum(n) @ 0x00468290SetFontDIDHelper(0x1a, &m_curFontObj, n)
  • SetFontColorNum(n) @ 0x004682b0SetFontColorHelper(0x1b, &m_curFontColor, n) and SetFontColorHelper(0x1d, &m_curTagFontColor, n)
  • DoFontReset @ 0x0046a560 → all three with index 0
  • AppendStringInfoWithFont(info, fontNum, colorNum) @ 0x00469de0 sets the slots then appends

So a single element can carry e.g. 0x1B = [white, green, red, yellow] and code selects the slot. The DAT sweep confirms multi-entry colour arrays exist ([white, green, red] ×7, [white, green, red, yellow] ×2).

0x1C is authored as a DID array alongside 0x1D TagFontColor on every "tag-capable" style (e.g. style 0x10000372 authors 0x1C = [0x40000000], the same DID as its 0x1A). UNVERIFIED: most likely TagFontDID (the font used for the [General]-style tag run), by symmetry with 0x1D and because it has no OnSetAttribute case (pull-based, like 0x1A/0x1B are for the helpers). Cheapest resolution: grep InqProperty(0x1c in a fresh Ghidra decompile of GlyphList::AddText/UIElement_Text::AddText_Internal.

2.4 State resolution — which colour is live

UIElement::Initialize @ 0x00462c90:

  1. SetState(m_desc.m_defaultState)
  2. then iterate m_desc.m_properties calling OnSetAttribute on each.

UIElement::SetState @ 0x00464e70 re-derives the effective property collection (m_desc.m_propertiesstate.m_propertiesm_instanceProperties) for the OLD and NEW state and re-fires OnSetAttribute for every property whose value changed. So a state change re-drives font, colour, and the outline flag. If the requested state has no StateDesc, SetState falls back to state 0 (the DirectState).

2.5 What the DATs actually author

All 101 LayoutDescs live in client_local_English.dat only; all fonts live in client_portal.dat (verified per-file — there is no duplicate-definition ambiguity).

Global sweep for property 0x21/0x22: outlining is used sparingly and deliberately — roughly 100 authored 0x21 = true rows across ~15 layouts, and only 9 authored 0x22 outline colours in the entire DAT set (values ARGB(255,17,15,7) and ARGB(255,0,0,102)). Everything else inherits the black ctor default.

Layout 0x2100003F is retail's text style sheet — 30+ type 0x0C style prototypes that other layouts inherit through BaseElement/BaseLayoutId. The two that matter:

elem 0x10000372  (CHAT TRANSCRIPT STYLE)
  0x14 HJustify      = Enum(2)            -> Left
  0x15 VJustify      = Enum(4)            -> Bottom
  0x1A FontDID[]     = [0x40000000]       -> 16 px bold serif
  0x1B FontColor[]   = [ARGB(255,204,204,204)]
  0x1C               = [0x40000000]
  0x1D TagFontColor[]= [ARGB(255,0,178,0)]   (green)
  0x23 margL = 0, 0x25 margU = 0
  (states) Normal_rollover/Highlight -> white; Normal_pressed/Ghosted -> (51,51,51)
  *** NO 0x21 -> the chat transcript is NOT outlined in retail ***

elem 0x10000377  (SPEWBOX LINE STYLE)
  0x14 HJustify      = Enum(1)            -> Center
  0x15 VJustify      = Enum(1)            -> Center
  0x1A FontDID[]     = [0x40000001]       -> 18 px bold serif
  0x1B FontColor[]   = [white]
  0x23 margL = 0, 0x25 margU = 0
  (state Ghosted) 0x1B -> (76,76,76)

The gold + outline family (0x100003820x10000388) uses the decorative display fonts 0x4000000E0x40000014 with 0x1B = ARGB(255,255,242,127) and 0x21 = true — that is the big centred announcement text, not the SpewBox.

2.6 The chat window — LayoutDesc 0x2100006F

Full tree walked. The transcript is element 0x10000011 (not 0x10000010, which is its type 0x03 container panel):

/0x10000600/0x10000010/ elem 0x10000011 type 0x00000000 (UIElement_Text)
    base = element 0x10000372 in layout 0x2100003F     <-- the style above
    pos(16,0) size(368x73)
    own DirectState props: 0x23 margL = 2, 0x24 margR = 2, 0x27 Selectable = true,
                           0x51 = true, 0x72 = Enum(0x10000012), 0xCD = Enum(3)
    NO own 0x1A / 0x1B / 0x21 / 0x22

Chat font truth: face 0x40000000, 16 px line height, baseline 12, no outline, default fill ARGB(255,204,204,204), tag colour green (0,178,0), left-justified, bottom-justified, 2 px left/right margin. Per-line colours come from ChatInterface's colour table stamped onto the glyph runs (already ported — see docs/research/2026-08-09-chat-retail-color-table.md), with (204,204,204) as the element default when a run carries none.


3. THE SHADOW/OUTLINE PASS

3.1 Two whole-string passes — UIElement_Text::DrawSelf @ 0x00467aa0

var_b0 = 1;
if (m_bitField & 0x10)      // SetOutline(true) / property 0x21
    var_b0 = 0;

if (var_b0 < 2) do {
    ... walk every glyph of the block ...
        if (var_b0 != 0)                        // FILL pass
            DrawCharacter(x, y, font, ch, glyph.color, 0x1000, m_curOutlineColor);
        else if (font->m_pBackgroundSurface == 0)   // OUTLINE pass, no bg plane
            for (j = -1; j <= 1; j++)
              for (k = -1; k <= 1; k++)
                if (k || j)
                    DrawCharacter(x+k, y+j, font, ch, m_curOutlineColor, 0x9000, 0xff000000);
        else                                     // OUTLINE pass, bg plane
            DrawCharacter(x, y, font, ch, glyph.color, 0x7000, m_curOutlineColor);
    ...
    cond = (var_b0 + 1) < 2;  var_b0 += 1;
} while (cond);                                  // 0x00467f6b / 0x00467f6e

Consequences:

  • outline OFF → the loop runs once (pass 1, fill only);
  • outline ON → the loop runs twice: pass 0 draws the outline for the whole block, pass 1 draws every fill on top. Fills therefore always sit above every neighbour's outline — interleaving per glyph (what acdream would do) lets glyph N+1's outline paint over glyph N's fill on tight kerning.
  • retail has two outline mechanisms and picks by data: bg plane when the font has one, otherwise 8 neighbour blits of the foreground glyph at ±1 px. This is the same data-driven-dispatch shape as feedback_retail_dispatch_is_data_driven.

3.2 SurfaceWindow::DrawCharacter @ 0x00442bd0 — flag bits

DrawCharacter(dst, x, y, font, unicode, fgColor /*arg6*/, flags /*arg7*/, outlineColor /*arg8*/)

Bit Effect Site
0x0001 fixed-width advance = maxCharWidth + 1 0x00442c2f
0x0002 apply m_BaselineOffset 0x00442c6f
0x0100 CopyAlpha instead of a colour blit 0x00442c80
0x1000 proportional advance hOffBefore + Width + hOffAfter, glyph at x + hOffBefore 0x00442c3a0x00442c4a
0x2000 skip the foreground plane 0x00442dea
0x4000 draw the background plane (also needs m_pBackgroundSurface != 0) 0x00442d02
0x8000 force the tint path (SurfaceWindow::Color) 0x00442dc1 / 0x00442e65

So 0x7000 = bg-plane-only, proportional; 0x1000 = fill-only, proportional; 0x9000 = fill-glyph tinted with the outline colour, proportional.

Both planes go through SurfaceWindow::Color(dst, src, Blit_4Alpha, colour) whenever the surface format is 0x1C (PFID_A8) — which font atlases always are — so the A8 coverage is tinted by the supplied colour. arg8 tints the background plane; arg6 tints the foreground plane. This is exactly acdream's shader model (A8 → (255,255,255,a), multiplied by the vertex tint). The mechanism matches; only the gating and the rect do not.

3.3 The rect inflation — CreateCharRectPair @ 0x00441480

Foreground call (0x00442cc0) and background call (0x00442d3a) differ in exactly two argument slots: the foreground passes 0 and nullptr; the background passes Font+0x40 (m_NumHorizontalBorderPixels) and Font+0x44 (m_NumVerticalBorderPixels).

The body inflates BOTH the source sub-rect and the destination rect symmetrically by those two values before clamping to the surface bounds:

dst.left = X - hB;  dst.top = Y - vB;  dst.right = X + W + hB;  dst.bottom = Y + H + vB;
src.left = Ox - hB; src.top = Oy - vB; src.right = Ox + W + hB; src.bottom = Oy + H + vB;
(then correlated clamping against the surface extents)

BN's regparm reconstruction scrambles the parameter names in this function (edx/ebx come in as unassigned registers), so the slot mapping above is inferred from the call-site diff, not read literally. It is however confirmed by measurement: §1.2 shows the background glyph's alpha bbox begins at (hB2, vB2) inside the inflated window and is W+4 × H+4, i.e. only an inflated rect can capture it, and the foreground glyph sits at exactly (hB, vB).

3.4 Comparison with UiRenderContext.DrawStringDat

src/AcDream.App/UI/UiRenderContext.cs:226293.

Retail acdream today Verdict
Two whole-block passes (outline block, then fill block) one loop, outline+fill interleaved per glyph ⚠️ differs on tight kerning
Outline gated by m_bitField & 0x10 ← property 0x21 gated by a bool outline = false parameter mechanism present
Outline enabled on the elements that author 0x21 no production call site ever passes outline: true outline never drawn
Outline colour = m_curOutlineColor (black default, property 0x22) hard-coded new Vector4(0,0,0,color.W) (line 249) correct in practice
bg source rect = (OxhB, OyvB, W+2hB, H+2vB), dest inflated to match bg source rect = (Ox, Oy, W, H), dest not inflated (lines 274279) outline invisible even if enabled
Fallback: 8 neighbour blits when the font has no bg plane none missing (matters for the 0-border fonts)
Advance hOffBefore + W + hOffAfter UiDatFont.GlyphAdvance — identical
Glyph at x + hOffBefore, y + vOffBefore, integer line baseline identical (lines 247260)
A8 × tint A8 → white+alpha × vertex tint

Rendered proof (probe, font 0x40000001, "You can't jump while in the air", gold on a grass-toned backdrop):

  • outline: false (acdream today) → flat glyphs, no border.
  • outline: true, not inflated → visually indistinguishable from no outline; the only artifact is a single dark pixel where a descender pokes out. The fill covers the whole un-inflated bg rect.
  • outline: true, inflated by the border pixels → the heavy black border the user photographed.

Verdict for Q3: same mechanism, two independent defects. Enabling the flag alone would change nothing visible. Both the flag and the rect inflation are required.

3.5 One more retail detail acdream does not implement

0x0002 (use m_BaselineOffset) — UiDatFont.BaselineOffset is loaded and stored but never read by any draw path. Retail's DrawSelf glyph loop does not set 0x0002, so the transcript and SpewBox paths are unaffected; leave it. Noted so the field is not mistaken for a gap.


4. THE SPEWBOX — resolved, not a placeholder any more

The earlier CH finding that element 0x1000004A / layout enum 0x10000012 is "absent from both dats" is WRONG. It is present. It was missed for two reasons, both mechanical: the element is a root of its layout (so a children-only walk skips it), and its font/colour live in a BaseElement in a different LayoutDesc plus a named state, not in its own DirectState.

4.1 Runtime path — gmSpewBoxUI

  • gmSpewBoxUI::Register @ 0x004d5dd0RegisterElementClass(0x10000016, Create).
  • gmSpewBoxUI::PostInit @ 0x004d5ab0SetShouldEraseBackground(1), SetMouseVisible(0), GetChildRecursive(0x10000049) → the ListBox, reads its property 0x10000028 into m_maxConcurrentItems (default 1 if absent), RegisterForGlobalMessage(3), RegisterNoticeHandler(0x186b6).
  • gmSpewBoxUI::RecvNotice_DisplayFinalStringInfo @ 0x004d60a0 — accepts only arg2 == 0x1a (the client-local / interface-text LogTextType) and queues the StringInfo.
  • gmSpewBoxUI::ListenToGlobalMessage @ 0x004d6090 → on global message 3, Update.
  • gmSpewBoxUI::Update @ 0x004d5df0: UIElementManager::CreateChildElementByEnum(s_pInstance, nullptr, 0x10000012, 0x1000004a)DynamicCast(0xC)SetText(trimmed)ResizeTo(listBox.width, h)RecalculateGlyphList → dedup against item 0 → InsertItem(item, 0) → delete item m_num-1 when over m_maxConcurrentItemsScrollToShow(0).

acdream's SpewBoxController already mirrors the tick source, newest-on-top ordering, and the 4-item cap correctly.

4.2 The authored line template — LayoutDesc 0x21000011 (client_local_English.dat)

/ elem 0x1000004A  type 0x00000000 (UIElement_Text)
    base = 0x10000377 in layout 0x2100003F
    size 450 x 18            <-- exactly Font 0x40000001's MaxCharHeight
    DefaultState = 0x10000002   (its only state)
    [DirectState]      0x14 HJustify = Enum(1)      -> Center
                       0x15 VJustify = Enum(1)      -> Center
    [state 0x10000002] 0x1B FontColor[] = [ARGB(255,255,0,0)]   <-- RED
                       0x21 OUTLINE    = true                    <-- OUTLINE ON
                       0x23 margL = 5
                       0x24 margR = 5
    inherited from 0x10000377:
                       0x1A FontDID[]  = [0x40000001]            <-- 18 px bold serif
    outline colour: NOT authored anywhere -> ctor default = BLACK

/ elem 0x10000048  type 0x10000016 (gmSpewBoxUI)
    size 450 x 72, edges L3 T1 R3 B2, pos (0,0), ROOT of the layout
    (L3/R3 = "centered" per ElementReader.ToAnchors; T1 = top-anchored)
    [DirectState] 0x3B = Bool(false)

/0x10000048/ elem 0x10000049 type 0x00000005 (ListBox)
    size 450 x 72, DefaultState = Ghosted
    0x10000028 MaxConcurrentItems = 4

Three independent cross-checks that the font is 0x40000001:

  1. the base style 0x10000377 authors 0x1A = [0x40000001] directly;
  2. the line template's authored height is 18 px and 0x40000001's MaxCharHeight is 18;
  3. 4 items × 18 px = 72 px = the authored box height.

Position is now derived, not guessed. 0x10000048 is a ROOT element of its layout, so its parent is the viewport: pos(0,0), L3/R3 centred, T1 top-anchored ⇒ a 450×72 block horizontally centred, flush to the top of the screen. That is exactly what SpewBoxController does today (TopOffset = 0, per-frame recentre) — the user-directed placement turns out to be the authored one. Register row AP-178's position entry can be retired.

4.3 The colour — the ONE unresolved conflict

  • DAT/decomp says: ARGB(255,255,0,0) — pure red — because DefaultState is 0x10000002 and UIElement::Initialize applies that state before the properties. This is also consistent with retail's chat colour table, where LogTextType 0x1A (the only type the SpewBox accepts) maps to colorBrightRed (1,0,0,1) (docs/research/2026-08-09-chat-retail-color-table.md, row 0x1A).
  • The user says: golden/amber, and pinned yellow #FFFF3F live at CH round 1.

UNVERIFIED — do not "fix" the colour off this document. Two things could reconcile it, and I cannot separate them statically:

  1. The ListBox (DefaultState = Ghosted) may propagate its state to inserted items via StateDesc::m_bPassToChildren (UIElement::SetState @ 0x00464ebf/0x00464ec6). If the item is moved out of 0x10000002, SetState re-fires OnSetAttribute for the changed keys and the colour reverts toward the base (white / Ghosted grey) — and the outline flag would go with it, which contradicts the screenshot, so this is unlikely but not excluded.
  2. A large part of "gold vs yellow" is the outline itself. Rendering #FFFF3F at 18 px with the black border eats the glyph edges and reads distinctly warmer/goldener than the same #FFFF3F drawn flat. Probe images of #FF0000, #FFFF3F, #FFF27F, #D2D264 and #FFFFFF at font 0x40000001 with the correct outline show #FFFF3F-with-outline matching a "golden/amber" description well, and #FF0000 matching it not at all.

Cheapest resolution, in order:

  • (a) Show the user the four probe renders side by side and let them point at one — zero cost, and their eye is the acceptance test anyway.
  • (b) If that is ambiguous, cdb one-liner against live retail: bp acclient!UIElement_Text::DrawSelf ".if (@@c++(((UIElement_Text*)@ecx)->m_bitField & 0x10)) { .printf \"col=%08x outline=%08x\\n\", @@c++(((UIElement_Text*)@ecx)->m_curFontColor), @@c++(((UIElement_Text*)@ecx)->m_curOutlineColor) }; gc" then trigger a jump-in-air refusal. That reads the live m_curFontColor directly.

Recommendation meanwhile: keep the user-pinned yellow (it is already a passed user gate), and land the face + size + outline, which are unambiguous. Re-open colour only if the user still flags it after the outline lands.

4.4 Answering "is there ANY on-screen text with authored style"

Yes — the SpewBox line template above is exactly that, and it is the only one of its kind. The other authored-outline on-screen families are the big centred announcement styles (0x100003820x10000388, decorative fonts 0x4000000E0x40000014, gold ARGB(255,255,242,127)), which are a different surface. There is no separate authored "wait cue"/ECM display-string element — Campaign CH already routed the portal wait cue through the SpewBox, which the decomp supports (notice type 0x1A is the only inbound channel).


5. ACDREAM GAP LIST + FIX PLAN

5.1 Where each attribute comes from today

Chat transcript

Attribute acdream today Retail Match?
Face 0x40000000InteractionRetainedUiComposition.cs:572 (UiDatFont.Load default UiDatFont.DefaultFontId = 0x40000000) → RetailUiRuntime.cs:843ChatWindowController.cs:293 Transcript.DatFont = datFont 0x40000000 via base style 0x10000372 prop 0x1A
Size 16 px (UiDatFont.LineHeight = MaxCharHeight) 16 px
Per-line colour UiText.Line.Color from the ported chat colour table glyph-run colour from ChatInterface
Default colour UiText.DefaultColor = Vector4.One (UiText.cs:77) ARGB(255,204,204,204) shade too bright
Tag colour none 0x1D = (0,178,0) green + 0x1C tag font missing
Outline never drawn (UiText.cs:562, :605, :428, :450, :471 all call DrawStringDat with the default outline: false) style 0x10000372 authors no 0x21 correct — chat must stay un-outlined
Margins UiText.Padding, not read from the DAT element 0x10000011 authors 0x23/0x24 = 2 ⚠️ minor
Justification HJustify/VJustify from 0x14/0x15 (ElementReader.cs:339+) Left / Bottom via base (inherited through Merge)

SpewBox

Attribute acdream today Retail Match?
Face 0x40000025SpewBoxController.cs:121 0x40000001 (base style 0x10000377 prop 0x1A) wrong font
Size 11 px line 18 px line (and the authored line box is 18 px tall) far too small
Fill colour (1,1,0.247,1)SpewBoxController.cs:184 authored (255,0,0); user-observed gold/yellow ⚠️ see §4.3
Outline none 0x21 = true, colour black the reported "shading"
Position centred, TopOffset = 0SpewBoxController.cs:137 centred (edges L3/R3), top-anchored (T1), root-of-layout ⇒ viewport top now derived
Extent 450×72 — SpewBoxController.cs:164-165 450×72
Max items 4 0x10000028 = 4
Margins none 0x23/0x24 = 5 ⚠️ minor

Shared plumbing

Gap Site
NumHorizontal/VerticalBorderPixels never read UiDatFont.cs:93-127 (Load), no field on UiDatFont
Background sub-rect not inflated UiRenderContext.cs:274-279
Outline never enabled UiRenderContext.cs:226 default outline = false; zero production callers pass true
Property 0x21 (outline) / 0x22 (outline colour) not imported ElementReader.cs:339-386 reads only 0x1A, 0x14, 0x15, 0x1B; LayoutImporter.cs:499-560 likewise
No 8-neighbour fallback for 0-border fonts UiRenderContext.DrawStringDat
Outline drawn per glyph, not per block UiRenderContext.DrawStringDat loop
Stale comment: "LayoutDesc property 0xd" UiRenderContext.cs:221

5.2 Fix plan

Fix 1 — UiDatFont carries the border pixels. Add public int BorderX { get; } / BorderY { get; } populated from font.NumHorizontalBorderPixels / NumVerticalBorderPixels in UiDatFont.Load (UiDatFont.cs:93). Cite Font::Serialize @ 0x00443650. Pure additive; no behaviour change until Fix 2.

Fix 2 — DrawStringDat blits the background plane with the inflated rect. In UiRenderContext.cs:272-280, when the outline pass runs use source (g.OffsetX - BorderX, g.OffsetY - BorderY, g.Width + 2*BorderX, g.Height + 2*BorderY) and destination (gx - BorderX, gy - BorderY, same size). Cite SurfaceWindow::DrawCharacter @ 0x00442d3a + CreateCharRectPair @ 0x00441480. Fixes 1 and 2 must land together — either alone is a no-op or a regression.

Fix 3 — restructure DrawStringDat into two whole-string passes. When outline is set, loop the string once emitting only outline quads, then again emitting only fill quads. Cite UIElement_Text::DrawSelf @ 0x00467f6b. Low risk, and it removes the tight-kerning artifact. Optional in the same commit: the 8-neighbour ±1 fallback when font.BackgroundTexture == 0 (cite 0x00467d7e0x00467e14), needed only if a 0-border font (0x40000017/18/21/22/23, 0x4000001A, 0x4000001D0x40000020, 0x40000030/31) is ever used for outlined text. Not currently reachable — file it rather than build it if scope is tight.

Fix 4 — SpewBox: face + size + outline. In SpewBoxController.cs:

  • RetailFontId 0x400000250x40000001 (three independent cross-checks in §4.2);
  • pass outline: true for this element's text. Cleanest seam: add public bool Outline { get; set; } to UiText and forward it into the DrawStringDat calls at UiText.cs:428/450/471/562/605, then set Outline = true on the SpewBox UiText.
  • rewrite the class remarks: position and extent are now authored (0x21000011 element 0x10000048), and the font is authored (0x2100003F element 0x10000377). Retire the AP-178 rows for position and font; keep a colour row pending §4.3.
  • optional: 5 px left/right margin from 0x23/0x24.

Fix 5 — chat transcript default shade + tag colour. Set the transcript's UiText.DefaultColor to (204/255, 204/255, 204/255, 1) from style 0x10000372 prop 0x1B, and leave the outline OFF (retail authors none). File the 0x1D tag colour (0,178,0) and the 0x1C tag font as a separate follow-up — it needs a per-run tag concept that UiText.Line does not have yet.

Fix 6 — import 0x21/0x22 properly. Add Outline (bool, prop 0x21) and OutlineColor (colour, prop 0x22, default black) to ElementInfo + ElementReader.ApplyCanonicalLegacyProjection + LayoutImporter.ReadState, with the same "derived wins" merge rule as FontDid, and have DatWidgetFactory push them onto the built UiText. That makes every authored-outline element in the DAT (~100 rows across 15 layouts) correct at once instead of one controller at a time. Correct the stale UiRenderContext.cs:221 comment to property 0x21/0x22 in the same commit.

Suggested order: 1+2 (mechanism) → 4 (the surface the user is looking at) → 5 → 6 → 3. Fixes 1+2+4 alone should reproduce the screenshot.

5.3 Divergence-register bookkeeping

  • Retire: AP-178's position and extent placeholders (both are authored and already matched), and its font placeholder once Fix 4 lands.
  • Add: a row for the chat transcript's missing tag-colour/tag-font (0x1D/0x1C) until Fix 5's follow-up lands.
  • Add: a row for the missing 8-neighbour outline fallback if Fix 3's optional half is deferred.
  • Keep, rewritten: the AP-178 colour row — now "authored (255,0,0) for state 0x10000002; acdream ships the user-pinned yellow pending the §4.3 resolution."

Appendix — probe method (reproducible)

A throwaway .NET 8 console referencing Chorizite.DatReaderWriter 2.1.7 + SixLabors.ImageSharp, run against %USERPROFILE%\Documents\Asheron's Call. It (a) sweeps Font 0x400000000x40000040 printing every serialized field plus the fg/bg RenderSurface dimensions and format, (b) walks every LayoutDesc root and child recursively across all four DATs printing each ElementDesc's BaseElement/BaseLayoutId, DefaultState, and every StateDesc property (DirectState and named states) by id, (c) measures per-glyph alpha bounding boxes in both atlases inside the inflated window, and (d) composites sample strings with the retail two-pass model to PNG. Nothing was written into the repo tree. The three walk features the earlier CH sweep lacked — root elements, BaseElement/BaseLayoutId inheritance, and named-state properties — are exactly what turned 0x1000004A from "absent" into fully specified.