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.
40 KiB
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:
// 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 12932–12958) and for each bucketized FPS value chooses a
color 0xff553320 (tan) or 0xff000000 (black) via arithmetic on the
sample rank:
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:
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)
// 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 6206–6259, 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)
// 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:
[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 bordersFUN_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:
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:
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
// 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
// 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, setscharTable).FUN_006974d0(flush) resetsDAT_008f9a98 = 0after drawing.FUN_005a13a0always uses the same buffer — no font-ID argument.
So the batching model is:
- Each text-draw call appends to the shared UI VB.
- When the active font changes (different atlas), the previous batch is flushed first.
- At end of UI render pass, one final flush.
This means typical per-frame UI is 2–6 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:
// 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:
_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 6119–6160) 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)
// --- 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
-
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
AcFontloaded from theFontDBObj. -
Our TextRenderer uses
PrimitiveType.Triangleswith per-vertex color in the shader — retail uses D3D8D3DRS_TEXTUREFACTOR. Per-vertex color in GL is actually better (fewer state-changes, can draw different-colored strings in one batch). Keep our approach. -
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.
-
Retail generates 6 verts per quad (unindexed); so do we. Match.
-
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.
-
Scissor rect / clipping is not in our current TextRenderer. We need to add
SetClip(Rectangle)/EndClip()to UiSpriteBatch usingGL_SCISSOR_TESTfor panels with overflow. -
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 conversion0x0043E640—FUN_0043e640— push world/view/proj matrices0x0043EC30—FUN_0043ec30— untextured filled rect/tri0x0043EC90—FUN_0043ec90— untextured line strip0x0043F5D0—FUN_0043f5d0— set identity + orthographic UI matrices0x0043F700—FUN_0043f700— restore matrices after UI pass0x0043F7F0—FUN_0043f7f0— debug/stats HUD0x0043FCD0—FUN_0043fcd0— top-of-frame render orchestrator0x00442D30—FUN_00442d30— blit a glyph onto the font atlas0x004434C0—FUN_004434c0— lookup FontCharDesc by unicode0x00443550—FUN_00443550— glyph advance (width+before+after)0x0044B870—FUN_0044b870— dat resource cache lookup (font/surface/etc.)0x005A13A0—FUN_005a13a0— "DrawStringAt" thin wrapper0x005A26A0—FUN_005a26a0— flush pending UI draws (chat trampoline)0x005A26D0—FUN_005a26d0— actual DrawPrimitiveUP via RenderDevice0x005A4390—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 cursor0x00697140—FUN_00697140— load font + bake glyph atlas0x00697770—FUN_00697770— build text quads (the hot path)0x006974D0—FUN_006974d0— flush font batch0x00698330—FUN_00698330—DrawStringentry point0x00699A30—FUN_00699a30— widget-relative text draw0x00692470—FUN_00692470— chat panel render + edit line0x00439320—FUN_00439320— restore OS cursor0x0043C1C0—FUN_0043c1c0— HCURSOR from RGBA bitmap (GDI path)
Global data addresses
DAT_00870340—g_RenderDeviceD3D*(singleton, ~0x500 bytes)DAT_00870340 + 0x468— innerIDirect3DDevice8*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 offsetDAT_0083846c— UI panel tree root pointerDAT_00838468— chat widget pointerDAT_00838197— "OS cursor is hidden" flagDAT_00818b0c— "ShowCursor counter" mirror
14. Porting risk assessment
Low risk:
- Font load. The
FontDBObj is already generated by DatReaderWriter; we just translate itsCharDescsinto ourAcFont.Glyph[], blit source surface pixels into an atlas. ACViewer already has a working reference (FontCache.csinreferences/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
glScissorto 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_00692b40are 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.csor similar — MonoGame port of the same algorithm. Should match this analysis closely.- ACME's
references/ACME/TextureHelpers.csandACME'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.