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

38 KiB
Raw Permalink Blame History

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 TranslateAcceleratorTranslateMessageDispatchMessageA) whose window class points at a stub WndProc at 0x00439860. The WndProc routes WM_* messages into a central Device object (DAT_00837ff4) that holds mouse cursor state, keyboard focus, modality/capture, and a timer/event queue. The UI tree is a dynamically-ID'd widget graph where every widget has a unique 32-bit event ID (custom app events live in the 0x10000000 namespace). Events are delivered to the owning panel's OnEvent(int *param_2) handler as a 4+-word struct: {source_id, target_widget, event_type, payload...}. The system uses virtual methods on the Device object (vtable offsets 0x18, 0x1c, 0x34, 0x38, 0x3c, 0x48, 0x4c, 0x58, 0x70, 0x74, 0x78, 0x88, 0x90, 0xa8) plus the widget's own vtable to route click, hover, drag, drop, and focus events.


1. The Win32 message pump

Entry point: FUN_00439e50 at 0x00439E50 (chunk 00430000, line 8265). This is the per-frame message-drain that the main loop calls each tick.

// Decompiled — FUN_00439e50 (pump one frame's worth of Windows messages)
char FUN_00439e50(void) {
    tagMSG msg;
    DAT_00838198 = 1;                                      // "we are in pump"
    int got = PeekMessageA(&msg, NULL, 0, 0, PM_REMOVE);   // 1 = PM_REMOVE
    while (got != 0 && msg.message != WM_QUIT /*0x12*/) {
        char handled_by_ime = FUN_006a1050(&msg);          // IME filter (stub: always 0)
        if (!handled_by_ime) {
            int handled_by_accel =
                FUN_00557a90(msg.hwnd, 0, &msg);           // TranslateAccelerator wrapper
            if (!handled_by_accel) {
                TranslateMessage(&msg);
                DispatchMessageA(&msg);                    // → LAB_00439860 WndProc
            }
        }
        got = PeekMessageA(&msg, NULL, 0, 0, PM_REMOVE);
    }
    // … window-activation bookkeeping at end …
    DAT_00838198 = 0;
    return DAT_00838194;                                    // returns quit flag
}

