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.
42 KiB
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 pointerDAT_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-countedCString-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.exeare infrastructure: aCStringrefcount helper (FUN_00402490,FUN_00407e40,FUN_0040b8f0), a fullCFont(chunk_00440000.c, with glyph array + codepoint-range map), a 32-bppCSurface/bitmap (PTR_FUN_0079c26cvtable, 0x28-byte header), aCKeystoneGluethat wraps Keystone's COM pointer, and an event/listener base (PTR_FUN_00801670, with add/remove via a UI-manager singletonDAT_00838374). Everything that looks like a "widget" in other AC reference repos (AC2D'scPictureBox,cStaticText,cEditBox,cScrollBar,cMovableWindow,cSkillWindow, …) lives on the Keystone side, not insideacclient.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+0x12cbytes inside the object (stored atparam_1 + 0x4bwhich is+0x12cbytes) — 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_00402490—CString::operator+=(const wchar_t *, size_t)— appendFUN_00407e40—CString::operator=(const wchar_t *)— assignFUN_0040b8f0— small wrapper that computes wcslen and appends (this is the function the preflight task was worried about)FUN_004022d0— ensure capacityFUN_004027b0—sprintf-style format into a CStringFUN_004300a0—sprintfappend
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
HWNDat+0x10c - Tracks raw mouse position, last-click point, double-click timing
- Calls
GetCursorPos/ScreenToClient/ClientToScreendirectly - Calls
FUN_00557a30(= KeystoneGetActive()viaDAT_00870c2c +0x2c) andFUN_00557a60(= KeystoneHitTest(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:
- Windows sends
WM_MOUSEMOVE(via Ghidra-undecompiled windowproc, not in the task-selected chunks). - The input manager (
chunk_00680000.c) updates cached mouse state. - Each frame, the client calls
FUN_00557a30/FUN_00557a60to check if Keystone owns the cursor. - If Keystone does, the 3D view ignores mouse input entirely; Keystone has already dispatched it internally.
- 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:
- Clear.
- Render 3D world.
- Hand the frame to Keystone for UI draw.
- Render extra native overlays.
- 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
-
Base UI element class and its vtable. There isn't one baked into
acclient.exe. The externalIKeystoneFrameworkinterface at*DAT_00870c2cis the closest analog, with observed methodsRelease(+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 insidekeystone.dll, which is not part of this decompile. -
Hierarchy by examining subclass extensions. The only class hierarchy fully resident in the client is UI-manager + listener:
CUIManager(vtablePTR_FUN_00799fc4, singletonDAT_00838374) and the listener baseCUIListener(vtablePTR_FUN_00801670). Panels that want to be notified embed the listener by multiple inheritance; I can count ~25–30 such embeddings in the chunks I surveyed (CharGen at 0x0047aa10 is the canonical example; others are distributed throughchunk_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. -
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.
+0x12cfor CharGen,+0x5f8for the CharGen screen, varying by class size), and that innerthisis what gets passed toCUIManager::AddListener. There is no common geometry field. -
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+0x12cand+0x5f8as 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. -
Container / panel class with a child list. None in
acclient.exe. Keystone owns the containment model. -
Button / clickable class. None in
acclient.exe. Buttons are Keystone-side; the client only sends a command-id viaSendCommandor a plugin-dispatch call. -
Text / label class. Not a class. Labels are
wchar_t *literals assigned toCStringbuffers throughFUN_00407e40/FUN_0040b8f0. The buffers are then passed as resource contents to Keystone — the client has noCLabel. -
Edit-box / text-entry class. The client has the
CStringback end (so paste-into-chat, type-in-username, etc. work), and the client callsImmGetContext/ImmAssociateContext(FUN_00557850) to let the IME talk to Keystone's focused edit control, but the edit control itself is inside Keystone. -
List / scrolling class. Not in
acclient.exe. -
Pseudocode for 4–5 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.
-
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.
// 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:- 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. - Which commands and plugins the retail client exchanges with
Keystone (resource IDs like
0x186a1, plugin names likeL"acpluginmanager", command codes like0x69) — we can cache these if we ever want to pretend to be Keystone for a plugin. - The frame pump shape (clear → scene → UI → overlays → present) — acdream already follows this; no change needed.
- Which primitives the retail client provides to Keystone (
- 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.mdUI-layer section should be updated to state: "acdream implements its own retained-mode UI inAcDream.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_00870340is 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,Resetat+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 atchunk_00430000.c:7862). TheDAT_00870340[0x2b]byte inFUN_0043fcd0is the "render is allowed this frame" gate.DAT_00837ff4is the input subsystem pointer, with a vtable that hasIsCursorVisible(bool)at+0x74(via the ShowCursor thunk atchunk_00430000.c:7840). Not UI either.DAT_0083e72cis a singleton pointer for the CharGen top-level class;FUN_004799c0sets it. There is exactly one CharGen screen at a time. That confirms the CharGen-screen-is-a-listener model.- Vtable
PTR_FUN_007ccb60is used as a temporary "placeholder" vtable during construction and destruction. Every object that has a listener subobject installsPTR_FUN_007ccb60first, 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),0x186a4etc. 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…0x1000003eetc. are similar IDs passed toFUN_004e8190,FUN_004e90d0, etc. — those areCharGenstate- machine event IDs (the CharGen is a state machine bridged into the UI through the same manager singleton). Not widgets.FUN_0054d2a0returns aCSurfaceask-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.caroundFUN_0043c6c0→FUN_0043cf00— full shape of the UI-manager / listener tables.chunk_00550000.caroundFUN_00557930→FUN_00557ef0— Keystone glue layer, complete vtable documentation ofDAT_00870c2c.chunk_00470000.caroundFUN_0047aa10→FUN_0047b2e0— CharGen screen state machine in full, and its registration IDs.chunk_004A0000.caroundFUN_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.caroundFUN_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.