acdream/docs/research/retail-ui/02-class-hierarchy.md
Erik 7230c1590f docs+feat(ui): retail UI deep-dive research + C# port scaffold
Deep investigation of the retail AC client's GUI subsystem, driven by 6
parallel Opus research agents, plus the first cut of a retail-faithful
retained-mode widget toolkit that scaffolds Phase D.

Research (docs/research/retail-ui/):
- 00-master-synthesis.md        — cross-slice synthesis + port plan
- 01-architecture-and-init.md   — WinMain, CreateMainWindow, frame loop,
                                  Keystone bring-up (7 globals mapped)
- 02-class-hierarchy.md         — key finding: UI lives in keystone.dll,
                                  not acclient.exe; CUIManager + CUIListener
                                  MI pattern, CFont + CSurface + CString
- 03-rendering.md               — 24-byte XYZRHW+UV verts, per-font
                                  256x256 atlas baked from RenderSurface,
                                  TEXTUREFACTOR coloring, DrawPrimitiveUP
- 04-input-events.md            — Win32 WndProc → Device (DAT_00837ff4)
                                  → widget OnEvent(+0x128); full event-type
                                  table (0x01 click, 0x07 tooltip ~1000ms,
                                  0x15 drag-begin, 0x21 enter, 0x3E drop)
- 05-panels.md                  — chat, attributes, skills, spells, paperdoll
                                  (25-slot layout), inventory, fellowship,
                                  allegiance — with wire-message bindings
- 06-hud-and-assets.md          — vital orbs (scissor fill), radar
                                  (0x06001388/0x06004CC1, 1.18× shrink),
                                  compass strip, dat asset catalog

Key insight: keystone.dll owns the actual widget toolkit — we cannot
port a class hierarchy from the decompile because it's not there.
Instead we implement our own retained-mode toolkit with retail-faithful
behavior (event codes, focus/modal/capture, drag-drop state machine)
and will consume the same portal.dat fonts + sprites so the visual
identity is preserved.

C# scaffold (src/AcDream.App/UI/):
- UiEvent          — 24-byte event struct + retail event-type constants
                     (0x01 click, 0x15 drag-begin, 0x201 WM_LBUTTONDOWN,
                     etc.) matching retail decompile switches
- UiElement        — base widget: children, ZOrder, focus/capture flags,
                     virtual OnDraw/OnEvent/OnHitTest/OnTick; children-
                     first hit test + back-to-front composite
- UiPanel          — panel, label, button primitives
- UiRenderContext  — 2D draw context with translate stack
- UiRoot           — top-of-tree + Device responsibilities (mouse/
                     keyboard state, focus, modal, capture, drag-drop,
                     tooltip timer); WorldMouseFallThrough/
                     WorldKeyFallThrough preserves existing camera
                     controls when no widget consumes
- UiHost           — packages UiRoot + TextRenderer + input wiring
                     helpers for one-line integration into GameWindow
- README.md        — orientation for future agents

Roadmap (docs/plans/2026-04-11-roadmap.md):
- D.1 marked shipped (debug overlay from 2026-04-17)
- D.2 expanded to include the retail UI framework landed here
- D.3-D.7 added: AcFont, dat sprites, core panels, HUD, CursorManager
- D.8 remains sound

All existing 470 tests pass. 0 warnings, 0 errors.
2026-04-17 19:13:02 +02:00