Key observations:

  • Uses 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 emptyCreateAcceleratorTableA((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):

WNDCLASSA wc = {0};
wc.lpfnWndProc   = (WNDPROC)&LAB_00439860;   // THE WndProc
wc.hInstance     = hInstance;
wc.hbrBackground = GetStockObject(GRAY_BRUSH /*4*/);
wc.lpszClassName = "Turbine Device Class";
RegisterClassA(&wc);
// …
hWnd = CreateWindowExA(0, "Turbine Device Class", title, styleFlags,
                      x, y, w, h, NULL, NULL, hInstance, NULL);
// stored as DAT_008381a4 — the one-and-only app window handle

The WndProc itself (LAB_00439860) is not present as a decompiled function in any chunk Ghidra produced — it's exported only as a label. From the call sites and the referenced globals (DAT_00837ff4 = the Device, DAT_008381a4 = HWND, the internal event IDs 0x1fd, 0x1fe, 0x1ff, 0x200, 0x201, 0x202, 0x203, 0x205, 0x207, 0x208 — all corresponding to WM_*) the WndProc is a big dispatcher that takes the WPARAM/LPARAM for each WM_ and calls a method on the Device object at DAT_00837ff4 via its vtable.

Reconstructed WndProc (pseudocode, based on dispatch call-sites):

LRESULT CALLBACK AcWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp)
{
    if (DAT_00837ff4 == NULL) return DefWindowProcA(hwnd, msg, wp, lp);
    int* device = *(int**)DAT_00837ff4;
    switch (msg) {
      // === Focus / window lifecycle ===
      case WM_ACTIVATE:        /* 0x06 */
      case WM_SETFOCUS:        /* 0x07 */
      case WM_KILLFOCUS:       /* 0x08 */
      case WM_SIZE:            /* 0x05 */
      case WM_CLOSE:           /* 0x10 */
      case WM_DESTROY:         /* 0x02 */
          return (*device->focus_handler)(msg, wp, lp);

      // === Mouse ===
      case WM_MOUSEMOVE:       /* 0x200 */
          // LOWORD(lp)=x, HIWORD(lp)=y, wp=key flags
          return (*device->on_mouse_move)((int)(short)(lp & 0xFFFF),
                                          (int)(short)((lp >> 16) & 0xFFFF),
                                          wp);
      case WM_LBUTTONDOWN:     /* 0x201 */
          return (*device->on_button)(1, 1 /*down*/,
                                     (short)(lp & 0xFFFF), (short)(lp >> 16));
      case WM_LBUTTONUP:       /* 0x202 */
          return (*device->on_button)(1, 0 /*up*/, );
      case WM_LBUTTONDBLCLK:   /* 0x203 */
          return (*device->on_dblclk)(1, );
      case WM_RBUTTONDOWN:     /* 0x204 */
      case WM_RBUTTONUP:       /* 0x205 */
      case WM_RBUTTONDBLCLK:   /* 0x206 */
      case WM_MBUTTONDOWN:     /* 0x207 */
      case WM_MBUTTONUP:       /* 0x208 */
          return (*device->on_button)();
      case WM_MOUSEWHEEL:      /* 0x020A */
          return (*device->on_wheel)((short)HIWORD(wp), lp);

      // === Keyboard ===
      case WM_KEYDOWN:         /* 0x100 */
      case WM_SYSKEYDOWN:      /* 0x104 */
          return (*device->on_keydown)(wp /*vk*/, lp /*scan+flags*/);
      case WM_KEYUP:           /* 0x101 */
      case WM_SYSKEYUP:        /* 0x105 */
          return (*device->on_keyup)(wp, lp);
      case WM_CHAR:            /* 0x102 */
          return (*device->on_char)(wp, lp);

      case WM_TIMER:           /* 0x113 */
          return (*device->on_timer)(wp);

      // === Paint / cursor ===
      case WM_PAINT:           /* 0x0F */
      case WM_ERASEBKGND:      /* 0x14 */
          return 1;                 // client paints via DirectX; no GDI
      case WM_SETCURSOR:       /* 0x20 */
          return (*device->on_setcursor)(hwnd, wp, lp);

      default:
          return DefWindowProcA(hwnd, msg, wp, lp);
    }
}

The "internal event IDs" 0x1FD, 0x1FE, 0x1FF, 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
  • 0x1FD0x1FF = custom variants (button-release-after-drag, context-click, etc.)

2. The Device object (DAT_00837ff4)

Every subsystem that reads mouse/keyboard or registers with the window pokes through this one global. Reconstructed vtable from the 80+ dispatch sites grepped:

Offset Inferred name Purpose
0x00 dtor
0x04 InitWindow(HWND) Takes the newly created HWND
0x08 Shutdown()
0x0C Tick() Per-frame timer pump
0x10 GetClientWidth()
0x14 GetClientHeight()
0x18 GetMouseX() Current cursor X (client coords)
0x1C GetMouseY() Current cursor Y
0x28 SetModalOverlay(widget)
0x2C GetActiveWidget()
0x34 RegisterTimerEvent(evt_type, target, delay_ms) Deferred event
0x38 FireEvent(evt_type, target) Immediate event
0x3C AddWidgetToRoot(widget) Attach panel to desktop
0x44 GetKeyboardFocus() Returns currently-focused widget
0x48 SetCapture(widget, kind) Mouse/modal capture
0x4C ReleaseCapture(kind)
0x58 IsLeftButtonDown()
0x6C TranslateAccelerator(...) Called by pump's FUN_00557a90 wrapper
0x70 ClientToScreen(pt, out)
0x74 SetDragCursor(on)
0x78 ResetDragDrop()
0x7C CheckRectHit(pt, rect)
0x88 GetCursorShape()
0x90 GetDragOffset(a, b, out)
0xA8 SetBusyOverlay(on) Hourglass / loading

