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.
38 KiB
Retail AC Client — UI Input Routing and Event System
Scope: How Win32 WndProc, mouse, keyboard, and drag-drop are routed through the retail AC client's UI tree, down to individual widgets, and how modality, focus, hover, and hotkeys are implemented.
Sources: docs/research/decompiled/ chunks 00430000, 00460000,
00470000, 004A0000, 004C0000, 00550000, 00560000, 006A0000,
006B0000. Cross-reference with references/holtburger/ (TUI client,
only partially applicable) and src/AcDream.App/Rendering/GameWindow.cs
for the current Silk.NET baseline.
Bottom line up front: the retail client runs a classic Win32
message pump (PeekMessageA → optional IME filter → optional
TranslateAccelerator → TranslateMessage → DispatchMessageA) whose
window class points at a stub WndProc at 0x00439860. The WndProc
routes WM_* messages into a central Device object
(DAT_00837ff4) that holds mouse cursor state, keyboard focus,
modality/capture, and a timer/event queue. The UI tree is a
dynamically-ID'd widget graph where every widget has a unique
32-bit event ID (custom app events live in the 0x10000000 namespace).
Events are delivered to the owning panel's OnEvent(int *param_2)
handler as a 4+-word struct: {source_id, target_widget, event_type, payload...}. The system uses virtual methods on the Device object
(vtable offsets 0x18, 0x1c, 0x34, 0x38, 0x3c, 0x48, 0x4c, 0x58, 0x70,
0x74, 0x78, 0x88, 0x90, 0xa8) plus the widget's own vtable to route
click, hover, drag, drop, and focus events.
1. The Win32 message pump
Entry point: FUN_00439e50 at 0x00439E50 (chunk 00430000, line
8265). This is the per-frame message-drain that the main loop calls
each tick.
// Decompiled — FUN_00439e50 (pump one frame's worth of Windows messages)
char FUN_00439e50(void) {
tagMSG msg;
DAT_00838198 = 1; // "we are in pump"
int got = PeekMessageA(&msg, NULL, 0, 0, PM_REMOVE); // 1 = PM_REMOVE
while (got != 0 && msg.message != WM_QUIT /*0x12*/) {
char handled_by_ime = FUN_006a1050(&msg); // IME filter (stub: always 0)
if (!handled_by_ime) {
int handled_by_accel =
FUN_00557a90(msg.hwnd, 0, &msg); // TranslateAccelerator wrapper
if (!handled_by_accel) {
TranslateMessage(&msg);
DispatchMessageA(&msg); // → LAB_00439860 WndProc
}
}
got = PeekMessageA(&msg, NULL, 0, 0, PM_REMOVE);
}
// … window-activation bookkeeping at end …
DAT_00838198 = 0;
return DAT_00838194; // returns quit flag
}
Key observations:
- Uses
PeekMessagenotGetMessage— non-blocking, game loop can render every frame regardless of message activity. - Drains all pending messages each call (inner
while), not one per frame. - IME hook (
FUN_006a1050) — the production build has it stubbed toreturn 0; this is a vestigial hook from the East-Asian localization path. Japanese/Korean builds would return non-zero on IME composition events. - Accelerator table is empty —
CreateAcceleratorTableA((LPACCEL)0, 0)at chunk00550000line 7044 creates a zero-entry accel table. All hotkeys are done in-widget, not via Win32.
Window class registration: FUN_0043bd0b (same chunk, line 9919):
WNDCLASSA wc = {0};
wc.lpfnWndProc = (WNDPROC)&LAB_00439860; // THE WndProc
wc.hInstance = hInstance;
wc.hbrBackground = GetStockObject(GRAY_BRUSH /*4*/);
wc.lpszClassName = "Turbine Device Class";
RegisterClassA(&wc);
// …
hWnd = CreateWindowExA(0, "Turbine Device Class", title, styleFlags,
x, y, w, h, NULL, NULL, hInstance, NULL);
// stored as DAT_008381a4 — the one-and-only app window handle
The WndProc itself (LAB_00439860) is not present as a decompiled
function in any chunk Ghidra produced — it's exported only as a label.
From the call sites and the referenced globals (DAT_00837ff4 = the
Device, DAT_008381a4 = HWND, the internal event IDs 0x1fd, 0x1fe,
0x1ff, 0x200, 0x201, 0x202, 0x203, 0x205, 0x207, 0x208
— all corresponding to WM_*) the WndProc is a big dispatcher that
takes the WPARAM/LPARAM for each WM_ and calls a method on the Device
object at DAT_00837ff4 via its vtable.
Reconstructed WndProc (pseudocode, based on dispatch call-sites):
LRESULT CALLBACK AcWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
{
if (DAT_00837ff4 == NULL) return DefWindowProcA(hwnd, msg, wp, lp);
int* device = *(int**)DAT_00837ff4;
switch (msg) {
// === Focus / window lifecycle ===
case WM_ACTIVATE: /* 0x06 */
case WM_SETFOCUS: /* 0x07 */
case WM_KILLFOCUS: /* 0x08 */
case WM_SIZE: /* 0x05 */
case WM_CLOSE: /* 0x10 */
case WM_DESTROY: /* 0x02 */
return (*device->focus_handler)(msg, wp, lp);
// === Mouse ===
case WM_MOUSEMOVE: /* 0x200 */
// LOWORD(lp)=x, HIWORD(lp)=y, wp=key flags
return (*device->on_mouse_move)((int)(short)(lp & 0xFFFF),
(int)(short)((lp >> 16) & 0xFFFF),
wp);
case WM_LBUTTONDOWN: /* 0x201 */
return (*device->on_button)(1, 1 /*down*/,
(short)(lp & 0xFFFF), (short)(lp >> 16));
case WM_LBUTTONUP: /* 0x202 */
return (*device->on_button)(1, 0 /*up*/, …);
case WM_LBUTTONDBLCLK: /* 0x203 */
return (*device->on_dblclk)(1, …);
case WM_RBUTTONDOWN: /* 0x204 */
case WM_RBUTTONUP: /* 0x205 */
case WM_RBUTTONDBLCLK: /* 0x206 */
case WM_MBUTTONDOWN: /* 0x207 */
case WM_MBUTTONUP: /* 0x208 */
return (*device->on_button)(…);
case WM_MOUSEWHEEL: /* 0x020A */
return (*device->on_wheel)((short)HIWORD(wp), lp);
// === Keyboard ===
case WM_KEYDOWN: /* 0x100 */
case WM_SYSKEYDOWN: /* 0x104 */
return (*device->on_keydown)(wp /*vk*/, lp /*scan+flags*/);
case WM_KEYUP: /* 0x101 */
case WM_SYSKEYUP: /* 0x105 */
return (*device->on_keyup)(wp, lp);
case WM_CHAR: /* 0x102 */
return (*device->on_char)(wp, lp);
case WM_TIMER: /* 0x113 */
return (*device->on_timer)(wp);
// === Paint / cursor ===
case WM_PAINT: /* 0x0F */
case WM_ERASEBKGND: /* 0x14 */
return 1; // client paints via DirectX; no GDI
case WM_SETCURSOR: /* 0x20 */
return (*device->on_setcursor)(hwnd, wp, lp);
default:
return DefWindowProcA(hwnd, msg, wp, lp);
}
}
The "internal event IDs" 0x1FD, 0x1FE, 0x1FF, 0x200–0x208 that appear
deep inside chunk 006A0000.c (FUN_006add50 et al.) reuse the Win32
WM_ numbers as the internal event type constants — the client just
propagates WM_LBUTTONDOWN = 0x201 straight through its own dispatch
layers. This means:
0x200= mouse-moved0x201= left-button-down (one of the two-word payload values)0x202= left-button-up0x203= left-double-click0x205= right-button-up0x207= middle-button-down0x208= middle-button-up0x1FD–0x1FF= custom variants (button-release-after-drag, context-click, etc.)
2. The Device object (DAT_00837ff4)
Every subsystem that reads mouse/keyboard or registers with the window pokes through this one global. Reconstructed vtable from the 80+ dispatch sites grepped:
| Offset | Inferred name | Purpose |
|---|---|---|
0x00 |
dtor | |
0x04 |
InitWindow(HWND) |
Takes the newly created HWND |
0x08 |
Shutdown() |
|
0x0C |
Tick() |
Per-frame timer pump |
0x10 |
GetClientWidth() |
|
0x14 |
GetClientHeight() |
|
0x18 |
GetMouseX() |
Current cursor X (client coords) |
0x1C |
GetMouseY() |
Current cursor Y |
0x28 |
SetModalOverlay(widget) |
|
0x2C |
GetActiveWidget() |
|
0x34 |
RegisterTimerEvent(evt_type, target, delay_ms) |
Deferred event |
0x38 |
FireEvent(evt_type, target) |
Immediate event |
0x3C |
AddWidgetToRoot(widget) |
Attach panel to desktop |
0x44 |
GetKeyboardFocus() |
Returns currently-focused widget |
0x48 |
SetCapture(widget, kind) |
Mouse/modal capture |
0x4C |
ReleaseCapture(kind) |
|
0x58 |
IsLeftButtonDown() |
|
0x6C |
TranslateAccelerator(...) |
Called by pump's FUN_00557a90 wrapper |
0x70 |
ClientToScreen(pt, out) |
|
0x74 |
SetDragCursor(on) |
|
0x78 |
ResetDragDrop() |
|
0x7C |
CheckRectHit(pt, rect) |
|
0x88 |
GetCursorShape() |
|
0x90 |
GetDragOffset(a, b, out) |
|
0xA8 |
SetBusyOverlay(on) |
Hourglass / loading |
Widget vtable offsets used by FUN_00462420 and friends in chunk
00460000:
| Offset | Purpose |
|---|---|
0x0C |
Serialize(buf, &size) — used for clipboard / drag payload |
0x14 |
InvokeCallback(evt_id) |
0x18 |
SetVisible(bool) |
0x24 |
SetEnabled(bool) |
0x2C |
Close() |
0x70 |
GetProperty(attrId, out) |
0x78 |
GetInt(attrId, out) |
0x88 |
GetString(attrId, out) |
0x9C |
ChangeState(newState) |
0xA0 |
GetOwner() / GetSourceWidget() |
0xA8 |
GetEventCallback(out) |
0xC8 |
GetEventCallbackB(out) |
0xD0 |
LookupEvent(attrId, out) |
0xFC |
OnDragStart() |
0x128 |
OnEvent(event_struct, 0) — the main event-handler |
3. The event struct — what a handler receives
Every widget's OnEvent (vtable + 0x128) is called with an int* event. The layout — reconstructed from how chunk 00470000.c and
004A0000.c unpack it — is:
struct Event {
int source_id; // param_2[0] — e.g. 0x100001d6 (drag source),
// 0x1000030a (button), 0x1000046f,…
int* target_widget; // param_2[1] — pointer to owner widget
int event_type; // param_2[2] — 1=click, 7=hover, 8=dblclick,
// 0x0e=right-click,
// 0x15=drag-begin,
// 0x1c=drag-over,
// 0x21=drag-entered-target,
// 0x3e=drop-released,
// 0x28=lose-focus, 0x29=gain-focus…
int data0; // param_2[3] — delta / button / flags
int data1; // param_2[4] — x or timer-id
int data2; // param_2[5] — y or payload pointer
int data3; // param_2[6] — extra
};
Event type catalog (from all switch-case sites):
| Code | Meaning | Evidence |
|---|---|---|
0x01 |
Click (left button released on) | chunk_00470000 ~11140, chunk_004C0000 ~9270 |
0x05 |
Hover enter | chunk_00460000 ~6253 (registered with ~10 ms) |
0x06 |
Hover leave | chunk_00460000 ~6254 |
0x07 |
Tooltip delay expired (mouse-over after delay) | chunk_00460000 ~6253, ~6280 |
0x08 |
Double-click / tooltip-second | chunk_00460000 ~6254 |
0x0A |
Scroll-wheel | chunk_00470000 ~11210 |
0x0E |
Right-click | chunk_004A0000 ~2674 |
0x15 |
Drag begin | chunk_004A0000 ~2707 |
0x1C |
Drag-over target | chunk_004A0000 ~2723 |
0x21 |
Drag entered a drop target | chunk_004A0000 ~2714 |
0x3E |
Drop released | chunk_004A0000 ~2754 |
(There are more; the above are the ones empirically encountered in inventory/paperdoll/attribute panels.)
4. Hit-test / routing: how a WM_LBUTTONDOWN reaches a button
The UI tree is a flat list of "panels" (top-level windows) each holding a tree of child widgets. Each widget has:
- A 32-bit unique
event_id(custom app events use0x10000000high bit). - A rect (
x, y, w, h) in panel-local coordinates. - A parent, children list, z-order.
- Visibility / enabled flags in offset
+0x694.
When a WM_LBUTTONDOWN arrives:
// Reconstructed from FUN_00468b80 + FUN_00468d30 + FUN_00468e20
// (chunk_00460000.c) plus the Device.OnButton vtable
void Device::OnButton(int button, int down, int x, int y) {
if (modal_overlay && !modal_overlay->contains(x, y)) {
// modal blocks clicks outside
return;
}
// Walk top panels in Z order (topmost first); each FUN_00468b80 call
// computes the cursor shape for one widget at (x, y)
for (Panel* p : panels_top_to_bottom) {
if (!p->visible) continue;
int local_x = x - p->origin_x;
int local_y = y - p->origin_y;
if (!p->hit_test(local_x, local_y)) continue;
// Depth-first within the panel. hit-test cursor returns:
// local_c = 1 if a widget claims the point, else 0
Widget* hit = p->pick(local_x, local_y);
if (hit == NULL) continue;
// Convert raw WM_ into custom event
Event e;
e.source_id = hit->event_id;
e.target_widget= hit;
e.event_type = (button == 1 && down) ? 0x201 : 0x202;
e.data0 = button_flags;
e.data1 = local_x;
e.data2 = local_y;
// Set capture so WM_LBUTTONUP goes back to same widget
if (down) device->SetCapture(hit, 1);
// Dispatch to the panel's OnEvent (vtable +0x128)
(hit->panel->vtbl[0x128/4])(&e, 0);
// Widget can consume or ignore — next panel is NOT checked
return;
}
// Nothing captured it — fall through to world-click handler
game_world_on_click(x, y, button);
}
Hit-test recursion (FUN_00468d30 + FUN_00473690 + FUN_00472b30):
// FUN_00468d30: point-in-rect test for one widget
// param_1 = widget, param_2 = {event_id, event_type}
// returns: low-byte 0/1 hit, high 24 bits = cursor variant hint
bool Widget::hit_test(int* event_out) {
int absolute_x = 0, absolute_y = 0;
FUN_004686b0(); // refresh cached offsets
if (!FUN_004732a0(*event_out, &absolute_x, &absolute_y)) return false;
if (this.flags & FLAG_HORIZONTAL /* +0x694 & 2 */) {
int bounds_h = FUN_0069fe60() - this.bounds.h - this.bounds.x;
return this.bounds.x <= absolute_x
&& absolute_x + 2 <= this.bounds.x + bounds_h;
} else {
int bounds_w = FUN_0069fe70() - this.bounds.w - this.bounds.y;
return this.bounds.y <= absolute_y
&& absolute_y + local_w <= this.bounds.y + bounds_w;
}
}
5. Focus system
Single global focus — Device::GetKeyboardFocus() (vtable +0x44)
returns one widget pointer or NULL. All WM_KEYDOWN / WM_CHAR go to
that widget.
Focus transfer:
- Mouse click on a focus-accepting widget → click handler calls
Device->SetKeyboardFocus(self). Tabkey in an edit control → widget's keydown handler interceptsVK_TABand walks the sibling list.- Pressing
Enterin the chat entry may loop focus back to world. FUN_00469780in chunk00460000.c(line 7665) dispatches keyboard input: it looks upparam_4(the VK code) in a hash table hanging off the panel's+0x18cbucket array. If the widget has a callback registered for that key, the callback's+0x14(InvokeCallback) fires.
6. Drag-drop
The drag-drop state machine lives in the paperdoll code (chunk_004A0000). The lifecycle:
┌────────────────────────┐
│ mouse-down on draggable widget (inventory slot, hotbar item)
│ → widget sets "drag-candidate" flag
│
│ mouse-move while button held + distance > 3 px
│ → Event{type=0x15, source=widget_id, data=payload_id}
│ dispatched to all potential targets
│ → Device::SetDragCursor(true) (vtable +0x74)
│ → cursor changes to "grab"
│
│ mouse-move over a widget that accepts drops
│ → Event{type=0x21, source=payload, target=widget}
│ target returns "ok" by setting a highlight flag
│ → Event{type=0x1c, ...} fired every subsequent move while over it
│ (used to keep showing a "this is where it lands" hint)
│
│ mouse-up (button released)
│ → if cursor over accepting target:
│ Event{type=0x3e, source=payload, target=widget,
│ data3=button_flags_from_down}
│ target runs drop handler:
│ - call FUN_004a4c90(x,y) to find slot under cursor
│ - call server-side "move item" message
│ → Device::ResetDragDrop() (vtable +0x78)
│ → if cursor NOT over target, item snaps back (no event)
│ → drag cursor cleared
└────────────────────────┘
Concrete evidence — paperdoll FUN_004A5FA0 (line 2660):
void Paperdoll::OnEvent(int* e) {
if (e[2] == 1) { // click
if (*e == 0x100005be) { // "unequip all" button
FUN_00460cc0(0xe, &e); // ask: we in combat mode?
if (e == 0) show_all_slots();
else hide_slots();
}
} else if (e[2] == 0x15) { // drag begin
if (*e == 0x100001d6) { // "item-from-inventory" drag source
widget.ChangeState(0x1000003f); // highlight the doll
}
} else if (e[2] == 0x21) { // enters a valid paperdoll slot
if (*e == 0x100001d6) {
int slot = FUN_004a4c90(e[5], e[6]); // hit test slot by local xy
if (slot != 0 && FUN_004a48f0(slot)) { // accepts this type?
FUN_0045e120(highlight_obj, 0x10, 0x10); // flash slot
FUN_006a9640(slot, 0, 0); // set cursor hint
}
}
} else if (e[2] == 0x1c) { // drop-here (drag ended over us)
if (*e == 0x100001d6) {
int slot = FUN_004a4c90(GetMouseX(), GetMouseY());
if (slot != 0) {
if (e[3] == 7) /* "used" from inventory */
FUN_0058d110(slot, 0); // send "equip to slot"
else if (e[3] == 8) /* … */
FUN_0058d110(slot, 0);
}
}
} else if (e[2] == 0x3e && *e == 0x100001d6) { // drop-released
if (e[3] != 0) { // accepted
if (hovered_slot != 0) {
do_equip(hovered_slot);
} else {
widget.ChangeState(0x1000003f); // snap-back highlight
}
}
}
FUN_00462420(e); // let base class propagate
}
Two important details:
- Single drag at a time — the
source_id = 0x100001d6is the only "inventory-drag-payload" identity; the Device tracks exactly one drag. - The payload isn't carried in the event — it's stashed in the
Device's drag state (
+0x74set byDevice::SetDragCursor(on)); targets look up the payload by asking the Device for the current drag source.
7. Hover & tooltips
Two separate event types:
-
0x05/0x06 —
enter/leave, fired immediately on mouse crossing the widget's rect. Used for hover-highlight (brightening a button frame). -
0x07 — delayed "tooltip" event. The widget registers itself via
Device::RegisterTimerEvent(0x07, self, delay_ms)at offset+0x34on hover enter (see chunk_00460000.c:6253):bVar2 = (*device_vtbl[0x34])(1, panel, unaff_retaddr + -10); // evt 1 ~immediate bVar3 = (*device_vtbl[0x34])(7, panel, unaff_retaddr); // evt 7 delayedThe
unaff_retaddrvalue is the delay in ms — reading the caller, AC uses ~1000 ms for tooltip delay and ~10 ms for the immediate hover-enter. If the mouse moves off before the timer fires, the widget callsDevice::FireEvent(7, self)(offset+0x38) with a zero-delay to cancel (i.e., the timer is cancelable by re-firing it with a cancel flag).
Tooltip rendering is a separate top-level overlay panel managed
by the UI manager. When event 0x07 fires, the widget returns its
tooltip text (via GetString, vtable +0x88) and the UI manager
positions a tooltip panel under the cursor.
8. Hotkey / keybind system
No Win32 accelerator table. The client creates an empty
CreateAcceleratorTableA((LPACCEL)0, 0) at chunk_00550000:7044. All
hotkeys are done inside the UI:
- The focus widget (usually the world view / chat when not entering
text) receives
WM_KEYDOWN. Device::OnKeydown(vk, lparam)forwards to the active panel'sOnKeyDown.- The top-level panel walks its keybind table (usually stored as a
hash of
VK → action_id). A match dispatches the action to the game system. - Quickbar slots
1–=are scan-code-based:VK_1throughVK_0andVK_OEM_MINUS,VK_OEM_PLUS, each mapped toUseItem(hotbar_slot). The MainView panel owns this binding. Tab/Shift+Tabtargets next/previous creature.- Panel-open hotkeys (
B,I,C, etc.) are handled by the top-level UI manager once focus is not in a text-entry field.
The test "is focus in text entry?" is simply Device::GetKeyboardFocus() != NULL && focus->is_edit_control().
9. Modal dialogs
Modality is implemented by one slot on the Device —
modal_overlay (set/cleared via vtable +0x48 / +0x4C). When set:
OnButtonat the Device level bails early if the click isn't inside the modal's rect.OnKeydownonly fires on the modal's subtree.- All other panels are drawn dimmed (a gray quad is layered behind the modal).
Evidence: FUN_00467710 sets flag 0x800 on a widget (the "I am the
modal" flag). FUN_006a0430 is called when the flag changes to trigger
a redraw of the dim-layer.
The login screen is the first modal: the main MainView panel is
created dimmed and the Login panel is added with the modal flag;
clicks outside Login do nothing until LoginComplete arrives.
10. Chat text entry
The chat-entry widget is an EditBox class living under the chat
panel. Its key behavior:
Enterto start typing: theMainViewpanel's keydown handler catchesVK_RETURNwhen focus is NOT already in chat; it callsChatEntry::StartEdit()which doesDevice::SetKeyboardFocus(self)and shows the entry caret.Escapeto cancel:EditBox::OnKeyDown(VK_ESCAPE)restores the old buffer, releases focus, hides the caret.Tabto switch channel:EditBox::OnKeyDown(VK_TAB)cycles the channel indicator (/s,/f,/a,/g, tell target). The channel is prepended to the message on submit.Enterin chat: submits the message, clears the buffer, releases focus.WM_CHARtyping: goes throughDevice::OnChar(wparam, lparam)which deposits the character at the caret position. The widget maintains awchar_tbuffer; see chunk_00560000.c for the manywcslen/wcscpysites on chat messages.
Scroll-back: the chat panel listens to WM_MOUSEWHEEL and scrolls its
rolling log when the cursor is over the chat area.
11. Click-through vs click-blocked
Two widget flags (at +0x694):
FLAG_CLICK_THROUGH(bit not definitively pinned — probably0x40): hit_test returns false even when the cursor is inside the rect. Used for decoration panels (character-portrait frames, ornamental dividers).FLAG_CLICK_CAPTURE(probably0x01): the normal mode; absorbs clicks that hit the widget.
The hit-test loop in Panel::pick skips any widget with
click-through, walks into children for others. Any widget that
returns "hit" stops the upward propagation.
World-click passthrough: when no UI panel claims the click, the
Device falls through to the world handler: MainView::on_world_click
does a ray-pick against the 3D scene, selecting creatures or
triggering walk-to-point commands.
12. Pseudocode — complete WndProc mouse-down flow
// ================= INBOUND =================
// Win32 → WndProc → Device::OnButton → Panel::OnMouseDown
// → Widget::hit_test → Widget::OnEvent → ...
LRESULT CALLBACK WndProc(HWND h, UINT m, WPARAM w, LPARAM l) {
switch (m) {
case WM_LBUTTONDOWN:
int x = (short)(l & 0xFFFF);
int y = (short)((l >> 16) & 0xFFFF);
return g_device->OnButton(1 /*btn*/, 1 /*down*/, x, y, w /*flags*/);
/* … */
}
}
void Device::OnButton(int btn, int down, int x, int y, WPARAM flags) {
// 1) Modal?
if (modal && !modal->rect.contains(x, y)) return;
// 2) Captured? (e.g. dragging)
if (captured_widget) {
Event e = {captured_widget->id, captured_widget,
down ? 0x201 : 0x202, flags, x, y};
captured_widget->panel->OnEvent(&e);
if (!down) captured_widget = NULL; // release on up
return;
}
// 3) Top-to-bottom z-order walk
for (Panel* p = panels_top; p != NULL; p = p->below) {
if (!p->visible) continue;
Widget* w = p->HitTest(x - p->origin_x, y - p->origin_y);
if (w == NULL) continue;
if (w->flags & FLAG_CLICK_THROUGH) continue;
// 4) Construct event and dispatch
Event e;
e.source_id = w->event_id;
e.target = w;
e.event_type = 0x201; // WM_LBUTTONDOWN
e.data0 = flags;
e.data1 = x - p->origin_x;
e.data2 = y - p->origin_y;
// 5) Set capture so matching WM_LBUTTONUP reaches same widget
if (down) { captured_widget = w; SetCapture(hwnd_ac); }
// 6) Widget handler — virtual OnEvent at +0x128
(w->panel->vtbl[0x128/4])(&e, 0);
// 7) Maybe spawn drag — if widget flags allow + mouse moves ≥ 3px
// this is arranged by the subsequent WM_MOUSEMOVE handler
return;
}
// 8) No UI claimed — hand off to game world
game_world->OnWorldClick(btn, x, y, flags);
}
13. Pseudocode — hit-test recursion
Widget* Panel::HitTest(int lx, int ly) {
// Walk children in reverse draw order (topmost-first)
for (Widget* c = last_child; c != NULL; c = c->prev_sibling) {
if (!c->visible || (c->flags & FLAG_CLICK_THROUGH)) continue;
if (!c->bounds.contains(lx, ly)) continue;
// Descend first
if (c->num_children > 0) {
Widget* deep = c->HitTest(lx - c->bounds.x, ly - c->bounds.y);
if (deep) return deep;
}
return c; // self consumed it
}
return NULL;
}
14. Pseudocode — drag-drop end-to-end
// INITIATION — within WM_MOUSEMOVE after button held + distance crossed
void Device::UpdateDrag(int x, int y) {
if (!drag_candidate) return;
if (!dragging &&
(abs(x - press_x) > 3 || abs(y - press_y) > 3))
{
// Promote to drag
dragging = true;
drag_source = drag_candidate;
drag_payload = drag_candidate->payload;
SetDragCursor(true); // vtbl +0x74
// Fire drag-begin to the SOURCE widget (lets it hide its icon)
Event e = {drag_source->id, drag_source, 0x15, payload, 0, 0};
drag_source->panel->OnEvent(&e);
}
if (dragging) {
// Find potential target under cursor
Widget* hover = HitTestAll(x, y);
if (hover && hover != last_hover) {
// Leaving old
if (last_hover) {
Event e = {drag_payload->id, last_hover, 0x1c, 0, x, y};
last_hover->panel->OnEvent(&e);
}
// Entering new
Event e2 = {drag_payload->id, hover, 0x21, 0, x, y};
hover->panel->OnEvent(&e2);
last_hover = hover;
}
}
}
// TERMINATION — WM_LBUTTONUP during drag
void Device::OnButton_Up_Drag(int x, int y) {
Widget* target = HitTestAll(x, y);
if (target) {
Event e = {drag_payload->id, target, 0x3e, 1 /*accepted*/, x, y};
target->panel->OnEvent(&e);
} else {
// Snap-back
Event e = {drag_payload->id, drag_source, 0x3e, 0 /*rejected*/, x, y};
drag_source->panel->OnEvent(&e);
}
ResetDragDrop(); // vtbl +0x78
SetDragCursor(false);
dragging = false;
drag_candidate = NULL;
last_hover = NULL;
}
15. Pseudocode — keyboard-focus routing
LRESULT WndProc_KeyDown(WPARAM vk, LPARAM lp) {
return g_device->OnKeyDown(vk, lp);
}
void Device::OnKeyDown(WPARAM vk, LPARAM lp) {
Widget* focused = GetKeyboardFocus(); // vtbl +0x44
// 1) If an edit box owns focus, give IT first shot
if (focused && focused->IsEditControl()) {
if (focused->OnKeyDown(vk, lp)) // consumed?
return;
}
// 2) Otherwise walk modal → active panels → main view
Panel* walk = modal_overlay ? modal_overlay : top_panel;
while (walk) {
if (walk->OnKeyDown(vk, lp)) return; // consumed
walk = walk->below;
}
// 3) Global hotkeys: panel-open, emotes, quickbar
if (vk == VK_B) { open_combat_settings_panel(); return; }
if (vk >= VK_1 && vk <= VK_0)
{ hotbar->Trigger(vk - VK_1); return; }
/* … */
}
16. Proposed C# port structure
Our current GameWindow.cs binds Silk.NET IInputContext directly to
camera + player controllers. To support a full retail UI we need to
splice a UI-tree dispatcher between Silk.NET's raw events and the
game systems. Proposed layer:
namespace AcDream.App.Ui;
/// <summary>
/// The entire retail-style event struct. One 24-byte record per event.
/// </summary>
public struct UiEvent
{
public uint SourceId; // widget event id (0x10000000+)
public IWidget Target;
public int Type; // 1=click, 7=hover, 0x15=drag-begin, ...
public int Data0;
public int Data1; // x in widget-local coords
public int Data2; // y in widget-local coords
public int Data3;
public object? Payload; // drag payload (ItemInstance, etc.)
}
public enum UiEventType
{
Click = 0x01,
HoverEnter = 0x05,
HoverLeave = 0x06,
Tooltip = 0x07,
DoubleClick = 0x08,
Scroll = 0x0A,
RightClick = 0x0E,
DragBegin = 0x15,
DragOver = 0x1C,
DragEnter = 0x21,
FocusLost = 0x28,
FocusGained = 0x29,
DropReleased = 0x3E,
MouseDown = 0x201,
MouseUp = 0x202,
MouseMove = 0x200,
KeyDown = 0x100,
KeyUp = 0x101,
Char = 0x102,
}
public interface IWidget
{
uint EventId { get; }
Rectangle Bounds { get; }
IPanel Panel { get; }
bool Visible { get; }
bool ClickThrough { get; }
bool AcceptsFocus { get; }
bool IsEditControl { get; }
bool OnEvent(in UiEvent e); // returns true if consumed
// Tooltip / dynamic state helpers (mirrors the vtable +0x88 / +0x70)
string? GetTooltipText();
}
public interface IPanel : IWidget
{
IReadOnlyList<IWidget> Children { get; }
IWidget? HitTest(int localX, int localY);
}
public interface IDevice
{
// Mirrors DAT_00837ff4 vtable
int MouseX { get; }
int MouseY { get; }
IWidget? KeyboardFocus { get; set; }
IWidget? Captured { get; set; }
IWidget? Modal { get; set; }
bool IsLeftButtonDown { get; }
// Event plumbing
void FireEvent(int evtType, IWidget target, object? payload = null);
void RegisterTimerEvent(int evtType, IWidget target, int delayMs);
// Drag state
IWidget? DragSource { get; }
object? DragPayload { get; }
void BeginDrag(IWidget source, object payload);
void EndDrag();
}
public sealed class UiRoot : IDevice
{
private readonly List<IPanel> _panels = new(); // top-down z order
private IWidget? _focus, _captured, _modal;
private readonly List<(long fireAtMs, UiEvent e)> _timers = new();
private long _nowMs;
private IWidget? _dragSource;
private object? _dragPayload;
private IWidget? _lastDragHover;
private int _pressX, _pressY;
private bool _dragArmed;
// Hooked from GameWindow.cs
public void OnMouseDown(int btn, int x, int y, int flags)
{
if (_modal != null && !_modal.Bounds.Contains(x, y)) return;
if (_captured != null) { DispatchButton(_captured, btn, true, x, y, flags); return; }
for (int i = _panels.Count - 1; i >= 0; --i)
{
var p = _panels[i];
if (!p.Visible) continue;
int lx = x - p.Bounds.X, ly = y - p.Bounds.Y;
var w = p.HitTest(lx, ly);
if (w == null || w.ClickThrough) continue;
_captured = w;
_pressX = x; _pressY = y; _dragArmed = true;
DispatchButton(w, btn, true, lx, ly, flags);
return;
}
// Fall through to world: let GameWindow's existing handlers run
WorldClicked?.Invoke(btn, x, y, flags);
}
public void OnMouseUp(int btn, int x, int y, int flags)
{
if (_dragSource != null) { FinishDrag(x, y); return; }
if (_captured != null)
{
int lx = x - _captured.Panel.Bounds.X;
int ly = y - _captured.Panel.Bounds.Y;
DispatchButton(_captured, btn, false, lx, ly, flags);
_captured = null;
_dragArmed = false;
}
}
public void OnMouseMove(int x, int y)
{
if (_dragArmed && !DragStarted(x, y)) MaybePromoteDrag(x, y);
if (_dragSource != null) UpdateDragHover(x, y);
// Dispatch hover enter/leave + restart tooltip timer
UpdateHoverChain(x, y);
}
public void OnChar(int codepoint)
{
if (_focus is { IsEditControl: true } e)
{
e.OnEvent(new UiEvent { Type = (int)UiEventType.Char, Target = e,
Data0 = codepoint });
}
}
public void OnKeyDown(int vk, int lp)
{
// focus widget first
if (_focus?.OnEvent(new UiEvent { Type = (int)UiEventType.KeyDown,
Target = _focus, Data0 = vk }) == true)
return;
// panel walk
var start = _modal ?? (_panels.Count > 0 ? _panels[^1] : null);
for (var p = start; p != null; p = PanelBelow(p))
{
if (p.OnEvent(new UiEvent { Type = (int)UiEventType.KeyDown,
Target = p, Data0 = vk })) return;
}
// global hotkeys
GlobalHotkey?.Invoke(vk, lp);
}
public void Tick(long nowMs)
{
_nowMs = nowMs;
for (int i = _timers.Count - 1; i >= 0; --i)
{
if (_timers[i].fireAtMs <= nowMs)
{
_timers[i].e.Target.OnEvent(_timers[i].e);
_timers.RemoveAt(i);
}
}
}
// ... DispatchButton, BeginDrag, FinishDrag, etc.
}
Integration into GameWindow.cs:
// In GameWindow.OnLoad
_uiRoot = new UiRoot();
_uiRoot.WorldClicked += (btn, x, y, flags) => HandleWorldClick(btn, x, y);
_uiRoot.GlobalHotkey += (vk, lp) => HandleHotkey(vk);
foreach (var mouse in _input.Mice)
{
mouse.MouseDown += (_, b) => _uiRoot.OnMouseDown(
b switch { MouseButton.Left => 1, MouseButton.Right => 2, _ => 3 },
(int)mouse.Position.X, (int)mouse.Position.Y, 0);
mouse.MouseUp += (_, b) => _uiRoot.OnMouseUp(
b switch { MouseButton.Left => 1, MouseButton.Right => 2, _ => 3 },
(int)mouse.Position.X, (int)mouse.Position.Y, 0);
mouse.MouseMove += (_, p) => _uiRoot.OnMouseMove((int)p.X, (int)p.Y);
mouse.Scroll += (_, s) => _uiRoot.OnScroll((int)(s.Y), 0);
}
foreach (var kb in _input.Keyboards)
{
kb.KeyDown += (_, k, _) => _uiRoot.OnKeyDown((int)k, 0);
kb.KeyUp += (_, k, _) => _uiRoot.OnKeyUp((int)k, 0);
kb.KeyChar += (_, c) => _uiRoot.OnChar(c);
}
This pattern:
- Preserves our current camera/player control when no panel exists (the "world click" fall-through).
- Gives retail-faithful event type codes (
0x1C,0x21,0x3E, etc.) so hand-ported panels work with the same magic numbers as the original. - Centralizes modality / focus / capture / drag in one object.
- Dispatches with one
IWidget.OnEvent(UiEvent)method, matching the retail widget vtable's+0x128slot.
17. Cross-references in our codebase
src/AcDream.App/Rendering/GameWindow.cs— current raw Silk.NETIInputContextbinding. Lines 214–510. The camera/player controller bindings should move behind the newUiRoot.WorldClicked/GlobalHotkeyevents.src/AcDream.App/Input/PlayerMovementController.cs— Keyboard-driven movement, untouched by this change. Will continue to listen to the non-focused keyboard state.- No existing files yet — all new files go under
src/AcDream.App/Ui/.
18. Open questions / next-phase scope
- Widget IDs from .dat? The retail client hard-codes IDs like
0x1000001c(attribute panel "+" button). These presumably come from a.datfile that maps panel XML → widget ID. Need to decompilecPanel::Reador the UI-layout loader to confirm. - Tooltip delay constant — grep the decompiled code for
0x3e8or1000nearRegisterTimerEvent(7, …)to pin the exact ms. - Exact modal z-ordering of the dim layer — is it painted before or after the modal itself? Probably between, i.e., base panels → dim quad → modal.
- Edit-control IME composition — the
FUN_006a1050IME hook is empty in the production build, but the Japanese builds call something. Skip for MVP. - Drag threshold — 3 px is a guess; double-check chunk
00469880for the exact distance. - Scroll-wheel widget routing — needs one more pass; events 0x0A
look like they carry the wheel delta in
data0, but the hit-test rule is "under the cursor", not "focused widget".
End.