1008 lines
42 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Retail AC UI — Class Hierarchy & Virtual Dispatch
**Scope:** map the retail `acclient.exe` UI class hierarchy and polymorphism
pattern from the decompiled 688K-line C corpus. Companion to
`01-overview.md`, `03-layout.md`, etc.
**Executive summary up front (because it inverts the usual assumption):**
> **The retail AC client does not have a custom C++ UI widget hierarchy
> baked into `acclient.exe`.** The entire interactive UI is delegated to an
> external COM/native library called **Keystone** (`keystone.dll`) plus two
> plugin DLLs (`plugins\ACHelpPlugin.dll`, `plugins\ACPluginManager.dll`)
> that are loaded at startup and talked to through a single interface
> pointer `DAT_00870c2c`. Panels like Attributes, Skills, the Paperdoll,
> the Spell window, Chat, Login screens, etc. are described to Keystone via
> name-based plugin messages and resource IDs; Keystone does layout,
> hit-testing, input dispatch, and draw. All of the wide-string label
> literals the preflight task flagged (`L"Attributes"`, `L"Strength"`,
> `L"Select a spell to cast"`, `L"Drag necklaces here to wear them"`, …)
> are **assembled into reference-counted `CString`-style text buffers** by
> the client and then handed to Keystone; they are not direct widget
> method calls.
>
> The client-side classes that do exist in `acclient.exe` are
> **infrastructure**: a `CString` refcount helper (`FUN_00402490`,
> `FUN_00407e40`, `FUN_0040b8f0`), a full `CFont` (`chunk_00440000.c`, with
> glyph array + codepoint-range map), a 32-bpp `CSurface`/bitmap
> (`PTR_FUN_0079c26c` vtable, 0x28-byte header), a `CKeystoneGlue` that
> wraps Keystone's COM pointer, and an event/listener base
> (`PTR_FUN_00801670`, with add/remove via a UI-manager singleton
> `DAT_00838374`). Everything that looks like a "widget" in other AC
> reference repos (AC2D's `cPictureBox`, `cStaticText`, `cEditBox`,
> `cScrollBar`, `cMovableWindow`, `cSkillWindow`, …) lives **on the
> Keystone side**, not inside `acclient.exe`. Those reference-repo classes
> are AC2D's *reimplementation* of what Keystone already did — they are
> not what the retail client's compiled code is doing.
>
> Concretely, this means acdream cannot "port a C# copy of
> `CUIElement`" from the decompile — there is no such single class.
> acdream has to pick between two paths: **(a) reimplement an equivalent
> UI toolkit from scratch on top of our Silk.NET renderer** (the honest
> approach, matching AC2D's strategy), or **(b) treat the UI as a
> scripted-from-dat layout described in terms of Keystone concepts**
> (harder; requires reverse-engineering Keystone's data format and
> protocol). Section 11 proposes the C# hierarchy we should adopt for
> path (a).
The rest of this document is the evidence for that conclusion plus the
inventory of primitives that *do* live in `acclient.exe`.
---
## 1. Method and evidence trail
I started from the task's input files (`chunk_00470000.c`,
`chunk_004A0000.c`, `chunk_004C0000.c`, `chunk_00560000.c`,
`chunk_005C0000.c`, `chunk_00430000.c`) and the labeled literals
(`L"Attributes"`, `L"Select a spell to cast"`, etc.). I traced those
literals through their argument-0 function (`FUN_0040b8f0`), then that
function's callee (`FUN_00402490`), then the sibling text-setter
(`FUN_00407e40`), then the setter's call sites, then the shared state
those call sites read (`DAT_00870340`, `DAT_00870c2c`, `DAT_00838374`,
`DAT_0083e72c`, etc.), then the singletons' creator sites
(`FUN_00557930`, `FUN_0054d110`, `FUN_0043c640`), then the frame loop
at `FUN_0043fcd0` that ties them together.
That chain is the map. Address references below are RVAs inside
`acclient.exe` as Ghidra labeled them; all `FUN_xxxxxxxx` / `DAT_xxxxxxxx`
/ `PTR_xxx_yyyyyyyy` symbols are Ghidra's auto-generated names and come
directly from `docs/research/decompiled/`.
---
## 2. What I thought would be the UI base class, and why it is not
The task's preflight preview identified `FUN_0040b8f0` as "called
everywhere with UI labels" (`L"Attributes"`, `L"Strength"`, `L"Mana"`,
etc.). That strongly suggested `CUIText::SetText` or `CUILabel::Create`
or similar.
Disassembly at `chunk_00400000.c:9331` shows the truth:
```c
// FUN_0040b8f0 at 0x0040B8F0 (size: 42 bytes)
void FUN_0040b8f0(wchar_t *param_1)
{
if ((param_1 != (wchar_t *)0x0) && (*param_1 != L'\0')) {
sVar1 = wcslen(param_1);
FUN_00402490(param_1, sVar1); // wcsncpy/refcount append into a
// CString target held in ECX
}
return;
}
```
and the sibling `FUN_00407e40` at `chunk_00400000.c:5759`:
```c
// FUN_00407e40 at 0x00407E40 (size: 198 bytes)
void __thiscall FUN_00407e40(uint *param_1, wchar_t *param_2)
{
/* CString::operator=(const wchar_t*) */
/* refcount the old buffer, allocate/reuse, wcsncpy param_2 in */
}
```
Both are `CString` operations, not widget calls. The `this` pointer that
holds the target buffer is set via `ECX` (thiscall) before the call site
and Ghidra's decompiler drops it at the source-line level, which is why
the call sites look like `FUN_00407e40(L"Drag necklaces here to wear them")`
— in the actual emitted assembly, `ECX` has already been loaded with the
pointer to the tooltip/status buffer that the label gets copied into.
At `chunk_00430000.c:9041` I see the pattern most cleanly — pasting from
the clipboard:
```c
GlobalUnlock(hMem);
CloseClipboard();
FUN_00407e40(puVar5); // assign pasted wide-string into the chat
// input buffer — the buffer is in ECX from the
// prior instruction
```
So the "UI labels via `FUN_0040b8f0`" preflight signal is real but
misleading: those labels are being baked into string objects that are
then fed to the UI layer — not written to widget state directly. The
widget layer itself is elsewhere.
---
## 3. Where the UI layer actually is: Keystone
Look at `chunk_00550000.c:7027` (`FUN_00557930`, the UI framework
bootstrap):
```c
undefined4 FUN_00557930(void) // InitKeystoneAndPlugins
{
if (DAT_00870c30 != (HMODULE)0x0) {
return 1; // already loaded
}
cVar2 = FUN_005577a0(); // feature-enabled check
if (cVar2 != '\0') {
DAT_00870c30 = LoadLibraryA("keystone.dll");
DAT_00870c34 = GetProcAddress(DAT_00870c30, "KeystoneCreate");
DAT_00870c38 = LoadLibraryA("plugins\\ACHelpPlugin.dll");
DAT_00870c44 = GetProcAddress(DAT_00870c38, "ExecutePlugin");
DAT_00870c48 = GetProcAddress(DAT_00870c38, "TerminatePlugin");
DAT_00870c3c = LoadLibraryA("plugins\\ACPluginManager.dll");
_DAT_00870c4c = GetProcAddress(DAT_00870c3c, "ExecutePlugin");
DAT_00870c50 = GetProcAddress(DAT_00870c3c, "TerminatePlugin");
if (DAT_00870c34 != 0) {
return 1;
}
DAT_00870c54 = CreateAcceleratorTableA((LPACCEL)0x0, 0);
}
return 0;
}
```
And in `FUN_00557850`, the Keystone window instance is created:
```c
undefined4 FUN_00557850(void) // CreateKeystoneMainWindow
{
/* ... */
if ((DAT_00870340 != 0) && (DAT_00870c34 != (code *)0x0)) {
/* get cwd, convert UTF-8 -> UTF-16, get the window handle + an HIMC */
DAT_00870c2c = (int *)(*DAT_00870c34)(
pHVar1, // HWND (game window)
*(undefined4 *)(DAT_00870340 + 0x468), // pointer from graphics dev
auStack_4018, // cwd (UTF-16)
0, 0, 0, 0);
/* ... */
if (DAT_00870c2c != (int *)0x0) {
(**(code **)(*DAT_00870c2c + 0x5c))(0x69, 2, &uStack_6004);
return 1;
}
}
return 0;
}
```
`DAT_00870c2c` is the **Keystone instance pointer** — a single vtable-backed
object the rest of the client talks to. All further UI behavior goes
through that pointer's vtable. Every UI method the task asked me to find
(hit-test, draw, tick, add child, handle mouse, handle key) is one
vtable slot on this object. The client does not implement those; it
invokes them.
### 3.1 Observed Keystone vtable slots
From cross-referencing the 10+ call sites I can see in chunk_00550000.c,
the Keystone instance vtable (offset 0x0 inside the object) looks like:
| Slot (byte off) | Callers / purpose |
|---|---|
| `+0x00` | vtable ptr itself (base-class vfptr) |
| `+0x08` | `Release()` / destructor — called at shutdown (`FUN_00557b50`, `FUN_00557e40`) |
| `+0x14` | `FindPlugin(const wchar_t *name)` — called with `L"acpluginmanager"` in `FUN_00557d80` |
| `+0x20` | `ProcessFrame()` / event-pump — called once per frame in `FUN_00557840`, which is itself called from the main render loop `FUN_0043fcd0` right after the 3D scene is drawn |
| `+0x24` | `CreateOrActivate(int,int,int,int)` — 4-arg plugin entry (`FUN_005579f0`) |
| `+0x28` | `ClearActive(0)``FUN_00557ac0` |
| `+0x2c` | `GetActive()` — returns current active panel |
| `+0x5c` | `SendCommand(0x69, 2, buffer)` — command-id dispatch |
| `+0x60` | `HitTest(POINT *)` — returns active UI object under the point (used by `FUN_005579f0` / input routing) |
| `+0x6c` | `TranslateAccelerator(wParam, haccel, lParam)` — keyboard shortcuts (`FUN_00557a90`) |
The vtable is not dense — there are gaps in what the client exercises.
Slots the client never calls (`+0x04`, `+0x0c`, `+0x10`, `+0x18`, `+0x30``+0x58`,
`+0x64`, `+0x68`) presumably exist but are only used Keystone-internally.
### 3.2 Plugin side-channel
`chunk_00550000.c:7212` (`FUN_00557c50`) shows the plugin bridge:
```c
(*DAT_00870c44)(DAT_00870c2c, FUN_00509430, aiStack_a0[0]);
/* ACHelpPlugin::ExecutePlugin(keystoneInstance, callback, stringResourceId) */
```
So panels that the plugin implements (help/tutorial, plugin-manager
dialog, etc.) are addressed by resource-ID / string-ID tuples rather
than by C++ class pointers. `FUN_00509430` is a client callback that
Keystone / the plugin can call back into. This is a clean plugin ABI,
not an inheritance hierarchy.
### 3.3 ACHelpPlugin specifically
Inside `ACHelpPlugin`, `FUN_00557e80` does a COM
`CoCreateInstance(CLSID_007cc680, NULL, CLSCTX_ALL, IID_007cc670, &p)`
followed by `QueryInterface(IID_007cc660)`, which is the classic
IE-embedded-browser sequence (the CLSIDs match the browser control
pattern). So the help/tutorial pane is literally a hosted Internet
Explorer control. That is fine for our archival purposes — we do not
want to replicate it — but it also means a meaningful chunk of the
retail UI is not even in `acclient.exe`, it is just OLE.
---
## 4. The one hierarchy that *does* live in acclient.exe: CKeystoneGlue + listener
The only C++ class hierarchy in the binary that matches "something
receives UI-ish events" is an internal listener/event-bus pattern tied
to the **UI manager singleton** `DAT_00838374`. It is not a widget
hierarchy, but it is the closest retail analog.
### 4.1 The UI manager singleton
`chunk_00430000.c:10405` (`FUN_0043c640`):
```c
undefined4 * __fastcall FUN_0043c640(undefined4 *param_1)
{
FUN_0043c6c0(); // base-class ctor
*param_1 = &PTR_FUN_00799fc4; // derived vtable
DAT_00838374 = param_1; // publish singleton
return param_1;
}
```
And `FUN_0043c680` is the accessor:
```c
undefined4 FUN_0043c680(void) { return DAT_00838374; }
```
`DAT_00838374` is accessed through a thunk in many chunks (e.g.
`thunk_FUN_0043c680` at `chunk_00470000.c:7709`) which makes the
cross-reference pattern obvious: any file that mentions `FUN_0043c680`
is consuming the UI/event manager.
### 4.2 The listener base class
The listener base has vtable `PTR_FUN_00801670`. Any "object that wants
to be notified of UI events" embeds it. See `FUN_0043c610`:
```c
void __fastcall FUN_0043c610(undefined4 *param_1)
{
int *piVar1;
*param_1 = &PTR_FUN_00801670; // take on the listener vtable
piVar1 = (int *)FUN_0043c680(); // DAT_00838374 (the UI manager)
if (piVar1 != (int *)0x0) {
(**(code **)(*piVar1 + 0xc))(param_1); // UIManager->AddListener(this)
}
if (DAT_00842adc != 0) {
FUN_00508980(param_1); // also register with world sim?
}
}
```
And the symmetric remove helper is `(**(code **)(*piVar2 + 0xc))(param_1)`
at many sites; vtable slot `+0x0c` on the UI manager is clearly the
add/remove listener entry point. The UI manager vtable also exposes slot
`+0x04` (from matching destroy patterns) for listener removal.
### 4.3 Multiple-inheritance is the polymorphism pattern
The client uses **C++ multiple inheritance with per-base-class vtables**
at distinct byte offsets inside a single object. This is the normal
Microsoft C++ MI layout. It is visible directly in the CharGen panel
constructor at `chunk_00470000.c:7828` (`FUN_0047aa10`):
```c
undefined4 * __fastcall FUN_0047aa10(undefined4 *param_1)
{
undefined4 *puVar1;
int *piVar2;
undefined4 *puStack_4;
puStack_4 = param_1;
FUN_004799c0(); // base init
param_1[0x4b] = &PTR_FUN_007ccb60;
puVar1 = param_1 + 0x4b;
*param_1 = &PTR_LAB_0079f870; // vtable #1 — main class
param_1[1] = &PTR_FUN_0079f810; // vtable #2 — second base
param_1[2] = &PTR_FUN_0079f7f8; // vtable #3 — third base
*puVar1 = &PTR_FUN_0079f550; // vtable #4 — listener base, at +0x12c
/* ... bunch of ID registrations through thunk_FUN_0043c680 ... */
piVar2 = (int *)FUN_0043c680();
if (piVar2 != (int *)0x0) {
(**(code **)(*piVar2 + 4))(0x186a1, puVar1);
(**(code **)(*piVar2 + 4))(100000, puVar1); /* 0x186a0 */
if (DAT_00837ff4 != (int *)0x0) {
(**(code **)(*DAT_00837ff4 + 0x34))(0xe, param_1 + 2, 4000);
}
}
return param_1;
}
```
Four vtables, four base classes, all compiled into the same object:
* `PTR_LAB_0079f870` — primary class vtable (the CharGen panel itself)
* `PTR_FUN_0079f810` — second base (likely the "screen" / state-machine base)
* `PTR_FUN_0079f7f8` — third base (likely a "listener" or "observer" interface)
* `PTR_FUN_0079f550` — fourth base, living at `+0x12c` bytes inside the
object (stored at `param_1 + 0x4b` which is `+0x12c` bytes) — this is
the UI-manager listener base
The registration call right after (`(**(code **)(*piVar2 + 4))(0x186a1, puVar1)`)
passes the *inner* vtable pointer `puVar1`, not the *outer* `param_1`.
That is the Microsoft MI trick: UI-manager code only knows how to cast
against the listener base; it gets handed a pointer that *is* the
listener subobject's `this`, with the correct adjustment already applied.
Equivalent slimmed-down constructor at `chunk_00470000.c:7954`
(`FUN_0047b030`) has two vtables, not four, but the same pattern:
```c
param_1[0x17e] = &PTR_FUN_007ccb60; // transient / placeholder
/* ... fields reset ... */
*param_1 = &PTR_FUN_007a0080; // main class
param_1[0x17e] = &PTR_FUN_0079fdd8; // listener base at +0x5f8
```
and the destructor pairs them in reverse, detaching from the UI manager
through the listener vtable before the main destructor runs
(`FUN_0047b160`):
```c
*param_1 = &PTR_FUN_007a0080;
*puVar1 = &PTR_FUN_0079fdd8;
piVar2 = (int *)FUN_0043c680();
if (piVar2 != (int *)0x0) {
(**(code **)(*piVar2 + 0xc))(puVar1); // UIManager->RemoveListener
}
*puVar1 = &PTR_FUN_007ccb60;
FUN_0043c610();
FUN_004726c0(); // base dtor
```
### 4.4 Vtables I can attribute with confidence
| Vtable symbol | Role | Evidence |
|---|---|---|
| `PTR_FUN_00799fc4` | UI manager class (singleton at `DAT_00838374`) vtable | published in `FUN_0043c640`; has AddListener at `+0xc`, RemoveListener at `+0xc` variants |
| `PTR_FUN_00801670` | Listener base (embedded as secondary base) | assigned in `FUN_0043c610` which then calls UIManager->AddListener |
| `PTR_FUN_0079f550` | CharGen-screen listener subobject vtable | inner vtable written alongside CharGen construction, registered under IDs 0x186a1 / 100000 |
| `PTR_LAB_0079f870` | CharGen main-class vtable | primary vtable of the big CharGen object (`FUN_0047aa10`) |
| `PTR_FUN_0079f810` | CharGen second base (likely state-machine) | second vtable of CharGen |
| `PTR_FUN_0079f7f8` | CharGen third base | third vtable of CharGen |
| `PTR_FUN_007a0080` | CharGen screen derived-class vtable | `FUN_0047b030` pairs it with listener `PTR_FUN_0079fdd8` |
| `PTR_FUN_0079fdd8` | CharGen screen listener subobject vtable | paired with `007a0080` |
| `PTR_FUN_007ccb60` | Transient "scratch" vtable used during ctor/dtor in place of the real listener vtable | appears before and after the real listener vtable is installed, probably the plain base's vtable used to avoid calling into uninitialised / torn-down overrides |
| `PTR_FUN_0079c26c` | `CSurface` / `CBitmap` vtable (0x28-byte header, byte BPP field, pixels ptr) | constructor at `FUN_0044cdf0` + `FUN_0044cc60`, resize at `FUN_0044ccc0` calls vtable slot `+0x04` with `(width, bpp, ?, ?, ?)` |
This is the piece of the hierarchy that *does* exist in
`acclient.exe`. It is not a widget hierarchy; it is an event/listener
infrastructure that character-generation screens and similar client-
side UI moments plug into.
---
## 5. The 40-byte `CSurface` object (`PTR_FUN_0079c26c`)
The one clearly delineated "graphics primitive" class I can identify in
the binary. Relevant ctors: `FUN_0044cc60`, `FUN_0044cdf0`,
`FUN_0054d2a0`.
```c
undefined4 * FUN_0044cc60(void)
{
undefined4 *puVar1;
if (DAT_0086734c != 0) {
/* WARNING: Could not recover jumptable at 0x0044cc75. Too many branches */
puVar1 = (undefined4 *)(**(code **)(*DAT_00870340 + 0x18))();
return puVar1; /* ask the graphics device for a pooled surface */
}
puVar1 = (undefined4 *)FUN_005df0f5(0x28); /* 40-byte alloc */
if (puVar1 != (undefined4 *)0x0) {
puVar1[1] = 0; /* +0x04 width */
puVar1[2] = 0; /* +0x08 height */
*(byte *)(puVar1 + 3) = 2; /* +0x0c bytes-per-pixel */
puVar1[4] = 0; /* +0x10 pixel buffer */
*(byte *)(puVar1 + 5) = 0; /* +0x14 byte flag */
*(byte *)((int)puVar1 + 0x15) = 0; /* +0x15 */
*(byte *)((int)puVar1 + 0x16) = 0; /* +0x16 dirty flag */
*(byte *)((int)puVar1 + 0x17) = 0; /* +0x17 */
puVar1[7] = 0; /* +0x1c */
puVar1[8] = 0; /* +0x20 */
*(byte *)(puVar1 + 9) = 0; /* +0x24 byte flag */
*puVar1 = &PTR_FUN_0079c26c; /* +0x00 vtable */
*(byte *)(puVar1 + 3) = 2; /* 2 = default BPP (16-bit??) */
*(byte *)(puVar1 + 6) = 1; /* +0x18 owns-buffer flag */
return puVar1;
}
return (undefined4 *)0x0;
}
```
The resize/create path at `FUN_0044ccc0`:
```c
undefined1 __thiscall FUN_0044ccc0(int *param_1, int param_2)
{
char cVar1;
/* call vtable slot +0x04 — Allocate(width, bpp, ?, ?, ?) */
cVar1 = (**(code **)(*param_1 + 4))(
*(undefined4 *)(param_2 + 8), /* source width */
*(byte *)(param_2 + 0xc), /* source bpp */
*(byte *)(param_2 + 0x14),
*(byte *)(param_2 + 0x15),
*(byte *)(param_2 + 0x18));
if (cVar1 == '\0') return 0;
/* memcpy source pixels into param_1[4] */
/* mark dirty, return true */
}
```
So `PTR_FUN_0079c26c` is `CSurface`, vtable slots:
| Slot | Method (guess) |
|---|---|
| `+0x00` | `~CSurface()` / scalar-deleting dtor |
| `+0x04` | `bool Allocate(int w, byte bpp, byte, byte, byte)` |
| `+0x08` | `void Release()` or `Destroy()` (no args) |
And the struct layout:
| Offset | Type | Field |
|---|---|---|
| `+0x00` | ptr | vtable (`PTR_FUN_0079c26c`) |
| `+0x04` | int | width |
| `+0x08` | int | height (or `dataSize`) |
| `+0x0c` | byte | bytesPerPixel (default 2) |
| `+0x10` | ptr | pixel buffer |
| `+0x14` | byte | flag (palette?) |
| `+0x15..17` | byte×3 | flags |
| `+0x18` | byte | owns-buffer |
| `+0x1c` | int | ? |
| `+0x20` | int | ? |
| `+0x24` | byte | flag |
This is plumbing for Keystone's drawing surface — the client owns the
pixels, Keystone owns the widget semantics.
---
## 6. The `CFont` class (`chunk_00440000.c`)
A clean glyph-lookup implementation. Struct layout derived from
`FUN_004434c0` (glyph lookup), `FUN_004435d0` (build range map),
`FUN_00443580` (has-glyph check), and `FUN_00443960` (clear/reset):
| Offset | Type | Field |
|---|---|---|
| `+0x00` | ptr | vtable |
| `+0x30..0x44` | int×6 | padding / char-cell metrics |
| `+0x38` | int | glyph count |
| `+0x3c` | ptr | glyph array (11 bytes per entry) |
| `+0x4c` | ptr | refcounted CString (font name) |
| `+0x50..0x5c` | int×? | metrics (advance, line height, ascent, descent) |
| `+0x60` | ptr | second refcounted CString (style?) |
| `+0x64` | ushort | first character (codepoint) |
| `+0x66` | ushort | last character |
| `+0x68` | int | range span count |
| `+0x6c` | ptr | ushort[] codepoint→glyph-index map |
Glyph entry (11 bytes):
| Offset | Type | Field |
|---|---|---|
| `+0x00` | ushort | unicode codepoint |
| `+0x02..0x05` | byte×4 | bitmap rect / atlas coords |
| `+0x06` | byte | advance A |
| `+0x07` | byte | advance B |
| `+0x08` | byte | advance C |
| `+0x09` | byte | leading whitespace |
| `+0x0a` | byte | trailing whitespace |
`FUN_00443580` is the canonical `CFont::HasGlyph(ushort ch)`:
```c
undefined1 __thiscall FUN_00443580(int self, ushort ch)
{
uint uVar1;
if (*(int *)(self + 0x3c) != 0) {
if (*(int *)(self + 0x6c) == 0) {
return 1; /* no range map => font covers all glyphs */
}
if (*(ushort *)(self + 0x64) <= ch && ch <= *(ushort *)(self + 0x66)) {
uVar1 = (uint)*(ushort *)(
*(int *)(self + 0x6c) + (ch - *(ushort *)(self + 0x64)) * 2);
if (uVar1 < *(uint *)(self + 0x38)) {
if (uVar1 * 0xb + *(int *)(self + 0x3c) != 0) return 1;
}
}
}
return 0;
}
```
This is textbook font-range-map lookup. The retail client draws glyph
bitmaps through this `CFont` and then Keystone composites them. acdream
already has font rendering in the renderer, so we port this class'
*struct layout* only if we want to consume the retail portal.dat font
records directly (which is in scope for a future phase).
---
## 7. `CString` refcount helper — the text primitive
Used everywhere. `chunk_00400000.c` has the family:
* `FUN_00402490``CString::operator+=(const wchar_t *, size_t)` — append
* `FUN_00407e40``CString::operator=(const wchar_t *)` — assign
* `FUN_0040b8f0` — small wrapper that computes wcslen and appends (this is
the function the preflight task was worried about)
* `FUN_004022d0` — ensure capacity
* `FUN_004027b0``sprintf`-style format into a CString
* `FUN_004300a0``sprintf` append
All of them manipulate a pimpl buffer reached through `*param_1`, with a
header right before the string:
| Offset (from `*param_1`) | Field |
|---|---|
| `-0x14` | vtable for refcount sub-object |
| `-0x10` | `LONG` reference count (manipulated through `InterlockedIncrement`/`InterlockedDecrement`) |
| `-0x0c` | capacity |
| `-0x08` | length / state |
| `-0x04` | length |
| `+0x00` | wide-char buffer (null terminated) |
Every wide-string literal the client uses — from `L"Drag necklaces here
to wear them"` to `L"Select a spell to cast"` to `L"Attributes"` — is
appended to one of these buffers, and the buffer is then pushed through
the Keystone bridge (via vtable slots on `DAT_00870c2c`) to be rendered.
acdream's C# port of this is trivial: we already have `System.String`,
and the refcount machinery is a garbage-collector concern. The only
thing we might need is a pooled `StringBuilder`-style reuse for
tooltip hot paths, which is a future optimization, not something to
mirror structurally.
---
## 8. What the input chunk (`chunk_00680000.c`) actually is
The other candidate base I looked at — the class at `FUN_006895d0` that
has `POINT` coords at offsets `+4,+8`, `PtInRect` calls, and flags at
`+0x358` — turns out to be the **DirectInput/Win32 mouse and keyboard
state manager**, not a widget. It:
* Owns `HWND` at `+0x10c`
* Tracks raw mouse position, last-click point, double-click timing
* Calls `GetCursorPos` / `ScreenToClient` / `ClientToScreen` directly
* Calls `FUN_00557a30` (= Keystone `GetActive()` via `DAT_00870c2c +0x2c`)
and `FUN_00557a60` (= Keystone `HitTest(POINT*)`) to ask Keystone
whether the mouse is currently over any Keystone widget before
deciding whether to route the mouse to the 3D view
So the input path is:
1. Windows sends `WM_MOUSEMOVE` (via Ghidra-undecompiled windowproc,
not in the task-selected chunks).
2. The input manager (`chunk_00680000.c`) updates cached mouse state.
3. Each frame, the client calls `FUN_00557a30` / `FUN_00557a60` to check
if Keystone owns the cursor.
4. If Keystone does, the 3D view ignores mouse input entirely; Keystone
has already dispatched it internally.
5. If Keystone does not, the 3D click/drag goes to the world-selection
path (physics raycasts into the scene).
The "widget hit test" lives inside Keystone, not inside `acclient.exe`.
There is no local `IsPointInside(point)` virtual method on the client
side.
---
## 9. The main UI frame dispatch (`FUN_0043fcd0` in `chunk_00430000.c`)
The only place the UI per-frame pump is visible from the decompile:
```c
void FUN_0043fcd0(void)
{
if ((char)DAT_00870340[0x2b] != '\0') {
int iVar1 = DAT_00870340[0x24]; /* back buffer width */
int iVar2 = DAT_00870340[0x23]; /* back buffer height */
int iVar3 = *DAT_00870340; /* graphics-device vtable */
uVar4 = FUN_0054fd30(0); /* get clear color */
uVar4 = FUN_0054fd20(uVar4);
(**(code **)(iVar3 + 0x40))(0, 0, uVar4); /* Device::Clear() */
if (unaff_BL != '\0' && DAT_00818c0c != '\0') {
FUN_004488a0(); /* draw 3D scene */
FUN_00557840(); /* Keystone->ProcessFrame()
= draw + tick UI */
}
if (DAT_0083846c != 0) FUN_005da8f0(); /* overlay #1 */
if (DAT_00838468 != 0) FUN_00692470(); /* overlay #2 */
FUN_0043f7f0(); /* final 2D overlays
(cursor, tooltip) */
(**(code **)(*DAT_00870340 + 0x40))(
iVar2, iVar1, unaff_EDI, uVar6, 0); /* Device::Present() */
(**(code **)(*DAT_00870340 + 0x24))(); /* post-present housekeeping */
(**(code **)(*DAT_00870340 + 0x28))();
FUN_0043e6b0();
}
}
```
This is unambiguous:
1. Clear.
2. Render 3D world.
3. Hand the frame to Keystone for UI draw.
4. Render extra native overlays.
5. Present.
The UI layer is a *single* subsystem the client calls into between 3D
and present. Everything inside Keystone — panels, widgets, text entry,
scrolling, layout — is opaque to `acclient.exe`.
---
## 10. Explicit answers to the task's checklist questions
1. **Base UI element class and its vtable.** There isn't one baked into
`acclient.exe`. The external `IKeystoneFramework` interface at
`*DAT_00870c2c` is the closest analog, with observed methods
`Release` (`+0x08`), `FindPlugin(name)` (`+0x14`), `ProcessFrame`
(`+0x20`), `CreateOrActivate(a,b,c,d)` (`+0x24`), `ClearActive`
(`+0x28`), `GetActive` (`+0x2c`), `SendCommand(id, op, buf)`
(`+0x5c`), `HitTest(POINT*)` (`+0x60`), `TranslateAccelerator`
(`+0x6c`). Keystone's *own* widget base class is inside
`keystone.dll`, which is not part of this decompile.
2. **Hierarchy by examining subclass extensions.** The only class
hierarchy fully resident in the client is UI-manager + listener:
`CUIManager` (vtable `PTR_FUN_00799fc4`, singleton
`DAT_00838374`) and the listener base `CUIListener` (vtable
`PTR_FUN_00801670`). Panels that want to be notified embed the
listener by multiple inheritance; I can count ~2530 such
embeddings in the chunks I surveyed (CharGen at 0x0047aa10 is the
canonical example; others are distributed through
`chunk_00470000.c`, `chunk_004A0000.c`, `chunk_004C0000.c`,
`chunk_004E0000.c`, and ~20 more). None of these are visual
widgets; they are game-state observers that use the UI-manager
singleton as a notification hub.
3. **Common struct layout / consistent offsets.** The client classes do
not share a common "x/y/w/h at consistent offset" pattern. The one
layout convention that *is* consistent is MI: the secondary vtable
of the listener base lives at a fixed offset inside the derived
object (e.g. `+0x12c` for CharGen, `+0x5f8` for the CharGen screen,
varying by class size), and that inner `this` is what gets passed
to `CUIManager::AddListener`. There is no common geometry field.
4. **Polymorphism pattern.** Classic Microsoft C++ **multiple
inheritance with per-base-class vtables at known offsets**. Primary
vtable at `+0x00`; secondary / tertiary bases at `+0x04`, `+0x08`,
or at deeper offsets like `+0x12c` and `+0x5f8` as their subobjects
are laid out. Method dispatch is `(**(code **)(*obj + slot))(obj, …)`
against whichever vtable the caller holds a pointer to. This is
not a tagged union or a discriminated enum — every polymorphic
operation in the client is a vtable call.
5. **Container / panel class with a child list.** None in
`acclient.exe`. Keystone owns the containment model.
6. **Button / clickable class.** None in `acclient.exe`. Buttons are
Keystone-side; the client only sends a command-id via
`SendCommand` or a plugin-dispatch call.
7. **Text / label class.** Not a class. Labels are `wchar_t *`
literals assigned to `CString` buffers through `FUN_00407e40` /
`FUN_0040b8f0`. The buffers are then passed as resource contents to
Keystone — the client has no `CLabel`.
8. **Edit-box / text-entry class.** The client has the `CString` back
end (so paste-into-chat, type-in-username, etc. work), and the
client calls `ImmGetContext` / `ImmAssociateContext`
(`FUN_00557850`) to let the IME talk to Keystone's focused edit
control, but the edit control itself is inside Keystone.
9. **List / scrolling class.** Not in `acclient.exe`.
10. **Pseudocode for 45 base-class virtual methods.** Since the widget
base does not exist in this binary, the only thing I can write
pseudocode for is the Keystone-bridge layer and the
listener/UI-manager pair. See section 11.
11. **Equivalent C# base class + hierarchy.** See section 11.
---
## 11. Recommended C# hierarchy for acdream
Given that retail delegated to Keystone and Keystone is not in the
decompile, we have two paths:
**(A) Implement our own toolkit on top of our existing renderer.** This
is the honest pragmatic choice and matches what AC2D did. The C# base
class acdream should adopt is a retained-mode scene graph with per-node
rectangle, children list, parent pointer, z-order, visibility, and
virtual hit-test / draw / tick / key / mouse. This is strictly
acdream's choice, not a port — retail's choice was "delegate to
Keystone", and Keystone is the part we cannot port.
```csharp
// AcDream.App/UI/UiElement.cs
public abstract class UiElement
{
// --- Geometry ---
public float Left { get; set; }
public float Top { get; set; }
public float Width { get; set; }
public float Height { get; set; }
// Absolute screen-space rect (computed by walking Parent chain).
public Rectangle AbsoluteBounds => /* Parent-offset aware */;
// --- Hierarchy ---
public UiElement? Parent { get; internal set; }
public IReadOnlyList<UiElement> Children => _children;
private readonly List<UiElement> _children = new();
// --- State ---
public bool Visible { get; set; } = true;
public bool Enabled { get; set; } = true;
public int ZOrder { get; set; } // higher = in front
public bool CapturesMouse { get; set; } // for drag operations
// --- Events (flat, not per-type-abstractor like AC2D) ---
public event Action<UiElement, MouseButtonEvent>? MouseDown;
public event Action<UiElement, MouseButtonEvent>? MouseUp;
public event Action<UiElement, MouseMoveEvent>? MouseMove;
public event Action<UiElement, MouseWheelEvent>? MouseWheel;
public event Action<UiElement, KeyEvent>? KeyDown;
public event Action<UiElement, KeyEvent>? KeyUp;
public event Action<UiElement, TextInputEvent>? TextInput;
public event Action<UiElement>? GotFocus;
public event Action<UiElement>? LostFocus;
// --- Virtuals that subclasses override ---
// Concrete widgets (UiPanel, UiLabel, UiButton, UiEditBox, UiScrollBar)
// override OnDraw; everything else has a sensible default.
protected abstract void OnDraw(UiRenderContext ctx, double alpha);
protected virtual void OnTick(double deltaSeconds) { }
protected virtual bool OnHitTest(float x, float y)
=> x >= 0 && x < Width && y >= 0 && y < Height;
protected virtual bool OnMouseMessage(MouseMessage m) => false;
protected virtual bool OnKeyMessage(KeyMessage m) => false;
protected virtual bool OnTextInputMessage(TextInputMessage m) => false;
// --- Parent API ---
public void AddChild(UiElement child)
{
if (child.Parent != null) child.Parent.RemoveChild(child);
child.Parent = this;
_children.Add(child);
}
public bool RemoveChild(UiElement child)
{
if (!_children.Remove(child)) return false;
child.Parent = null;
return true;
}
// --- Framework entry points (called by UiRoot) ---
internal void Draw(UiRenderContext ctx, double alpha)
{
if (!Visible) return;
OnDraw(ctx, alpha);
// Children sorted by ZOrder ascending (painter's algorithm).
foreach (var c in _children.OrderBy(c => c.ZOrder))
c.Draw(ctx, alpha);
}
internal void Tick(double dt)
{
OnTick(dt);
for (int i = 0; i < _children.Count; i++) _children[i].Tick(dt);
}
internal UiElement? HitTest(float localX, float localY)
{
if (!Visible || !Enabled) return null;
// Children first (top of Z), then self.
for (int i = _children.Count - 1; i >= 0; i--)
{
var c = _children[i];
var hit = c.HitTest(localX - c.Left, localY - c.Top);
if (hit != null) return hit;
}
return OnHitTest(localX, localY) ? this : null;
}
}
// Concrete subclasses:
public class UiPanel : UiElement { /* draws a texture 9-slice background */ }
public class UiLabel : UiElement { public string Text; public Color Color; }
public class UiButton : UiPanel { public string Text; public event Action? Click; }
public class UiEditBox : UiElement { public string Text; public bool MultiLine; }
public class UiScrollBar : UiElement { public int Min, Max, Value; public bool Horizontal; }
public class UiImage : UiElement { public uint DatPictureId; }
public class UiList : UiElement { /* virtualized scroll list */ }
public class UiWindow : UiPanel { public string Title; public bool CanClose; public bool CanMove; public bool CanResize; }
// Top of tree:
public class UiRoot : UiElement
{
// Owns focus, drag capture, cursor state. Called once per frame from
// AcDream.App.Rendering.GameWindow after the 3D scene and before Present.
public void DispatchFrame(double dt, InputSnapshot input, UiRenderContext ctx)
{
Tick(dt);
HandleInput(input);
Draw(ctx, alpha: 0.0);
}
}
```
Pseudocode for the four critical base virtuals follows retail's
*spirit* (depth-first child traversal, children-first hit testing,
Z-order painter's algorithm, short-circuit event delivery) without
trying to match bit-for-bit a layout that isn't actually in the retail
client.
```pseudo
function UiElement.Draw(ctx, alpha):
if not visible: return
OnDraw(ctx, alpha) # own content
for c in children sorted by ZOrder:
ctx.PushTranslate(c.Left, c.Top)
c.Draw(ctx, alpha)
ctx.PopTranslate()
function UiElement.Tick(dt):
OnTick(dt) # animations, caret blink, etc.
for c in children:
c.Tick(dt)
function UiElement.HitTest(x, y):
if not visible or not enabled: return null
# Children are painted back-to-front, so hit-test front-to-back.
for c in reversed(children ordered by ZOrder):
hit = c.HitTest(x - c.Left, y - c.Top)
if hit != null: return hit
return self if OnHitTest(x, y) else null
function UiElement.HandleMouse(msg):
# msg.X/msg.Y are in parent coords; convert to local on each recursion.
if msg.Type == MouseMove and capturing_element != null:
return capturing_element.HandleMouse(msg.TranslatedTo(capturing_element))
hit = HitTest(msg.X - Left, msg.Y - Top)
if hit == null: return false
# Walk from hit up through ancestors until someone handles the event.
walker = hit
while walker != null:
if walker.OnMouseMessage(msg.TranslatedTo(walker)):
return true
walker = walker.Parent
return false
function UiElement.HandleKey(msg):
# Keyboard goes to the focused element with ancestor bubble-up.
target = focused_element ?? self
while target != null:
if target.OnKeyMessage(msg): return true
target = target.Parent
return false
```
**(B) Try to reverse-engineer and reimplement Keystone.** This would
require decompiling `keystone.dll` (not in our decompile set) and
understanding the resource-ID dispatch model. This is a real option
long term if we want genuine retail look-and-feel, but it is out of
scope for the present task. For now, acdream should take path (A) and
implement a lean toolkit that renders the SAME dat-borne textures
(icons, 9-slice frames) and the SAME CFont glyphs that the retail
client used; that preserves the visual identity even though the class
hierarchy underneath is new.
---
## 12. Implications for acdream
* There is no "decompile first, port faithfully" path for the widget
tree, because the widget tree lives in `keystone.dll`. For the UI,
the decompile tells us:
1. Which *primitives* the retail client provides to Keystone (`CFont`,
`CSurface`, `CString`) and their struct layouts — we can port
these if and when we need to consume retail portal.dat font
records or embed-atlas records.
2. Which *commands* and *plugins* the retail client exchanges with
Keystone (resource IDs like `0x186a1`, plugin names like
`L"acpluginmanager"`, command codes like `0x69`) — we can cache
these if we ever want to pretend to be Keystone for a plugin.
3. The **frame pump shape** (clear → scene → UI → overlays → present)
— acdream already follows this; no change needed.
* The follow-on research slices (layout data, input pipeline, etc.)
should assume path (A). If any slice finds evidence of an in-binary
widget they should flag it, but they won't — I've walked the listener
tree and it isn't there.
* The `docs/architecture/acdream-architecture.md` UI-layer section should
be updated to state: "acdream implements its own retained-mode UI in
`AcDream.App/UI/`; the retail binary delegates to Keystone, which
acdream does not reimplement; our UI is visually consistent with
retail by consuming the same portal.dat textures and fonts, not by
structural class equivalence."
---
## 13. Loose ends the next agent should know about
* `DAT_00870340` is **not** a UI object. It is the Direct3D swap-chain /
back-buffer wrapper (width at `+0x94`, height at `+0x90`, Clear /
Present vtable at `+0x40` / `+0x44`, `Reset` at `+0x18`, `0x24`,
`0x28`). It is called everywhere because every 2D overlay and every
3D draw needs it. I originally thought the `*(char *)(DAT_00870340 +
0x10) == '\0'` check was a UI visibility flag; on re-reading, it is
the "graphics device is active / not minimized" flag that controls
cursor behaviour (`LoadCursorA(0x7f00)` fallback at
`chunk_00430000.c:7862`). The `DAT_00870340[0x2b]` byte in
`FUN_0043fcd0` is the "render is allowed this frame" gate.
* `DAT_00837ff4` is the **input** subsystem pointer, with a vtable that
has `IsCursorVisible(bool)` at `+0x74` (via the ShowCursor thunk at
`chunk_00430000.c:7840`). Not UI either.
* `DAT_0083e72c` is a singleton pointer for the CharGen top-level class;
`FUN_004799c0` sets it. There is exactly one CharGen screen at a
time. That confirms the CharGen-screen-is-a-listener model.
* Vtable `PTR_FUN_007ccb60` is used as a temporary "placeholder" vtable
during construction and destruction. Every object that has a listener
subobject installs `PTR_FUN_007ccb60` first, does some plain-base
work, then swaps in the real listener vtable, and reverses this at
teardown. Useful to recognize when reading constructors.
* The `0x186a1`, `100000` (`0x186a0`), `0x186a4` etc. IDs in the CharGen
ctor/dtor look like Keystone resource IDs (they are contiguous and
decimal-friendly: 100000, 100001, 100002, 100003, 100004). If any
future task needs to drive Keystone directly, these are the channels.
* `0x10000001``0x1000003e` etc. are similar IDs passed to
`FUN_004e8190`, `FUN_004e90d0`, etc. — those are `CharGen` state-
machine event IDs (the CharGen is a state machine bridged into the
UI through the same manager singleton). Not widgets.
* `FUN_0054d2a0` returns a `CSurface` ask-or-allocate result; the
calling code assumes the singleton graphics device will hand back a
pooled surface when the device exists, else the client allocates its
own 40-byte surface. This is the code path to match when we
implement acdream's UI texture cache.
---
## 14. Minimum additional chunks a follow-up UI pass should read
If a later research task revisits this area, these chunks deserve a
closer pass than I gave them:
* `chunk_00430000.c` around `FUN_0043c6c0``FUN_0043cf00` — full
shape of the UI-manager / listener tables.
* `chunk_00550000.c` around `FUN_00557930``FUN_00557ef0` — Keystone
glue layer, complete vtable documentation of `DAT_00870c2c`.
* `chunk_00470000.c` around `FUN_0047aa10``FUN_0047b2e0` — CharGen
screen state machine in full, and its registration IDs.
* `chunk_004A0000.c` around `FUN_004a5200``FUN_004a5680` — the
paperdoll slot tooltip formatter; shows the complete set of
"drag X here to wear it" patterns and therefore the slot-to-string
table.
* `chunk_004C0000.c` around `FUN_004c7700``FUN_004c7f00` — spell
casting UI integration.
None of these will change the conclusion in this document — they will
flesh out the *commands and strings* the client exchanges with
Keystone, not produce a hidden widget base class.