Widget vtable offsets used by FUN_00462420 and friends in chunk 00460000:

Offset Purpose
0x0C Serialize(buf, &size) — used for clipboard / drag payload
0x14 InvokeCallback(evt_id)
0x18 SetVisible(bool)
0x24 SetEnabled(bool)
0x2C Close()
0x70 GetProperty(attrId, out)
0x78 GetInt(attrId, out)
0x88 GetString(attrId, out)
0x9C ChangeState(newState)
0xA0 GetOwner() / GetSourceWidget()
0xA8 GetEventCallback(out)
0xC8 GetEventCallbackB(out)
0xD0 LookupEvent(attrId, out)
0xFC OnDragStart()
0x128 OnEvent(event_struct, 0)the main event-handler

3. The event struct — what a handler receives

Every widget's OnEvent (vtable + 0x128) is called with an int* event. The layout — reconstructed from how chunk 00470000.c and 004A0000.c unpack it — is:

struct Event {
    int  source_id;        // param_2[0]  — e.g. 0x100001d6 (drag source),
                           //               0x1000030a (button), 0x1000046f,…
    int* target_widget;    // param_2[1]  — pointer to owner widget
    int  event_type;       // param_2[2]  — 1=click, 7=hover, 8=dblclick,
                           //               0x0e=right-click,
                           //               0x15=drag-begin,
                           //               0x1c=drag-over,
                           //               0x21=drag-entered-target,
                           //               0x3e=drop-released,
                           //               0x28=lose-focus, 0x29=gain-focus…
    int  data0;            // param_2[3]  — delta / button / flags
    int  data1;            // param_2[4]  — x or timer-id
    int  data2;            // param_2[5]  — y or payload pointer
    int  data3;            // param_2[6]  — extra
};

Event type catalog (from all switch-case sites):

Code Meaning Evidence
0x01 Click (left button released on) chunk_00470000 ~11140, chunk_004C0000 ~9270
0x05 Hover enter chunk_00460000 ~6253 (registered with ~10 ms)
0x06 Hover leave chunk_00460000 ~6254
0x07 Tooltip delay expired (mouse-over after delay) chunk_00460000 ~6253, ~6280
0x08 Double-click / tooltip-second chunk_00460000 ~6254
0x0A Scroll-wheel chunk_00470000 ~11210
0x0E Right-click chunk_004A0000 ~2674
0x15 Drag begin chunk_004A0000 ~2707
0x1C Drag-over target chunk_004A0000 ~2723
0x21 Drag entered a drop target chunk_004A0000 ~2714
0x3E Drop released chunk_004A0000 ~2754

(There are more; the above are the ones empirically encountered in inventory/paperdoll/attribute panels.)


4. Hit-test / routing: how a WM_LBUTTONDOWN reaches a button

