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.
1008 lines
42 KiB
Markdown
1008 lines
42 KiB
Markdown
# 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 ~25–30 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 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.
|
||
|
||
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.
|