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

41 KiB
Raw Blame History

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_0054d0c0FUN_0058bf30
DAT_00870340 (ptr) Turbine Core — main engine object that owns the GL surface, dat resources, and UI tools FUN_0054d110DAT_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:

  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:

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_0043ad90FUN_0043ac60FUN_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_00557850KeystoneCreate 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 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):

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 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 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)

  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.