The UI tree is a flat list of "panels" (top-level windows) each holding a tree of child widgets. Each widget has:

  • A 32-bit unique event_id (custom app events 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:

// Reconstructed from FUN_00468b80 + FUN_00468d30 + FUN_00468e20
// (chunk_00460000.c) plus the Device.OnButton vtable
void Device::OnButton(int button, int down, int x, int y) {
    if (modal_overlay && !modal_overlay->contains(x, y)) {
        // modal blocks clicks outside
        return;
    }
    // Walk top panels in Z order (topmost first); each FUN_00468b80 call
    // computes the cursor shape for one widget at (x, y)
    for (Panel* p : panels_top_to_bottom) {
        if (!p->visible) continue;
        int local_x = x - p->origin_x;
        int local_y = y - p->origin_y;
        if (!p->hit_test(local_x, local_y)) continue;

        // Depth-first within the panel. hit-test cursor returns:
        //   local_c = 1 if a widget claims the point, else 0
        Widget* hit = p->pick(local_x, local_y);
        if (hit == NULL) continue;

        // Convert raw WM_ into custom event
        Event e;
        e.source_id    = hit->event_id;
        e.target_widget= hit;
        e.event_type   = (button == 1 && down) ? 0x201 : 0x202;
        e.data0        = button_flags;
        e.data1        = local_x;
        e.data2        = local_y;
        // Set capture so WM_LBUTTONUP goes back to same widget
        if (down) device->SetCapture(hit, 1);

        // Dispatch to the panel's OnEvent (vtable +0x128)
        (hit->panel->vtbl[0x128/4])(&e, 0);

        // Widget can consume or ignore — next panel is NOT checked
        return;
    }
    // Nothing captured it — fall through to world-click handler
    game_world_on_click(x, y, button);
}

Hit-test recursion (FUN_00468d30 + FUN_00473690 + FUN_00472b30):

// FUN_00468d30: point-in-rect test for one widget
//   param_1 = widget, param_2 = {event_id, event_type}
//   returns: low-byte 0/1 hit, high 24 bits = cursor variant hint
bool Widget::hit_test(int* event_out) {
    int absolute_x = 0, absolute_y = 0;
    FUN_004686b0();                                 // refresh cached offsets
    if (!FUN_004732a0(*event_out, &absolute_x, &absolute_y)) return false;

    if (this.flags & FLAG_HORIZONTAL /* +0x694 & 2 */) {
        int bounds_h = FUN_0069fe60() - this.bounds.h - this.bounds.x;
        return this.bounds.x <= absolute_x
            && absolute_x + 2 <= this.bounds.x + bounds_h;
    } else {
        int bounds_w = FUN_0069fe70() - this.bounds.w - this.bounds.y;
        return this.bounds.y <= absolute_y
            && absolute_y + local_w <= this.bounds.y + bounds_w;
    }
}

5. Focus system

Single global focusDevice::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):

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/0x06enter/leave, fired immediately on mouse crossing the widget's rect. Used for hover-highlight (brightening a button frame).

  • 0x07delayed "tooltip" event. The widget registers itself via Device::RegisterTimerEvent(0x07, self, delay_ms) at offset +0x34 on hover enter (see chunk_00460000.c:6253):

    bVar2 = (*device_vtbl[0x34])(1, panel, unaff_retaddr + -10);  // evt 1 ~immediate
    bVar3 = (*device_vtbl[0x34])(7, panel, unaff_retaddr);        // evt 7 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 Devicemodal_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

// ================= INBOUND =================
//  Win32   → WndProc → Device::OnButton → Panel::OnMouseDown
//          → Widget::hit_test → Widget::OnEvent → ...

LRESULT CALLBACK WndProc(HWND h, UINT m, WPARAM w, LPARAM l) {
    switch (m) {
      case WM_LBUTTONDOWN:
        int x = (short)(l & 0xFFFF);
        int y = (short)((l >> 16) & 0xFFFF);
        return g_device->OnButton(1 /*btn*/, 1 /*down*/, x, y, w /*flags*/);
      /* … */
    }
}

void Device::OnButton(int btn, int down, int x, int y, WPARAM flags) {
    // 1) Modal?
    if (modal && !modal->rect.contains(x, y)) return;

    // 2) Captured? (e.g. dragging)
    if (captured_widget) {
        Event e = {captured_widget->id, captured_widget,
                   down ? 0x201 : 0x202, flags, x, y};
        captured_widget->panel->OnEvent(&e);
        if (!down) captured_widget = NULL;   // release on up
        return;
    }

    // 3) Top-to-bottom z-order walk
    for (Panel* p = panels_top; p != NULL; p = p->below) {
        if (!p->visible) continue;
        Widget* w = p->HitTest(x - p->origin_x, y - p->origin_y);
        if (w == NULL) continue;
        if (w->flags & FLAG_CLICK_THROUGH) continue;

        // 4) Construct event and dispatch
        Event e;
        e.source_id   = w->event_id;
        e.target      = w;
        e.event_type  = 0x201;                     // WM_LBUTTONDOWN
        e.data0       = flags;
        e.data1       = x - p->origin_x;
        e.data2       = y - p->origin_y;

        // 5) Set capture so matching WM_LBUTTONUP reaches same widget
        if (down) { captured_widget = w; SetCapture(hwnd_ac); }

        // 6) Widget handler — virtual OnEvent at +0x128
        (w->panel->vtbl[0x128/4])(&e, 0);

        // 7) Maybe spawn drag — if widget flags allow + mouse moves ≥ 3px
        //    this is arranged by the subsequent WM_MOUSEMOVE handler
        return;
    }

    // 8) No UI claimed — hand off to game world
    game_world->OnWorldClick(btn, x, y, flags);
}

