# 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 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 text); } // --- Loads all Font objects from the dats, bakes atlases on demand --- public sealed class FontCache { private readonly Dictionary _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 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.