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:
parent
ff325abd7b
commit
7230c1590f
15 changed files with 8041 additions and 5 deletions
|
|
@ -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.
|
**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:**
|
**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.1 — 2D ortho overlay + font rendering.** ✅ SHIPPED 2026-04-17 as the dev-facing debug overlay (StbTrueTypeSharp system-font atlas + `TextRenderer` + `DebugOverlay`).
|
||||||
- **D.2 — Chat window + nameplates.** First UI widgets. Chat consumes Phase B.5 messages; nameplates render per-entity 3D-to-2D projected labels.
|
- **D.2 — Retail UI framework + first panels.** Research + scaffold landed 2026-04-17 (see `docs/research/retail-ui/`). Ships:
|
||||||
- **D.3 — Inventory / character / spell panels.** Requires a widget framework (layout, focus, input routing). Scope unbounded — ship minimum viable first.
|
- `UiRoot` + `UiElement` + `UiPanel` + `UiHost` with retail-faithful event codes (`0x01` click, `0x15` drag-begin, `0x3E` drop, `0x201` WM_LBUTTONDOWN, tooltip delay ~1000ms, etc.)
|
||||||
- **D.4 — Sound.** `SoundTable` parser, `Sound` dat decode, audio engine (OpenAL via Silk.NET.OpenAL), per-entity 3D positional audio, optional music.
|
- 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
671
docs/research/retail-ui/00-master-synthesis.md
Normal file
671
docs/research/retail-ui/00-master-synthesis.md
Normal 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`?
|
||||||
1011
docs/research/retail-ui/01-architecture-and-init.md
Normal file
1011
docs/research/retail-ui/01-architecture-and-init.md
Normal file
File diff suppressed because it is too large
Load diff
1008
docs/research/retail-ui/02-class-hierarchy.md
Normal file
1008
docs/research/retail-ui/02-class-hierarchy.md
Normal file
File diff suppressed because it is too large
Load diff
1013
docs/research/retail-ui/03-rendering.md
Normal file
1013
docs/research/retail-ui/03-rendering.md
Normal file
File diff suppressed because it is too large
Load diff
1034
docs/research/retail-ui/04-input-events.md
Normal file
1034
docs/research/retail-ui/04-input-events.md
Normal file
File diff suppressed because it is too large
Load diff
1062
docs/research/retail-ui/05-panels.md
Normal file
1062
docs/research/retail-ui/05-panels.md
Normal file
File diff suppressed because it is too large
Load diff
1093
docs/research/retail-ui/06-hud-and-assets.md
Normal file
1093
docs/research/retail-ui/06-hud-and-assets.md
Normal file
File diff suppressed because it is too large
Load diff
111
src/AcDream.App/UI/README.md
Normal file
111
src/AcDream.App/UI/README.md
Normal 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.
|
||||||
|
```
|
||||||
203
src/AcDream.App/UI/UiElement.cs
Normal file
203
src/AcDream.App/UI/UiElement.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
90
src/AcDream.App/UI/UiEvent.cs
Normal file
90
src/AcDream.App/UI/UiEvent.cs
Normal 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,
|
||||||
|
}
|
||||||
102
src/AcDream.App/UI/UiHost.cs
Normal file
102
src/AcDream.App/UI/UiHost.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
93
src/AcDream.App/UI/UiPanel.cs
Normal file
93
src/AcDream.App/UI/UiPanel.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/AcDream.App/UI/UiRenderContext.cs
Normal file
62
src/AcDream.App/UI/UiRenderContext.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
473
src/AcDream.App/UI/UiRoot.cs
Normal file
473
src/AcDream.App/UI/UiRoot.cs
Normal 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.
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue