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>
616 lines
35 KiB
Markdown
616 lines
35 KiB
Markdown
# 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. `(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 `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_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: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)` @ `0x00468290` → `SetFontDIDHelper(0x1a, &m_curFontObj, n)`
|
||
- `SetFontColorNum(n)` @ `0x004682b0` → `SetFontColorHelper(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_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 `LayoutDesc`s 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`/`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 `(hB−2, vB−2)` 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: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 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_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`:
|
||
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
|
||
(`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`:
|
||
- `RetailFontId` `0x40000025` → **`0x40000001`** (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 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.
|