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

42 KiB
Raw Permalink Blame History

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:

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

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

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

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:

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:

(*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):

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:

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:

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

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:

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

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

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:

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

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_00402490CString::operator+=(const wchar_t *, size_t) — append
  • FUN_00407e40CString::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_004027b0sprintf-style format into a CString
  • FUN_004300a0sprintf 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:

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.


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.

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

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.
  • 0x100000010x1000003e 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_0043c6c0FUN_0043cf00 — full shape of the UI-manager / listener tables.
  • chunk_00550000.c around FUN_00557930FUN_00557ef0 — Keystone glue layer, complete vtable documentation of DAT_00870c2c.
  • chunk_00470000.c around FUN_0047aa10FUN_0047b2e0 — CharGen screen state machine in full, and its registration IDs.
  • chunk_004A0000.c around FUN_004a5200FUN_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_004c7700FUN_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.