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.
1011 lines
41 KiB
Markdown
1011 lines
41 KiB
Markdown
# 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.
|