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.
1034 lines
38 KiB
Markdown
1034 lines
38 KiB
Markdown
# 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.**
|