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.
41 KiB
Retail AC Client — UI Architecture, Initialization, and Main Loop
Research slice: process entry → window → device → UI → frame loop.
Method: decompiled acclient.exe (22,225 functions via Ghidra pyghidra
headless); cross-check against vendored reference repos where they cover
the same topic.
Unless noted otherwise, every function and global cited here is taken
directly from the decompiled source in
C:\Users\erikn\source\repos\acdream\docs\research\decompiled\chunk_*.c.
The reference repos under references/ (ACE, ACViewer, WorldBuilder,
holtburger, Chorizite.ACProtocol, AC2D) do not cover the in-process
UI layer at all — they are emulators, dat viewers, or protocol libraries.
Everything below about Keystone / the main window / the frame loop is
novel to this slice.
0. TL;DR for the porting agent
The retail client is a single-window, single-process, single-render-thread C++ app built around three "Cxxx" globals:
Global (DAT_) |
Role | Acquired by |
|---|---|---|
DAT_008381a4 (HWND) |
Main game window ("Turbine Device Class") |
CreateWindowExA @ 0x0043BD0B |
DAT_0086734c (ptr) |
Graphics driver adapter (COM-like factory) | FUN_0054d0c0 → FUN_0058bf30 |
DAT_00870340 (ptr) |
Turbine Core — main engine object that owns the GL surface, dat resources, and UI tools | FUN_0054d110 → DAT_0086734c vtable[0xc] |
DAT_00837ff4 (ptr) |
Render device (window-specific, lightweight) | FUN_006895d0 @ 0x006895D0 |
DAT_00870c30 (HMODULE) |
keystone.dll |
FUN_00557930 |
DAT_00870c34 (code*) |
KeystoneCreate export |
GetProcAddress |
DAT_00870c2c (ptr) |
Keystone UI root (instance returned by KeystoneCreate) |
FUN_00557850 |
DAT_00870c38/3c (HMODULE) |
ACHelpPlugin.dll, ACPluginManager.dll |
LoadLibraryA |
DAT_00870c54 (HACCEL) |
Accelerator table | CreateAcceleratorTableA |
DAT_00818b64 |
Packed (width << 16 | height) of backbuffer |
DAT_00838194 |
Quit flag ("app should exit") | FUN_00439230 (QuitApp) |
DAT_00838196 |
"Main window alive" flag | FUN_0043BA60 tail |
DAT_00838198 |
"Currently pumping messages" re-entrancy guard | FUN_00439e50 |
DAT_00838199 |
"Redraw requested" flag | WM_PAINT path |
DAT_008381a8 |
1 when app was launched with cmdline args | FUN_0043BA60 |
The UI framework is Keystone (proprietary Turbine XML-based UI toolkit
— same engine later used in Lord of the Rings Online / DDO). It is shipped
as keystone.dll next to acclient.exe and exposes a single C export,
KeystoneCreate, that returns a vtable-dispatched root object. All
panels, buttons, fonts, textures, and input are hidden behind that one
pointer. AC is a Keystone client; it does not own the UI code.
The frame loop runs three things back-to-back:
- Message pump (
FUN_00439e50) —PeekMessageA+TranslateMessageDispatchMessageAuntil the queue is empty. The app's WndProc (LAB_00439860) hands each message first to the UI filter (FUN_00557a90→ Keystone's vtable[0x6c]), then to the renderer (DAT_00837ff4vtable[0x70]).DefWindowProcAis the fallthrough.
- Game tick (
FUN_004554b0) — advances game state / physics / scripts; also drains per-frame command queues. - Render frame (
FUN_0045d0b0) — BeginScene-equivalent, draws 3D world, draws UI as overlay, EndScene-equivalent, present.
1. Process entry and window-class registration
1.1 Entry point (WinMain equivalent)
CRT startup @ 0x005DF1xx (chunk_005D0000.c:~11280)
└─ __getmainargs, _initterm, parse cmdline
└─ GetStartupInfoA, GetModuleHandleA(NULL)
└─ FUN_004013a0(hInstance, NULL, lpCmdLine, nShowCmd) // <-- WinMain
│
├─ FUN_00406300() // preflight checks
├─ FUN_00406d60() // exception handler setup
├─ GetCommandLineA()
├─ FUN_00401120() // _control87 — disable FPU exceptions
├─ DAT_00837720 = 0x40000001
├─ FUN_00413850(0, &PTR_DAT_008183b4) // mount factory table
├─ FUN_0055af00 / 00555990 / 00558230 // setup log / dat / COM
├─ piVar2 = FUN_00401160(&DAT_007936b8) // factory: App object
├─ piVar2->vtable[0x10](0, 0, 1) // Application::Initialize
├─ FUN_00401340("Asheron's Call") // title string
├─ piVar2->vtable[0x1c](stack, 1, 1) // Application::Run <-- main loop lives here
├─ piVar2->vtable[0x2c]() // Application::Shutdown
├─ FUN_004010f0(piVar2) // release singletons
├─ FUN_004020c0 / FUN_00406f90 // teardown
└─ return 0
- WinMain =
FUN_004013a0at 0x004013A0 (decompiled aschunk_00400000.c:288-341). - The App object is a virtual class; its vtable is at
&DAT_007936b8(chunk_00400000.c:313). Slots we care about:[0x10]=Initialize(hInstance, pfnMsg, isRestart)— see FUN_00412180 @ 0x00412180 (chunk_00410000.c:1785+)[0x1c]=Run(p1, p2, p3)— calls into one of the two frame pump variants below[0x2c]=Shutdown()— unclear which FUN_ slot
1.2 Application::Initialize (vtable[0x10])
Decompiled as FUN_00412180:
bool Application::Initialize(pfnMsg, lpCmdLine, nShowCmd, ...) {
_set_new_handler(LAB_00411580);
if (this->vtable[0x80](this)) // pre-init hook; bails out if app
return false; // is a secondary instance
GetVersionExA(...);
if (version == Win9x)
LoadLibraryA("unicows.dll"); // Unicode shim on Win98/ME
FUN_0040fcd0(); // init dat file cache
FUN_0042c800(); // init string tables
this->field_0x41 = FUN_0054bb50(); // singleton: audio
this->field_0x42 = FUN_0054bb70(); // singleton: renderer-factory wrapper
this->vtable[0x68](); // network bind setup
if (!this->vtable[0x70]()) // preferences
return false;
if (!this->vtable[0x74](lpCmdLine, nShowCmd, hasArgs)) // window + D3D init (see §2)
return false;
if (!FUN_004221c0()) // acqr locator
return false;
return this->vtable[0x7c](pfnMsg) != 0; // final wiring
}
Key takeaway: vtable[0x74] is the "create the window AND initialize the render device" method, and its call site is what drives all of §2.
1.3 Window-class registration and CreateWindow
All from chunk_00430000.c:9848-10046. This is a single ~1100-byte
function that does window-class registration, window creation, renderer
device creation, UI wiring, and chat-command registration in one go.
Call it CreateMainWindow (no FUN_ label visible because it's
reachable only via vtable[0x74]).
CreateMainWindow(titleStr, x, y, w, h, fullscreen, iconId) {
if (DAT_00838196 != 0) return 0; // already created
GetVersionExA(&local_94);
DAT_0083819c = local_94.dwPlatformId; // 0 = Win9x, 2 = NT
if (local_94.dwPlatformId == 0) // explicit "no Win9x" check
goto fail;
hInstance = GetModuleHandleA(param[0]); // resolves exe module
if (DAT_0086734c != 0) return 0; // renderer already alive
FUN_0054d0c0(&local_f0); // build graphics-driver factory
// → DAT_0086734c
FUN_0043b2d0(); // parse --window / --fullscreen
FUN_00439140(); // defaults: 800×600×32bpp
FUN_00439370(&local_d8); // sanitize size (≥ 800 × ≥ 600)
//--- step 1: register class "Turbine Device Class" ---
local_bc.lpfnWndProc = (WNDPROC)&LAB_00439860; // <-- WndProc
local_bc.cbClsExtra = 0;
local_bc.cbWndExtra = 0;
local_bc.style = 0;
local_bc.hInstance = hInstance;
local_bc.hIcon = LoadIconA(hInstance, (LPCSTR)0x65);
local_bc.hbrBackground = GetStockObject(BLACK_BRUSH); // 4
local_bc.lpszMenuName = (LPCSTR)*titleStr;
local_bc.hCursor = NULL; // cursor set later
local_bc.lpszClassName = "Turbine Device Class";
AVar3 = RegisterClassA(&local_bc);
if (!AVar3 && GetLastError() != ERROR_CLASS_ALREADY_EXISTS) goto fail;
//--- step 2: choose style; center on desktop; apply workarea ---
DWORD style = fullscreen ? 0x82000000
: 0x82ca0000 | (isPopup ? 0x10000000 : 0);
// adjust for SM_CXFRAME / SM_CYFRAME; SystemParametersInfoA(SPI_GETWORKAREA)
// snaps window inside the work area (accounts for taskbar).
//--- step 3: CreateWindowEx ---
DAT_008381a4 = CreateWindowExA(
0, "Turbine Device Class", titleStr, style,
x, y, w, h, NULL, NULL, hInstance, NULL);
if (!DAT_008381a4) goto fail;
//--- step 4: allocate render device (FUN_006895d0) ---
if (FUN_005df0f5(0x3f8))
DAT_00837ff4 = FUN_006895d0(); // see §3.1
cVar2 = DAT_00837ff4->vtable[4](DAT_008381a4); // device->AttachWindow
if (!cVar2) return 0;
if (showWindow) {
ShowWindow(DAT_008381a4, SW_SHOWNORMAL);
UpdateWindow(DAT_008381a4);
SetForegroundWindow(DAT_008381a4);
SetActiveWindow(DAT_008381a4);
SetWindowPos(DAT_008381a4, HWND_TOPMOST, 0,0,0,0,
SWP_NOMOVE|SWP_NOSIZE|SWP_NOACTIVATE|0x100);
}
//--- step 5: bring up renderer + UI (FUN_0043ad90, see §3) ---
if (!FUN_0043ad90(param_d8, param_d4, isFullscreen))
return 0;
//--- step 6: audio & console ---
if (FUN_005df0f5(8))
DAT_008381ac = FUN_00439210(); // ImmDisableIME equivalent
//--- step 7: register ~30 chat / console commands ---
FUN_00401340("Exits the application");
FUN_00401340(&DAT_00799d90); // name: "Quit" or "Exit"
FUN_00436580(&LAB_00439830, ...);
// ...many more...
FUN_00401340("Restarts the rendering engine and applies new display settings");
FUN_00401340("UpdatePresentation");
FUN_00436580(FUN_0043a510, ...);
FUN_00401340("ForceDisplayResolution [<Width> <Height>]");
FUN_004366d0(FUN_0043aa70, ...);
SetThreadExecutionState(0x80000001); // keep display on / prevent sleep
DAT_00838196 = 1; // "main window alive"
return 1;
}
1.4 The window procedure itself (LAB_00439860)
Ghidra did not emit a FUN_00439860 block — the gap between
FUN_00439840 (ends ~0x0043985D) and FUN_00439d50 holds it but the
decompiler skipped producing C for it, likely due to inline asm or a
non-standard prolog. We can still reconstruct it from the message pump
(§4), which tells us every consumer it forwards to:
LRESULT CALLBACK TurbineWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) {
// 1. Give Keystone first shot (panels eat mouse/keyboard first).
if (DAT_00870c2c != NULL) {
LRESULT ui = DAT_00870c2c->vtable[0x6c](hwnd, msg, wp, lp);
if (ui != 0)
return ui; // UI swallowed it
}
// 2. Give the render device a chance (WM_ACTIVATE, WM_SIZE,
// WM_DISPLAYCHANGE, WM_PAINT, WM_ERASEBKGND).
if (DAT_00837ff4 != NULL) {
LRESULT rd = DAT_00837ff4->vtable[0x70](&msg_struct, &handled);
if (handled) return rd;
}
// 3. Minimal direct handling in the WndProc itself:
// WM_CLOSE → DAT_00838194 = 1 (quit flag)
// WM_ACTIVATEAPP → DAT_00818b68 toggles
// WM_SETCURSOR → LoadCursorA(NULL, IDC_ARROW)
// WM_CHAR / WM_KEYDOWN → forwarded to chat command parser
// 4. Default
return DefWindowProcA(hwnd, msg, wp, lp);
}
The fact that the pump calls TranslateMessage only if both
FUN_006a1050 (IME filter, stub returning 0) and FUN_00557a90
(Keystone peek-in) return 0 is the smoking gun. See §4.
1.5 Global state planted by window creation
| Address | Type | Meaning |
|---|---|---|
DAT_008381a4 |
HWND | The single client HWND |
DAT_0083819c |
int | Win platform ID (2=NT family) |
DAT_00838194 |
bool | Quit requested |
DAT_00838195 |
bool | Renderer alive |
DAT_00838196 |
bool | Window alive |
DAT_00838197 |
bool | Cursor hidden |
DAT_00838198 |
bool | Inside PeekMessage loop (guard) |
DAT_00838199 |
bool | Redraw requested |
DAT_008381a0 |
bool | Fullscreen mode |
DAT_008381a8 |
int | 1 if started with cmdline args |
DAT_00818b02 |
bool | Show-frame (windowed mode) flag |
DAT_00818b04/08 |
int | Windowed size (for restore) |
DAT_00818b64 |
packed | (width<<16 |
DAT_00818b68..72 |
bytes | Windowed-vs-fullscreen state mask |
2. Renderer / device bring-up
Two objects; order matters.
2.1 Graphics-driver factory (DAT_0086734c)
FUN_0054d0c0(&mode_out)
DAT_0086734c = FUN_0058bf30() // allocate the factory COM-ish
DAT_0086734c->vtable[4](&mode_out) // Init — probes available backends
FUN_0058bf30 is in chunk_00580000.c; it's a factory that internally
picks the best of (DirectX 6 DirectDraw / Glide / software). None of our
modern clients will have to emulate that stack — we pick Silk.NET OpenGL
unconditionally. But we DO have to replicate the TWO-object factoring
(factory vs core) because so much of the code calls through
DAT_0086734c->vtable[0xc] to get the core.
2.2 Turbine Core (DAT_00870340)
FUN_0054d110(hwnd, backbufW, backbufH):
DAT_00870340 = DAT_0086734c->vtable[0xc](); // CreateCore factory method
if (!DAT_00870340) return 0;
return DAT_00870340->vtable[4](hwnd, w, h); // Core::Init(hwnd, w, h)
Turbine Core owns:
- GL / DirectDraw surfaces
- dat file handles (portal.dat, cell.dat, client_portal.dat, etc.)
- font atlases
- keystone instance handle (at
+0x468— see §5.2) - field at
+0x10= "isFullscreen" - vtable[0x40] = Present / Flush
2.3 Render device (DAT_00837ff4)
FUN_006895d0() constructs a lightweight device record (234 bytes of
zeroed fields plus two vtable pointers). It's allocated before
Turbine Core because the WndProc needs something pointable even during
the WM_CREATE that fires from CreateWindowExA.
It calls CoInitialize(NULL) at construction time. Field layout (offsets
in dwords):
| Offset | Meaning |
|---|---|
| +0 | vtable = PTR_FUN_008000c8 |
| +0x40..0x46 | misc zero-init fields |
| +0x47..0x49 | 0xFFFFFFFF sentinels |
| +0x4a | vtable = PTR_FUN_00800088 — sub-object |
| +0x67 | vtable = PTR_FUN_0080008c — sub-object |
| +0xd6 | hash-bucket count |
| +0xf3..0xf5 | state bits |
| +0xf8 | 1 (enabled flag) |
Vtable slots observed in the wild (§1.4, §3, §7):
| Slot | Role |
|---|---|
| 0x04 | AttachWindow(hwnd) |
| 0x10 | BeginFrame / BeginScene |
| 0x18 | GetWidth |
| 0x1c | GetHeight |
| 0x34 | SetTransform / SetRenderState |
| 0x38 | SetLight |
| 0x3c | DrawPrimitive (or DrawIndexed) |
| 0x48 | Present / EndFrame |
| 0x4c | DrawScreenQuad |
| 0x58 | IsDeviceLost |
| 0x70 | WndProc filter — returns LRESULT + handled-bool |
| 0x74 | SetCursorConfinement(bool) |
| 0xa8 | SetAlphaTest(bool) |
2.4 Post-device UI handshake (FUN_0043ad90 → FUN_0043ac60 → FUN_0054e1a0)
FUN_0043ad90(w, h, isFullscreen) {
DAT_00838195 = 0;
if (!FUN_0043ac60(w, h, isFullscreen)) return 0;
DAT_00838195 = 1;
return 1;
}
FUN_0043ac60(w, h, isFullscreen) {
if (isFullscreen) {
hdc = CreateICA("Display", NULL, NULL, NULL);
int bpp = GetDeviceCaps(hdc, BITSPIXEL);
DeleteDC(hdc);
if (bpp == 16) FUN_0043a8f0(); // 16bpp mode
else if (bpp != 32) { print_error(); exit(1); }
}
if (!FUN_00439370(&size)) return 0;
size.hwnd1 = DAT_008381a4;
size.hwnd2 = DAT_008381a4;
if (!FUN_0054e1a0(&size1, &size2)) return 0; // core init
if (!FUN_004402d0()) return 0; // resource init
FUN_0054e6a0(); // late wiring
return 1;
}
FUN_0054e1a0(p1, p2) {
if (!FUN_004154a0()) return 0; // probe — always true
if (FUN_0044b810()) { // audio mixer ready?
if (FUN_0054d110(DAT_0086734c[2], p1, p2)) { // Turbine Core init
FUN_00557850(); // -> Keystone create (§5)
if (FUN_00448810()) return 1; // app-specific hook
FUN_00557b50(); // Keystone destroy on fail
FUN_0044b820(); // audio teardown
FUN_0054d160(); // core teardown
}
}
// unwind DAT_0086734c
return 0;
}
So the canonical first-run order is:
CreateWindowEx
→ DAT_00837ff4 = CRenderDevice() // light shim
→ DAT_00837ff4->AttachWindow(hwnd)
→ (ShowWindow / UpdateWindow)
→ FUN_0043ad90 → FUN_0043ac60:
→ DAT_00870340 = CreateCore() // Turbine Core
→ Core->Init(hwnd, w, h) // real GL surface, dats online
→ KeystoneCreate(hwnd, ...) // UI system (§5)
→ Application::PostDeviceInit // game wiring (FUN_00448810)
3. Render device — frame-side vtable usage
The device is the 4th global (DAT_00837ff4). The frame loop calls its
vtable[0x10] to start a frame (BeginFrame) and vtable[0x48] to present
(EndFrame). BeginFrame happens inside FUN_0045d0b0 (see §6.3); the
final Present is inside the Turbine-Core flush (FUN_0043fcd0) via
DAT_00870340->vtable[0x40](w, h, ...).
Present path (chunk_00430000.c:12978):
void RenderFrameFlushAndPresent() {
if (!DAT_00870340->fieldIsFullscreen) return; // offset +0x2b
// grab backbuffer dims
int w = DAT_00870340->field_0x24; // width
int h = DAT_00870340->field_0x23; // height
void* core = DAT_00870340->vtable0; // core vtable
uint t1 = FUN_0054fd30(0); // timing tick
uint t2 = FUN_0054fd20(t1);
DAT_00870340->vtable[0x40](0, 0, t2); // Begin2DPhase?
if (condition) {
FUN_004488a0(); // flush ui event queue
FUN_00557840(); // Keystone frame end
}
if (DAT_0083846c) FUN_005da8f0(); // cinematic overlay
if (DAT_00838468) FUN_00692470(); // video subsystem
FUN_0043f7f0(); // perf overlay
DAT_00870340->vtable[0x40](h, w, ...); // End2DPhase
DAT_00870340->field_0x2a = 0;
DAT_00870340->vtable[0x24](); // Present backbuffer
DAT_00870340->vtable[0x28](); // Swap / flip
FUN_0043e6b0(); // reset per-frame counters
}
4. Main-loop message pump (FUN_00439e50)
Address: 0x00439E50 in chunk_00430000.c:8265-8297, size 213 bytes.
Returns DAT_00838194 (the quit flag) — truthy means "app wants to quit".
Faithful pseudocode:
bool PumpMessages() {
DAT_00838198 = 1; // re-entrancy guard
tagMSG msg;
int peek = PeekMessageA(&msg, NULL, 0, 0, PM_REMOVE);
while (peek != 0 && msg.message != WM_QUIT /* 0x12 */) {
// 1st filter: IME / composition — stub returns 0 in retail
if (FUN_006a1050(&msg) == 0) {
// 2nd filter: Keystone hot-keys / accelerators
if (FUN_00557a90(msg.hwnd, 0 /*defaults to HACCEL*/, &msg) == 0) {
TranslateMessage(&msg);
DispatchMessageA(&msg); // routes into WndProc
}
}
peek = PeekMessageA(&msg, NULL, 0, 0, PM_REMOVE);
}
// Redraw fence: if someone requested a redraw while we were pumping,
// toggle the "was-active" flag so the next frame starts fresh.
if (DAT_00838199) {
if (DAT_0086734c != 0 && DAT_00838197 /*cursor hidden*/) {
if (DAT_00818b02 == 0 || DAT_00818b68 != 0)
DAT_00818b68 = 0;
else
DAT_00818b68 = 1;
}
DAT_00838199 = 0;
}
DAT_00838198 = 0;
return (bool)DAT_00838194;
}
4.1 FUN_00557a90 — Keystone's pre-dispatch hook
LRESULT Keystone_PeekMessage(HWND hwnd, HACCEL haccel, LPMSG msg) {
if (DAT_00870c2c == NULL) return 0; // Keystone not alive yet
if (haccel == NULL) haccel = DAT_00870c54; // default to our accels
return DAT_00870c2c->vtable[0x6c](hwnd, haccel, msg);
}
This is why Keystone gets first shot at input — the pump checks with
Keystone before it calls TranslateMessage, so Keystone can suppress
a key-down or convert it into a menu command.
4.2 FUN_006a1050 — IME hook
A 3-byte function that just returns 0. So in retail, the IME filter is a
no-op. ImmGetContext / ImmAssociateContext still happens during
Keystone creation (§5.2), so Asian-language IME works, but it's handled
inside Keystone rather than by a separate pre-dispatch filter.
4.3 Re-entrancy / recursion
DAT_00838198 guards against PumpMessages calling itself. Nothing in
the pump body calls back into PumpMessages directly, but
DispatchMessageA can — e.g. a menu handler that runs a modal dialog
loop of its own.
5. UI system (Keystone) initialization
5.1 Library discovery
FUN_00557930 at 0x00557930 (chunk_00550000.c:7017) is the
"keystone + plugins resolver". Runs once during core startup:
bool Keystone_LoadDlls() {
if (DAT_00870c30 != NULL) return 1; // idempotent
if (!FUN_005577a0()) return 0; // msxml4.dll required
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 != NULL) return 1;
DAT_00870c54 = CreateAcceleratorTableA(NULL, 0); // empty accel table
return 0;
}
5.2 FUN_005577a0 — msxml4 probe
bool MsXml4IsAvailable() {
hLibModule = LoadLibraryA("msxml4.dll");
if (!hLibModule) { DAT_00870c58 = 0; return 0; }
DAT_00870c58 = 1;
FUN_00557e40();
DAT_00870c58 = FUN_00557e80(); // verify registry / DLL version
FreeLibrary(hLibModule);
FUN_00557e60();
return DAT_00870c58;
}
Keystone uses MSXML4 to parse the UI layout XML blobs packed in the
client dats (.layout, .skn files referenced elsewhere). Our port
will need an equivalent XML deserializer if we load retail UI layouts
as-is — but we can also bake them into code; see §8 porting notes.
5.3 FUN_00557850 — KeystoneCreate call
bool Keystone_CreateRoot() {
if (DAT_00870340 == 0 || DAT_00870c34 == NULL) return 0;
char cwd[0x2000];
if (!_getcwd(cwd, 0x2000)) return 0;
wchar_t cwdW[0x2000];
MultiByteToWideChar(0, 0, cwd, -1, cwdW, 0x2000);
HWND hwnd = DAT_008381a4;
HIMC himc = ImmGetContext(hwnd);
ImmReleaseContext(hwnd, himc);
DAT_00870c2c = DAT_00870c34( // KeystoneCreate(...)
hwnd, // target HWND
DAT_00870340[0x468/4], // Turbine Core handle field
cwdW, // working directory
0, 0, 0, 0);
ImmAssociateContext(hwnd, himc);
if (DAT_00870c2c != NULL) {
int evtPayload = 0;
DAT_00870c2c->vtable[0x5c](0x69, 2, &evtPayload); // focus msg
return 1;
}
return 0;
}
The arguments passed to KeystoneCreate are, by experimentation and
convention:
HWND hwnd— target windowvoid* coreHandle— the engine-side handle Keystone embeds for callbacks (resources, input, rendering integration)LPCWSTR workingDir— where to find XML layouts & font atlases- 4 unused
NULLslots (reserved for callbacks, probably)
5.4 Keystone vtable, as observed
| Slot | Role |
|---|---|
| 0x08 | Release / destroy |
| 0x14 | FindPanel(name) |
| 0x20 | Shutdown (full) |
| 0x24 | CreatePanel(..., 4 args) |
| 0x28 | SetActiveElement(elem) |
| 0x2c | GetActiveElement() |
| 0x5c | SendEvent(type, subtype, payload) |
| 0x60 | QueryState(arg) |
| 0x6c | WndProcFilter(hwnd, haccel, &MSG) — the input pump's hook |
5.5 Fonts and other dat-based resources
Portal-dat region registration (near chunk_00410000.c:13510) maps the
following regions for use by Keystone:
| Name | Resource ID range | Extension |
|---|---|---|
emp/property |
— | .font |
fonts |
0x40000000..0x40000fff |
.font |
fonts_local |
0x40001000..0x40ffffff |
.font_local |
| (empty name) | 0x41000000..0x41ffffff |
StringTable (DAT_00796760) |
stringtable |
0x78000000..0x7fffffff |
.dbpc, .pmat |
properties |
— | — |
These arrays of FileID → filename pairs are how Keystone resolves "font://ac_fondant_36" at runtime.
5.6 UI commands it registers (chat side)
The chat parser registers these user-facing UI commands
(chunk_00570000.c:7298+):
@saveui <filename>/@loadui <filename>— persist layout@lockui— toggle edit-mode / lock@saveautoui/@loadautoui— auto-saved layout on disk
These go through FUN_0056fae0, the chat-command registrar. The
handlers FUN_00570dc0 / FUN_00570f20 / LAB_00571180 live in
chunk_00570000.c. They all dispatch through DAT_00870c2c (the
Keystone root).
6. Frame loop structure
6.1 The two PumpMessages callers
Two functions call FUN_00439e50:
| Caller | Address | Purpose |
|---|---|---|
FUN_00411630 |
0x00411630 | Main in-game frame step |
FUN_00411fa0 |
0x00411FA0 | Login/connect waiting-state frame step |
They share the same 7-function rendering tail. Both are called via
FUN_004017c0 @ 0x004017C0 (the object-owned dispatch):
void FUN_004017c0(int self) {
self->vtable_at_0x118[0x20](); // start-of-frame hook
FUN_00411fa0(); // or 00411630 depending on state
}
The difference: FUN_00411630 is called while waiting for the world
server to respond (during login / portal transitions) and returns after
one iteration; FUN_00411fa0 returns 1 to continue / 0 to quit.
6.2 Frame body (in-game)
void MainFrame_InGame(App* app) {
FUN_0040fbd0(); // stat counters
if (PumpMessages()) { // quit requested?
FUN_00543fc0(); // shutdown ACK
return;
}
FUN_00543440(); // reconcile network state
app->cursor->vtable[0x48](); // cursor tick
FUN_0045d0b0(); // render frame (§6.3)
FUN_004554b0(); // game tick (§6.4)
FUN_0043e690(); // empty in retail — perf marker
FUN_0043dc70(); // mouse / input
FUN_00455610(); // late game-tick fixup
FUN_0043fcd0(1); // flush + Present (§3)
FUN_004392b0(); // Sleep(~1ms) to cap at ~60 FPS
}
6.3 Render body (FUN_0045d0b0 @ 0x0045D0B0)
void RenderFrame(World* w) {
FUN_0045a350(); // begin scene / clear backbuffer
FUN_004596b0(); // draw 3D world (terrain, static objs)
if (w->drawUnderwaterFx)
FUN_0045cde0(); // water / underwater surface
FUN_0045b7c0(); // draw alpha / particles / billboards
FUN_0045b4c0(3, 0); // switch to orthographic / 2D
if (DAT_00837ff4 != NULL)
DAT_00837ff4->vtable[0x10](); // device BeginScene for 2D
FUN_0045b900(); // draw Keystone UI as 2D quads
}
FUN_0045b900 walks the list of on-screen UI elements
(DAT_00870340->field_0x9c + 0xb4, a linked list of panels) and calls
FUN_0045b8a0 on each, which in turn calls FUN_0045ad80 (the per-panel
draw routine) and recurses through children via
FUN_00464110 / FUN_00464490 (first-child / next-sibling iterators).
6.4 Game tick (FUN_004554b0)
void GameTick(World* w) {
if (!w->frozen) {
if (!w->sendingLogout && FUN_00455d00())
FUN_00455830();
if (w->chat != NULL && w->chat->queueLen > 0)
FUN_00455ad0(w->chat + 0x48, 0); // flush chat buffer
FUN_00509480(); // physics engine step
FUN_0050a420(); // AI / server-sync
if (DAT_008ee9c8) { // playing cinematic?
FUN_005a7800();
FUN_005062e0();
}
FUN_005524a0(); // scripts
} else {
FUN_00455d00(); // frozen: minimal state
}
FUN_0043f7b0(); // frame post-flush
// drain main-thread command queue
while (w->deferredQueue != NULL) {
ptr = w->deferredQueue->head;
if (!w->vtable[1](ptr)) FUN_00453a20(ptr);
else w->vtable[2](ptr);
InterlockedDecrement(ptr->refcount);
}
if (!w->minimized)
w->renderer->vtable[0xa8](); // sync render state
FUN_0054d700(); // misc cleanup
}
So the tick order within one frame is:
Pump → {Render begin → draw 3D → draw UI → Render end/Present} → Game state → Sleep
Which is unusual — rendering happens BEFORE the game tick, not after. The reason is that the rendering pass reads a stable snapshot of the world built at the end of the previous frame; the current tick then builds the snapshot for next frame. This is a double-buffered simulation state pattern. Our port should mirror this.
7. Input dispatch wiring
Two layers.
7.1 Win32 → App bridge
FUN_00439240 @ 0x00439240 (chunk_00430000.c:7780):
int Renderer_FilterMessage(UINT msg, WPARAM wp, LPARAM lp, HWND hwnd, int* handled) {
if (DAT_00838196 && DAT_00837ff4) {
MSG m = { msg, wp, lp, hwnd, GetMessageTime(), ... };
return DAT_00837ff4->vtable[0x70](&m, handled);
}
*handled = 0;
return 0;
}
This is the post-Keystone, pre-DefWindowProc filter called from the WndProc. It lets the renderer eat WM_SIZE / WM_DISPLAYCHANGE etc.
7.2 UI mouse hit-test
FUN_00689890 @ 0x00689890 (chunk_00680000.c) is the MouseCursor
object's TryHit method — it builds a rect around the cursor (InflateRect),
asks PtInRect, and routes to FUN_00689520 which ultimately posts
hover / click events into the Keystone event queue. This is our
reference for "UI click dispatch order": cursor → drag check → drop target check → hover → click.
7.3 Cursor management
Three small helpers manage cursor visibility / default arrow:
FUN_00439320@ 0x00439320 — "hide cursor" (clears DAT_00838197)FUN_00439400@ 0x00439400 — "show cursor" + fall back toLoadCursorA(NULL, IDC_ARROW /*0x7f00*/)FUN_004392f0@ 0x004392F0 — "confine cursor to client" viaDAT_00837ff4->vtable[0x74](1)
Retail behavior quirk: the hardware cursor is replaced by a
software-drawn cursor as soon as the UI loads. The hardware IDC_ARROW
is only visible during the launcher → window transition. Keystone owns
cursor icons; DAT_00870340->field_0x10 != 0 means "UI is drawing a
cursor", and the Win32 cursor is suppressed.
8. C# port shape (no code yet — just the architecture contract)
The retail layout maps cleanly onto Silk.NET if we keep the four-object factoring:
| Retail global | Proposed C# class | Owns |
|---|---|---|
DAT_008381a4 (HWND) |
AcDream.App.Rendering.GameWindow (exists) |
Silk window |
DAT_0086734c |
AcDream.App.Rendering.Driver.GraphicsDriverFactory |
GL backend selection, probes |
DAT_00870340 |
AcDream.App.Rendering.TurbineCore |
dat sources, GL device, font atlases, Keystone handle |
DAT_00837ff4 |
AcDream.App.Rendering.RenderDevice |
per-frame GL state, 2D+3D camera |
DAT_00870c2c |
AcDream.App.UI.KeystoneRoot |
UI panels, layout, input dispatch |
8.1 Where our GameWindow.cs fits
Our existing GameWindow.cs is roughly the union of TurbineWndProc
(implicit — Silk forwards Win32 for us) + CreateMainWindow +
MainFrame_InGame. The structure:
GameWindow.Run() = WinMain body + vtable[0x1c] (App::Run) combined
window.Load = OnLoad = FUN_0054e1a0 (core + post-device init)
window.Update = OnUpdate = FUN_004554b0 (game tick)
window.Render = OnRender = FUN_0045d0b0 + FUN_0043fcd0 (render + present)
We currently have no direct equivalent of PumpMessages because Silk.NET
pumps messages inside window.Run() and fires synthetic events. That's
fine — but we still need to preserve the ordering invariant from
§6.4: the render pass reads a snapshot built by the previous tick. In
Silk.NET terms: OnUpdate and OnRender are given distinct dts and
our simulation state must be double-buffered. Look at our code — we
currently do physics inside OnUpdate (late), which is fine; the render
pass reads the updated state. This matches retail.
8.2 Proposed UI scaffolding (what to add next)
AcDream.App.UI.UIHost— one per-GameWindow. Owns a stack of panels, aFontcache, an input dispatcher, and a draw pass. Methods roughly matching Keystone vtable:OpenPanel(name)↔Keystone::CreatePanel(slot 0x24)FindPanel(name)↔ slot 0x14SetFocus(elem)↔ slot 0x28DispatchWin32Message(...)↔ slot 0x6cSendEvent(type, subtype, payload)↔ slot 0x5cDrawFrame(deltaSeconds)↔ called fromOnRenderafter 3D world
AcDream.App.UI.Panel/Widget— retain-mode tree with first-child / next-sibling iterators matchingFUN_00464110/FUN_00464490. Each hasDraw(gl, spriteBatch)andHitTest(pt).- Input interception — our existing
_input.Mice/_input.Keyboardshandlers inGameWindow.OnLoadshould callUIHost.DispatchMouseMovefirst, and only move the camera if the UI didn't claim the event. This matches §1.4's "Keystone first, renderer second, Def last". - Font and texture loading — Keystone pulled
.fontfrom dat region0x40000000..0x40000FFF. Our port already hasDatCollection; add aFontDatresolver that reads the same region and emits a Silk.NET-usable texture atlas. - Layout format — retail stores XML layouts parsed via MSXML4.
We don't want to ship an XML interpreter for the MVP. Plan: start
with code-defined panels (login form, world view HUD) and deserialize
XML in a later milestone. Keystone XML schemas are documented in
references/holtburger/(briefly) and in some old Turbine docs — not an R1 concern.
8.3 Two pumps vs one
Retail has FUN_00411630 (wait-on-server) and FUN_00411fa0 (in-world).
Our Silk pump is unconditional; the state machine is in
StreamingController + PlayerController. The behavioral difference
only matters for "are we in a modal loading screen" — we can model that
as a Boolean flag and skip the game-tick portion of OnUpdate when it's
set.
8.4 Quitting cleanly
DAT_00838194 = 1is set byFUN_00439230@ 0x00439230 (a one-liner that flips the flag). That's the equivalent of ourWindow.Close().- The WM_QUIT (0x12) check in the pump is also triggered by the OS on shutdown. Silk handles WM_QUIT internally.
- Shutdown order in retail is:
FUN_00543fc0(server goodbye) → vtable[0x2c] (app shutdown) → teardown singletons →FUN_00406f90. OurOnClosinghandler should do the same: network-flush, dispose physics, dispose renderers, dispose dats.
8.5 Cross-validation with reference repos
- ACViewer / WorldBuilder: both are MonoGame / Silk.NET viewers — neither has a retail-style multi-window bring-up or a Keystone layer. We cannot borrow UI code from them, only dat-decoding helpers.
- ACE: server only. No WndProc / no UI. Irrelevant to this slice.
- holtburger: Rust TUI client. Confirms the overall life-cycle (login → post-login → world frames) but its "UI" is Ratatui — not comparable.
- AC2D: C++ client demo. Its
cInterface.cppconfirms that the retail cursor / WndProc flow exists but is much simpler there (no Keystone — AC2D draws its UI directly). Useful only as a sanity check that our frame order (pump → render → tick → sleep) is the right shape.
All of this confirms one important conclusion: there is no existing
reference repo that replicates Keystone. That part of the port is
genuinely novel. Plan the UI work as an internal AcDream.App.UI
module from scratch, not as a port.
9. Lookup tables for future sub-agents
Master table of every FUN_ / DAT_ referenced above:
| Symbol | Addr | File | Role |
|---|---|---|---|
| WinMain (renamed) | 0x004013A0 | chunk_00400000.c | app entry |
FUN_00401700 |
0x00401700 | chunk_00400000.c | string copy helper |
FUN_004017c0 |
0x004017C0 | chunk_00400000.c | wraps frame call |
FUN_00412180 |
0x00412180 | chunk_00410000.c | App::Initialize |
FUN_00411630 |
0x00411630 | chunk_00410000.c | pre-login frame step |
FUN_00411fa0 |
0x00411FA0 | chunk_00410000.c | main frame step |
FUN_00439140 |
0x00439140 | chunk_00430000.c | default window size 800×600 |
FUN_00439230 |
0x00439230 | chunk_00430000.c | QuitApp (sets DAT_00838194) |
FUN_00439240 |
0x00439240 | chunk_00430000.c | renderer msg filter |
FUN_00439320 |
0x00439320 | chunk_00430000.c | HideCursor |
FUN_00439370 |
0x00439370 | chunk_00430000.c | sanitize-window-size |
FUN_00439400 |
0x00439400 | chunk_00430000.c | ShowCursor |
FUN_0043985D-ish LAB_00439860 |
0x00439860 | chunk_00430000.c | WndProc (not decompiled) |
FUN_00439e50 |
0x00439E50 | chunk_00430000.c | PumpMessages |
| CreateMainWindow (renamed) | 0x0043BA60 | chunk_00430000.c | window + device bring-up |
FUN_0043ac60 |
0x0043AC60 | chunk_00430000.c | renderer init dispatcher |
FUN_0043ad90 |
0x0043AD90 | chunk_00430000.c | renderer init wrapper |
FUN_0043fcd0 |
0x0043FCD0 | chunk_00430000.c | flush + Present |
FUN_004554b0 |
0x004554B0 | chunk_00450000.c | GameTick |
FUN_0045d0b0 |
0x0045D0B0 | chunk_00450000.c | RenderFrame |
FUN_0045a350 |
0x0045A350 | chunk_00450000.c | begin scene |
FUN_004596b0 |
0x004596B0 | chunk_00450000.c | draw 3D world |
FUN_0045b7c0 |
0x0045B7C0 | chunk_00450000.c | draw alpha pass |
FUN_0045b900 |
0x0045B900 | chunk_00450000.c | draw UI / 2D pass |
FUN_0054d0c0 |
0x0054D0C0 | chunk_00540000.c | build driver factory |
FUN_0054d110 |
0x0054D110 | chunk_00540000.c | build Turbine Core |
FUN_0054e1a0 |
0x0054E1A0 | chunk_00540000.c | post-device wiring |
FUN_00557850 |
0x00557850 | chunk_00550000.c | KeystoneCreate call site |
FUN_00557930 |
0x00557930 | chunk_00550000.c | Keystone + plugin DLL load |
FUN_00557a90 |
0x00557A90 | chunk_00550000.c | Keystone pre-dispatch filter |
FUN_005577a0 |
0x005577A0 | chunk_00550000.c | MSXML4 probe |
FUN_006895d0 |
0x006895D0 | chunk_00680000.c | alloc render device |
FUN_00689890 |
0x00689890 | chunk_00680000.c | mouse hit-test dispatch |
DAT_008381a4 |
0x008381A4 | — | HWND |
DAT_0086734c |
0x0086734C | — | graphics factory |
DAT_00870340 |
0x00870340 | — | Turbine Core |
DAT_00837ff4 |
0x00837FF4 | — | render device |
DAT_00870c30 |
0x00870C30 | — | keystone.dll HMODULE |
DAT_00870c34 |
0x00870C34 | — | KeystoneCreate fn ptr |
DAT_00870c2c |
0x00870C2C | — | Keystone root |
DAT_00870c54 |
0x00870C54 | — | HACCEL |
DAT_00838194 |
0x00838194 | — | Quit flag |
10. Open questions for follow-up agents
- WndProc body — Ghidra skipped 0x00439860. A raw-bytes dump (not yet in our decompiled set) would let us confirm the Keystone-then-renderer-then-DefWindowProc ordering exactly.
KeystoneCreatesignature — we infer the 7 args from the call site, but the 4 trailing NULL slots may be (pfnRender, pfnInput, pfnResourceResolve, pfnCommand) callbacks. DumpingPTR_FUN_008000c8's surrounding strings inchunk_00800000.c(RDATA) should reveal Keystone's export list.FUN_0045b900draw pass — we haven't enumerated the full Widget vtable. Slots [0xb0], [0xb4] (enable/disable hook) and [0xbc] through [0xf8] appear repeatedly elsewhere; a dedicated sub-agent should map them.- Panel registration order — we know
@saveui/@loaduipersist layouts but not the list of well-known panel names (chat_window,compass,hp_bar, etc.) the default UI creates. Grepchunk_00570000.c/chunk_00580000.cfor string constants like"panel_"or"pnl_"to enumerate. - Font atlas format —
.fontfiles live in dat regions0x40000000..0x40000FFF; their binary layout is inDatReaderWriteralready, but we haven't validated rendering round-trip.
End of document.