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.
1013 lines
40 KiB
Markdown
1013 lines
40 KiB
Markdown
# 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.
|