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>
35 KiB
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 throughChorizite.DatReaderWriter2.1.7 — the same readerDatCollectionuses 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. (OffsetX−hB, OffsetY−vB, 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 (3–6) 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 (0x40000000–0x40000032, 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 |
0x4000000E…0x40000014 |
24…48 | — | — | 4–6 | 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
MaxCharHeight→LineHeight✅ - reads
BaselineOffset→BaselineOffset✅ (stored but never used — see §3.4) - uploads
ForegroundSurfaceDataIdandBackgroundSurfaceDataId✅ - builds the
char → FontCharDescmap ✅ - 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_margL … m_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:221says the outline flag is "LayoutDesc property 0xd". That is the switch case index, not the property id. The authored property id is0x21; the outline colour is0x22.
2.2 Defaults — UIElement_Text::UIElement_Text @ 0x00468570
m_bitField = 0x300(0x00468 5eb) → bit0x10(outline) starts CLEAR. Outline is opt-in per element.m_curFontColor = RGBAColor_Whitem_curTagFontColor = RGBAColor_Whitem_curOutlineColor = RGBAColor_Black(0x004686cb, packed fromRGBAColor_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)@0x00468290→SetFontDIDHelper(0x1a, &m_curFontObj, n)SetFontColorNum(n)@0x004682b0→SetFontColorHelper(0x1b, &m_curFontColor, n)andSetFontColorHelper(0x1d, &m_curTagFontColor, n)DoFontReset@0x0046a560→ all three with index 0AppendStringInfoWithFont(info, fontNum, colorNum)@0x00469de0sets 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:
SetState(m_desc.m_defaultState)- then iterate
m_desc.m_propertiescallingOnSetAttributeon each.
UIElement::SetState @ 0x00464e70 re-derives the effective property collection
(m_desc.m_properties ⊕ state.m_properties ⊕ m_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 (0x10000382–0x10000388) uses the decorative display fonts
0x4000000E–0x40000014 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 |
0x00442c3a–0x00442c4a |
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/ebxcome 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(hB−2, vB−2)inside the inflated window and isW+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:226–293.
| 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 = (Ox−hB, Oy−vB, W+2hB, H+2vB), dest inflated to match |
bg source rect = (Ox, Oy, W, H), dest not inflated (lines 274–279) |
❌ 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 247–260) | ✅ |
| 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 @ 0x004d5dd0→RegisterElementClass(0x10000016, Create).gmSpewBoxUI::PostInit @ 0x004d5ab0→SetShouldEraseBackground(1),SetMouseVisible(0),GetChildRecursive(0x10000049)→ the ListBox, reads its property0x10000028intom_maxConcurrentItems(default 1 if absent),RegisterForGlobalMessage(3),RegisterNoticeHandler(0x186b6).gmSpewBoxUI::RecvNotice_DisplayFinalStringInfo @ 0x004d60a0— accepts onlyarg2 == 0x1a(the client-local / interface-textLogTextType) and queues theStringInfo.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 itemm_num-1when overm_maxConcurrentItems→ScrollToShow(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:
- the base style
0x10000377authors0x1A = [0x40000001]directly; - the line template's authored height is 18 px and
0x40000001'sMaxCharHeightis 18; - 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 — becauseDefaultStateis0x10000002andUIElement::Initializeapplies that state before the properties. This is also consistent with retail's chat colour table, whereLogTextType 0x1A(the only type the SpewBox accepts) maps tocolorBrightRed (1,0,0,1)(docs/research/2026-08-09-chat-retail-color-table.md, row0x1A). - The user says: golden/amber, and pinned yellow
#FFFF3Flive at CH round 1.
UNVERIFIED — do not "fix" the colour off this document. Two things could reconcile it, and I cannot separate them statically:
- The ListBox (
DefaultState = Ghosted) may propagate its state to inserted items viaStateDesc::m_bPassToChildren(UIElement::SetState @ 0x00464ebf/0x00464ec6). If the item is moved out of0x10000002,SetStatere-firesOnSetAttributefor 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. - A large part of "gold vs yellow" is the outline itself. Rendering
#FFFF3Fat 18 px with the black border eats the glyph edges and reads distinctly warmer/goldener than the same#FFFF3Fdrawn flat. Probe images of#FF0000,#FFFF3F,#FFF27F,#D2D264and#FFFFFFat font0x40000001with the correct outline show#FFFF3F-with-outline matching a "golden/amber" description well, and#FF0000matching 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 livem_curFontColordirectly.
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
(0x10000382–0x10000388, decorative fonts 0x4000000E–0x40000014, 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 | 0x40000000 — InteractionRetainedUiComposition.cs:572 (UiDatFont.Load default UiDatFont.DefaultFontId = 0x40000000) → RetailUiRuntime.cs:843 → ChatWindowController.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 | 0x40000025 — SpewBoxController.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 = 0 — SpewBoxController.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 0x00467d7e–0x00467e14), needed only if a 0-border font
(0x40000017/18/21/22/23, 0x4000001A, 0x4000001D–0x40000020, 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:
RetailFontId0x40000025→0x40000001(three independent cross-checks in §4.2);- pass
outline: truefor this element's text. Cleanest seam: addpublic bool Outline { get; set; }toUiTextand forward it into theDrawStringDatcalls atUiText.cs:428/450/471/562/605, then setOutline = trueon the SpewBoxUiText. - rewrite the class remarks: position and extent are now authored (
0x21000011element0x10000048), and the font is authored (0x2100003Felement0x10000377). 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 state0x10000002; 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 0x40000000–0x40000040
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.