# 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;

/// <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`:**

```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.**