13. Pseudocode — hit-test recursion

Widget* Panel::HitTest(int lx, int ly) {
    // Walk children in reverse draw order (topmost-first)
    for (Widget* c = last_child; c != NULL; c = c->prev_sibling) {
        if (!c->visible || (c->flags & FLAG_CLICK_THROUGH)) continue;
        if (!c->bounds.contains(lx, ly)) continue;
        // Descend first
        if (c->num_children > 0) {
            Widget* deep = c->HitTest(lx - c->bounds.x, ly - c->bounds.y);
            if (deep) return deep;
        }
        return c;   // self consumed it
    }
    return NULL;
}

14. Pseudocode — drag-drop end-to-end

// INITIATION — within WM_MOUSEMOVE after button held + distance crossed
void Device::UpdateDrag(int x, int y) {
    if (!drag_candidate) return;

    if (!dragging &&
        (abs(x - press_x) > 3 || abs(y - press_y) > 3))
    {
        // Promote to drag
        dragging      = true;
        drag_source   = drag_candidate;
        drag_payload  = drag_candidate->payload;
        SetDragCursor(true);                   // vtbl +0x74
        // Fire drag-begin to the SOURCE widget (lets it hide its icon)
        Event e = {drag_source->id, drag_source, 0x15, payload, 0, 0};
        drag_source->panel->OnEvent(&e);
    }
    if (dragging) {
        // Find potential target under cursor
        Widget* hover = HitTestAll(x, y);
        if (hover && hover != last_hover) {
            // Leaving old
            if (last_hover) {
                Event e = {drag_payload->id, last_hover, 0x1c, 0, x, y};
                last_hover->panel->OnEvent(&e);
            }
            // Entering new
            Event e2 = {drag_payload->id, hover, 0x21, 0, x, y};
            hover->panel->OnEvent(&e2);
            last_hover = hover;
        }
    }
}

// TERMINATION — WM_LBUTTONUP during drag
void Device::OnButton_Up_Drag(int x, int y) {
    Widget* target = HitTestAll(x, y);
    if (target) {
        Event e = {drag_payload->id, target, 0x3e, 1 /*accepted*/, x, y};
        target->panel->OnEvent(&e);
    } else {
        // Snap-back
        Event e = {drag_payload->id, drag_source, 0x3e, 0 /*rejected*/, x, y};
        drag_source->panel->OnEvent(&e);
    }
    ResetDragDrop();                           // vtbl +0x78
    SetDragCursor(false);
    dragging = false;
    drag_candidate = NULL;
    last_hover = NULL;
}

15. Pseudocode — keyboard-focus routing

LRESULT WndProc_KeyDown(WPARAM vk, LPARAM lp) {
    return g_device->OnKeyDown(vk, lp);
}

void Device::OnKeyDown(WPARAM vk, LPARAM lp) {
    Widget* focused = GetKeyboardFocus();    // vtbl +0x44

    // 1) If an edit box owns focus, give IT first shot
    if (focused && focused->IsEditControl()) {
        if (focused->OnKeyDown(vk, lp))       // consumed?
            return;
    }

    // 2) Otherwise walk modal → active panels → main view
    Panel* walk = modal_overlay ? modal_overlay : top_panel;
    while (walk) {
        if (walk->OnKeyDown(vk, lp)) return;   // consumed
        walk = walk->below;
    }

    // 3) Global hotkeys: panel-open, emotes, quickbar
    if (vk == VK_B) { open_combat_settings_panel(); return; }
    if (vk >= VK_1 && vk <= VK_0)
        { hotbar->Trigger(vk - VK_1); return; }
    /* … */
}

