acdream/docs/research/retail-ui/04-input-events.md
Erik 7230c1590f docs+feat(ui): retail UI deep-dive research + C# port scaffold
Deep investigation of the retail AC client's GUI subsystem, driven by 6
parallel Opus research agents, plus the first cut of a retail-faithful
retained-mode widget toolkit that scaffolds Phase D.

Research (docs/research/retail-ui/):
- 00-master-synthesis.md        — cross-slice synthesis + port plan
- 01-architecture-and-init.md   — WinMain, CreateMainWindow, frame loop,
                                  Keystone bring-up (7 globals mapped)
- 02-class-hierarchy.md         — key finding: UI lives in keystone.dll,
                                  not acclient.exe; CUIManager + CUIListener
                                  MI pattern, CFont + CSurface + CString
- 03-rendering.md               — 24-byte XYZRHW+UV verts, per-font
                                  256x256 atlas baked from RenderSurface,
                                  TEXTUREFACTOR coloring, DrawPrimitiveUP
- 04-input-events.md            — Win32 WndProc → Device (DAT_00837ff4)
                                  → widget OnEvent(+0x128); full event-type
                                  table (0x01 click, 0x07 tooltip ~1000ms,
                                  0x15 drag-begin, 0x21 enter, 0x3E drop)
- 05-panels.md                  — chat, attributes, skills, spells, paperdoll
                                  (25-slot layout), inventory, fellowship,
                                  allegiance — with wire-message bindings
- 06-hud-and-assets.md          — vital orbs (scissor fill), radar
                                  (0x06001388/0x06004CC1, 1.18× shrink),
                                  compass strip, dat asset catalog

Key insight: keystone.dll owns the actual widget toolkit — we cannot
port a class hierarchy from the decompile because it's not there.
Instead we implement our own retained-mode toolkit with retail-faithful
behavior (event codes, focus/modal/capture, drag-drop state machine)
and will consume the same portal.dat fonts + sprites so the visual
identity is preserved.

C# scaffold (src/AcDream.App/UI/):
- UiEvent          — 24-byte event struct + retail event-type constants
                     (0x01 click, 0x15 drag-begin, 0x201 WM_LBUTTONDOWN,
                     etc.) matching retail decompile switches
- UiElement        — base widget: children, ZOrder, focus/capture flags,
                     virtual OnDraw/OnEvent/OnHitTest/OnTick; children-
                     first hit test + back-to-front composite
- UiPanel          — panel, label, button primitives
- UiRenderContext  — 2D draw context with translate stack
- UiRoot           — top-of-tree + Device responsibilities (mouse/
                     keyboard state, focus, modal, capture, drag-drop,
                     tooltip timer); WorldMouseFallThrough/
                     WorldKeyFallThrough preserves existing camera
                     controls when no widget consumes
- UiHost           — packages UiRoot + TextRenderer + input wiring
                     helpers for one-line integration into GameWindow
- README.md        — orientation for future agents

Roadmap (docs/plans/2026-04-11-roadmap.md):
- D.1 marked shipped (debug overlay from 2026-04-17)
- D.2 expanded to include the retail UI framework landed here
- D.3-D.7 added: AcFont, dat sprites, core panels, HUD, CursorManager
- D.8 remains sound

All existing 470 tests pass. 0 warnings, 0 errors.
2026-04-17 19:13:02 +02:00

1034 lines
38 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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, 0x2000x208 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 214510. 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.**