# 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. ```c // 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 `PeekMessage` not `GetMessage` — 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 to `return 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 chunk `00550000` line 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): ```c 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):** ```c 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-moved - `0x201` = left-button-down (one of the two-word payload values) - `0x202` = left-button-up - `0x203` = left-double-click - `0x205` = right-button-up - `0x207` = middle-button-down - `0x208` = middle-button-up - `0x1FD`–`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: ```c 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 use `0x10000000` high 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: ```c // 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`): ```c // 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)`. - `Tab` key in an edit control → widget's keydown handler intercepts `VK_TAB` and walks the sibling list. - Pressing `Enter` in the chat entry may loop focus back to world. - `FUN_00469780` in chunk `00460000.c` (line 7665) dispatches keyboard input: it looks up `param_4` (the VK code) in a hash table hanging off the panel's `+0x18c` bucket 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): ```c 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 = 0x100001d6` is 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 (`+0x74` set by `Device::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 `+0x34` on hover enter (see chunk\_00460000.c:6253): ```c bVar2 = (*device_vtbl[0x34])(1, panel, unaff_retaddr + -10); // evt 1 ~immediate bVar3 = (*device_vtbl[0x34])(7, panel, unaff_retaddr); // evt 7 delayed ``` The `unaff_retaddr` value 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 calls `Device::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: 1. The focus widget (usually the world view / chat when not entering text) receives `WM_KEYDOWN`. 2. `Device::OnKeydown(vk, lparam)` forwards to the active panel's `OnKeyDown`. 3. 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. 4. Quickbar slots `1`–`=` are scan-code-based: `VK_1` through `VK_0` and `VK_OEM_MINUS`, `VK_OEM_PLUS`, each mapped to `UseItem(hotbar_slot)`. The MainView panel owns this binding. 5. `Tab` / `Shift+Tab` targets next/previous creature. 6. 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: - `OnButton` at the Device level bails early if the click isn't inside the modal's rect. - `OnKeydown` only 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: - **`Enter` to start typing:** the `MainView` panel's keydown handler catches `VK_RETURN` when focus is NOT already in chat; it calls `ChatEntry::StartEdit()` which does `Device::SetKeyboardFocus(self)` and shows the entry caret. - **`Escape` to cancel:** `EditBox::OnKeyDown(VK_ESCAPE)` restores the old buffer, releases focus, hides the caret. - **`Tab` to 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. - **`Enter` in chat:** submits the message, clears the buffer, releases focus. - **`WM_CHAR` typing:** goes through `Device::OnChar(wparam, lparam)` which deposits the character at the caret position. The widget maintains a `wchar_t` buffer; see chunk\_00560000.c for the many `wcslen/wcscpy` sites 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 — probably `0x40`): hit_test returns false even when the cursor is inside the rect. Used for decoration panels (character-portrait frames, ornamental dividers). - `FLAG_CLICK_CAPTURE` (probably `0x01`): 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 ```c // ================= 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 ```c 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 ```c // 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 ```c 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: ```csharp namespace AcDream.App.Ui; /// /// The entire retail-style event struct. One 24-byte record per event. /// 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 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 _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`:** ```csharp // 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 `+0x128` slot. --- ## 17. Cross-references in our codebase - `src/AcDream.App/Rendering/GameWindow.cs` — current raw Silk.NET `IInputContext` binding. Lines 214–510. The camera/player controller bindings should move behind the new `UiRoot.WorldClicked` / `GlobalHotkey` events. - `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 1. **Widget IDs from .dat?** The retail client hard-codes IDs like `0x1000001c` (attribute panel "+" button). These presumably come from a `.dat` file that maps panel XML → widget ID. Need to decompile `cPanel::Read` or the UI-layout loader to confirm. 2. **Tooltip delay constant** — grep the decompiled code for `0x3e8` or `1000` near `RegisterTimerEvent(7, …)` to pin the exact ms. 3. **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. 4. **Edit-control IME composition** — the `FUN_006a1050` IME hook is empty in the production build, but the Japanese builds call something. Skip for MVP. 5. **Drag threshold** — 3 px is a guess; double-check chunk `00469880` for the exact distance. 6. **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.**