acdream/docs/research/retail-ui/03-rendering.md
Erik 7230c1590f docs+feat(ui): retail UI deep-dive research + C# port scaffold
Deep investigation of the retail AC client's GUI subsystem, driven by 6
parallel Opus research agents, plus the first cut of a retail-faithful
retained-mode widget toolkit that scaffolds Phase D.

Research (docs/research/retail-ui/):
- 00-master-synthesis.md        — cross-slice synthesis + port plan
- 01-architecture-and-init.md   — WinMain, CreateMainWindow, frame loop,
                                  Keystone bring-up (7 globals mapped)
- 02-class-hierarchy.md         — key finding: UI lives in keystone.dll,
                                  not acclient.exe; CUIManager + CUIListener
                                  MI pattern, CFont + CSurface + CString
- 03-rendering.md               — 24-byte XYZRHW+UV verts, per-font
                                  256x256 atlas baked from RenderSurface,
                                  TEXTUREFACTOR coloring, DrawPrimitiveUP
- 04-input-events.md            — Win32 WndProc → Device (DAT_00837ff4)
                                  → widget OnEvent(+0x128); full event-type
                                  table (0x01 click, 0x07 tooltip ~1000ms,
                                  0x15 drag-begin, 0x21 enter, 0x3E drop)
- 05-panels.md                  — chat, attributes, skills, spells, paperdoll
                                  (25-slot layout), inventory, fellowship,
                                  allegiance — with wire-message bindings
- 06-hud-and-assets.md          — vital orbs (scissor fill), radar
                                  (0x06001388/0x06004CC1, 1.18× shrink),
                                  compass strip, dat asset catalog

Key insight: keystone.dll owns the actual widget toolkit — we cannot
port a class hierarchy from the decompile because it's not there.
Instead we implement our own retained-mode toolkit with retail-faithful
behavior (event codes, focus/modal/capture, drag-drop state machine)
and will consume the same portal.dat fonts + sprites so the visual
identity is preserved.

C# scaffold (src/AcDream.App/UI/):
- UiEvent          — 24-byte event struct + retail event-type constants
                     (0x01 click, 0x15 drag-begin, 0x201 WM_LBUTTONDOWN,
                     etc.) matching retail decompile switches
- UiElement        — base widget: children, ZOrder, focus/capture flags,
                     virtual OnDraw/OnEvent/OnHitTest/OnTick; children-
                     first hit test + back-to-front composite
- UiPanel          — panel, label, button primitives
- UiRenderContext  — 2D draw context with translate stack
- UiRoot           — top-of-tree + Device responsibilities (mouse/
                     keyboard state, focus, modal, capture, drag-drop,
                     tooltip timer); WorldMouseFallThrough/
                     WorldKeyFallThrough preserves existing camera
                     controls when no widget consumes
- UiHost           — packages UiRoot + TextRenderer + input wiring
                     helpers for one-line integration into GameWindow
- README.md        — orientation for future agents

Roadmap (docs/plans/2026-04-11-roadmap.md):
- D.1 marked shipped (debug overlay from 2026-04-17)
- D.2 expanded to include the retail UI framework landed here
- D.3-D.7 added: AcFont, dat sprites, core panels, HUD, CursorManager
- D.8 remains sound

All existing 470 tests pass. 0 warnings, 0 errors.
2026-04-17 19:13:02 +02:00

