# 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 12932–12958) 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 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`)

```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 **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`:

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

```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.
