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

40 KiB
Raw Permalink Blame History

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 systemFUN_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 1293212958) 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 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)

// 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 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:1839FUN_00699a30(0, iVar12, lineText, 0xffeaeaea) — fixed gray for system lines.
  • FUN_00692470:1847FUN_00699a30(0, 0, &DAT_00801708, 0xff999999) — darker gray for prompt.
  • FUN_00692470:1918FUN_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, 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:

// 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 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)

// --- 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

  • 0x0043DCD0FUN_0043dcd0 — px→NDC conversion
  • 0x0043E640FUN_0043e640 — push world/view/proj matrices
  • 0x0043EC30FUN_0043ec30 — untextured filled rect/tri
  • 0x0043EC90FUN_0043ec90 — untextured line strip
  • 0x0043F5D0FUN_0043f5d0 — set identity + orthographic UI matrices
  • 0x0043F700FUN_0043f700 — restore matrices after UI pass
  • 0x0043F7F0FUN_0043f7f0 — debug/stats HUD
  • 0x0043FCD0FUN_0043fcd0 — top-of-frame render orchestrator
  • 0x00442D30FUN_00442d30 — blit a glyph onto the font atlas
  • 0x004434C0FUN_004434c0 — lookup FontCharDesc by unicode
  • 0x00443550FUN_00443550 — glyph advance (width+before+after)
  • 0x0044B870FUN_0044b870 — dat resource cache lookup (font/surface/etc.)
  • 0x005A13A0FUN_005a13a0 — "DrawStringAt" thin wrapper
  • 0x005A26A0FUN_005a26a0 — flush pending UI draws (chat trampoline)
  • 0x005A26D0FUN_005a26d0 — actual DrawPrimitiveUP via RenderDevice
  • 0x005A4390FUN_005a4390 — UI state enable/disable (blend, Z)
  • 0x005DA8F0FUN_005da8f0 — UI root tree walker (7835-byte function!)
  • 0x006970B0FUN_006970b0 — reset font VB write cursor
  • 0x00697140FUN_00697140 — load font + bake glyph atlas
  • 0x00697770FUN_00697770 — build text quads (the hot path)
  • 0x006974D0FUN_006974d0 — flush font batch
  • 0x00698330FUN_00698330DrawString entry point
  • 0x00699A30FUN_00699a30 — widget-relative text draw
  • 0x00692470FUN_00692470 — chat panel render + edit line
  • 0x00439320FUN_00439320 — restore OS cursor
  • 0x0043C1C0FUN_0043c1c0 — HCURSOR from RGBA bitmap (GDI path)

Global data addresses

  • DAT_00870340g_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.