# 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 [ ]"); 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 ` / `@loadui ` — 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.