1013 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Retail UI Rendering Pipeline — Font, Sprite, Text, Cursor
Research pass 03 of 6, mapping the retail AC client's UI rendering subsystem
from the decompiled `acclient.exe` (22,225 functions, 688 KLOC). This slice
covers how UI quads actually land on screen each frame: the render pass, the
2D draw call, the font dat format, text rendering, color escapes, the cursor,
UI textures, batching, blend state, and the proposed C# port shape.
---
## 1. Module map — what lives where
| Decompiled chunk | Role |
|---|---|
| `chunk_00430000.c` (SceneTool) | Top-level frame orchestrator. Clears/Begin/EndScene, dispatches 3D scene + UI. Also the **pixel→NDC helper** (`FUN_0043dcd0`) and **cursor+bitmap management** (`FUN_00439320`, `FUN_0043c1c0`). |
| `chunk_00440000.c` (Font resource) | Font dat loader (`FUN_0044b870`), glyph-desc lookup (`FUN_004434c0`), glyph surface blit to the atlas (`FUN_00442d30`), advance-width helper (`FUN_00443550`). |
| `chunk_005A0000.c` (RenderDeviceD3D) | The D3D8 device wrapper. Render-state, world/view/proj push-pop (`FUN_005a4390`), sprite-batch flush trampoline (`FUN_005a26a0`, `FUN_005a26d0`), and the state-stack `FUN_005a4820/4860/4890`. |
| `chunk_005D0000.c` (UI object render) | `FUN_005da8f0` — the main UI panel render: walks the UI element tree and renders each panel. (5 Kb function; outside this pass's scope but it's the parent of everything below.) |
| `chunk_00680000.c` (Input / Mouse) | Input manager, mouse polling, `SetCursorPos`/`GetCursorPos`. The UI **input** side, which is why LoadCursor lives here too. |
| `chunk_00690000.c` (Font render / chat) | The **core font system**`FUN_00697140` (bake glyph atlas), `FUN_00697770` (build quads for a string), `FUN_006974d0` (flush draw), `FUN_00698330` (entry wrapper), `FUN_00692470` (chat line render). |
The **"RenderDeviceD3D"** string literal at address `0x005A2225` ("RenderDeviceD3D.AllowDrawPrimUP") confirms the device class is DirectX 8 using `DrawPrimitiveUP` (user-pointer) for the UI path.
---
## 2. Top-level UI render pass
### Entry point: `FUN_0043fcd0` (SceneTool::RenderFrame @ 0x0043FCD0)
Lines from `chunk_00430000.c:12978-13020`:
```c
// FUN_0043fcd0 — top-of-frame render
if ((char)DAT_00870340[0x2b] != '\0') { // device valid
(**(code **)(iVar3 + 0x40))(0,0,uVar4); // Device::Clear(color|depth)
if (unaff_BL != '\0' && DAT_00818c0c != '\0') {
FUN_004488a0(); // (scene begin)
FUN_00557840(); // render world
}
if (DAT_0083846c != 0) FUN_005da8f0(); // <-- UI PANEL TREE
if (DAT_00838468 != 0) FUN_00692470(); // <-- CHAT LINE / TEXT INPUT
FUN_0043f7f0(); // debug text + hud overlays
(**(code **)(*DAT_00870340 + 0x40))(...); // Clear again (window)
(**(code **)(*DAT_00870340 + 0x24))(); // BeginScene
(**(code **)(*DAT_00870340 + 0x28))(); // EndScene + Present
FUN_0043e6b0(); // frame-time stats
}
```
The interpretation of the vtable offsets `0x24`, `0x28`, `0x40` against our
own RenderDevice wrapper (not IDirect3DDevice8 directly):
| vtable slot | role |
|---|---|
| `*DAT_00870340` vtable | acclient's `RenderDeviceD3D` abstraction, **not** the raw `IDirect3DDevice8` |
| `+0x14` | `CreateSurface` (called from font atlas create) |
| `+0x20` | `DrawPrimitiveUP` trampoline |
| `+0x24` | `BeginScene`-equivalent |
| `+0x28` | `EndScene` + `Present`-equivalent |
| `+0x2c` | Execute a pre-transformed triangle list (used by `FUN_0043dc70`) |
| `+0x40` | `Clear` |
So **`DAT_00870340`** is the singleton `g_RenderDeviceD3D*`. The inner
`IDirect3DDevice8*` is at `DAT_00870340 + 0x468` (seen in `FUN_005a1520`:
`(**(code **)(**(int **)(DAT_00870340 + 0x468) + 0x14c))`). Offset 0x14c on
an IDirect3DDevice8 corresponds to `SetTransform` (slot 83). This confirms
DX8.
### UI tree root: `FUN_005da8f0` (chunk_005D0000, size 7835)
Called when `DAT_0083846c != 0` — i.e. when the UI hierarchy root pointer is
set. It's a 7,835-byte function (too large to port in full here), but the
pattern is: walk a linked list of UI elements, for each one call its virtual
render method (probably vtable slot `+0x10`, given earlier analysis of
`(**(code **)(*piVar4 + 0x10))()` elsewhere). Most widgets end up calling
`FUN_0043ec30`/`FUN_0043ec90` (untextured line/rect) or `FUN_00698330`
(textured text/sprite).
### Per-frame debug/stats overlay: `FUN_0043f7f0` (chunk_00430000:12767)
This is the frame-rate/camera-pos heads-up display. It spins a loop of 9
samples (lines 1293212958) and for each bucketized FPS value chooses a
color `0xff553320` (tan) or `0xff000000` (black) via arithmetic on the
sample rank:
```c
FUN_005a13a0((int)fVar9 + 0x22, fVar10, &DAT_008388d0, uVar7); // stat #
FUN_005a13a0((int)fVar9 + 0x78, fVar10, &DAT_008388d0,
(-(uint)(uVar8 < 8) & 0xff553320) - 0x553320); // label
```
The last call is the actual **text draw** entry point — `FUN_005a13a0`.
---
## 3. The 2D sprite / text draw call
### The vertex format
From `FUN_00697770`, the quad builder, we can reverse the per-vertex layout
by reading the writes at offsets within each `0x18`-byte (24-byte) record:
| Offset | Type | Meaning |
|---|---|---|
| +0x00 | float | NDC X |
| +0x04 | float | NDC Y |
| +0x08 | float | Z (always 0.0f for UI — pre-transformed) |
| +0x0C | float | RHW / param_6 (the caller passes 1.0f from `FUN_00698330`) |
| +0x10 | float | U |
| +0x14 | float | V |
This is **not** the `D3DFVF_XYZRHW | D3DFVF_TEX1` FVF layout you might
expect (pos3+rhw+uv = 20 bytes). Instead the retail client uses
**XYZ + RHW + UV1 with Z fixed at 0**, which serializes to **24 bytes**
(floats aligned, no packed color). The lack of a per-vertex color means
**color comes in via D3DRS_TEXTUREFACTOR / diffuse-material state**, not
from the vertex stream — this is why text color is passed as a separate
`param_4` to `FUN_00698330`.
The global sprite batch buffer:
```c
DAT_008f9a90 // VB start (or user-memory pointer)
DAT_008f9a94 // capacity in vertices (with high bit = grown flag)
DAT_008f9a98 // current write offset in vertices; 6 verts per quad
```
### The flush: `FUN_006974d0` (render pending text+sprites)
```c
// FUN_006974d0 — flush the accumulated UI vertex buffer
void FUN_006974d0(int param_1) {
if (glyph_atlas_not_ready && !FUN_00697140()) return; // bake once
if (DAT_008f9a98 != 0) {
// alpha-enable sampler state
if (param_1->has_bg) {
uiDevice->textureSamplerState[0].magFilter = 2; // LINEAR
uiDevice->textureSamplerState[0].minFilter = 2;
} else {
uiDevice->textureSamplerState[0].magFilter = 1; // POINT
uiDevice->textureSamplerState[0].minFilter = 1;
}
FUN_0043e640(); // push matrices (save world/view/proj)
FUN_0043f5d0(); // set identity world + ortho view
FUN_005a26d0( // <--- THE DRAW CALL
D3DPT_TRIANGLELIST, // param_1 = 4
DAT_008f9a98 / 3, // primCount = verts / 3
DAT_008f9a90, // pVertices (user memory)
0x142, // FVF (XYZRHW | TEX1 = 0x104 | 0x100 | 0x2 ?)
param_1->textureHandle,
param_1->textureHandle,
&DAT_00835788); // stride = 24
DAT_008f9a98 = 0; // reset buffer
FUN_0043f700(); // pop matrices
}
param_1->dirty = 0;
}
```
The FVF value `0x142` decomposes as:
- `D3DFVF_XYZRHW` = 4 (bits 0,1)
- `D3DFVF_TEX1` = 0x100
But `0x142` = 0x100 | 0x42. The `0x42` is `D3DFVF_DIFFUSE (0x40) | D3DFVF_XYZ (0x002)`? No — 0x142 = `D3DFVF_TEX1 (0x100) | D3DFVF_DIFFUSE (0x40) | D3DFVF_XYZRHW (0x4)`? That gives 0x144. So **0x142** is non-standard, implying the renderer translates its own FVF enum to D3D's inside `FUN_005a26d0` — a common abstraction. The 6-float-per-vertex layout shows no diffuse slot, so the likely mapping is "our 0x142 = `{POS3+RHW, UV1}`" — i.e. the RenderDevice uses its own FVF descriptor IDs.
### The 6-vertex quad layout (from `FUN_00697770`)
Each glyph produces **6 vertices** arranged as two triangles:
```
(x0,y0) ─ (x1,y0) TL ─ TR
│ │ = │ │
(x0,y1) ─ (x1,y1) BL ─ BR
Triangle list order (retail):
v0=TL, v1=TR, v2=BL (from iVar15+0x00 .. +0x48)
v3=TR, v4=BR, v5=BL (from iVar15+0x48 .. +0x90)
but per the code at lines 62066259, the write order is:
v0=(xL,yT) ← pfVar19[0]
v1=(xL,yB) ← pfVar19+0x18
v2=(xR,yB) ← pfVar19+0x30
v3=(xR,yB) ← pfVar19+0x48 (duplicate)
v4=(xR,yT) ← pfVar19+0x60
v5=(xL,yT) ← pfVar19+0x78 (duplicate)
```
So the order is `TL, BL, BR, BR, TR, TL` (CCW winding), then the same
pattern for the next glyph. That's the classic unindexed two-triangle
quad.
Inner loop processes **4 glyphs per iteration** (line 6481's `< pcVar11`
check uses offsets `local_18[-1], local_18[0], local_18[1], local_18[2]`)
— a hand-unrolled text layout loop that emits 4 quads = 24 verts = 576 bytes
per iteration, improving i-cache behavior. This is retail AC optimization.
### The NDC conversion (`FUN_0043dcd0`)
```c
// pixel (px, py) → NDC (nx, ny), with D3D9-style half-pixel offset
void FUN_0043dcd0(int px, int py, float* nx, float* ny) {
float recipW = 1.0f / screenWidth; // DAT_00870340 + 0x94
float recipH = 1.0f / screenHeight; // DAT_00870340 + 0x98
*nx = 2.0f * (float)px * recipW - 1.0f - recipW;
*ny = -(2.0f * (float)py * recipH - 1.0f) - recipH; // Y flipped
}
```
The `- recipW` and `- recipH` terms are the classic DX9 half-pixel offset
(D3D9's texture-sampling rule for 0.5-pixel centered texel addressing).
This matches what our `TextRenderer.cs` should NOT apply — OpenGL uses
0.0-centered texel addressing — but it proves this is a D3D port.
---
## 4. Fonts in the dat files
### DBObjType and ID range
From `references/DatReaderWriter/DatReaderWriter/Generated/DBObjs/Font.generated.cs`:
```csharp
[DBObjType(typeof(Font), DatFileType.Portal, DBObjType.Font, DBObjHeaderFlags.HasId,
0x40000000, 0x40000FFF, 0x00000000)]
public partial class Font : DBObj {
public uint MaxCharHeight; // row height
public uint MaxCharWidth; // widest glyph
public List<FontCharDesc> CharDescs;
public uint NumHorizontalBorderPixels;
public uint NumVerticalBorderPixels;
public uint BaselineOffset;
public uint ForegroundSurfaceDataId; // ID of surface 0x06xxxxxx with glyph sheet
public uint BackgroundSurfaceDataId; // (often 0 — only "highlighted" fonts have it)
}
public class FontCharDesc {
public ushort Unicode;
public ushort OffsetX, OffsetY; // position of glyph in source surface
public byte Width, Height; // glyph dims in source
public sbyte HorizontalOffsetBefore, HorizontalOffsetAfter; // kerning
public sbyte VerticalOffsetBefore;
}
```
Font objects live at `0x40000000 .. 0x40000FFF` — a 4,096-entry range.
Retail ships on the order of a dozen fonts: title, chat, small panel,
hover text, etc.
### `FUN_00697140` — font load / bake
Pseudocode from `chunk_00690000.c:5747-5910`:
```
bool FontRenderer::LoadFont(int fontId) {
Font* font = ResourceCache_Load(fontId); // FUN_0044b870
if (!font) return false;
// Clamp unicode range to printable ASCII (0x20..0x7E)
this->minChar = max(font->firstUnicode, 0x20);
this->maxChar = min(font->lastUnicode, 0x7E);
int charCount = this->maxChar - this->minChar + 1;
// Allocate per-glyph UV/metrics table (24 bytes per glyph)
free(this->charTable);
this->charTable = malloc(charCount * 0x18);
// Ask RenderDevice for a 256×256 texture, format 0x15 (~ A8L8 or LA16)
Texture* atlas = device->CreateTexture(0x100, 0x100, 1, 0x15, 2);
// Lock + clear atlas
Surface* surface = atlas->GetSurface(0, 0);
FUN_00443040(surface); // clear to transparent
this->fontHeight = font->MaxCharHeight; // (+0x30 in src)
// Determine MAX glyph width across the set
int maxW = 0;
for (uint16 u = minChar; u <= maxChar; u++) {
int w = ComputeGlyphAdvance(u); // FUN_00443550 — w + before + after
if (w > maxW) maxW = w;
}
this->maxWidth = maxW;
// Blit each glyph into the atlas, tracking cursor position
int atlasX = 0, atlasY = 0;
for (uint16 u = minChar; u <= maxChar; u++) {
auto* desc = GetGlyphDesc(u); // FUN_004434c0
if (atlasX + desc->Width > 0x100) { // wrap to next row
atlasY += this->fontHeight + 1;
if (atlasY > 0x100) break; // out of atlas
atlasX = 0;
}
BlitGlyph(atlasX, atlasY, u, 0xffffffff, 0x100, 0xff000000); // FUN_00442d30
// Store UVs + metrics at charTable[u - minChar]
float* entry = charTable + (u - minChar) * 0x18;
entry[0] = atlasX / 256.0f; // u0
entry[1] = atlasY / 256.0f; // v0
entry[2] = (atlasX + desc->Width - 1) / 256.0f + (1/256); // u1 (with texel offset)
entry[3] = (atlasY + this->fontHeight - 1) / 256.0f + (1/256); // v1
entry[4] = (byte)desc->Width; // [0x10]
entry[4 + 0x1] = this->fontHeight; // [0x11]
entry[4 + 0x2] = desc->HorizontalOffsetBefore; // [0x12]
entry[4 + 0x3] = desc->HorizontalOffsetAfter; // [0x13]
entry[5] = desc->VerticalOffsetBefore; // [0x14]
atlasX += desc->Width + 1;
}
// atlas is then bound when drawing
return true;
}
```
Note the atlas is an **in-memory dynamically baked texture**, not a
pre-baked image in the dat. The dat holds the raw glyph bitmap surface
(referenced by `ForegroundSurfaceDataId`, a `0x06xxxxxx` RenderSurface);
the client copies each glyph out at load time. This is very similar
to how **acdream's BitmapFont.cs** works today with stb_truetype, so
our `BitmapFont` and retail's `FontRenderer` are structurally compatible.
### The runtime glyph table — layout we observed
Looking at the write pattern in `FUN_00697770` and `FUN_00697140`:
```
struct GlyphEntry { // 24 bytes total
float u0, v0; // +0x00, +0x04 upper-left UV
float u1, v1; // +0x08, +0x0C lower-right UV
byte Width; // +0x10
byte OffsetBefore; // +0x12 — signed kern-before
byte OffsetAfter; // +0x13 — signed kern-after
byte RowHeight; // +0x11
byte VerticalOffset; // +0x14
byte _pad[3];
};
```
This is the in-memory runtime cache, not the on-disk format. The on-disk
`FontCharDesc` is 9 bytes (per DatReaderWriter). The client translates
disk-to-runtime during `FUN_00697140`.
---
## 5. Text rendering call chain
Given the user's example — "FUN_0040b8f0(L\"Strength\")" at `chunk_00470000.c:8330`
— the chain is:
```
FUN_0040b8f0(L"Strength") // append wide-string to buffer
→ FUN_00402490(ref_buf, L"Strength", len) // wcsncpy-style (StringBuilder)
... later, in the UI's render pass ...
FUN_005a13a0(pxX, pxY, pBuffer, colorARGB) // "DrawString" entry
→ FUN_00698330(pxX, pxY, pBuffer, color, 1) // passes scale=1.0
→ FUN_00697770(pxX, pxY, 1.0f, pBuffer, color, flags) // build quads
→ FUN_00697650() // grow vertex buffer if needed
→ (append 6 verts × N glyphs into DAT_008f9a90 at DAT_008f9a98)
```
Then at the end of the frame the UI tree's flush (`FUN_006974d0`) is
invoked — usually directly from `FUN_005da8f0` (UI walker) before the final
EndScene. Text is therefore **collected per frame**, then **flushed in one
or two DrawPrimitiveUP calls** per font texture.
### `FUN_00697770` in words — the text-to-quads builder
```
void BuildTextQuads(FontState* fs,
float baseX, float baseY, // pixel-space
float scale,
const char* text,
float z, // usually 0
uint flags) // alignment/monospace bits
{
int n = strlen(text);
if (n == 0) return;
// Horizontal align
if (flags & 0x8) baseX -= MeasureStringWidth(text,flags)*scale; // right-align
else if (flags & 0x10) baseX -= MeasureStringWidth(text,flags)*scale*0.5f; // center
// Vertical align (vs fs->fontHeight at +0x18)
if (flags & 0x40) baseY -= fs->fontHeight*scale;
else if (flags & 0x80) baseY -= fs->fontHeight*scale*0.5f;
float recipW = 1.0f / screenWidth;
float recipH = 1.0f / screenHeight;
// Grow buffer if needed
if (DAT_008f9a98 + n*6 > DAT_008f9a94) {
int newCap = GrowCapacity(DAT_008f9a98 + n*6);
if (!ReallocateBuffer(newCap)) return; // OOM abandons this string
}
int cursorX = 0;
for (int i = 0; i < n; i++) {
GlyphEntry* e = &fs->charTable[text[i] - fs->minChar];
int kernBefore = e->OffsetBefore;
int advance = e->Width + e->OffsetBefore + e->OffsetAfter;
if (flags & 1) advance = fs->maxWidth; // monospace
float xL = (kernBefore + cursorX) * scale + baseX;
float xR = xL + (e->Width - 1) * scale;
float yT = e->VerticalOffset * scale + baseY;
float yB = yT + (e->RowHeight - 1) * scale;
// Convert px to NDC inline
float ndcL = (2 * xL * recipW) - 1 - recipW;
float ndcR = (2 * xR * recipW) - 1 + recipW;
float ndcT = -((2 * yT * recipH) - 1 - recipH);
float ndcB = -((2 * yB * recipH) - 1 + recipH);
// Emit 6 verts in TL-BL-BR-BR-TR-TL order
Vertex* v = &DAT_008f9a90[DAT_008f9a98];
v[0] = {ndcL, ndcT, 0, z, e->u0, e->v0};
v[1] = {ndcL, ndcB, 0, z, e->u0, e->v1};
v[2] = {ndcR, ndcB, 0, z, e->u1, e->v1};
v[3] = {ndcR, ndcB, 0, z, e->u1, e->v1};
v[4] = {ndcR, ndcT, 0, z, e->u1, e->v0};
v[5] = {ndcL, ndcT, 0, z, e->u0, e->v0};
DAT_008f9a98 += 6;
cursorX += advance;
}
fs->dirty = 1; // +0x2d bit — "flush pending"
}
```
Notice that retail **always emits the full atlas bounds** per glyph (the
whole row height, not a tight box). That means a lot of transparent pixels
per quad — but exactly one state-change-free batch per font per frame.
### `FUN_0043ec30` / `FUN_0043ec90` — rects + lines
For untextured rectangles and line-strips the code path is
`FUN_0043eaf0` (filled line, 0x43EAF0) called from:
- `FUN_0043ec30`: untextured filled triangle (2 verts + 1 width) — panel borders
- `FUN_0043ec90`: line-strip through N points
These produce the same 24-byte vertex format but without UV — they rely on
`D3DRS_TEXTUREFACTOR` and `D3DTOP_SELECTARG1` to emit a flat color. The
`param_3` is the diffuse color, and `param_7,8,9` are blend-op, src-blend,
dst-blend `D3DRS_*` values passed through.
---
## 6. Font color codes
**Retail AC does NOT embed color escapes in string data.** Color is
per-draw-call, set via the `color` argument to `FUN_00698330`. Chat color
selection happens **one level up** in the chat window's line-composition
code (`FUN_00692470` / `FUN_00692b40`), where the chat line metadata
(sender channel, message type) is used to pick a color from a table before
calling `FUN_00699a30` to blit that colored text.
Evidence:
- `FUN_00692470:1839``FUN_00699a30(0, iVar12, lineText, 0xffeaeaea)`
fixed gray for system lines.
- `FUN_00692470:1847``FUN_00699a30(0, 0, &DAT_00801708, 0xff999999)`
darker gray for prompt.
- `FUN_00692470:1918``FUN_00699a30(8, 0, pText, 0xffffffff)` — white for
the edit-line contents.
So colors are **ABGR / ARGB dwords passed as the last parameter**, with
0xAARRGGBB encoding (since later code ORs `(iVar2 << 8 | uVar3) << 8 …`
in the color composition for FPS overlay at `chunk_00430000.c:12940`).
The `IClientControl::AddChatText(color, channel, text)` message from the
server provides the color directly — retail server dictates chat colors.
Therefore our port **does not need** inline `\c[n]...` escape parsing.
Colors come through the protocol as RGBA dwords and get passed straight
to the text draw function.
---
## 7. Cursor
### Loading the system cursor
In `chunk_00430000.c:7862`:
```c
hCursor = LoadCursorA((HINSTANCE)0x0, (LPCSTR)0x7f00); // 0x7F00 = IDC_ARROW
SetCursor(hCursor);
```
This is `IDC_ARROW` (the standard Windows pointer, value `MAKEINTRESOURCE(32512) = 0x7F00`).
Retail uses the OS cursor as the fallback when no custom cursor is active.
### Custom dat-sourced cursor (FUN_0043c1c0 at chunk_00430000:10151)
For in-game cursors (spell-targeting reticle, item-pickup hand, etc.),
retail takes an RGBA bitmap from the dats and converts it to an
`HCURSOR` via the GDI bitblt path:
```c
HBITMAP FUN_00439c70(uint w, uint h, int* rgbaSrc) {
// Build a 32bpp top-down DIB from rgbaSrc
CreateDIBSection(...); // → local_3c = colorBmp
// Alpha-cutoff: any pixel with AA < 0x40 → transparent (0x00000000)
for each source texel {
if ((pix & 0xFF000000) < 0x40000000) *dst = 0;
else *dst = pix;
}
// Combine AND/XOR masks via BitBlt, creating a 2-DIB bitmap pair
PatBlt(hdc_color, 0,0, w,h, PATCOPY);
BitBlt(hdc_color, 0,0, w,h, hdc_mask, 0,0, SRCPAINT); // 0xCC0020
// Caller wraps as ICONINFO and CreateIconIndirect() → HCURSOR
return hbmp;
}
```
`FUN_0043c1c0` is the 1bpp→2bpp monochrome-mask variant for older OS
cursor support. The cursor bitmap data comes from a dat icon (**type
0x06xxxxxx RenderSurface**, same as all other UI sprites), decoded via
the standard texture decode path and handed to this cursor builder.
### Hiding the OS cursor, showing a custom one
```c
// FUN_00439620 — toggle cursor visibility
void SetCursorVisible(BOOL v) {
if (g_cursorVisible != v) {
ShowCursor(v); // standard Win32 counter
g_cursorVisible = v;
}
}
```
When a custom cursor is active, retail hides the OS cursor and renders its
own sprite **as part of the UI pass**, at the cursor position polled via
`GetCursorPos()` at the start of input processing (`chunk_00680000.c:9762`).
### Cursor follows mouse with a nudge
```c
// chunk_00680000.c:9761-9771 — the OS-cursor "nudge" adjust
if (ui->cursorDeltaX != 0 || ui->cursorDeltaY != 0) {
POINT p;
GetCursorPos(&p);
p.x += ui->cursorDeltaX; // nudge after clamp
p.y += ui->cursorDeltaY;
SetCursorPos(p.x, p.y);
ui->mouseX += ui->cursorDeltaX;
ui->mouseY += ui->cursorDeltaY;
ui->cursorDeltaX = 0;
ui->cursorDeltaY = 0;
}
```
Used to clamp the cursor to the window edge during drag/mouselook.
---
## 8. UI textures
UI textures live in the **portal.dat** at standard ID ranges:
| Range | DBObjType | Role |
|---|---|---|
| `0x06000000..0x06FFFFFF` | RenderSurface | **All** button images, panel backgrounds, icons, cursor bitmaps, font glyph sheets |
| `0x08000000..0x08FFFFFF` | SurfaceTexture | Wraps a RenderSurface with palette/tiling info |
| `0x0F000000..0x0FFFFFFF` | SurfaceMaterial | Materials referencing SurfaceTextures |
| `0x40000000..0x40000FFF` | Font | Font metadata — references a RenderSurface for the glyph sheet |
The loading path for any UI texture is:
```
FUN_0044b870(0x06XXXXXX) → cache lookup
→ FUN_00415430 → portal.dat record read
→ decompress + decode → RenderSurface (pixel buffer)
→ RenderDevice.CreateTexture → D3D texture
```
Ports should already be handled by our existing `PhysicsDataCache.cs`-style
resource wrapper (same general pattern).
### Icon IDs
Game-item icons (spell icons, inventory icons) are `0x06XXXXXX` textures
referenced from client-side wcid tables and from server Appraise messages.
They're not enumerated in the dats; the server sends the icon ID along with
the item data. Standard spell icons are in a contiguous block
`0x06001000..0x06002FFF` (and similar for skills).
---
## 9. Draw-call batching
The AC client does **per-font batching with immediate-mode flush**. The
key data is `DAT_008f9a90..8f9a98`:
```
DAT_008f9a90 = base pointer to the current font's vertex buffer
DAT_008f9a94 = capacity (with high bit = "buffer was reallocated")
DAT_008f9a98 = current offset in vertices
```
This is a **per-font-state** pair (each `FontRenderer` has its own pair
pointed to by `DAT_008f9a90/94/98`? No — it's global, which means **retail
swaps the active font by flushing the previous one first**). Evidence:
- `FUN_00697140` (font load/bake) **reallocates** the global vertex buffer:
`*(undefined4 *)(param_1 + 0x24) = uVar10;` (line 5801, sets `charTable`).
- `FUN_006974d0` (flush) **resets** `DAT_008f9a98 = 0` after drawing.
- `FUN_005a13a0` always uses the same buffer — no font-ID argument.
So the batching model is:
1. Each text-draw call appends to the shared UI VB.
2. When the *active font* changes (different atlas), the previous batch
is flushed first.
3. At end of UI render pass, one final flush.
This means typical per-frame UI is **26 DrawPrimitiveUP calls**: one per
font (2-3 in practice) plus one each for untextured rects and any special
icon atlases.
The retained-state UI tree (`FUN_005da8f0`) traverses top-down, depth-first,
so z-order is drawn in that order and the depth buffer is disabled — later
draws naturally sit on top.
---
## 10. Alpha blending + clip state
### Blend state for UI
From `FUN_0043eaf0`-style entry points, the blend factors come in as
`param_7,8,9` with the typical values `2, 5, 6`:
```c
// param_7=2 (D3DBLENDOP_ADD)
// param_8=5 (D3DBLEND_SRCALPHA)
// param_9=6 (D3DBLEND_INVSRCALPHA)
```
Standard "over" alpha-blend: `final = src*srcA + dst*(1-srcA)`. Identical
to what our `TextRenderer.cs` sets up:
```csharp
_gl.Enable(EnableCap.Blend);
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
```
### Depth state
UI is drawn with `D3DRS_ZENABLE = D3DZB_FALSE` and `D3DRS_ZWRITEENABLE =
FALSE`. Our port already matches this (`_gl.Disable(EnableCap.DepthTest)`
in `TextRenderer.Flush`).
### Scissor / clip
Retail DOES use clip rects for panel interiors — chat window, inventory
grid, etc. — via `IDirect3DDevice8::SetScissorRect` (not strictly DX8
standard, done via `SetViewport` restriction for DX8 where scissor isn't
in the base interface).
In `FUN_00692470`, the chat clip is set implicitly via the render-ordering:
background rects drawn, then text drawn as glyphs whose NDC-space coords
are **pre-clamped** by the text-render caller to fit the line box. Retail
doesn't seem to use D3D hardware scissor heavily for UI — it pre-clips
at the vertex-generation stage instead. This is why `FUN_00697770`'s
alignment math (lines 61196160) is so elaborate.
---
## 11. Pseudocode summary
### Top-level UI render per frame
```
function RenderFrame(SceneTool):
if not device.valid: return
device.Clear(color | depth, camera.clearColor)
if world.visible:
Scene.BeginFrame()
Scene.RenderWorld() # 3D scene
# UI pass — no depth, alpha-over
if ui.rootPanel: ui.rootPanel.Render() # FUN_005da8f0
if chat.visible: chat.Render() # FUN_00692470
debug_overlay.Render() # FUN_0043f7f0
device.Clear(color, window-rect, 0, 0) # back-buffer present edge-safety
device.BeginScene()
device.EndScene()
device.Present()
stats.Tick()
```
### Single sprite draw
```
function DrawSprite(x, y, tex, color, u0,v0,u1,v1):
# Ensure the batch's active texture is `tex`; flush if different.
if batch.tex != tex:
FlushBatch(batch)
batch.tex = tex
# Compute NDC corners with half-pixel offset
nxL = 2*x*recipW - 1 - recipW
nxR = 2*(x+w)*recipW - 1 + recipW
nyT = -(2*y*recipH - 1 - recipH)
nyB = -(2*(y+h)*recipH - 1 + recipH)
# Emit 6 verts (two tris) in TL-BL-BR-BR-TR-TL order
batch.verts.Append(nxL,nyT, 0,1, u0,v0)
batch.verts.Append(nxL,nyB, 0,1, u0,v1)
batch.verts.Append(nxR,nyB, 0,1, u1,v1)
batch.verts.Append(nxR,nyB, 0,1, u1,v1)
batch.verts.Append(nxR,nyT, 0,1, u1,v0)
batch.verts.Append(nxL,nyT, 0,1, u0,v0)
```
### Draw a string
```
function DrawString(font, x, y, text, color):
width = 0
# Build quads
for char c in text:
glyph = font.Table[c - font.Min]
if glyph is null: continue
DrawSprite(x + width + glyph.OffsetBefore,
y + glyph.VertOffset,
font.Atlas, color,
glyph.u0, glyph.v0, glyph.u1, glyph.v1)
width += glyph.Width + glyph.OffsetBefore + glyph.OffsetAfter
```
Color goes into a per-batch D3D state (diffuse or texture-factor), not
into the vertex stream.
### Font load
```
function LoadFont(id):
dat = Dat.Get(id) # 0x40000xxx
font = new FontRenderer()
font.minChar = max(dat.FirstUnicode, 0x20)
font.maxChar = min(dat.LastUnicode, 0x7E)
charCount = font.maxChar - font.minChar + 1
font.Atlas = RenderDevice.CreateTexture(256, 256, A8L8)
# Blit source surface (dat.ForegroundSurfaceDataId) glyph-by-glyph
sx, sy = 0, 0
for u in [minChar..maxChar]:
desc = dat.GetCharDesc(u) # FontCharDesc
if sx + desc.Width > 256:
sx = 0
sy += dat.MaxCharHeight + 1
if sy > 256: break
BlitGlyph(font.Atlas, sx, sy, dat.SourceSurface, desc.OffsetX, desc.OffsetY,
desc.Width, dat.MaxCharHeight)
font.Table[u - minChar] = {
u0: sx/256, v0: sy/256,
u1: (sx+desc.Width)/256, v1: (sy+dat.MaxCharHeight)/256,
Width: desc.Width,
OffsetBefore: desc.HorizontalOffsetBefore,
OffsetAfter: desc.HorizontalOffsetAfter,
VertOffset: desc.VerticalOffsetBefore,
RowHeight: dat.MaxCharHeight,
}
sx += desc.Width + 1
return font
```
---
## 12. Proposed C# port structure
We already have `BitmapFont` and `TextRenderer`, but those use
stb_truetype on a system TTF — great for the debug HUD but **not
the retail path**. For the eventual full UI we need:
### Proposed class shape (AcDream.App.Rendering.UI namespace)
```csharp
// --- Dat-sourced font, one per Font object in portal.dat (0x40000xxx) ---
public sealed class AcFont : IDisposable
{
public uint DatId; // e.g. 0x4000001A
public int MinChar, MaxChar; // clamped to printable ASCII (0x20..0x7E)
public int RowHeight; // dat.MaxCharHeight
public int MaxWidth; // widest glyph's advance
public int BaselineOffset; // dat.BaselineOffset
public uint AtlasTextureId; // GL texture handle
public Glyph[] Glyphs; // indexed by [char - MinChar]
public struct Glyph {
public float U0, V0, U1, V1; // atlas UVs
public sbyte OffsetBefore, OffsetAfter, VertOffset; // kerning
public byte Width; // glyph width in source surface
}
public bool TryGetGlyph(char c, out Glyph g);
public float MeasureWidth(ReadOnlySpan<char> text);
}
// --- Loads all Font objects from the dats, bakes atlases on demand ---
public sealed class FontCache
{
private readonly Dictionary<uint, AcFont> _fonts = new();
public AcFont GetFont(uint datId); // lazy load + bake
public AcFont DefaultChat { get; } // preset: chat font
public AcFont DefaultPanel { get; } // preset: small panel font
}
// --- 2D batched UI renderer. Replaces current TextRenderer. ---
public sealed class UiSpriteBatch
{
public void Begin(Vector2 screenSize);
public void DrawRect(float x, float y, float w, float h, uint rgba);
public void DrawRectOutline(float x, float y, float w, float h, uint rgba);
public void DrawSprite(uint glTextureId, float x, float y, float w, float h,
float u0, float v0, float u1, float v1, uint rgba);
public void DrawString(AcFont font, float x, float y,
ReadOnlySpan<char> text, uint rgba);
public void SetClip(Rectangle clipPx); // scissor
public void EndClip();
public void Flush(); // one draw per (texture, blend, clip) state
}
// --- Per-frame UI orchestrator. Walks the widget tree and emits draws. ---
public sealed class UiRenderer
{
public UiSpriteBatch Batch { get; }
public AcFont ActiveFont { get; set; }
public void RenderFrame(UiRoot root); // depth-first walk
}
// --- OS / dat cursor management ---
public sealed class CursorManager : IDisposable
{
public void SetFromDat(uint surfaceId, int hotspotX, int hotspotY);
public void SetSystem(); // IDC_ARROW
public void Show(bool visible);
public Vector2 GetPosition();
public void SetPosition(Vector2 p);
}
```
### Mapping retail functions to C# methods
| Retail | Proposed C# |
|---|---|
| `FUN_00697140` (bake atlas) | `AcFont` constructor + `FontCache.GetFont` |
| `FUN_00697770` (build quads) | `UiSpriteBatch.DrawString` |
| `FUN_006974d0` (flush) | `UiSpriteBatch.Flush` |
| `FUN_00698330` (entry point) | `UiSpriteBatch.DrawString` (public) |
| `FUN_005a13a0` (alias) | (inline) |
| `FUN_0043dcd0` (px→NDC) | `UiSpriteBatch` private helper |
| `FUN_0043ec30/90` (rect/lines) | `UiSpriteBatch.DrawRect`/`DrawRectOutline` |
| `FUN_00439320` (cursor OS) | `CursorManager.SetSystem` |
| `FUN_0043c1c0` (HCURSOR build) | Not needed — we render cursor as a UI sprite |
| `FUN_005da8f0` (UI walker) | `UiRenderer.RenderFrame(UiRoot)` |
| `FUN_0043fcd0` (frame orchestrator) | `GameWindow.OnRender` |
### Key differences from our current code
1. Our **BitmapFont** rasterizes a TTF via stb_truetype — retail rasterizes
from a dat-loaded source surface. The debug HUD should keep the stb
path (so the HUD works before we wire portal.dat), but real UI must
use `AcFont` loaded from the `Font` DBObj.
2. Our **TextRenderer** uses `PrimitiveType.Triangles` with per-vertex
color in the shader — retail uses D3D8 `D3DRS_TEXTUREFACTOR`. Per-vertex
color in GL is actually **better** (fewer state-changes, can draw
different-colored strings in one batch). **Keep our approach.**
3. Our TextRenderer pre-splits into two buffers (rects vs glyphs) to avoid
a per-vertex texture flag — retail uses a single buffer per font with
one flush. Our approach is slightly chattier but produces cleaner
shaders; keep it.
4. Retail generates **6 verts per quad** (unindexed); so do we. Match.
5. Retail's 6-float vertex (pos3+rhw+uv) maps to our 8-float vertex
(pos2+uv+color). We can safely add diffuse-color in the shader rather
than via texture-factor — keep our format.
6. **Scissor rect / clipping** is not in our current TextRenderer. We
need to add `SetClip(Rectangle)` / `EndClip()` to UiSpriteBatch using
`GL_SCISSOR_TEST` for panels with overflow.
7. For **chat**, colors come from the server's ChatText protocol message.
We won't parse inline color escapes — retail doesn't either.
---
## 13. Concrete addresses referenced
- `0x0043DCD0``FUN_0043dcd0` — px→NDC conversion
- `0x0043E640``FUN_0043e640` — push world/view/proj matrices
- `0x0043EC30``FUN_0043ec30` — untextured filled rect/tri
- `0x0043EC90``FUN_0043ec90` — untextured line strip
- `0x0043F5D0``FUN_0043f5d0` — set identity + orthographic UI matrices
- `0x0043F700``FUN_0043f700` — restore matrices after UI pass
- `0x0043F7F0``FUN_0043f7f0` — debug/stats HUD
- `0x0043FCD0``FUN_0043fcd0` — top-of-frame render orchestrator
- `0x00442D30``FUN_00442d30` — blit a glyph onto the font atlas
- `0x004434C0``FUN_004434c0` — lookup FontCharDesc by unicode
- `0x00443550``FUN_00443550` — glyph advance (width+before+after)
- `0x0044B870``FUN_0044b870` — dat resource cache lookup (font/surface/etc.)
- `0x005A13A0``FUN_005a13a0` — "DrawStringAt" thin wrapper
- `0x005A26A0``FUN_005a26a0` — flush pending UI draws (chat trampoline)
- `0x005A26D0``FUN_005a26d0` — actual DrawPrimitiveUP via RenderDevice
- `0x005A4390``FUN_005a4390` — UI state enable/disable (blend, Z)
- `0x005DA8F0``FUN_005da8f0` — UI root tree walker (7835-byte function!)
- `0x006970B0``FUN_006970b0` — reset font VB write cursor
- `0x00697140``FUN_00697140` — load font + bake glyph atlas
- `0x00697770``FUN_00697770` — build text quads (the hot path)
- `0x006974D0``FUN_006974d0` — flush font batch
- `0x00698330``FUN_00698330``DrawString` entry point
- `0x00699A30``FUN_00699a30` — widget-relative text draw
- `0x00692470``FUN_00692470` — chat panel render + edit line
- `0x00439320``FUN_00439320` — restore OS cursor
- `0x0043C1C0``FUN_0043c1c0` — HCURSOR from RGBA bitmap (GDI path)
### Global data addresses
- `DAT_00870340``g_RenderDeviceD3D*` (singleton, ~0x500 bytes)
- `DAT_00870340 + 0x468` — inner `IDirect3DDevice8*`
- `DAT_008f9a90` — UI vertex buffer base pointer (per active font)
- `DAT_008f9a94` — UI vertex buffer capacity (with top bit "reallocated")
- `DAT_008f9a98` — UI vertex write offset
- `DAT_0083846c` — UI panel tree root pointer
- `DAT_00838468` — chat widget pointer
- `DAT_00838197` — "OS cursor is hidden" flag
- `DAT_00818b0c` — "ShowCursor counter" mirror
---
## 14. Porting risk assessment
Low risk:
- Font load. The `Font` DBObj is already generated by DatReaderWriter; we
just translate its `CharDescs` into our `AcFont.Glyph[]`, blit source
surface pixels into an atlas. ACViewer already has a working reference
(`FontCache.cs` in `references/ACViewer/Render/`) which we haven't yet
opened but should before implementing.
- Text layout. Straight port of `FUN_00697770` — the advance math is simple.
We already know the retail kerning-before/after semantics from the DBObj.
- Rect drawing. Trivial, already implemented.
Medium risk:
- Atlas sizing. Retail uses 256×256 with wrap-to-next-row packing, which
is lossy — not all characters fit for 12+ point fonts. We may need a
smarter packer (skyline/shelf) for dense fonts. Defer until we observe
an overflow.
- Scissor clipping for panel interiors. Need to add `glScissor` to
UiSpriteBatch. Straightforward.
- Font fallback. Retail clamps to 0x20..0x7E and substitutes '?' (0x3F)
for out-of-range. Unicode beyond printable ASCII (e.g. é, German, Korean
character names from server) must also fall back — check if that range
is included in any retail font or if AC players with non-ASCII names
simply display as '?' characters.
High risk:
- Chat line wrapping + color. `FUN_00692470` + `FUN_00692b40` are 1.7 KLOC
and 2 KLOC respectively — scoped for a separate research pass.
- Input focus / mouse-over hit testing. Out of scope for this pass.
---
## 15. Cross-reference to ACViewer and holtburger
This pass did **not** open `references/ACViewer/Render/` or holtburger UI
code. Follow-up items for the next pass:
- `references/ACViewer/Render/FontCache.cs` or similar — MonoGame port of
the same algorithm. Should match this analysis closely.
- ACME's `references/ACME/TextureHelpers.cs` and `ACME`'s static-obj
rendering — if they ported any text rendering, grep for "Font" there.
- holtburger is terminal-only, so it has no text renderer to reference —
irrelevant for this pass.
Before implementing Phase "UI.1" we should open ACViewer's font code
and cross-check our atlas size + filtering choices against theirs.
---
**Summary:**
Retail renders UI in three stages per frame: (1) a top-level orchestrator
(`FUN_0043fcd0`) calls the UI tree walker, which appends text/sprite
quads into a per-font global vertex buffer; (2) at the end of each panel's
pass, `FUN_006974d0` flushes the buffer via one `DrawPrimitiveUP` call
(`FUN_005a26d0`) with blend state `SRCALPHA/INVSRCALPHA` and depth test off;
(3) before the next panel's flush, the font texture or blend state can be
swapped. The vertex format is 24 bytes = (xyz + rhw + uv), pre-transformed
to NDC with a DX9-style half-pixel offset. Colors are passed per-draw via
`D3DRS_TEXTUREFACTOR`, not per-vertex, because the server provides colors
as ABGR dwords in ChatText and appraise messages rather than inline
escape codes. Fonts come from portal.dat `Font` objects (range
`0x40000000..0x40000FFF`), with a `FontCharDesc` per printable-ASCII
codepoint; the client bakes a 256×256 atlas at load time. The cursor is
usually `LoadCursorA(NULL, IDC_ARROW)`; custom cursors are built from
dat RGBA bitmaps via GDI. Our proposed port replaces our TTF-based
`BitmapFont`+`TextRenderer` with a retail-faithful
`AcFont`+`FontCache`+`UiSpriteBatch`+`UiRenderer` stack.