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

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

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

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

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

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

1011 lines
41 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

# Retail AC 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 | various WM_SIZE paths |
| `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:
1. **Message pump** (`FUN_00439e50`) `PeekMessageA` + `TranslateMessage`
+ `DispatchMessageA` until 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_00837ff4` vtable[0x70]). `DefWindowProcA` is the fallthrough.
2. **Game tick** (`FUN_004554b0`) advances game state / physics /
scripts; also drains per-frame command queues.
3. **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_004013a0` at 0x004013A0** (decompiled as
`chunk_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`:
```c
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]).
```c
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 | height) of render target |
| `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)`:
```c
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`)
```c
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):
```c
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:
```c
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
```c
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:
```c
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
```c
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
```c
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 window
- `void* coreHandle` the engine-side handle Keystone embeds for
callbacks (resources, input, rendering integration)
- `LPCWSTR workingDir` where to find XML layouts & font atlases
- 4 unused `NULL` slots (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):
```c
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)
```c
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)
```c
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`)
```c
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):
```c
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 to
`LoadCursorA(NULL, IDC_ARROW /*0x7f00*/)`
- `FUN_004392f0` @ 0x004392F0 "confine cursor to client" via
`DAT_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 `dt`s 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)
1. **`AcDream.App.UI.UIHost`** one per-GameWindow. Owns a stack of
panels, a `Font` cache, an input dispatcher, and a draw pass.
Methods roughly matching Keystone vtable:
- `OpenPanel(name)` `Keystone::CreatePanel` (slot 0x24)
- `FindPanel(name)` slot 0x14
- `SetFocus(elem)` slot 0x28
- `DispatchWin32Message(...)` slot 0x6c
- `SendEvent(type, subtype, payload)` slot 0x5c
- `DrawFrame(deltaSeconds)` called from `OnRender` after 3D world
2. **`AcDream.App.UI.Panel` / `Widget`** retain-mode tree with
first-child / next-sibling iterators matching `FUN_00464110` /
`FUN_00464490`. Each has `Draw(gl, spriteBatch)` and `HitTest(pt)`.
3. **Input interception** our existing `_input.Mice` / `_input.Keyboards`
handlers in `GameWindow.OnLoad` should call `UIHost.DispatchMouseMove`
*first*, and only move the camera if the UI didn't claim the event.
This matches §1.4's "Keystone first, renderer second, Def last".
4. **Font and texture loading** Keystone pulled `.font` from dat
region `0x40000000..0x40000FFF`. Our port already has `DatCollection`;
add a `FontDat` resolver that reads the same region and emits a
Silk.NET-usable texture atlas.
5. **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 = 1` is set by `FUN_00439230` @ 0x00439230 (a one-liner
that flips the flag). That's the equivalent of our `Window.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`.
Our `OnClosing` handler 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.cpp` confirms 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
1. **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.
2. **`KeystoneCreate` signature** we infer the 7 args from the call
site, but the 4 trailing NULL slots may be (pfnRender, pfnInput,
pfnResourceResolve, pfnCommand) callbacks. Dumping
`PTR_FUN_008000c8`'s surrounding strings in `chunk_00800000.c` (RDATA)
should reveal Keystone's export list.
3. **`FUN_0045b900` draw 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.
4. **Panel registration order** we know `@saveui` / `@loadui` persist
layouts but not the list of well-known panel names (`chat_window`,
`compass`, `hp_bar`, etc.) the default UI creates. Grep
`chunk_00570000.c` / `chunk_00580000.c` for string constants like
`"panel_"` or `"pnl_"` to enumerate.
5. **Font atlas format** `.font` files live in dat regions
`0x40000000..0x40000FFF`; their binary layout is in `DatReaderWriter`
already, but we haven't validated rendering round-trip.
End of document.