16. Proposed C# port structure

Our current GameWindow.cs binds Silk.NET IInputContext directly to camera + player controllers. To support a full retail UI we need to splice a UI-tree dispatcher between Silk.NET's raw events and the game systems. Proposed layer:

namespace AcDream.App.Ui;

/// <summary>
/// The entire retail-style event struct. One 24-byte record per event.
/// </summary>
public struct UiEvent
{
    public uint   SourceId;       // widget event id (0x10000000+)
    public IWidget Target;
    public int    Type;           // 1=click, 7=hover, 0x15=drag-begin, ...
    public int    Data0;
    public int    Data1;          // x in widget-local coords
    public int    Data2;          // y in widget-local coords
    public int    Data3;
    public object? Payload;       // drag payload (ItemInstance, etc.)
}

public enum UiEventType
{
    Click         = 0x01,
    HoverEnter    = 0x05,
    HoverLeave    = 0x06,
    Tooltip       = 0x07,
    DoubleClick   = 0x08,
    Scroll        = 0x0A,
    RightClick    = 0x0E,
    DragBegin     = 0x15,
    DragOver      = 0x1C,
    DragEnter     = 0x21,
    FocusLost     = 0x28,
    FocusGained   = 0x29,
    DropReleased  = 0x3E,
    MouseDown     = 0x201,
    MouseUp       = 0x202,
    MouseMove     = 0x200,
    KeyDown       = 0x100,
    KeyUp         = 0x101,
    Char          = 0x102,
}

public interface IWidget
{
    uint   EventId { get; }
    Rectangle Bounds { get; }
    IPanel Panel  { get; }
    bool   Visible { get; }
    bool   ClickThrough { get; }
    bool   AcceptsFocus { get; }
    bool   IsEditControl { get; }

    bool   OnEvent(in UiEvent e);    // returns true if consumed
    // Tooltip / dynamic state helpers (mirrors the vtable +0x88 / +0x70)
    string? GetTooltipText();
}

public interface IPanel : IWidget
{
    IReadOnlyList<IWidget> Children { get; }
    IWidget? HitTest(int localX, int localY);
}

public interface IDevice
{
    // Mirrors DAT_00837ff4 vtable
    int  MouseX { get; }
    int  MouseY { get; }
    IWidget? KeyboardFocus { get; set; }
    IWidget? Captured      { get; set; }
    IWidget? Modal         { get; set; }
    bool IsLeftButtonDown { get; }

    // Event plumbing
    void FireEvent(int evtType, IWidget target, object? payload = null);
    void RegisterTimerEvent(int evtType, IWidget target, int delayMs);

    // Drag state
    IWidget? DragSource  { get; }
    object?  DragPayload { get; }
    void     BeginDrag(IWidget source, object payload);
    void     EndDrag();
}

public sealed class UiRoot : IDevice
{
    private readonly List<IPanel> _panels = new();   // top-down z order
    private IWidget? _focus, _captured, _modal;
    private readonly List<(long fireAtMs, UiEvent e)> _timers = new();
    private long _nowMs;
    private IWidget? _dragSource;
    private object?  _dragPayload;
    private IWidget? _lastDragHover;
    private int _pressX, _pressY;
    private bool _dragArmed;

    // Hooked from GameWindow.cs
    public void OnMouseDown(int btn, int x, int y, int flags)
    {
        if (_modal != null && !_modal.Bounds.Contains(x, y)) return;

        if (_captured != null) { DispatchButton(_captured, btn, true, x, y, flags); return; }

        for (int i = _panels.Count - 1; i >= 0; --i)
        {
            var p = _panels[i];
            if (!p.Visible) continue;
            int lx = x - p.Bounds.X, ly = y - p.Bounds.Y;
            var w = p.HitTest(lx, ly);
            if (w == null || w.ClickThrough) continue;
            _captured = w;
            _pressX = x; _pressY = y; _dragArmed = true;
            DispatchButton(w, btn, true, lx, ly, flags);
            return;
        }
        // Fall through to world: let GameWindow's existing handlers run
        WorldClicked?.Invoke(btn, x, y, flags);
    }

    public void OnMouseUp(int btn, int x, int y, int flags)
    {
        if (_dragSource != null) { FinishDrag(x, y); return; }
        if (_captured != null)
        {
            int lx = x - _captured.Panel.Bounds.X;
            int ly = y - _captured.Panel.Bounds.Y;
            DispatchButton(_captured, btn, false, lx, ly, flags);
            _captured = null;
            _dragArmed = false;
        }
    }

    public void OnMouseMove(int x, int y)
    {
        if (_dragArmed && !DragStarted(x, y)) MaybePromoteDrag(x, y);
        if (_dragSource != null) UpdateDragHover(x, y);
        // Dispatch hover enter/leave + restart tooltip timer
        UpdateHoverChain(x, y);
    }

    public void OnChar(int codepoint)
    {
        if (_focus is { IsEditControl: true } e)
        {
            e.OnEvent(new UiEvent { Type = (int)UiEventType.Char, Target = e,
                                    Data0 = codepoint });
        }
    }

    public void OnKeyDown(int vk, int lp)
    {
        // focus widget first
        if (_focus?.OnEvent(new UiEvent { Type = (int)UiEventType.KeyDown,
                                          Target = _focus, Data0 = vk }) == true)
            return;
        // panel walk
        var start = _modal ?? (_panels.Count > 0 ? _panels[^1] : null);
        for (var p = start; p != null; p = PanelBelow(p))
        {
            if (p.OnEvent(new UiEvent { Type = (int)UiEventType.KeyDown,
                                        Target = p, Data0 = vk })) return;
        }
        // global hotkeys
        GlobalHotkey?.Invoke(vk, lp);
    }

    public void Tick(long nowMs)
    {
        _nowMs = nowMs;
        for (int i = _timers.Count - 1; i >= 0; --i)
        {
            if (_timers[i].fireAtMs <= nowMs)
            {
                _timers[i].e.Target.OnEvent(_timers[i].e);
                _timers.RemoveAt(i);
            }
        }
    }
    // ... DispatchButton, BeginDrag, FinishDrag, etc.
}

Integration into GameWindow.cs:

// In GameWindow.OnLoad
_uiRoot = new UiRoot();
_uiRoot.WorldClicked += (btn, x, y, flags) => HandleWorldClick(btn, x, y);
_uiRoot.GlobalHotkey += (vk, lp) => HandleHotkey(vk);

foreach (var mouse in _input.Mice)
{
    mouse.MouseDown += (_, b) => _uiRoot.OnMouseDown(
        b switch { MouseButton.Left => 1, MouseButton.Right => 2, _ => 3 },
        (int)mouse.Position.X, (int)mouse.Position.Y, 0);
    mouse.MouseUp   += (_, b) => _uiRoot.OnMouseUp(
        b switch { MouseButton.Left => 1, MouseButton.Right => 2, _ => 3 },
        (int)mouse.Position.X, (int)mouse.Position.Y, 0);
    mouse.MouseMove += (_, p) => _uiRoot.OnMouseMove((int)p.X, (int)p.Y);
    mouse.Scroll    += (_, s) => _uiRoot.OnScroll((int)(s.Y), 0);
}
foreach (var kb in _input.Keyboards)
{
    kb.KeyDown  += (_, k, _) => _uiRoot.OnKeyDown((int)k, 0);
    kb.KeyUp    += (_, k, _) => _uiRoot.OnKeyUp((int)k, 0);
    kb.KeyChar  += (_, c)    => _uiRoot.OnChar(c);
}

This pattern:

  • Preserves our current camera/player control when no panel exists (the "world click" fall-through).
  • Gives retail-faithful event type codes (0x1C, 0x21, 0x3E, etc.) so hand-ported panels work with the same magic numbers as the original.
  • Centralizes modality / focus / capture / drag in one object.
  • Dispatches with one IWidget.OnEvent(UiEvent) method, matching the retail widget vtable's +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.