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.
This commit is contained in:
Erik 2026-04-17 19:13:02 +02:00
parent ff325abd7b
commit 7230c1590f
15 changed files with 8041 additions and 5 deletions

View file

@ -108,12 +108,22 @@ Plus polish that doesn't get its own phase number:
**Goal:** chat window, nameplates, inventory, and audio. Can run concurrently with Phase B or C because it doesn't touch gameplay/net/rendering surfaces.
**Sub-pieces:**
- **D.1 — 2D ortho overlay + font rendering.** Separate shader and render pass drawn after 3D. Font: FreeType via Silk.NET bindings, or bitmap fonts as a simpler first pass.
- **D.2 — Chat window + nameplates.** First UI widgets. Chat consumes Phase B.5 messages; nameplates render per-entity 3D-to-2D projected labels.
- **D.3 — Inventory / character / spell panels.** Requires a widget framework (layout, focus, input routing). Scope unbounded — ship minimum viable first.
- **D.4 — Sound.** `SoundTable` parser, `Sound` dat decode, audio engine (OpenAL via Silk.NET.OpenAL), per-entity 3D positional audio, optional music.
- **D.1 — 2D ortho overlay + font rendering.** ✅ SHIPPED 2026-04-17 as the dev-facing debug overlay (StbTrueTypeSharp system-font atlas + `TextRenderer` + `DebugOverlay`).
- **D.2 — Retail UI framework + first panels.** Research + scaffold landed 2026-04-17 (see `docs/research/retail-ui/`). Ships:
- `UiRoot` + `UiElement` + `UiPanel` + `UiHost` with retail-faithful event codes (`0x01` click, `0x15` drag-begin, `0x3E` drop, `0x201` WM_LBUTTONDOWN, tooltip delay ~1000ms, etc.)
- Focus / modal / capture / drag-drop / hover state machine
- `WorldMouseFallThrough` / `WorldKeyFallThrough` preserves existing camera+player controls
- First concrete panel (chat window) uses wire messages Phase 4.7 already parses
- **D.3 — AcFont from portal.dat.** Replace stb_truetype system font with retail `Font` DBObjs (`0x40000000..0x40000FFF`) baked from `RenderSurface` source sheets — see research slice 03 §4. Preserves retail visual identity.
- **D.4 — Dat sprites + 9-slice panel backgrounds.** Load `RenderSurface` (`0x06xxxxxx`) as GL textures; add `DrawSprite` to `UiRenderContext`. Enables retail panel art.
- **D.5 — Core panels.** Attributes (`chunk_00470000.c:FUN_0047ba70`), Skills (same), Paperdoll (`chunk_004A0000.c:FUN_004A5200`), Inventory, Spellbook (`chunk_004C0000.c`), Fellowship, Allegiance. Each uses the port sketches in slice 05.
- **D.6 — HUD.** Vital orbs (scissor-rect partial fill, dat sprites `0x060013B2`), radar (`0x06001388` / `0x06004CC1`, 1.18× range factor), compass strip (scrolling U), target name plate, damage floaters, selection indicator. See slice 06.
- **D.7 — Cursor manager.** OS + dat-sourced custom cursors (`FUN_0043c1c0` GDI HCURSOR builder pattern from slice 03).
- **D.8 — Sound.** `SoundTable` parser, `Sound` dat decode, audio engine (OpenAL via Silk.NET.OpenAL), per-entity 3D positional audio, optional music.
**Acceptance:** see other players' chat in a chat window, see nameplates above NPCs, hear footsteps and sword hits.
**Reference docs:** `docs/research/retail-ui/00-master-synthesis.md` + slices 01-06. Every AC-specific behavior has a decompiled FUN_ / DAT_ citation.
**Acceptance:** chat messages display in a retail-style panel, health/stamina/mana orbs fill correctly, attributes panel shows player stats, inventory opens with drag-drop working, and sound plays on hit/footstep.
---

View file

@ -0,0 +1,671 @@
# Retail AC Client GUI — Master Synthesis
**Date:** 2026-04-17
**Sources:** 6 parallel Opus research passes over the decompiled
`acclient.exe` (22,225 functions / 688K lines). Individual deep dives:
- [`01-architecture-and-init.md`](01-architecture-and-init.md) — process entry → window → device → frame loop
- [`02-class-hierarchy.md`](02-class-hierarchy.md) — UI class hierarchy + virtual dispatch
- [`03-rendering.md`](03-rendering.md) — font, sprite, text, cursor render pipeline
- [`04-input-events.md`](04-input-events.md) — Win32 WndProc → Device → widget event routing
- [`05-panels.md`](05-panels.md) — chat, attributes, skills, spell, paperdoll, inventory, etc.
- [`06-hud-and-assets.md`](06-hud-and-assets.md) — globes, radar, compass + dat-file UI assets
This document is the combined, cross-referenced picture of how the
retail AC client renders and drives its UI, plus the port plan for
acdream.
---
## 0. TL;DR — single most important finding
**The retail UI widget toolkit is not in `acclient.exe`.** The client
loads `keystone.dll` (plus two plugins `ACHelpPlugin.dll`,
`ACPluginManager.dll`) at startup via `LoadLibraryA`, gets a single
COM-like interface pointer (`DAT_00870c2c`), and delegates all widget
layout, hit-testing, input, and drawing to Keystone. The client
supplies primitives Keystone consumes (`CFont`, `CSurface`, `CString`,
the D3D device) and routes commands by resource ID (`0x10000000+`
custom event IDs, `0x21xxxxxx` LayoutDesc IDs, `0x06xxxxxx` sprites,
`0x40000xxx` fonts).
**Implication for acdream:** we cannot "port a C++ widget hierarchy
from the decompile" — there is none. We implement our own retained-mode
toolkit on top of Silk.NET OpenGL, **consuming the same dat-borne
textures and fonts** so the UI is visually identical to retail even
though the class hierarchy underneath is new. This is the same
approach AC2D took. Section 11 below describes the C# layout.
---
## 1. Process / frame architecture (from slice 01)
```
WinMain @ 0x004013A0 (chunk_00400000.c)
└─ App::Initialize (vtable[0x10])
├─ dat cache init, string-table init
├─ vtable[0x74] "CreateMainWindow"
│ ├─ RegisterClassA("Turbine Device Class")
│ ├─ CreateWindowExA(..) → DAT_008381a4 (HWND)
│ ├─ graphics factory → DAT_0086734c
│ ├─ render device → DAT_00837ff4 (lightweight)
│ ├─ Turbine Core → DAT_00870340 (GL surfaces + dats)
│ └─ Keystone bring-up:
│ LoadLibraryA("keystone.dll") → DAT_00870c30
│ LoadLibraryA("plugins\\ACHelpPlugin.dll")
│ LoadLibraryA("plugins\\ACPluginManager.dll")
│ GetProcAddress "KeystoneCreate" → DAT_00870c34
│ KeystoneCreate(hwnd, core, cwd, 0,0,0,0) → DAT_00870c2c
├─ App::Run (vtable[0x1c]) — main loop forever
│ while (!quit) {
│ PumpMessages @ 0x00439E50 // FUN_00439e50
│ PeekMessageA
│ IME filter (stub)
│ Keystone->TranslateAccelerator (vtable +0x6c)
│ TranslateMessage + DispatchMessageA → WndProc @ 0x00439860
│ RenderFrame @ 0x0045D0B0 // 3D world + UI + overlays
│ GameTick @ 0x004554B0 // physics + AI + scripts
│ FlushPresent @ 0x0043FCD0 // clear + begin + UI + end + Present
│ Sleep(~1ms)
│ }
└─ App::Shutdown (vtable[0x2c])
```
**Frame body ordering (important):** render → tick → Sleep. Retail
renders BEFORE the game tick, because the render pass reads a stable
snapshot built at the end of the previous frame (double-buffered
simulation state). acdream already follows this pattern.
**Global state block (acdream should replicate the 4-way factoring):**
| Retail global | Role | Proposed C# class |
|---------------|-----------------------------|------------------------------------------------|
| `DAT_008381a4` | HWND | (Silk.NET `IWindow`) |
| `DAT_0086734c` | Graphics factory | (handled by Silk.NET GL selection) |
| `DAT_00870340` | Turbine Core (GL + dats + fonts) | `AcDream.App.UI.UiHost` |
| `DAT_00837ff4` | Render/input device | `AcDream.App.UI.UiDevice` |
| `DAT_00870c2c` | Keystone root (widgets) | `AcDream.App.UI.UiRoot` (our toolkit) |
---
## 2. Rendering pipeline (from slice 03)
### 2.1 Top-level frame
```
FUN_0043fcd0 SceneTool::RenderFrame
├─ RenderDevice::Clear(color|depth)
├─ if world.visible:
│ Scene::BeginFrame
│ Scene::RenderWorld (terrain, statics, lights)
├─ if DAT_0083846c != 0: FUN_005da8f0() <-- UI PANEL TREE WALK (7.8 KB func)
├─ if DAT_00838468 != 0: FUN_00692470() <-- CHAT TEXT + EDIT LINE
├─ FUN_0043f7f0() <-- DEBUG/STATS HUD
├─ RenderDevice::Clear(color only)
├─ RenderDevice::BeginScene
├─ RenderDevice::EndScene + Present
└─ FUN_0043e6b0() <-- frame stats
```
### 2.2 Single sprite / text quad
- Vertex format: **24 bytes** = `(x, y, z=0, rhw, u, v)` — XYZRHW + TEX1
- No per-vertex color; color is set via `D3DRS_TEXTUREFACTOR` per draw
- 6 verts per quad (TL, BL, BR, BR, TR, TL — CCW with D3D conventions)
- NDC conversion applies a DX9-style **half-pixel offset**
- Global buffer at `DAT_008f9a90..8f9a98` (base, capacity, write-offset)
- Hot loop unrolled 4 glyphs per iteration
- Flush: `FUN_006974d0``FUN_005a26d0(D3DPT_TRIANGLELIST, verts/3, pV, 0x142, tex, tex, stride=24)`
### 2.3 Fonts
- DBObj type `Font` at IDs `0x40000000..0x40000FFF` (portal.dat)
- On-disk `FontCharDesc` per glyph (9 bytes: codepoint, offsetX/Y, w, h, kern-before/after, vertOffset)
- References a `RenderSurface` (`0x06xxxxxx`) for the source pixel sheet
- Runtime baking (`FUN_00697140`) builds a **256×256 atlas** from the source surface, clamps to printable ASCII 0x20-0x7E (or wider if present), and caches glyph UVs + metrics as 24-byte runtime records
- **No inline color escapes.** Chat colors arrive as full ARGB dwords on the wire in `ChatText`/`ChannelBroadcast` messages
### 2.4 UI textures
| ID range | DBObjType | Role |
|---------------------------|------------------|------|
| `0x06000000..0x06FFFFFF` | RenderSurface | **All** button images, panel backgrounds, icons, cursor bitmaps, font glyph sheets |
| `0x08000000..0x08FFFFFF` | SurfaceTexture | Wraps a RenderSurface with palette/tiling info |
| `0x0F000000..0x0FFFFFFF` | SurfaceMaterial | Materials referencing SurfaceTextures |
| `0x40000000..0x40000FFF` | Font | Font metadata — references a RenderSurface |
| `0x21xxxxxx` | LayoutDesc | **Panel layouts** — tree of ElementDescs + StateDescs |
| `0x23/0x24xxxxxx` | StringTable | Localized UI strings |
| `0x14xxxxxx` | MasterInputMap | Default keybinds |
| `0x41xxxxxx` | LanguageInfo | Localization root |
### 2.5 Blend + depth + clipping
- Blend: `SRC_ALPHA / INV_SRC_ALPHA` ("over")
- Depth: off for UI
- Clipping: retail mostly pre-clips at the vertex-generation stage; true hardware scissor is used in some panel interiors (chat scroll, inventory grid)
---
## 3. Class hierarchy / polymorphism (from slice 02)
### 3.1 What is in acclient.exe
| Class | Vtable | Role |
|---------------------------|--------------------------|------------------------------------------|
| `CUIManager` (singleton) | `PTR_FUN_00799fc4` | `DAT_00838374`; AddListener/RemoveListener at vtable +0x0c |
| `CUIListener` (embedded base) | `PTR_FUN_00801670` | Any object wanting UI events embeds this as MI secondary base |
| `CFont` (chunk_00440000.c) | — | Glyph lookup, range map, atlas blit |
| `CSurface` | `PTR_FUN_0079c26c` | 40-byte bitmap wrapper (w, h, bpp, pixels) |
| `CString` helpers | `FUN_00402490/07e40/0b8f0` | Refcounted wide-string buffer (string builder) |
### 3.2 The Keystone interface (`DAT_00870c2c`)
Observed vtable slots (from call sites in `acclient.exe`):
| Slot | Role |
|-------|---------------------------------------------------------------|
| +0x08 | Release / destructor |
| +0x14 | FindPlugin(wchar_t* name) |
| +0x20 | ProcessFrame — called once per frame from `FUN_0043fcd0` |
| +0x24 | CreateOrActivate(a, b, c, d) — 4-arg plugin entry |
| +0x28 | ClearActive(0) |
| +0x2c | GetActive |
| +0x5c | SendCommand(id, op, buffer) — generic command dispatch |
| +0x60 | HitTest(POINT*) |
| +0x6c | TranslateAccelerator(hwnd, haccel, &MSG) — pump's pre-filter |
### 3.3 Polymorphism pattern
Classic MSVC **multiple inheritance with per-base-class vtables**. A
widget like CharGen has four vtables at fixed offsets inside the
object (`+0x00`, `+0x04`, `+0x08`, `+0x12C`). AddListener is passed
the INNER `this` pointer (the listener subobject), not the outer.
### 3.4 Port decision
**acdream implements its own retained-mode widget toolkit.** Keystone
is not decompiled here and reverse-engineering it is out of scope. We
build `UiElement` / `UiPanel` / `UiButton` / `UiLabel` / `UiEditBox` /
etc. with virtual `OnDraw` / `OnHitTest` / `OnMouseMessage` /
`OnKeyMessage`**not** multiple inheritance.
---
## 4. Input / event routing (from slice 04)
### 4.1 Message pump
```c
char FUN_00439e50(void) { // 0x00439E50
DAT_00838198 = 1;
MSG msg;
while (PeekMessageA(&msg, NULL, 0, 0, PM_REMOVE) && msg.message != WM_QUIT) {
if (FUN_006a1050(&msg)) continue; // IME filter (stub)
if (FUN_00557a90(msg.hwnd, 0, &msg)) continue; // Keystone accel
TranslateMessage(&msg);
DispatchMessageA(&msg); // → WndProc at 0x00439860
}
DAT_00838198 = 0;
return DAT_00838194; // quit flag
}
```
### 4.2 WndProc
Ghidra did not decompile `LAB_00439860` but its behavior is
reconstructed from dispatch sites:
- Win32 WM_* numbers are **reused as internal event type codes**
(`0x200` mouse-move, `0x201` LDown, `0x202` LUp, `0x204/205` RDown/Up, etc.)
- All WM_* forwarded to `DAT_00837ff4` (the Device)'s vtable methods
### 4.3 Device object (`DAT_00837ff4`) vtable
| Slot | Method |
|--------|-------------------------------------------------------------|
| +0x04 | AttachWindow(HWND) |
| +0x10 | BeginScene |
| +0x18 | GetMouseX |
| +0x1C | GetMouseY |
| +0x34 | RegisterTimerEvent(eventType, target, delayMs) — tooltip delays |
| +0x38 | FireEvent(eventType, target) |
| +0x44 | GetKeyboardFocus |
| +0x48 | SetCapture(widget, kind) |
| +0x4C | ReleaseCapture(kind) |
| +0x6C | TranslateAccelerator |
| +0x70 | WndProc filter — `(MSG*, bool* handled)` |
| +0x74 | SetDragCursor(bool) |
| +0x78 | ResetDragDrop |
### 4.4 Event struct — delivered to widget vtable +0x128
```c
struct Event {
int source_id; // widget event id (0x10000000+ app range)
Widget* target_widget;
int event_type; // see table below
int data0, data1, data2, data3;
}
```
Widget vtable +0x128 = `OnEvent(Event*)`.
### 4.5 Event type catalog
| Code | Meaning | Notes |
|--------|-------------------------------|-------|
| 0x01 | Click (left-up-over) | |
| 0x05 | Hover enter | |
| 0x06 | Hover leave | |
| 0x07 | Tooltip (delayed, ~1000ms) | Target registers via Device::RegisterTimerEvent(7, self, ms) |
| 0x08 | Double-click | |
| 0x0A | Scroll-wheel | data0 = wheel delta |
| 0x0E | Right-click | |
| 0x15 | Drag begin | Sent to source on motion > ~3px |
| 0x1C | Drag-over (continuous) | |
| 0x21 | Drag entered target | |
| 0x3E | Drop released | data3 = accepted? |
| 0x201 | Mouse button down (raw) | |
| 0x202 | Mouse button up (raw) | |
| 0x200 | Mouse move | |
| 0x100 | Key down | data0 = VK |
| 0x101 | Key up | |
| 0x102 | Char typed | |
### 4.6 Drag-drop
Single-drag-at-a-time. The Device holds `DragSource` + `DragPayload`;
the event struct carries only IDs. Targets ask the Device for the
current payload. Drag threshold ~3px from button-down to promote.
### 4.7 Modality
One `modal_overlay` slot on the Device. Clicks outside the modal's
rect are dropped. All other panels are drawn dimmed (gray quad
underneath). Login screen is the first modal.
### 4.8 Focus / hotkeys
**No Win32 accelerator table.** CreateAcceleratorTableA called with
empty table. All hotkeys done in-widget:
- Focus widget (or top panel if no focus) gets first shot at WM_KEYDOWN
- If not consumed, walk top-level panels in z-order
- Finally, global hotkeys (1-0 quickbar, B/I/C panel-open, etc.)
- Chat-typing mode checks focus: if focus is in EditBox, suppress hotkeys
---
## 5. Panel inventory (from slice 05)
Summarized mapping of retail panels to the decompiled code:
| Panel | Chunk | Key function / evidence |
|-----------------------|--------------------------|--------------------------------------------------------|
| Chat window | chunk_00570000.c | Channel bitmask + ChatMessageType 0x00-0x1F + 7 wire opcodes |
| Character Attributes | chunk_00470000.c | `FUN_0047ba70` — 10 attribute rows, 4 skill categories |
| Skills | (same as Attributes) | Embedded in attribute sheet |
| Spellbar / Spellbook | chunk_004C0000.c | `FUN_004C7700-004C7F00` — 8 tabs × 7 slots |
| Paperdoll / Equipment | chunk_004A0000.c | `FUN_004A5200` — 25 slots at offsets +0x604..+0x664 |
| Inventory | chunk_004A0000/05C0000 | Burden system + error string catalog |
| Quickbar | (light coverage) | 1-10 hotkey slots |
| Fellowship | (GameEventFellowshipFullUpdate) | Member list + XP share |
| Allegiance | (Allegiance messages) | Patron/vassal tree |
| Character Select | (Login flow) | Character slots + create-new |
| Login | — | First modal |
| Options | chunk_00400000.c (partial) | 55 CharacterOption bits |
### 5.1 Chat channels (32-bit bitmask)
Full channel enum: Abuse (0x1), Admin (0x2), Audit (0x4), Advocate1-3,
QA1/2, Debug, Sentinel, Help, **Fellow (0x800), Vassals (0x1000),
Patron (0x2000), Monarch (0x4000)**, 8 Society town channels, CoVassals,
AllegianceBroadcast, FellowBroadcast, 3 Society groups, Olthoi.
### 5.2 Chat message types (color / filter enum)
0x00 Broadcast (default), 0x02 Speech, 0x03 Tell, 0x04 OutgoingTell,
0x05 System (red), 0x06 Combat, 0x07 Magic, 0x08 Channel (pink), 0x0A
Social (yellow), 0x0C Emote, 0x0D Advancement, 0x0E Abuse, 0x12
Allegiance, 0x13 Fellowship, 0x14 WorldBroadcast (green), 0x15
CombatEnemy (red), 0x16 CombatSelf (pink), 0x18 Craft, 0x19 Salvaging
(green), 0x1F AdminTell.
### 5.3 Paperdoll slot offsets
25 equipment slots at offsets `+0x604` through `+0x664` (4 bytes
apart) in the paperdoll panel's `this`. Correlated with the EquipMask
bit layout.
### 5.4 Cross-panel primitives identified
These recur across multiple panels and should be extracted as shared
C# helpers:
- `WidgetLookup` — lookup by resource ID (`FUN_00463c00(0x1000xxxx)`)
- `RichTextBuilder` — stream-append wide-string + format tokens
- `TooltipBuilder``FUN_0042dc80` open → content → `FUN_0042e590` close
- `DragDropRouter` — routes 0x15 / 0x1C / 0x21 / 0x3E chains
- `RowList<T>` — used by skills, spells, fellowship members, allegiance tree
- `PropertyUpdateBus` — dispatches `GameMessagePrivateUpdate*` into bound widgets
---
## 6. HUD + dat-asset catalog (from slice 06)
### 6.1 HUD elements
| Element | Rendering technique | Retail dat IDs (from AC2D) |
|---------------------|--------------------------------------------------------------------|----------------------------|
| Vital orbs (3) | Textured quad + **scissor rect** for fill fraction; not gradients | `0x060013B2` (vitals icon) |
| Radar | Polar plot with blips; `1.18 * range` shrink factor; player arrow center | `0x06001388`, `0x06004CC1` (bezel) |
| Compass strip | Scrolling horizontal texture; U-offset = `heading_deg / 360` | — |
| Minimap | Larger radar variant | `0x06001065` |
| Quickbar | 10 slots; hotkey = 1-0 | `0x06001AB2` (slot) |
| Target health bar | Plate appears over selected creature | — |
| Damage floaters | Text rising above head for N seconds | — |
| Announcement strip | Center-top for big events | — |
| World hover name | Text over a 3D object under the cursor | — |
### 6.2 UI dat type catalog (authoritative; verified from chunk_00410000.c range dispatcher)
| ID range | DBObjType | Size | Role |
|---------------------------|-------------------|------|------|
| `0x04xxxxxx` | Palette | var | 256-entry BGRA8 palettes |
| `0x05xxxxxx` | PaletteSet | var | Tintable palette groups |
| `0x06xxxxxx..0x07xxxxxx` | RenderSurface | var | All UI images (buttons, icons, backgrounds, glyph sheets, cursors) |
| `0x08xxxxxx` | Surface | var | Extended surface with palette ref |
| `0x0F000000..0x0FFFFFFF` | SurfaceMaterial | var | Materials |
| `0x14xxxxxx` | MasterInputMap | var | Default keybind table |
| `0x21xxxxxx` | LayoutDesc | var | **Panel layouts** (tree of ElementDesc + StateDesc + Media) |
| `0x23-0x24xxxxxx` | StringTable | var | Localized strings |
| `0x40000000..0x40000FFF` | Font | var | Font metadata + glyph records |
| `0x41xxxxxx` | LanguageInfo | var | Localization root |
### 6.3 Vital update wire messages
- `GameMessagePrivateUpdateAttribute` (0x02E3) — 21 bytes per attribute
- `GameMessagePrivateUpdateVital` (0x02E7) — 25 bytes
- `GameMessagePrivateUpdateAttribute2ndLevel` (0x02E9) — max-value refresh
- `GameEventPlayerDescription` (0xF7B0/0x0013) — initial full dump at login
VitalId enum: 0x01 MaxHealth, 0x03 MaxStamina, 0x05 MaxMana
CurVitalId enum: 0x02 CurHealth, 0x04 CurStamina, 0x06 CurMana
### 6.4 Cursor
- Default: `LoadCursorA(NULL, IDC_ARROW=0x7F00)`
- Custom cursors: built from RGBA dat bitmap via GDI path
(`FUN_0043c1c0` / `FUN_00439c70`) → `HCURSOR` via `CreateIconIndirect`
- Rendering mode: OS cursor suppressed when UI draws its own sprite
- `SetCursorPos` used in mouse-look mode to recenter
---
## 7. Proposed C# port structure
### 7.1 Namespace + project layout
```
src/AcDream.App/UI/
UiRoot.cs // top-of-tree, implements IDevice, called from GameWindow
UiElement.cs // base widget class
UiPanel.cs // container with children
UiLabel.cs // text
UiButton.cs // clickable
UiEditBox.cs // text entry
UiScrollBar.cs
UiList.cs // scrollable list
UiImage.cs // sprite
UiWindow.cs // movable / closable frame
AcFont.cs // dat-sourced font (portal.dat Font DBObj)
FontCache.cs // lazy-load font atlases
UiSpriteBatch.cs // 2D batched quad renderer (replaces our debug TextRenderer)
UiRenderer.cs // per-frame UI orchestrator — walks tree, emits draws
UiEvent.cs // event struct matching retail's 24-byte payload
CursorManager.cs // OS + dat cursor management
src/AcDream.App/UI/Panels/
ChatWindow.cs
AttributesPanel.cs
SkillsPanel.cs
SpellPanel.cs
PaperdollPanel.cs
InventoryPanel.cs
QuickbarPanel.cs
FellowshipPanel.cs
AllegiancePanel.cs
LoginPanel.cs
CharacterSelectPanel.cs
src/AcDream.App/UI/Hud/
VitalOrbs.cs
RadarPanel.cs
CompassStrip.cs
SelectionTarget.cs
DamageFloaters.cs
AnnouncementStrip.cs
WorldHoverName.cs
```
### 7.2 Scaffold to ship in this session
Minimum viable scaffold matching the research findings:
1. `UiEvent` struct + event type enum (24 bytes, matches retail)
2. `UiElement` abstract base with virtual Draw/OnMouse/OnKey/OnTick/OnHitTest + children list
3. `UiPanel` container that composites children
4. `UiRoot` that implements `IDevice` semantics (focus, modal, capture,
drag) and can be driven by `GameWindow`'s existing Silk.NET input
handlers
5. `UiSpriteBatch` — derived from our existing `TextRenderer` but with
scissor support added
6. `UiRenderer` — per-frame entry point, walks the tree
7. Integration stub in `GameWindow.cs` — creates `UiRoot`, routes
Silk.NET mouse/keyboard to it with "world fall-through" for any
unconsumed event
Later (follow-up sessions):
- `AcFont` from portal.dat Font DBObj (slice 3 has exact on-disk layout)
- `CursorManager` for dat-cursor support
- First real panel — chat window — since we have wire messages ready
- Vital orbs once server sends `GameMessagePrivateUpdateVital`
### 7.3 Event type mapping to Silk.NET input
| Silk.NET event | UiEvent type |
|---------------------------------|-----------------------------|
| IMouse.MouseDown (Left) | 0x201 |
| IMouse.MouseUp (Left) | 0x202 |
| IMouse.MouseMove | 0x200 |
| IMouse.Scroll | 0x0A |
| IKeyboard.KeyDown | 0x100 |
| IKeyboard.KeyUp | 0x101 |
| IKeyboard.KeyChar | 0x102 |
| (synthesized on hover-delay) | 0x07 (tooltip) |
| (synthesized on drag distance) | 0x15/0x1C/0x21/0x3E drag-chain |
| (synthesized by UiRoot) | 0x05/0x06 hover enter/leave |
### 7.4 Key constants to preserve
Using retail's magic numbers lets hand-ported panel code (e.g., a
future port of `FUN_0047ba70``AttributesPanel`) use the same
event_type switches the original uses:
```csharp
public static class UiEventType
{
public const int Click = 0x01;
public const int HoverEnter = 0x05;
public const int HoverLeave = 0x06;
public const int Tooltip = 0x07;
public const int DoubleClick = 0x08;
public const int Scroll = 0x0A;
public const int RightClick = 0x0E;
public const int DragBegin = 0x15;
public const int DragOver = 0x1C;
public const int DragEnter = 0x21;
public const int DropReleased = 0x3E;
public const int MouseDown = 0x201;
public const int MouseUp = 0x202;
public const int MouseMove = 0x200;
public const int KeyDown = 0x100;
public const int KeyUp = 0x101;
public const int Char = 0x102;
}
```
### 7.5 Integration into existing `GameWindow.cs`
Current state: `GameWindow` binds Silk.NET `IInputContext` directly to
`PlayerMovementController` + camera. We insert `UiRoot` between:
```csharp
// In OnLoad:
_uiRoot = new UiRoot();
_uiRoot.WorldMouseFallThrough += (button, x, y, flags) => { /* existing camera/world handling */ };
_uiRoot.WorldKeyFallThrough += (vk, lp) => { /* existing camera/world hotkeys */ };
mouse.MouseDown += (_, b) => _uiRoot.OnMouseDown(MapButton(b), (int)m.Position.X, (int)m.Position.Y, 0);
// similar for MouseUp, MouseMove, Scroll, KeyDown, KeyUp, KeyChar
```
UiRoot dispatches to focused widget / modal / top panel first; if no
widget consumes the event, the `WorldMouseFallThrough` /
`WorldKeyFallThrough` events fire, preserving the existing camera
controls (sensitivity, RMB orbit, etc.).
### 7.6 Render integration
In `GameWindow.OnRender`, after the 3D scene and before the debug HUD:
```csharp
// 3D world (existing)
_terrain?.Draw(camera, frustum);
_staticMesh?.Draw(...);
_debugLines?.Flush(...);
// Retail UI pass
_uiRenderer.BeginFrame(screenSize);
_uiRoot.Draw(_uiRenderer);
_uiRenderer.EndFrame(); // flushes sprite batches
// Existing debug overlay (dev HUD)
_debugOverlay?.Draw(...);
```
---
## 8. What we do NOT port
### 8.1 Keystone itself
`keystone.dll` is binary-only and not decompiled. We do not attempt to
match its internal API. Our widget toolkit is analogous but not byte-
compatible.
### 8.2 XML layout loader
Retail stores panel layouts as MSXML4-parsed XML. Our panels are
code-defined for the MVP. If we ever want to consume the retail
`.dat`-embedded layouts directly, we write an XML->widget-tree
deserializer in a later phase.
### 8.3 ACHelpPlugin's embedded browser
The help/tutorial pane is a hosted Internet Explorer control
(`CoCreateInstance(CLSID_007cc680)`). We ship our own help pane (flat
text or a WebView2 embed if we ever need HTML).
### 8.4 IME composition
The production build has the IME hook stubbed. We don't target CJK
localizations for the MVP.
---
## 9. Risk + sequencing
### 9.1 What can ship next session (low risk)
1. `UiElement` + `UiPanel` + `UiRoot` + `UiSpriteBatch` scaffold (this session)
2. Port `BitmapFont``AcFont` from portal.dat `Font` DBObj
3. Chat window panel (wire messages already parsed by `WorldSession`)
### 9.2 Medium risk
4. Vital orbs + radar (needs server `PrivateUpdateVital` wiring)
5. Inventory panel (needs item CreateObject parsing + icon lookup)
6. Drag-drop plumbing (requires tooltip timer + capture + payload state)
### 9.3 High risk
7. Character creation screen (needs the whole Attribute/Skill model)
8. Trade window (multi-player state, confirms)
9. Spellcasting UI (component consumption, formula display, cast progress)
---
## 10. Lookup table: retail function → proposed C# method
Only the high-confidence mappings. Many more in the slice docs.
| Retail address | Role | Proposed C# class.method |
|----------------|---------------------------|-----------------------------------------|
| `0x004013A0` | WinMain | `Program.Main` |
| `0x0043BA60` | CreateMainWindow | `GameWindow.OnLoad` |
| `0x00439E50` | PumpMessages | (Silk.NET internal) |
| `0x00439860` | WndProc | (Silk.NET internal; our UiRoot handles) |
| `0x0045D0B0` | RenderFrame | `GameWindow.OnRender` |
| `0x004554B0` | GameTick | `GameWindow.OnUpdate` |
| `0x0043FCD0` | Flush+Present | (Silk.NET internal) |
| `0x005DA8F0` | UI tree walker | `UiRenderer.Draw(UiRoot)` |
| `0x00697140` | Font load+bake | `AcFont` constructor |
| `0x00697770` | Text→quads | `UiSpriteBatch.DrawString` |
| `0x006974D0` | Flush text | `UiSpriteBatch.Flush` |
| `0x0043DCD0` | px→NDC | `UiSpriteBatch` private helper |
| `0x0043C1C0` | HCURSOR from bitmap | `CursorManager.SetFromDat` |
| `0x00557850` | KeystoneCreate call | `UiRoot` constructor |
| `0x00692470` | Chat panel render | `ChatWindow.Draw` |
| `0x0047BA70` | Attributes panel render | `AttributesPanel.Draw` |
| `0x004A5200` | Paperdoll slot tooltip | `PaperdollPanel.BuildSlotTooltip` |
---
## 11. Summary — what this pass ships today
- **Full research** (this synthesis + 6 detailed slice docs)
- **C# port scaffold** (sections 7.1-7.6):
- `UiEvent`, `UiEventType`, `UiElement`, `UiPanel`, `UiRoot`, `UiSpriteBatch`
- Integration stub in `GameWindow` — hooks Silk.NET events through
UiRoot with world fall-through
- **Updated architecture doc**`docs/architecture/acdream-architecture.md`
gets a new UI-layer section pointing at the synthesis
- **Roadmap** — new phase **U1 Retail UI Framework** appended
Not shipped this session (scheduled for follow-up):
- Font loading from portal.dat (AcFont from Font DBObj)
- CursorManager with dat-sourced custom cursors
- Any concrete panel (chat / attributes / etc.)
- Vital orb HUD
- Scissor clip support in UiSpriteBatch
---
## 12. Open questions
1. **Widget IDs source.** The hardcoded `0x1000001c`, `0x100002fc`
resource IDs — are these in `keystone.dll`'s layout tables, or in
`0x21xxxxxx` LayoutDesc dats? Likely dats; confirm by dumping one
LayoutDesc and looking for these IDs inside.
2. **Tooltip delay.** Retail registers the timer with some constant ms;
grep for `0x3e8` (1000) or `0x1f4` (500) near
`RegisterTimerEvent(7, …)`.
3. **Chat protocol gaps.** We know 6 wire messages drive chat; need
to verify tab-filter persistence (is the "General" tab's filter
mask stored server-side or client-side?).
4. **Inventory weight warnings.** The error string catalog
mentioned in slice 05 needs full enumeration when we port
the inventory panel.
5. **Quickbar persistence.** Server-side or saved to `.ini`?

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,111 @@
# AcDream.App.UI — Retail-style UI toolkit
This is acdream's retained-mode UI toolkit. It mirrors the **behavior**
of the retail AC client (hit-testing, modal, capture, drag-drop,
tooltip delay, focus routing, event type codes) without trying to
byte-match the retail binary — because the retail widgets live in
`keystone.dll`, which we don't decompile.
## Research
All design decisions in this directory are grounded in the master
synthesis + six deep-dive docs under
[`docs/research/retail-ui/`](../../../docs/research/retail-ui/):
| Document | Topic |
|---|---|
| [`00-master-synthesis.md`](../../../docs/research/retail-ui/00-master-synthesis.md) | Cross-slice synthesis + C# port plan |
| [`01-architecture-and-init.md`](../../../docs/research/retail-ui/01-architecture-and-init.md) | Process entry, window, main loop |
| [`02-class-hierarchy.md`](../../../docs/research/retail-ui/02-class-hierarchy.md) | CUIManager / CUIListener / CFont / CSurface |
| [`03-rendering.md`](../../../docs/research/retail-ui/03-rendering.md) | Font atlas, 2D quad batch, cursor |
| [`04-input-events.md`](../../../docs/research/retail-ui/04-input-events.md) | WndProc → Device → widget event routing |
| [`05-panels.md`](../../../docs/research/retail-ui/05-panels.md) | Chat, attributes, spells, paperdoll, inventory |
| [`06-hud-and-assets.md`](../../../docs/research/retail-ui/06-hud-and-assets.md) | Vital orbs, radar, compass + dat asset catalog |
## Files
- `UiEvent.cs` — 24-byte event struct + retail-faithful type constants
(`0x01` click, `0x15` drag-begin, `0x3E` drop, `0x201` WM_LBUTTONDOWN, …)
- `UiElement.cs` — base widget with `OnDraw` / `OnEvent` / `OnHitTest` /
`OnTick` virtuals, children list, ZOrder, focus/capture flags
- `UiPanel.cs``UiPanel` (rect + optional bg/border), `UiLabel`, `UiButton`
- `UiRenderContext.cs` — per-frame draw context with translate stack
- `UiRoot.cs` — top-of-tree + "Device" responsibilities (mouse/keyboard
state, focus, modal, capture, drag-drop, tooltip timer). Mirrors the
retail `DAT_00837ff4` Device object's vtable.
- `UiHost.cs` — one-shot wrapper: owns the `UiRoot`, a `TextRenderer`,
and a default `BitmapFont`. Provides `WireMouse` / `WireKeyboard`
helpers for Silk.NET plumbing.
## Integration pattern
```csharp
// GameWindow.OnLoad
_uiHost = new UiHost(_gl!, shadersDir, _debugFont);
_uiHost.Root.WorldMouseFallThrough += (btn, x, y, flags) => HandleWorldClick(btn, x, y, flags);
_uiHost.Root.WorldKeyFallThrough += (vk, lp) => HandleHotkey(vk);
foreach (var mouse in _input.Mice) _uiHost.WireMouse(mouse);
foreach (var kb in _input.Keyboards) _uiHost.WireKeyboard(kb);
// Add panels
var chat = new Panels.ChatWindow { Left = 10, Top = 400, Width = 500, Height = 250 };
_uiHost.Root.AddChild(chat);
// GameWindow.OnRender — after the 3D scene
_uiHost.Tick(deltaSeconds);
_uiHost.Draw(new Vector2(_window!.Size.X, _window.Size.Y));
```
## What's scaffolded vs what still needs building
### Shipped in the scaffold (this session)
- UI tree + event routing + focus + modal + capture + drag-drop
- Hit-testing (children-first, Z-order tie-break)
- Tooltip timer (~1000ms)
- Hover enter/leave, click vs right-click, scroll, keyboard
- World fall-through so existing camera/player controls still work
- Simple text/rect drawing through the existing `BitmapFont` +
`TextRenderer` pipeline
### To build next
1. **`AcFont`** + **`FontCache`** — load `Font` DBObjs from
`portal.dat` range `0x40000000..0x40000FFF`, bake 256×256 glyph
atlas from the referenced `RenderSurface` (`0x06xxxxxx`). See
[slice 03 §4](../../../docs/research/retail-ui/03-rendering.md#4-fonts-in-the-dat-files).
2. **Dat sprite loader** — decode `RenderSurface` dats as GL textures;
add `DrawSprite(uint datId, Rectangle dest, uint rgba)` to
`UiRenderContext`.
3. **`CursorManager`** — OS cursor + dat-sourced custom cursors via
[slice 03 §7](../../../docs/research/retail-ui/03-rendering.md#7-cursor).
4. **Scissor clipping** — for panels with scrollable interiors (chat,
inventory grid). `GL_SCISSOR_TEST` wrapped in
`UiRenderContext.PushScissor` / `PopScissor`.
5. **First concrete panel — `ChatWindow`** since we have all 6 wire
messages parsed already. See
[slice 05 §1](../../../docs/research/retail-ui/05-panels.md#1-chat-window).
6. **Vital orbs HUD** once the server sends
`GameMessagePrivateUpdateVital`. See
[slice 06 A.1](../../../docs/research/retail-ui/06-hud-and-assets.md#a1-health--stamina--mana-globes).
## Retail magic numbers the scaffold preserves
Because hand-ported panel code will copy the retail switch-case
structure, we keep the magic constants:
```csharp
// Event types
UiEventType.Click == 0x01 // chunk_00470000.c ~11140
UiEventType.Tooltip == 0x07 // chunk_00460000.c ~6253 (~1000ms delay)
UiEventType.DragBegin == 0x15 // chunk_004A0000.c ~2707
UiEventType.DragEnter == 0x21 // chunk_004A0000.c ~2714
UiEventType.DragOver == 0x1C // chunk_004A0000.c ~2723
UiEventType.DropReleased == 0x3E // chunk_004A0000.c ~2754
UiEventType.MouseDown == 0x201 // WM_LBUTTONDOWN
UiEventType.MouseUp == 0x202 // WM_LBUTTONUP
// Event IDs
// Widget event IDs live in the 0x10000000+ range (retail convention).
// UiRoot auto-assigns EventIds starting at 0x10000001.
```

View file

@ -0,0 +1,203 @@
using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// Base class for every UI widget in the retained-mode tree.
///
/// Design notes:
/// - Retail AC delegates widget semantics to the external
/// <c>keystone.dll</c> library (see
/// <c>docs/research/retail-ui/02-class-hierarchy.md</c> — there is no
/// widget hierarchy inside <c>acclient.exe</c> itself). We implement
/// our own retained-mode toolkit here, matching the <i>behavior</i>
/// described in the decompile without trying to byte-match Keystone's
/// internal class layout.
/// - Events use the retail-faithful <see cref="UiEvent"/> struct and
/// the <see cref="UiEventType"/> constants so that hand-ported panel
/// code can use the same magic numbers the decompiled C uses
/// (e.g. <c>if (e.Type == 0x15) ...</c> for drag-begin).
/// - Hit-testing is children-first (topmost wins) with Z-order tie
/// breaking; drawing is back-to-front so later children appear on top.
/// - Coordinates are in <b>screen pixels</b>, origin top-left.
/// <see cref="Bounds"/> is in the parent's local coordinate space.
/// </summary>
public abstract class UiElement
{
// ── Identity ─────────────────────────────────────────────────────────
/// <summary>
/// Unique 32-bit event ID. Retail uses the range <c>0x10000000+</c>
/// for custom app events (see
/// <c>docs/research/retail-ui/04-input-events.md §3</c>). Assigned
/// by <see cref="UiRoot"/> when the element is added to the tree.
/// </summary>
public uint EventId { get; internal set; }
/// <summary>Human-readable name for debugging / FindByName.</summary>
public string? Name { get; init; }
// ── Geometry ────────────────────────────────────────────────────────
/// <summary>X in the parent's local pixel space.</summary>
public float Left { get; set; }
public float Top { get; set; }
public float Width { get; set; }
public float Height { get; set; }
/// <summary>Absolute (screen-space) top-left, computed by walking Parent.</summary>
public Vector2 ScreenPosition
{
get
{
var p = new Vector2(Left, Top);
var parent = Parent;
while (parent is not null)
{
p += new Vector2(parent.Left, parent.Top);
parent = parent.Parent;
}
return p;
}
}
// ── State flags ─────────────────────────────────────────────────────
public bool Visible { get; set; } = true;
public bool Enabled { get; set; } = true;
/// <summary>
/// If true, <see cref="HitTest"/> skips this element — the event
/// passes through to whatever is behind. Used by decoration widgets
/// (portrait frames, ornamental dividers).
/// </summary>
public bool ClickThrough { get; set; }
/// <summary>
/// If true, <see cref="UiRoot"/> will set focus here on click,
/// routing WM_KEYDOWN / WM_CHAR to <see cref="OnEvent"/> as
/// <see cref="UiEventType.KeyDown"/> / <see cref="UiEventType.Char"/>.
/// </summary>
public bool AcceptsFocus { get; set; }
/// <summary>
/// True if this is a text-entry (edit box); used by focus routing
/// to suppress global hotkeys while typing.
/// </summary>
public bool IsEditControl { get; set; }
/// <summary>Painter's-algorithm z-order within siblings. Higher = on top.</summary>
public int ZOrder { get; set; }
// ── Tree structure ──────────────────────────────────────────────────
public UiElement? Parent { get; private set; }
private readonly List<UiElement> _children = new();
public IReadOnlyList<UiElement> Children => _children;
public virtual void AddChild(UiElement child)
{
if (child.Parent is not null) child.Parent.RemoveChild(child);
child.Parent = this;
_children.Add(child);
}
public virtual bool RemoveChild(UiElement child)
{
if (!_children.Remove(child)) return false;
child.Parent = null;
return true;
}
// ── Virtual overrides ───────────────────────────────────────────────
/// <summary>
/// Draw THIS element (not its children). Children are composited by
/// <see cref="UiRoot"/> after this returns.
/// </summary>
protected virtual void OnDraw(UiRenderContext ctx) { }
/// <summary>Per-frame tick (animations, timers, caret blink).</summary>
protected virtual void OnTick(double deltaSeconds) { }
/// <summary>
/// Custom hit-test override. Default is a rectangle containment
/// check on (<see cref="Width"/>, <see cref="Height"/>).
/// </summary>
protected virtual bool OnHitTest(float localX, float localY)
=> localX >= 0f && localX < Width && localY >= 0f && localY < Height;
/// <summary>
/// Event handler. Return <c>true</c> to consume the event (the
/// <see cref="UiRoot"/> will stop propagation). Return <c>false</c>
/// to let ancestors / fall-through handle it.
/// </summary>
public virtual bool OnEvent(in UiEvent e) => false;
/// <summary>
/// Tooltip text for this widget. Retail fires event 0x07 after
/// ~1000ms hover, then queries the widget's virtual "GetString"
/// (vtable +0x88) to render the tooltip body.
/// </summary>
public virtual string? GetTooltipText() => null;
// ── Framework entry points (internal, called by UiRoot) ─────────────
internal void DrawSelfAndChildren(UiRenderContext ctx)
{
if (!Visible) return;
// Translate into our local space.
ctx.PushTransform(Left, Top);
try
{
OnDraw(ctx);
// Children painted back-to-front (lowest ZOrder first).
if (_children.Count > 0)
{
// Avoid LINQ allocation by copying to a temp array and sorting.
var ordered = _children.ToArray();
Array.Sort(ordered, static (a, b) => a.ZOrder.CompareTo(b.ZOrder));
for (int i = 0; i < ordered.Length; i++)
ordered[i].DrawSelfAndChildren(ctx);
}
}
finally
{
ctx.PopTransform();
}
}
internal void TickSelfAndChildren(double dt)
{
if (!Visible) return;
OnTick(dt);
for (int i = 0; i < _children.Count; i++)
_children[i].TickSelfAndChildren(dt);
}
/// <summary>
/// Top-down, children-first hit-test. <paramref name="localX"/> /
/// <paramref name="localY"/> are in THIS element's local space.
/// Returns the topmost descendant (or this) at the point, or null.
/// </summary>
internal UiElement? HitTest(float localX, float localY)
{
if (!Visible || !Enabled || ClickThrough) return null;
// Children first, in reverse Z-order (topmost first).
if (_children.Count > 0)
{
var ordered = _children.ToArray();
Array.Sort(ordered, static (a, b) => b.ZOrder.CompareTo(a.ZOrder));
for (int i = 0; i < ordered.Length; i++)
{
var c = ordered[i];
var childHit = c.HitTest(localX - c.Left, localY - c.Top);
if (childHit is not null) return childHit;
}
}
return OnHitTest(localX, localY) ? this : null;
}
}

View file

@ -0,0 +1,90 @@
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// Per-event payload delivered to <see cref="UiElement.OnEvent"/>.
/// Mirrors the retail AC client's 24-byte event struct that is passed to
/// every widget's vtable slot +0x128 (<c>OnEvent(int* event)</c>).
///
/// Layout from decompiled <c>chunk_004A0000.c</c> paperdoll handler
/// <c>FUN_004A5FA0</c>:
/// <code>
/// int source_id; // param_2[0] — e.g. 0x100001d6 (drag source)
/// void* target_widget; // param_2[1]
/// int event_type; // param_2[2] — see UiEventType
/// int data0; // param_2[3]
/// int data1; // param_2[4] — typically x in local coords
/// int data2; // param_2[5] — typically y
/// int data3; // param_2[6]
/// </code>
/// </summary>
public readonly record struct UiEvent(
uint SourceId,
UiElement? Target,
int Type, // see <see cref="UiEventType"/>
int Data0 = 0,
int Data1 = 0,
int Data2 = 0,
int Data3 = 0,
object? Payload = null);
/// <summary>
/// Retail AC UI event-type constants. Each value matches the decompiled
/// switch-case in widgets' OnEvent handlers (e.g. 0x01 click, 0x15 drag
/// begin, 0x3E drop released). Win32 WM_* numbers are reused for raw
/// button/key/mouse events (0x200 = WM_MOUSEMOVE etc.) — this matches
/// retail where internal event codes collide deliberately with WM_*.
///
/// Evidence from decompile:
/// - 0x01 click — chunk_00470000.c ~11140, chunk_004C0000.c ~9270
/// - 0x05/0x06 hover — chunk_00460000.c ~6253
/// - 0x07 tooltip — chunk_00460000.c ~6253 (delayed via
/// Device::RegisterTimerEvent(7, widget, delayMs))
/// - 0x0A scroll — chunk_00470000.c ~11210
/// - 0x0E right-click— chunk_004A0000.c ~2674
/// - 0x15 drag begin — chunk_004A0000.c ~2707
/// - 0x1C drag-over — chunk_004A0000.c ~2723
/// - 0x21 drag-enter — chunk_004A0000.c ~2714
/// - 0x3E drop-released — chunk_004A0000.c ~2754
/// </summary>
public static class UiEventType
{
public const int Click = 0x01;
public const int HoverEnter = 0x05;
public const int HoverLeave = 0x06;
public const int Tooltip = 0x07;
public const int DoubleClick = 0x08;
public const int Scroll = 0x0A;
public const int RightClick = 0x0E;
public const int DragBegin = 0x15;
public const int DragOver = 0x1C;
public const int DragEnter = 0x21;
public const int FocusLost = 0x28;
public const int FocusGained = 0x29;
public const int DropReleased = 0x3E;
// Raw Win32-style event numbers (retail uses WM_* verbatim for internal dispatch).
public const int MouseMove = 0x200;
public const int MouseDown = 0x201; // left button down
public const int MouseUp = 0x202; // left button up
public const int DoubleClickLeft = 0x203;
public const int RightDown = 0x204;
public const int RightUp = 0x205;
public const int MiddleDown = 0x207;
public const int MiddleUp = 0x208;
public const int KeyDown = 0x100;
public const int KeyUp = 0x101;
public const int Char = 0x102;
}
/// <summary>
/// Mouse button enum matching retail's 1/2/3 encoding.
/// </summary>
public enum UiMouseButton
{
Left = 1,
Right = 2,
Middle = 3,
}

View file

@ -0,0 +1,102 @@
using System.Numerics;
using AcDream.App.Rendering;
using Silk.NET.Input;
using Silk.NET.OpenGL;
namespace AcDream.App.UI;
/// <summary>
/// Packages the <see cref="UiRoot"/>, the 2D sprite batcher
/// (<see cref="Rendering.TextRenderer"/>), and a default font so
/// <c>GameWindow</c> can wire the retail-style UI in with one
/// construction and a handful of input callbacks.
///
/// Usage (from <c>GameWindow.OnLoad</c>):
/// <code>
/// _uiHost = new UiHost(_gl, shadersDir, _debugFont);
/// _uiHost.Root.WorldMouseFallThrough += (btn, x, y, f) => HandleWorldClick(btn, x, y);
/// _uiHost.Root.WorldKeyFallThrough += (vk, lp) => HandleHotkey(vk);
///
/// foreach (var mouse in _input.Mice)
/// _uiHost.WireMouse(mouse);
/// foreach (var kb in _input.Keyboards)
/// _uiHost.WireKeyboard(kb);
/// </code>
///
/// And per frame (from <c>GameWindow.OnRender</c>):
/// <code>
/// _uiHost.Tick(deltaSeconds);
/// _uiHost.Draw(new Vector2(_window!.Size.X, _window.Size.Y));
/// </code>
///
/// Retail analog: the trio of <c>DAT_00870340</c> (Core, owns fonts/atlases),
/// <c>DAT_00837ff4</c> (Device, owns input state), <c>DAT_00870c2c</c>
/// (Keystone root, widget tree). We fuse them into a single host class
/// because we're not linking to Keystone.
/// </summary>
public sealed class UiHost : System.IDisposable
{
public UiRoot Root { get; } = new();
public TextRenderer TextRenderer { get; }
public BitmapFont? DefaultFont { get; set; }
private long _startTicks = System.Environment.TickCount64;
public UiHost(GL gl, string shaderDir, BitmapFont? defaultFont = null)
{
TextRenderer = new TextRenderer(gl, shaderDir);
DefaultFont = defaultFont;
}
// ── Per-frame ──────────────────────────────────────────────────────
public void Tick(double deltaSeconds)
{
long now = System.Environment.TickCount64 - _startTicks;
Root.Tick(deltaSeconds, now);
}
public void Draw(Vector2 screenSize)
{
// Set UiRoot bounds to full screen so HitTestTopDown works.
Root.Width = screenSize.X;
Root.Height = screenSize.Y;
var ctx = new UiRenderContext(TextRenderer, screenSize, DefaultFont);
TextRenderer.Begin(screenSize);
Root.Draw(ctx);
TextRenderer.Flush(DefaultFont);
}
// ── Input wiring helpers ───────────────────────────────────────────
public void WireMouse(IMouse mouse)
{
mouse.MouseDown += (_, b) =>
Root.OnMouseDown(MapButton(b), (int)mouse.Position.X, (int)mouse.Position.Y);
mouse.MouseUp += (_, b) =>
Root.OnMouseUp(MapButton(b), (int)mouse.Position.X, (int)mouse.Position.Y);
mouse.MouseMove += (_, p) =>
Root.OnMouseMove((int)p.X, (int)p.Y);
mouse.Scroll += (_, s) =>
Root.OnScroll((int)s.Y);
}
public void WireKeyboard(IKeyboard kb)
{
kb.KeyDown += (_, k, _) => Root.OnKeyDown((int)k);
kb.KeyUp += (_, k, _) => Root.OnKeyUp((int)k);
kb.KeyChar += (_, c) => Root.OnChar(c);
}
private static UiMouseButton MapButton(MouseButton b) => b switch
{
MouseButton.Left => UiMouseButton.Left,
MouseButton.Right => UiMouseButton.Right,
MouseButton.Middle => UiMouseButton.Middle,
_ => UiMouseButton.Left,
};
public void Dispose()
{
TextRenderer.Dispose();
}
}

View file

@ -0,0 +1,93 @@
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// Rectangular container with an optional translucent background and
/// border. Used as the base of every retail panel (attributes, chat,
/// inventory, login, etc.).
///
/// Retail has panel background art stored as 9-slice sprite assets in
/// the <c>0x06xxxxxx</c> RenderSurface range, and composed via
/// <c>LayoutDesc</c> (<c>0x21xxxxxx</c>) trees. Until our
/// <c>AcFont</c>/<c>UiSpriteBatch</c> consumes those directly, we draw a
/// simple translucent rectangle so panels are visible during development.
/// </summary>
public class UiPanel : UiElement
{
/// <summary>Background fill color. Set <see cref="Vector4.Zero"/> to skip.</summary>
public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0.55f);
/// <summary>Border color. Set <see cref="Vector4.Zero"/> to skip.</summary>
public Vector4 BorderColor { get; set; } = new(0.15f, 0.15f, 0.2f, 0.8f);
public float BorderThickness { get; set; } = 1f;
protected override void OnDraw(UiRenderContext ctx)
{
if (BackgroundColor.W > 0f)
ctx.DrawRect(0, 0, Width, Height, BackgroundColor);
if (BorderColor.W > 0f && BorderThickness > 0f)
ctx.DrawRectOutline(0, 0, Width, Height, BorderColor, BorderThickness);
}
}
/// <summary>
/// Static text label. Draws a single line of text using the context's
/// default font (or an override). Does not consume input.
///
/// Equivalent retail primitive: wide-string appended to a CString via
/// <c>FUN_0040b8f0</c> then drawn by the widget's draw method through
/// <c>FUN_00698330</c>.
/// </summary>
public class UiLabel : UiElement
{
public string Text { get; set; } = string.Empty;
public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f);
public UiLabel() { ClickThrough = true; }
protected override void OnDraw(UiRenderContext ctx)
=> ctx.DrawString(Text, 0, 0, TextColor);
}
/// <summary>
/// Simple clickable button: panel background + centered label + click
/// callback. Retail equivalent is Keystone's button widget, driven by
/// a <c>StateDesc</c> per <c>UIStateId</c> (normal / hot / pressed /
/// disabled) from the panel layout.
/// </summary>
public class UiButton : UiPanel
{
public string Text { get; set; } = string.Empty;
public Vector4 TextColor { get; set; } = new(1f, 1f, 1f, 1f);
public event System.Action? Click;
public UiButton()
{
BackgroundColor = new Vector4(0.1f, 0.1f, 0.15f, 0.8f);
BorderColor = new Vector4(0.45f, 0.45f, 0.55f, 1f);
}
public override bool OnEvent(in UiEvent e)
{
if (e.Type == UiEventType.Click && Enabled)
{
Click?.Invoke();
return true;
}
return false;
}
protected override void OnDraw(UiRenderContext ctx)
{
base.OnDraw(ctx);
if (Text.Length == 0 || ctx.DefaultFont is null) return;
float textW = ctx.DefaultFont.MeasureWidth(Text);
float tx = (Width - textW) * 0.5f;
float ty = (Height - ctx.DefaultFont.LineHeight) * 0.5f;
ctx.DrawString(Text, tx, ty, TextColor);
}
}

View file

@ -0,0 +1,62 @@
using System.Numerics;
using AcDream.App.Rendering;
namespace AcDream.App.UI;
/// <summary>
/// Per-frame drawing context passed through the <see cref="UiElement"/>
/// tree. Wraps a <see cref="TextRenderer"/> (our 2D sprite batcher) and a
/// transform stack so elements can draw in local coordinates.
///
/// Retail equivalent: the implicit context <c>FUN_005da8f0</c> walks with
/// when iterating the UI tree. Our version is explicit so it plugs
/// cleanly into Silk.NET.
/// </summary>
public sealed class UiRenderContext
{
public TextRenderer TextRenderer { get; }
public BitmapFont? DefaultFont { get; set; }
public Vector2 ScreenSize { get; }
// Transform stack — simple 2D translate (no rotation/scale for UI).
private readonly System.Collections.Generic.List<Vector2> _stack = new();
private Vector2 _current;
public UiRenderContext(TextRenderer tr, Vector2 screenSize, BitmapFont? defaultFont = null)
{
TextRenderer = tr;
ScreenSize = screenSize;
DefaultFont = defaultFont;
}
/// <summary>Push a relative translate. Must be paired with <see cref="PopTransform"/>.</summary>
public void PushTransform(float dx, float dy)
{
_stack.Add(_current);
_current += new Vector2(dx, dy);
}
public void PopTransform()
{
if (_stack.Count == 0) return;
_current = _stack[^1];
_stack.RemoveAt(_stack.Count - 1);
}
public Vector2 CurrentOrigin => _current;
// ── Pass-through draw helpers (add current translate) ──────────────
public void DrawRect(float x, float y, float w, float h, Vector4 color)
=> TextRenderer.DrawRect(_current.X + x, _current.Y + y, w, h, color);
public void DrawRectOutline(float x, float y, float w, float h, Vector4 color, float thickness = 1f)
=> TextRenderer.DrawRectOutline(_current.X + x, _current.Y + y, w, h, color, thickness);
public void DrawString(string text, float x, float y, Vector4 color, BitmapFont? font = null)
{
var f = font ?? DefaultFont;
if (f is null) return;
TextRenderer.DrawString(f, text, _current.X + x, _current.Y + y, color);
}
}

View file

@ -0,0 +1,473 @@
using System;
using System.Collections.Generic;
using System.Numerics;
namespace AcDream.App.UI;
/// <summary>
/// Top-level UI container. Implements the retail "Device" responsibilities
/// (mouse cursor tracking, keyboard focus, modal overlay, mouse capture,
/// drag-drop state machine, tooltip timer). Routes Silk.NET input events
/// into the widget tree with retail-faithful <see cref="UiEvent"/>
/// semantics.
///
/// Retail analog: the <c>DAT_00837ff4</c> Device object (see
/// <c>docs/research/retail-ui/04-input-events.md §2</c>). That object has
/// a ~20-slot vtable; the methods we emulate here are:
///
/// <list type="bullet">
/// <item>+0x18 / +0x1C : <see cref="MouseX"/> / <see cref="MouseY"/></item>
/// <item>+0x34 : <see cref="RegisterTimerEvent"/> (tooltip delay)</item>
/// <item>+0x38 : <see cref="FireEvent"/></item>
/// <item>+0x44 : <see cref="KeyboardFocus"/></item>
/// <item>+0x48 / +0x4C : <see cref="SetCapture"/> / <see cref="ReleaseCapture"/></item>
/// <item>+0x74 / +0x78 : drag cursor set / reset</item>
/// </list>
///
/// When no widget consumes an event, the <see cref="WorldMouseFallThrough"/>
/// or <see cref="WorldKeyFallThrough"/> event fires so the game world
/// (camera, player controller) still receives input.
/// </summary>
public sealed class UiRoot : UiElement
{
// ── Device-level state ───────────────────────────────────────────────
public int MouseX { get; private set; }
public int MouseY { get; private set; }
public bool LeftButtonDown { get; private set; }
public bool RightButtonDown { get; private set; }
public bool MiddleButtonDown { get; private set; }
/// <summary>Widget currently receiving keyboard events.</summary>
public UiElement? KeyboardFocus { get; private set; }
/// <summary>
/// Single modal overlay; while set, mouse clicks outside its rect
/// are ignored. Retail sets this via Device vtable +0x48.
/// </summary>
public UiPanel? Modal { get; set; }
/// <summary>Widget with mouse capture (during click-drag).</summary>
public UiElement? Captured { get; private set; }
/// <summary>Current drag source (set between drag-begin and drop/cancel).</summary>
public UiElement? DragSource { get; private set; }
public object? DragPayload { get; private set; }
private UiElement? _lastDragHoverTarget;
private int _pressX, _pressY;
private bool _dragCandidate;
private const int DragDistanceThreshold = 3; // pixels, retail-observed
// Hover / tooltip tracking.
private UiElement? _hoverWidget;
private long _hoverStartedMs;
private const int TooltipDelayMs = 1000; // retail typical
private bool _tooltipFired;
private long _nowMs;
/// <summary>Raised when an event was not consumed by any widget.</summary>
public event Action<UiMouseButton, int, int, uint>? WorldMouseFallThrough;
/// <summary>Raised when a key was not consumed by any widget.</summary>
public event Action<int /*vk*/, uint /*lparam*/>? WorldKeyFallThrough;
/// <summary>Raised when mouse moved and no widget captured.</summary>
public event Action<int, int>? WorldMouseMoveFallThrough;
/// <summary>Raised on scroll fall-through (world zoom, etc.).</summary>
public event Action<int /*dy*/>? WorldScrollFallThrough;
private uint _nextEventId = 0x10000001u;
public override void AddChild(UiElement child)
{
if (child.EventId == 0) child.EventId = _nextEventId++;
base.AddChild(child);
}
// ── Per-frame pumping ────────────────────────────────────────────────
public void Tick(double dt, long nowMs)
{
_nowMs = nowMs;
// Tooltip timer: once mouse has hovered over the same widget for
// TooltipDelayMs, fire a Tooltip event on it exactly once.
if (_hoverWidget is not null && !_tooltipFired
&& _nowMs - _hoverStartedMs >= TooltipDelayMs)
{
var e = new UiEvent(_hoverWidget.EventId, _hoverWidget, UiEventType.Tooltip);
_hoverWidget.OnEvent(in e);
_tooltipFired = true;
}
TickSelfAndChildren(dt);
}
public void Draw(UiRenderContext ctx)
{
// Render children (panels) sorted by z-order — modal last so it
// sits on top.
DrawSelfAndChildren(ctx);
}
// ── Input entry points (called from GameWindow's Silk.NET handlers) ──
public void OnMouseMove(int x, int y)
{
int dx = x - MouseX;
int dy = y - MouseY;
MouseX = x;
MouseY = y;
// If we have capture, deliver MouseMove to the captured widget
// AND drive drag state machine; do NOT fall through.
if (Captured is not null)
{
DispatchMouseMove(Captured, x, y);
// Promote to drag if candidate and moved far enough.
if (_dragCandidate && DragSource is null)
{
if (Math.Abs(x - _pressX) > DragDistanceThreshold
|| Math.Abs(y - _pressY) > DragDistanceThreshold)
{
BeginDrag(Captured, payload: null);
}
}
if (DragSource is not null)
UpdateDragHover(x, y);
return;
}
// Not captured: track hover for tooltips + fall through.
UpdateHover(x, y);
WorldMouseMoveFallThrough?.Invoke(x, y);
}
public void OnMouseDown(UiMouseButton btn, int x, int y, uint flags = 0)
{
MouseX = x; MouseY = y;
UpdateButtonFlag(btn, down: true);
_pressX = x; _pressY = y;
// Modal blocks clicks outside its bounds.
if (Modal is not null && !ContainsAbsolute(Modal, x, y))
return;
var (target, lx, ly) = HitTestTopDown(x, y);
if (target is null)
{
WorldMouseFallThrough?.Invoke(btn, x, y, flags);
return;
}
// Set keyboard focus if target accepts it.
if (target.AcceptsFocus) SetKeyboardFocus(target);
// Capture + arm drag candidate (drag promotes on subsequent MouseMove > threshold).
SetCapture(target);
_dragCandidate = true;
// Dispatch raw MouseDown event (retail uses WM_LBUTTONDOWN = 0x201).
int rawType = btn switch
{
UiMouseButton.Left => UiEventType.MouseDown,
UiMouseButton.Right => UiEventType.RightDown,
UiMouseButton.Middle => UiEventType.MiddleDown,
_ => UiEventType.MouseDown,
};
var e = new UiEvent(target.EventId, target, rawType,
Data0: (int)flags, Data1: (int)lx, Data2: (int)ly);
BubbleEvent(target, in e);
}
public void OnMouseUp(UiMouseButton btn, int x, int y, uint flags = 0)
{
MouseX = x; MouseY = y;
UpdateButtonFlag(btn, down: false);
if (DragSource is not null)
{
FinishDrag(x, y);
ReleaseCapture();
_dragCandidate = false;
return;
}
if (Captured is not null)
{
int rawType = btn switch
{
UiMouseButton.Left => UiEventType.MouseUp,
UiMouseButton.Right => UiEventType.RightUp,
UiMouseButton.Middle => UiEventType.MiddleUp,
_ => UiEventType.MouseUp,
};
var sp = Captured.ScreenPosition;
var raw = new UiEvent(Captured.EventId, Captured, rawType,
Data0: (int)flags,
Data1: (int)(x - sp.X), Data2: (int)(y - sp.Y));
BubbleEvent(Captured, in raw);
// If left-up over the same element that received the down, emit Click.
if (btn == UiMouseButton.Left && ContainsAbsolute(Captured, x, y))
{
var click = new UiEvent(Captured.EventId, Captured, UiEventType.Click,
Data0: (int)flags,
Data1: (int)(x - sp.X), Data2: (int)(y - sp.Y));
BubbleEvent(Captured, in click);
}
else if (btn == UiMouseButton.Right && ContainsAbsolute(Captured, x, y))
{
var click = new UiEvent(Captured.EventId, Captured, UiEventType.RightClick,
Data0: (int)flags);
BubbleEvent(Captured, in click);
}
ReleaseCapture();
_dragCandidate = false;
return;
}
// No capture — give the world a chance.
WorldMouseFallThrough?.Invoke(btn, x, y, flags);
}
public void OnScroll(int dy)
{
// Scroll goes to the widget under the cursor (not the focused one).
var (target, lx, ly) = HitTestTopDown(MouseX, MouseY);
if (target is null)
{
WorldScrollFallThrough?.Invoke(dy);
return;
}
var e = new UiEvent(target.EventId, target, UiEventType.Scroll, Data0: dy,
Data1: (int)lx, Data2: (int)ly);
BubbleEvent(target, in e);
}
public void OnKeyDown(int vk, uint lparam = 0)
{
// Focus widget first.
if (KeyboardFocus is not null)
{
var e = new UiEvent(KeyboardFocus.EventId, KeyboardFocus, UiEventType.KeyDown,
Data0: vk, Data1: (int)lparam);
if (BubbleEvent(KeyboardFocus, in e)) return;
}
// If the focused widget is NOT an edit control, also consult the modal /
// top panel. Edit controls absorb all keys (prevents hotkeys while typing).
if (KeyboardFocus is null || !KeyboardFocus.IsEditControl)
{
var root = Modal ?? (UiElement)this;
var e = new UiEvent(root.EventId, root, UiEventType.KeyDown,
Data0: vk, Data1: (int)lparam);
if (BubbleEvent(root, in e)) return;
}
WorldKeyFallThrough?.Invoke(vk, lparam);
}
public void OnKeyUp(int vk, uint lparam = 0)
{
if (KeyboardFocus is not null)
{
var e = new UiEvent(KeyboardFocus.EventId, KeyboardFocus, UiEventType.KeyUp,
Data0: vk, Data1: (int)lparam);
if (BubbleEvent(KeyboardFocus, in e)) return;
}
// Key up rarely falls through; game logic generally keys off KeyDown.
}
public void OnChar(int codepoint)
{
if (KeyboardFocus is null || !KeyboardFocus.IsEditControl) return;
var e = new UiEvent(KeyboardFocus.EventId, KeyboardFocus, UiEventType.Char,
Data0: codepoint);
BubbleEvent(KeyboardFocus, in e);
}
// ── Focus + capture ─────────────────────────────────────────────────
public void SetKeyboardFocus(UiElement? e)
{
if (KeyboardFocus == e) return;
if (KeyboardFocus is not null)
{
var lost = new UiEvent(KeyboardFocus.EventId, KeyboardFocus, UiEventType.FocusLost);
KeyboardFocus.OnEvent(in lost);
}
KeyboardFocus = e;
if (e is not null)
{
var gained = new UiEvent(e.EventId, e, UiEventType.FocusGained);
e.OnEvent(in gained);
}
}
public void SetCapture(UiElement e) => Captured = e;
public void ReleaseCapture() => Captured = null;
// ── Drag-drop (retail event chain 0x15 → 0x21 → 0x1C → 0x3E) ────────
private void BeginDrag(UiElement source, object? payload)
{
DragSource = source;
DragPayload = payload;
var e = new UiEvent(source.EventId, source, UiEventType.DragBegin, Payload: payload);
source.OnEvent(in e);
}
private void UpdateDragHover(int x, int y)
{
var (t, lx, ly) = HitTestTopDown(x, y);
if (ReferenceEquals(t, _lastDragHoverTarget)) return;
// Leave old target.
if (_lastDragHoverTarget is not null)
{
var eLeave = new UiEvent(DragSource!.EventId, _lastDragHoverTarget,
UiEventType.DragOver, Data1: x, Data2: y,
Payload: DragPayload);
_lastDragHoverTarget.OnEvent(in eLeave);
}
// Enter new target.
if (t is not null)
{
var eEnter = new UiEvent(DragSource!.EventId, t, UiEventType.DragEnter,
Data1: (int)lx, Data2: (int)ly,
Payload: DragPayload);
t.OnEvent(in eEnter);
}
_lastDragHoverTarget = t;
}
private void FinishDrag(int x, int y)
{
var (t, lx, ly) = HitTestTopDown(x, y);
var target = t ?? DragSource!;
var accepted = t is not null && t != DragSource;
var e = new UiEvent(DragSource!.EventId, target, UiEventType.DropReleased,
Data0: accepted ? 1 : 0,
Data1: (int)lx, Data2: (int)ly,
Payload: DragPayload);
target.OnEvent(in e);
DragSource = null;
DragPayload = null;
_lastDragHoverTarget = null;
}
// ── Hover / tooltip ─────────────────────────────────────────────────
private void UpdateHover(int x, int y)
{
var (w, _, _) = HitTestTopDown(x, y);
if (ReferenceEquals(w, _hoverWidget)) return;
if (_hoverWidget is not null)
{
var leave = new UiEvent(_hoverWidget.EventId, _hoverWidget, UiEventType.HoverLeave);
_hoverWidget.OnEvent(in leave);
}
_hoverWidget = w;
_hoverStartedMs = _nowMs;
_tooltipFired = false;
if (w is not null)
{
var enter = new UiEvent(w.EventId, w, UiEventType.HoverEnter);
w.OnEvent(in enter);
}
}
// ── Helpers ─────────────────────────────────────────────────────────
public void FireEvent(int type, UiElement target, object? payload = null)
{
var e = new UiEvent(target.EventId, target, type, Payload: payload);
target.OnEvent(in e);
}
public void RegisterTimerEvent(int type, UiElement target, int delayMs,
object? payload = null)
{
_timers.Add((_nowMs + delayMs, new UiEvent(target.EventId, target, type, Payload: payload)));
}
private readonly List<(long fireAt, UiEvent e)> _timers = new();
private void UpdateButtonFlag(UiMouseButton b, bool down)
{
switch (b)
{
case UiMouseButton.Left: LeftButtonDown = down; break;
case UiMouseButton.Right: RightButtonDown = down; break;
case UiMouseButton.Middle: MiddleButtonDown = down; break;
}
}
private (UiElement? element, float localX, float localY) HitTestTopDown(int x, int y)
{
// Modal gets exclusive hit-test.
if (Modal is not null)
{
var mp = Modal.ScreenPosition;
var mh = Modal.HitTest(x - mp.X, y - mp.Y);
if (mh is not null) return (mh, x - mp.X, y - mp.Y);
return (null, 0, 0);
}
// Walk top-level children in reverse Z-order (topmost first).
var kids = new UiElement[Children.Count];
for (int i = 0; i < Children.Count; i++) kids[i] = Children[i];
Array.Sort(kids, static (a, b) => b.ZOrder.CompareTo(a.ZOrder));
foreach (var c in kids)
{
var cp = c.ScreenPosition;
var hit = c.HitTest(x - cp.X, y - cp.Y);
if (hit is not null)
return (hit, x - cp.X, y - cp.Y);
}
return (null, 0, 0);
}
private static bool ContainsAbsolute(UiElement e, int x, int y)
{
var sp = e.ScreenPosition;
return x >= sp.X && x < sp.X + e.Width
&& y >= sp.Y && y < sp.Y + e.Height;
}
private void DispatchMouseMove(UiElement target, int x, int y)
{
var sp = target.ScreenPosition;
var e = new UiEvent(target.EventId, target, UiEventType.MouseMove,
Data1: (int)(x - sp.X), Data2: (int)(y - sp.Y));
BubbleEvent(target, in e);
}
/// <summary>
/// Call <see cref="UiElement.OnEvent"/> on <paramref name="start"/>;
/// if it returns false, walk the Parent chain.
/// </summary>
private bool BubbleEvent(UiElement start, in UiEvent e)
{
var w = start;
while (w is not null)
{
if (w.OnEvent(in e)) return true;
w = w.Parent;
}
return false;
}
protected override void OnDraw(UiRenderContext ctx)
{
// Root itself draws nothing; children do.
}
}