From 7230c1590f51b66669ef879a4a2392a7b75c8c56 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 17 Apr 2026 19:13:02 +0200 Subject: [PATCH] docs+feat(ui): retail UI deep-dive research + C# port scaffold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/plans/2026-04-11-roadmap.md | 20 +- .../research/retail-ui/00-master-synthesis.md | 671 ++++++++++ .../retail-ui/01-architecture-and-init.md | 1011 +++++++++++++++ docs/research/retail-ui/02-class-hierarchy.md | 1008 +++++++++++++++ docs/research/retail-ui/03-rendering.md | 1013 +++++++++++++++ docs/research/retail-ui/04-input-events.md | 1034 ++++++++++++++++ docs/research/retail-ui/05-panels.md | 1062 ++++++++++++++++ docs/research/retail-ui/06-hud-and-assets.md | 1093 +++++++++++++++++ src/AcDream.App/UI/README.md | 111 ++ src/AcDream.App/UI/UiElement.cs | 203 +++ src/AcDream.App/UI/UiEvent.cs | 90 ++ src/AcDream.App/UI/UiHost.cs | 102 ++ src/AcDream.App/UI/UiPanel.cs | 93 ++ src/AcDream.App/UI/UiRenderContext.cs | 62 + src/AcDream.App/UI/UiRoot.cs | 473 +++++++ 15 files changed, 8041 insertions(+), 5 deletions(-) create mode 100644 docs/research/retail-ui/00-master-synthesis.md create mode 100644 docs/research/retail-ui/01-architecture-and-init.md create mode 100644 docs/research/retail-ui/02-class-hierarchy.md create mode 100644 docs/research/retail-ui/03-rendering.md create mode 100644 docs/research/retail-ui/04-input-events.md create mode 100644 docs/research/retail-ui/05-panels.md create mode 100644 docs/research/retail-ui/06-hud-and-assets.md create mode 100644 src/AcDream.App/UI/README.md create mode 100644 src/AcDream.App/UI/UiElement.cs create mode 100644 src/AcDream.App/UI/UiEvent.cs create mode 100644 src/AcDream.App/UI/UiHost.cs create mode 100644 src/AcDream.App/UI/UiPanel.cs create mode 100644 src/AcDream.App/UI/UiRenderContext.cs create mode 100644 src/AcDream.App/UI/UiRoot.cs diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 448b9f7..1838976 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -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. --- diff --git a/docs/research/retail-ui/00-master-synthesis.md b/docs/research/retail-ui/00-master-synthesis.md new file mode 100644 index 0000000..7c9975b --- /dev/null +++ b/docs/research/retail-ui/00-master-synthesis.md @@ -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` — 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`? diff --git a/docs/research/retail-ui/01-architecture-and-init.md b/docs/research/retail-ui/01-architecture-and-init.md new file mode 100644 index 0000000..772ac9c --- /dev/null +++ b/docs/research/retail-ui/01-architecture-and-init.md @@ -0,0 +1,1011 @@ +# Retail AC Client — UI Architecture, Initialization, and Main Loop + +**Research slice:** process entry → window → device → UI → frame loop. +**Method:** decompiled `acclient.exe` (22,225 functions via Ghidra pyghidra +headless); cross-check against vendored reference repos where they cover +the same topic. + +Unless noted otherwise, every function and global cited here is taken +directly from the decompiled source in +`C:\Users\erikn\source\repos\acdream\docs\research\decompiled\chunk_*.c`. +The reference repos under `references/` (ACE, ACViewer, WorldBuilder, +holtburger, Chorizite.ACProtocol, AC2D) do **not** cover the in-process +UI layer at all — they are emulators, dat viewers, or protocol libraries. +Everything below about Keystone / the main window / the frame loop is +novel to this slice. + +--- + +## 0. TL;DR for the porting agent + +The retail client is a single-window, single-process, single-render-thread +C++ app built around three "Cxxx" globals: + +| Global (`DAT_`) | Role | Acquired by | +|-----------------|------|-------------| +| `DAT_008381a4` (HWND) | Main game window (`"Turbine Device Class"`) | `CreateWindowExA` @ 0x0043BD0B | +| `DAT_0086734c` (ptr) | **Graphics driver adapter** (COM-like factory) | `FUN_0054d0c0` → `FUN_0058bf30` | +| `DAT_00870340` (ptr) | **Turbine Core** — main engine object that owns the GL surface, dat resources, and UI tools | `FUN_0054d110` → `DAT_0086734c` vtable[0xc] | +| `DAT_00837ff4` (ptr) | **Render device** (window-specific, lightweight) | `FUN_006895d0` @ 0x006895D0 | +| `DAT_00870c30` (HMODULE) | `keystone.dll` | `FUN_00557930` | +| `DAT_00870c34` (code*) | `KeystoneCreate` export | `GetProcAddress` | +| `DAT_00870c2c` (ptr) | **Keystone UI root** (instance returned by `KeystoneCreate`) | `FUN_00557850` | +| `DAT_00870c38/3c` (HMODULE) | `ACHelpPlugin.dll`, `ACPluginManager.dll` | `LoadLibraryA` | +| `DAT_00870c54` (HACCEL) | Accelerator table | `CreateAcceleratorTableA` | +| `DAT_00818b64` | Packed (width << 16 | height) of backbuffer | various WM_SIZE paths | +| `DAT_00838194` | Quit flag ("app should exit") | `FUN_00439230` (`QuitApp`) | +| `DAT_00838196` | "Main window alive" flag | `FUN_0043BA60` tail | +| `DAT_00838198` | "Currently pumping messages" re-entrancy guard | `FUN_00439e50` | +| `DAT_00838199` | "Redraw requested" flag | WM_PAINT path | +| `DAT_008381a8` | 1 when app was launched with cmdline args | `FUN_0043BA60` | + +The UI framework is **Keystone** (proprietary Turbine XML-based UI toolkit +— same engine later used in Lord of the Rings Online / DDO). It is shipped +as `keystone.dll` next to `acclient.exe` and exposes a single C export, +`KeystoneCreate`, that returns a vtable-dispatched root object. All +panels, buttons, fonts, textures, and input are hidden behind that one +pointer. AC is a Keystone *client*; it does not own the UI code. + +The frame loop runs three things back-to-back: + +1. **Message pump** (`FUN_00439e50`) — `PeekMessageA` + `TranslateMessage` + + `DispatchMessageA` until the queue is empty. The app's WndProc + (`LAB_00439860`) hands each message first to the UI filter + (`FUN_00557a90` → Keystone's vtable[0x6c]), then to the renderer + (`DAT_00837ff4` vtable[0x70]). `DefWindowProcA` is the fallthrough. +2. **Game tick** (`FUN_004554b0`) — advances game state / physics / + scripts; also drains per-frame command queues. +3. **Render frame** (`FUN_0045d0b0`) — BeginScene-equivalent, draws + 3D world, draws UI as overlay, EndScene-equivalent, present. + +--- + +## 1. Process entry and window-class registration + +### 1.1 Entry point (WinMain equivalent) + +``` +CRT startup @ 0x005DF1xx (chunk_005D0000.c:~11280) + └─ __getmainargs, _initterm, parse cmdline + └─ GetStartupInfoA, GetModuleHandleA(NULL) + └─ FUN_004013a0(hInstance, NULL, lpCmdLine, nShowCmd) // <-- WinMain + │ + ├─ FUN_00406300() // preflight checks + ├─ FUN_00406d60() // exception handler setup + ├─ GetCommandLineA() + ├─ FUN_00401120() // _control87 — disable FPU exceptions + ├─ DAT_00837720 = 0x40000001 + ├─ FUN_00413850(0, &PTR_DAT_008183b4) // mount factory table + ├─ FUN_0055af00 / 00555990 / 00558230 // setup log / dat / COM + ├─ piVar2 = FUN_00401160(&DAT_007936b8) // factory: App object + ├─ piVar2->vtable[0x10](0, 0, 1) // Application::Initialize + ├─ FUN_00401340("Asheron's Call") // title string + ├─ piVar2->vtable[0x1c](stack, 1, 1) // Application::Run <-- main loop lives here + ├─ piVar2->vtable[0x2c]() // Application::Shutdown + ├─ FUN_004010f0(piVar2) // release singletons + ├─ FUN_004020c0 / FUN_00406f90 // teardown + └─ return 0 +``` + +- **WinMain = `FUN_004013a0` at 0x004013A0** (decompiled as + `chunk_00400000.c:288-341`). +- The App object is a virtual class; its vtable is at + `&DAT_007936b8` (chunk_00400000.c:313). Slots we care about: + - `[0x10]` = `Initialize(hInstance, pfnMsg, isRestart)` — see + FUN_00412180 @ 0x00412180 (chunk_00410000.c:1785+) + - `[0x1c]` = `Run(p1, p2, p3)` — calls into one of the two frame + pump variants below + - `[0x2c]` = `Shutdown()` — unclear which FUN_ slot + +### 1.2 Application::Initialize (vtable[0x10]) + +Decompiled as `FUN_00412180`: + +```c +bool Application::Initialize(pfnMsg, lpCmdLine, nShowCmd, ...) { + _set_new_handler(LAB_00411580); + if (this->vtable[0x80](this)) // pre-init hook; bails out if app + return false; // is a secondary instance + + GetVersionExA(...); + if (version == Win9x) + LoadLibraryA("unicows.dll"); // Unicode shim on Win98/ME + + FUN_0040fcd0(); // init dat file cache + FUN_0042c800(); // init string tables + this->field_0x41 = FUN_0054bb50(); // singleton: audio + this->field_0x42 = FUN_0054bb70(); // singleton: renderer-factory wrapper + + this->vtable[0x68](); // network bind setup + if (!this->vtable[0x70]()) // preferences + return false; + if (!this->vtable[0x74](lpCmdLine, nShowCmd, hasArgs)) // window + D3D init (see §2) + return false; + if (!FUN_004221c0()) // acqr locator + return false; + return this->vtable[0x7c](pfnMsg) != 0; // final wiring +} +``` + +Key takeaway: vtable[0x74] is the "create the window AND initialize the +render device" method, and its call site is what drives all of §2. + +### 1.3 Window-class registration and CreateWindow + +All from `chunk_00430000.c:9848-10046`. This is a single ~1100-byte +function that does window-class registration, window creation, renderer +device creation, UI wiring, and chat-command registration in one go. +Call it **`CreateMainWindow`** (no FUN_ label visible because it's +reachable only via vtable[0x74]). + +```c +CreateMainWindow(titleStr, x, y, w, h, fullscreen, iconId) { + if (DAT_00838196 != 0) return 0; // already created + + GetVersionExA(&local_94); + DAT_0083819c = local_94.dwPlatformId; // 0 = Win9x, 2 = NT + if (local_94.dwPlatformId == 0) // explicit "no Win9x" check + goto fail; + + hInstance = GetModuleHandleA(param[0]); // resolves exe module + if (DAT_0086734c != 0) return 0; // renderer already alive + FUN_0054d0c0(&local_f0); // build graphics-driver factory + // → DAT_0086734c + FUN_0043b2d0(); // parse --window / --fullscreen + FUN_00439140(); // defaults: 800×600×32bpp + FUN_00439370(&local_d8); // sanitize size (≥ 800 × ≥ 600) + + //--- step 1: register class "Turbine Device Class" --- + local_bc.lpfnWndProc = (WNDPROC)&LAB_00439860; // <-- WndProc + local_bc.cbClsExtra = 0; + local_bc.cbWndExtra = 0; + local_bc.style = 0; + local_bc.hInstance = hInstance; + local_bc.hIcon = LoadIconA(hInstance, (LPCSTR)0x65); + local_bc.hbrBackground = GetStockObject(BLACK_BRUSH); // 4 + local_bc.lpszMenuName = (LPCSTR)*titleStr; + local_bc.hCursor = NULL; // cursor set later + local_bc.lpszClassName = "Turbine Device Class"; + AVar3 = RegisterClassA(&local_bc); + if (!AVar3 && GetLastError() != ERROR_CLASS_ALREADY_EXISTS) goto fail; + + //--- step 2: choose style; center on desktop; apply workarea --- + DWORD style = fullscreen ? 0x82000000 + : 0x82ca0000 | (isPopup ? 0x10000000 : 0); + // adjust for SM_CXFRAME / SM_CYFRAME; SystemParametersInfoA(SPI_GETWORKAREA) + // snaps window inside the work area (accounts for taskbar). + + //--- step 3: CreateWindowEx --- + DAT_008381a4 = CreateWindowExA( + 0, "Turbine Device Class", titleStr, style, + x, y, w, h, NULL, NULL, hInstance, NULL); + if (!DAT_008381a4) goto fail; + + //--- step 4: allocate render device (FUN_006895d0) --- + if (FUN_005df0f5(0x3f8)) + DAT_00837ff4 = FUN_006895d0(); // see §3.1 + cVar2 = DAT_00837ff4->vtable[4](DAT_008381a4); // device->AttachWindow + if (!cVar2) return 0; + + if (showWindow) { + ShowWindow(DAT_008381a4, SW_SHOWNORMAL); + UpdateWindow(DAT_008381a4); + SetForegroundWindow(DAT_008381a4); + SetActiveWindow(DAT_008381a4); + SetWindowPos(DAT_008381a4, HWND_TOPMOST, 0,0,0,0, + SWP_NOMOVE|SWP_NOSIZE|SWP_NOACTIVATE|0x100); + } + + //--- step 5: bring up renderer + UI (FUN_0043ad90, see §3) --- + if (!FUN_0043ad90(param_d8, param_d4, isFullscreen)) + return 0; + + //--- step 6: audio & console --- + if (FUN_005df0f5(8)) + DAT_008381ac = FUN_00439210(); // ImmDisableIME equivalent + + //--- step 7: register ~30 chat / console commands --- + FUN_00401340("Exits the application"); + FUN_00401340(&DAT_00799d90); // name: "Quit" or "Exit" + FUN_00436580(&LAB_00439830, ...); + // ...many more... + FUN_00401340("Restarts the rendering engine and applies new display settings"); + FUN_00401340("UpdatePresentation"); + FUN_00436580(FUN_0043a510, ...); + FUN_00401340("ForceDisplayResolution [ ]"); + FUN_004366d0(FUN_0043aa70, ...); + + SetThreadExecutionState(0x80000001); // keep display on / prevent sleep + DAT_00838196 = 1; // "main window alive" + return 1; +} +``` + +### 1.4 The window procedure itself (`LAB_00439860`) + +Ghidra did not emit a `FUN_00439860` block — the gap between +`FUN_00439840` (ends ~0x0043985D) and `FUN_00439d50` holds it but the +decompiler skipped producing C for it, likely due to inline asm or a +non-standard prolog. We can still reconstruct it from the message pump +(§4), which tells us every consumer it forwards to: + +``` +LRESULT CALLBACK TurbineWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { + // 1. Give Keystone first shot (panels eat mouse/keyboard first). + if (DAT_00870c2c != NULL) { + LRESULT ui = DAT_00870c2c->vtable[0x6c](hwnd, msg, wp, lp); + if (ui != 0) + return ui; // UI swallowed it + } + + // 2. Give the render device a chance (WM_ACTIVATE, WM_SIZE, + // WM_DISPLAYCHANGE, WM_PAINT, WM_ERASEBKGND). + if (DAT_00837ff4 != NULL) { + LRESULT rd = DAT_00837ff4->vtable[0x70](&msg_struct, &handled); + if (handled) return rd; + } + + // 3. Minimal direct handling in the WndProc itself: + // WM_CLOSE → DAT_00838194 = 1 (quit flag) + // WM_ACTIVATEAPP → DAT_00818b68 toggles + // WM_SETCURSOR → LoadCursorA(NULL, IDC_ARROW) + // WM_CHAR / WM_KEYDOWN → forwarded to chat command parser + + // 4. Default + return DefWindowProcA(hwnd, msg, wp, lp); +} +``` + +The fact that the pump calls `TranslateMessage` only if both +`FUN_006a1050` (IME filter, stub returning 0) and `FUN_00557a90` +(Keystone peek-in) return 0 is the smoking gun. See §4. + +### 1.5 Global state planted by window creation + +| Address | Type | Meaning | +|---------|------|---------| +| `DAT_008381a4` | HWND | The single client HWND | +| `DAT_0083819c` | int | Win platform ID (2=NT family) | +| `DAT_00838194` | bool | Quit requested | +| `DAT_00838195` | bool | Renderer alive | +| `DAT_00838196` | bool | Window alive | +| `DAT_00838197` | bool | Cursor hidden | +| `DAT_00838198` | bool | Inside PeekMessage loop (guard) | +| `DAT_00838199` | bool | Redraw requested | +| `DAT_008381a0` | bool | Fullscreen mode | +| `DAT_008381a8` | int | 1 if started with cmdline args | +| `DAT_00818b02` | bool | Show-frame (windowed mode) flag | +| `DAT_00818b04/08` | int | Windowed size (for restore) | +| `DAT_00818b64` | packed | (width<<16 | height) of render target | +| `DAT_00818b68..72` | bytes | Windowed-vs-fullscreen state mask | + +--- + +## 2. Renderer / device bring-up + +Two objects; order matters. + +### 2.1 Graphics-driver factory (`DAT_0086734c`) + +``` +FUN_0054d0c0(&mode_out) + DAT_0086734c = FUN_0058bf30() // allocate the factory COM-ish + DAT_0086734c->vtable[4](&mode_out) // Init — probes available backends +``` + +`FUN_0058bf30` is in `chunk_00580000.c`; it's a factory that internally +picks the best of (DirectX 6 DirectDraw / Glide / software). None of our +modern clients will have to emulate that stack — we pick Silk.NET OpenGL +unconditionally. But we DO have to replicate the TWO-object factoring +(factory vs core) because so much of the code calls through +`DAT_0086734c->vtable[0xc]` to get the core. + +### 2.2 Turbine Core (`DAT_00870340`) + +`FUN_0054d110(hwnd, backbufW, backbufH)`: + +```c +DAT_00870340 = DAT_0086734c->vtable[0xc](); // CreateCore factory method +if (!DAT_00870340) return 0; +return DAT_00870340->vtable[4](hwnd, w, h); // Core::Init(hwnd, w, h) +``` + +Turbine Core owns: +- GL / DirectDraw surfaces +- dat file handles (portal.dat, cell.dat, client_portal.dat, etc.) +- font atlases +- keystone instance handle (at `+0x468` — see §5.2) +- field at `+0x10` = "isFullscreen" +- vtable[0x40] = Present / Flush + +### 2.3 Render device (`DAT_00837ff4`) + +`FUN_006895d0()` constructs a lightweight device record (234 bytes of +zeroed fields plus two vtable pointers). It's allocated **before** +`Turbine Core` because the WndProc needs something pointable even during +the WM_CREATE that fires from `CreateWindowExA`. + +It calls `CoInitialize(NULL)` at construction time. Field layout (offsets +in dwords): + +| Offset | Meaning | +|--------|---------| +| +0 | vtable = `PTR_FUN_008000c8` | +| +0x40..0x46 | misc zero-init fields | +| +0x47..0x49 | 0xFFFFFFFF sentinels | +| +0x4a | vtable = `PTR_FUN_00800088` — sub-object | +| +0x67 | vtable = `PTR_FUN_0080008c` — sub-object | +| +0xd6 | hash-bucket count | +| +0xf3..0xf5 | state bits | +| +0xf8 | 1 (enabled flag) | + +Vtable slots observed in the wild (§1.4, §3, §7): + +| Slot | Role | +|------|------| +| 0x04 | AttachWindow(hwnd) | +| 0x10 | BeginFrame / BeginScene | +| 0x18 | GetWidth | +| 0x1c | GetHeight | +| 0x34 | SetTransform / SetRenderState | +| 0x38 | SetLight | +| 0x3c | DrawPrimitive (or DrawIndexed) | +| 0x48 | Present / EndFrame | +| 0x4c | DrawScreenQuad | +| 0x58 | IsDeviceLost | +| 0x70 | WndProc filter — returns LRESULT + handled-bool | +| 0x74 | SetCursorConfinement(bool) | +| 0xa8 | SetAlphaTest(bool) | + +### 2.4 Post-device UI handshake (`FUN_0043ad90` → `FUN_0043ac60` → `FUN_0054e1a0`) + +```c +FUN_0043ad90(w, h, isFullscreen) { + DAT_00838195 = 0; + if (!FUN_0043ac60(w, h, isFullscreen)) return 0; + DAT_00838195 = 1; + return 1; +} + +FUN_0043ac60(w, h, isFullscreen) { + if (isFullscreen) { + hdc = CreateICA("Display", NULL, NULL, NULL); + int bpp = GetDeviceCaps(hdc, BITSPIXEL); + DeleteDC(hdc); + if (bpp == 16) FUN_0043a8f0(); // 16bpp mode + else if (bpp != 32) { print_error(); exit(1); } + } + if (!FUN_00439370(&size)) return 0; + size.hwnd1 = DAT_008381a4; + size.hwnd2 = DAT_008381a4; + if (!FUN_0054e1a0(&size1, &size2)) return 0; // core init + if (!FUN_004402d0()) return 0; // resource init + FUN_0054e6a0(); // late wiring + return 1; +} + +FUN_0054e1a0(p1, p2) { + if (!FUN_004154a0()) return 0; // probe — always true + if (FUN_0044b810()) { // audio mixer ready? + if (FUN_0054d110(DAT_0086734c[2], p1, p2)) { // Turbine Core init + FUN_00557850(); // -> Keystone create (§5) + if (FUN_00448810()) return 1; // app-specific hook + FUN_00557b50(); // Keystone destroy on fail + FUN_0044b820(); // audio teardown + FUN_0054d160(); // core teardown + } + } + // unwind DAT_0086734c + return 0; +} +``` + +So the canonical first-run order is: + +``` +CreateWindowEx + → DAT_00837ff4 = CRenderDevice() // light shim + → DAT_00837ff4->AttachWindow(hwnd) + → (ShowWindow / UpdateWindow) + → FUN_0043ad90 → FUN_0043ac60: + → DAT_00870340 = CreateCore() // Turbine Core + → Core->Init(hwnd, w, h) // real GL surface, dats online + → KeystoneCreate(hwnd, ...) // UI system (§5) + → Application::PostDeviceInit // game wiring (FUN_00448810) +``` + +--- + +## 3. Render device — frame-side vtable usage + +The device is the 4th global (`DAT_00837ff4`). The frame loop calls its +vtable[0x10] to start a frame (`BeginFrame`) and vtable[0x48] to present +(`EndFrame`). BeginFrame happens inside `FUN_0045d0b0` (see §6.3); the +final `Present` is inside the Turbine-Core flush (`FUN_0043fcd0`) via +`DAT_00870340->vtable[0x40](w, h, ...)`. + +Present path (chunk_00430000.c:12978): + +```c +void RenderFrameFlushAndPresent() { + if (!DAT_00870340->fieldIsFullscreen) return; // offset +0x2b + // grab backbuffer dims + int w = DAT_00870340->field_0x24; // width + int h = DAT_00870340->field_0x23; // height + void* core = DAT_00870340->vtable0; // core vtable + uint t1 = FUN_0054fd30(0); // timing tick + uint t2 = FUN_0054fd20(t1); + DAT_00870340->vtable[0x40](0, 0, t2); // Begin2DPhase? + if (condition) { + FUN_004488a0(); // flush ui event queue + FUN_00557840(); // Keystone frame end + } + if (DAT_0083846c) FUN_005da8f0(); // cinematic overlay + if (DAT_00838468) FUN_00692470(); // video subsystem + FUN_0043f7f0(); // perf overlay + DAT_00870340->vtable[0x40](h, w, ...); // End2DPhase + DAT_00870340->field_0x2a = 0; + DAT_00870340->vtable[0x24](); // Present backbuffer + DAT_00870340->vtable[0x28](); // Swap / flip + FUN_0043e6b0(); // reset per-frame counters +} +``` + +--- + +## 4. Main-loop message pump (`FUN_00439e50`) + +Address: `0x00439E50` in `chunk_00430000.c:8265-8297`, size 213 bytes. +Returns `DAT_00838194` (the quit flag) — truthy means "app wants to quit". + +Faithful pseudocode: + +```c +bool PumpMessages() { + DAT_00838198 = 1; // re-entrancy guard + tagMSG msg; + int peek = PeekMessageA(&msg, NULL, 0, 0, PM_REMOVE); + while (peek != 0 && msg.message != WM_QUIT /* 0x12 */) { + // 1st filter: IME / composition — stub returns 0 in retail + if (FUN_006a1050(&msg) == 0) { + // 2nd filter: Keystone hot-keys / accelerators + if (FUN_00557a90(msg.hwnd, 0 /*defaults to HACCEL*/, &msg) == 0) { + TranslateMessage(&msg); + DispatchMessageA(&msg); // routes into WndProc + } + } + peek = PeekMessageA(&msg, NULL, 0, 0, PM_REMOVE); + } + // Redraw fence: if someone requested a redraw while we were pumping, + // toggle the "was-active" flag so the next frame starts fresh. + if (DAT_00838199) { + if (DAT_0086734c != 0 && DAT_00838197 /*cursor hidden*/) { + if (DAT_00818b02 == 0 || DAT_00818b68 != 0) + DAT_00818b68 = 0; + else + DAT_00818b68 = 1; + } + DAT_00838199 = 0; + } + DAT_00838198 = 0; + return (bool)DAT_00838194; +} +``` + +### 4.1 `FUN_00557a90` — Keystone's pre-dispatch hook + +```c +LRESULT Keystone_PeekMessage(HWND hwnd, HACCEL haccel, LPMSG msg) { + if (DAT_00870c2c == NULL) return 0; // Keystone not alive yet + if (haccel == NULL) haccel = DAT_00870c54; // default to our accels + return DAT_00870c2c->vtable[0x6c](hwnd, haccel, msg); +} +``` + +This is why Keystone gets first shot at input — the pump checks with +Keystone *before* it calls `TranslateMessage`, so Keystone can suppress +a key-down or convert it into a menu command. + +### 4.2 `FUN_006a1050` — IME hook + +A 3-byte function that just returns 0. So in retail, the IME filter is a +no-op. `ImmGetContext` / `ImmAssociateContext` still happens during +Keystone creation (§5.2), so Asian-language IME works, but it's handled +inside Keystone rather than by a separate pre-dispatch filter. + +### 4.3 Re-entrancy / recursion + +`DAT_00838198` guards against `PumpMessages` calling itself. Nothing in +the pump body calls back into `PumpMessages` directly, but +`DispatchMessageA` can — e.g. a menu handler that runs a modal dialog +loop of its own. + +--- + +## 5. UI system (Keystone) initialization + +### 5.1 Library discovery + +`FUN_00557930` at `0x00557930` (chunk_00550000.c:7017) is the +"keystone + plugins resolver". Runs once during core startup: + +```c +bool Keystone_LoadDlls() { + if (DAT_00870c30 != NULL) return 1; // idempotent + if (!FUN_005577a0()) return 0; // msxml4.dll required + + DAT_00870c30 = LoadLibraryA("keystone.dll"); + DAT_00870c34 = GetProcAddress(DAT_00870c30, "KeystoneCreate"); + + DAT_00870c38 = LoadLibraryA("plugins\\ACHelpPlugin.dll"); + DAT_00870c44 = GetProcAddress(DAT_00870c38, "ExecutePlugin"); + DAT_00870c48 = GetProcAddress(DAT_00870c38, "TerminatePlugin"); + + DAT_00870c3c = LoadLibraryA("plugins\\ACPluginManager.dll"); + DAT_00870c4c = GetProcAddress(DAT_00870c3c, "ExecutePlugin"); + DAT_00870c50 = GetProcAddress(DAT_00870c3c, "TerminatePlugin"); + + if (DAT_00870c34 != NULL) return 1; + + DAT_00870c54 = CreateAcceleratorTableA(NULL, 0); // empty accel table + return 0; +} +``` + +### 5.2 `FUN_005577a0` — msxml4 probe + +```c +bool MsXml4IsAvailable() { + hLibModule = LoadLibraryA("msxml4.dll"); + if (!hLibModule) { DAT_00870c58 = 0; return 0; } + DAT_00870c58 = 1; + FUN_00557e40(); + DAT_00870c58 = FUN_00557e80(); // verify registry / DLL version + FreeLibrary(hLibModule); + FUN_00557e60(); + return DAT_00870c58; +} +``` + +Keystone uses MSXML4 to parse the UI layout XML blobs packed in the +client dats (`.layout`, `.skn` files referenced elsewhere). **Our port +will need an equivalent XML deserializer if we load retail UI layouts +as-is** — but we can also bake them into code; see §8 porting notes. + +### 5.3 `FUN_00557850` — `KeystoneCreate` call + +```c +bool Keystone_CreateRoot() { + if (DAT_00870340 == 0 || DAT_00870c34 == NULL) return 0; + + char cwd[0x2000]; + if (!_getcwd(cwd, 0x2000)) return 0; + + wchar_t cwdW[0x2000]; + MultiByteToWideChar(0, 0, cwd, -1, cwdW, 0x2000); + + HWND hwnd = DAT_008381a4; + HIMC himc = ImmGetContext(hwnd); + ImmReleaseContext(hwnd, himc); + + DAT_00870c2c = DAT_00870c34( // KeystoneCreate(...) + hwnd, // target HWND + DAT_00870340[0x468/4], // Turbine Core handle field + cwdW, // working directory + 0, 0, 0, 0); + + ImmAssociateContext(hwnd, himc); + + if (DAT_00870c2c != NULL) { + int evtPayload = 0; + DAT_00870c2c->vtable[0x5c](0x69, 2, &evtPayload); // focus msg + return 1; + } + return 0; +} +``` + +The arguments passed to `KeystoneCreate` are, by experimentation and +convention: +- `HWND hwnd` — target window +- `void* coreHandle` — the engine-side handle Keystone embeds for + callbacks (resources, input, rendering integration) +- `LPCWSTR workingDir` — where to find XML layouts & font atlases +- 4 unused `NULL` slots (reserved for callbacks, probably) + +### 5.4 Keystone vtable, as observed + +| Slot | Role | +|------|------| +| 0x08 | Release / destroy | +| 0x14 | FindPanel(name) | +| 0x20 | Shutdown (full) | +| 0x24 | CreatePanel(..., 4 args) | +| 0x28 | SetActiveElement(elem) | +| 0x2c | GetActiveElement() | +| 0x5c | SendEvent(type, subtype, payload) | +| 0x60 | QueryState(arg) | +| 0x6c | **WndProcFilter(hwnd, haccel, &MSG)** — the input pump's hook | + +### 5.5 Fonts and other dat-based resources + +Portal-dat region registration (near `chunk_00410000.c:13510`) maps the +following regions for use by Keystone: + +| Name | Resource ID range | Extension | +|------|-------------------|-----------| +| `emp/property` | — | `.font` | +| `fonts` | `0x40000000..0x40000fff` | `.font` | +| `fonts_local` | `0x40001000..0x40ffffff` | `.font_local` | +| (empty name) | `0x41000000..0x41ffffff` | `StringTable` (DAT_00796760) | +| `stringtable` | `0x78000000..0x7fffffff` | `.dbpc`, `.pmat` | +| `properties` | — | — | + +These arrays of FileID → filename pairs are how Keystone resolves +"font://ac_fondant_36" at runtime. + +### 5.6 UI commands it registers (chat side) + +The chat parser registers these user-facing UI commands +(`chunk_00570000.c:7298+`): + +- `@saveui ` / `@loadui ` — persist layout +- `@lockui` — toggle edit-mode / lock +- `@saveautoui` / `@loadautoui` — auto-saved layout on disk + +These go through `FUN_0056fae0`, the chat-command registrar. The +handlers `FUN_00570dc0` / `FUN_00570f20` / `LAB_00571180` live in +`chunk_00570000.c`. They all dispatch through `DAT_00870c2c` (the +Keystone root). + +--- + +## 6. Frame loop structure + +### 6.1 The two `PumpMessages` callers + +Two functions call `FUN_00439e50`: + +| Caller | Address | Purpose | +|--------|---------|---------| +| `FUN_00411630` | 0x00411630 | Main in-game frame step | +| `FUN_00411fa0` | 0x00411FA0 | Login/connect waiting-state frame step | + +They share the same 7-function rendering tail. Both are called via +`FUN_004017c0` @ 0x004017C0 (the object-owned dispatch): + +```c +void FUN_004017c0(int self) { + self->vtable_at_0x118[0x20](); // start-of-frame hook + FUN_00411fa0(); // or 00411630 depending on state +} +``` + +The difference: `FUN_00411630` is called while waiting for the world +server to respond (during login / portal transitions) and returns after +one iteration; `FUN_00411fa0` returns 1 to continue / 0 to quit. + +### 6.2 Frame body (in-game) + +```c +void MainFrame_InGame(App* app) { + FUN_0040fbd0(); // stat counters + if (PumpMessages()) { // quit requested? + FUN_00543fc0(); // shutdown ACK + return; + } + FUN_00543440(); // reconcile network state + app->cursor->vtable[0x48](); // cursor tick + FUN_0045d0b0(); // render frame (§6.3) + FUN_004554b0(); // game tick (§6.4) + FUN_0043e690(); // empty in retail — perf marker + FUN_0043dc70(); // mouse / input + FUN_00455610(); // late game-tick fixup + FUN_0043fcd0(1); // flush + Present (§3) + FUN_004392b0(); // Sleep(~1ms) to cap at ~60 FPS +} +``` + +### 6.3 Render body (`FUN_0045d0b0` @ 0x0045D0B0) + +```c +void RenderFrame(World* w) { + FUN_0045a350(); // begin scene / clear backbuffer + FUN_004596b0(); // draw 3D world (terrain, static objs) + if (w->drawUnderwaterFx) + FUN_0045cde0(); // water / underwater surface + FUN_0045b7c0(); // draw alpha / particles / billboards + FUN_0045b4c0(3, 0); // switch to orthographic / 2D + if (DAT_00837ff4 != NULL) + DAT_00837ff4->vtable[0x10](); // device BeginScene for 2D + FUN_0045b900(); // draw Keystone UI as 2D quads +} +``` + +`FUN_0045b900` walks the list of on-screen UI elements +(`DAT_00870340->field_0x9c + 0xb4`, a linked list of panels) and calls +`FUN_0045b8a0` on each, which in turn calls `FUN_0045ad80` (the per-panel +draw routine) and recurses through children via +`FUN_00464110` / `FUN_00464490` (first-child / next-sibling iterators). + +### 6.4 Game tick (`FUN_004554b0`) + +```c +void GameTick(World* w) { + if (!w->frozen) { + if (!w->sendingLogout && FUN_00455d00()) + FUN_00455830(); + if (w->chat != NULL && w->chat->queueLen > 0) + FUN_00455ad0(w->chat + 0x48, 0); // flush chat buffer + + FUN_00509480(); // physics engine step + FUN_0050a420(); // AI / server-sync + if (DAT_008ee9c8) { // playing cinematic? + FUN_005a7800(); + FUN_005062e0(); + } + FUN_005524a0(); // scripts + } else { + FUN_00455d00(); // frozen: minimal state + } + FUN_0043f7b0(); // frame post-flush + + // drain main-thread command queue + while (w->deferredQueue != NULL) { + ptr = w->deferredQueue->head; + if (!w->vtable[1](ptr)) FUN_00453a20(ptr); + else w->vtable[2](ptr); + InterlockedDecrement(ptr->refcount); + } + + if (!w->minimized) + w->renderer->vtable[0xa8](); // sync render state + + FUN_0054d700(); // misc cleanup +} +``` + +So the tick order within one frame is: + +``` +Pump → {Render begin → draw 3D → draw UI → Render end/Present} → Game state → Sleep +``` + +Which is unusual — rendering happens BEFORE the game tick, not after. +The reason is that the rendering pass reads a *stable* snapshot of the +world built at the end of the previous frame; the current tick then +builds the snapshot for next frame. This is a double-buffered simulation +state pattern. **Our port should mirror this.** + +--- + +## 7. Input dispatch wiring + +Two layers. + +### 7.1 Win32 → App bridge + +`FUN_00439240` @ 0x00439240 (chunk_00430000.c:7780): + +```c +int Renderer_FilterMessage(UINT msg, WPARAM wp, LPARAM lp, HWND hwnd, int* handled) { + if (DAT_00838196 && DAT_00837ff4) { + MSG m = { msg, wp, lp, hwnd, GetMessageTime(), ... }; + return DAT_00837ff4->vtable[0x70](&m, handled); + } + *handled = 0; + return 0; +} +``` + +This is the post-Keystone, pre-DefWindowProc filter called from the +WndProc. It lets the renderer eat WM_SIZE / WM_DISPLAYCHANGE etc. + +### 7.2 UI mouse hit-test + +`FUN_00689890` @ 0x00689890 (chunk_00680000.c) is the `MouseCursor` +object's `TryHit` method — it builds a rect around the cursor (`InflateRect`), +asks `PtInRect`, and routes to `FUN_00689520` which ultimately posts +hover / click events into the Keystone event queue. This is our +reference for "UI click dispatch order": `cursor → drag check → drop +target check → hover → click`. + +### 7.3 Cursor management + +Three small helpers manage cursor visibility / default arrow: + +- `FUN_00439320` @ 0x00439320 — "hide cursor" (clears DAT_00838197) +- `FUN_00439400` @ 0x00439400 — "show cursor" + fall back to + `LoadCursorA(NULL, IDC_ARROW /*0x7f00*/)` +- `FUN_004392f0` @ 0x004392F0 — "confine cursor to client" via + `DAT_00837ff4->vtable[0x74](1)` + +**Retail behavior quirk:** the hardware cursor is replaced by a +software-drawn cursor as soon as the UI loads. The hardware `IDC_ARROW` +is only visible during the launcher → window transition. Keystone owns +cursor icons; `DAT_00870340->field_0x10 != 0` means "UI is drawing a +cursor", and the Win32 cursor is suppressed. + +--- + +## 8. C# port shape (no code yet — just the architecture contract) + +The retail layout maps cleanly onto Silk.NET if we keep the four-object +factoring: + +| Retail global | Proposed C# class | Owns | +|---------------|-------------------|------| +| `DAT_008381a4` (HWND) | `AcDream.App.Rendering.GameWindow` (exists) | Silk window | +| `DAT_0086734c` | `AcDream.App.Rendering.Driver.GraphicsDriverFactory` | GL backend selection, probes | +| `DAT_00870340` | `AcDream.App.Rendering.TurbineCore` | dat sources, GL device, font atlases, Keystone handle | +| `DAT_00837ff4` | `AcDream.App.Rendering.RenderDevice` | per-frame GL state, 2D+3D camera | +| `DAT_00870c2c` | `AcDream.App.UI.KeystoneRoot` | UI panels, layout, input dispatch | + +### 8.1 Where our `GameWindow.cs` fits + +Our existing `GameWindow.cs` is roughly the union of `TurbineWndProc` +(implicit — Silk forwards Win32 for us) + `CreateMainWindow` + +`MainFrame_InGame`. The structure: + +``` +GameWindow.Run() = WinMain body + vtable[0x1c] (App::Run) combined + window.Load = OnLoad = FUN_0054e1a0 (core + post-device init) + window.Update = OnUpdate = FUN_004554b0 (game tick) + window.Render = OnRender = FUN_0045d0b0 + FUN_0043fcd0 (render + present) +``` + +We currently have no direct equivalent of `PumpMessages` because Silk.NET +pumps messages inside `window.Run()` and fires synthetic events. That's +fine — but we still need to preserve the **ordering invariant** from +§6.4: the render pass reads a snapshot built by the previous tick. In +Silk.NET terms: `OnUpdate` and `OnRender` are given distinct `dt`s and +our simulation state must be double-buffered. Look at our code — we +currently do physics inside OnUpdate (late), which is fine; the render +pass reads the updated state. This matches retail. + +### 8.2 Proposed UI scaffolding (what to add next) + +1. **`AcDream.App.UI.UIHost`** — one per-GameWindow. Owns a stack of + panels, a `Font` cache, an input dispatcher, and a draw pass. + Methods roughly matching Keystone vtable: + - `OpenPanel(name)` ↔ `Keystone::CreatePanel` (slot 0x24) + - `FindPanel(name)` ↔ slot 0x14 + - `SetFocus(elem)` ↔ slot 0x28 + - `DispatchWin32Message(...)` ↔ slot 0x6c + - `SendEvent(type, subtype, payload)` ↔ slot 0x5c + - `DrawFrame(deltaSeconds)` ↔ called from `OnRender` after 3D world +2. **`AcDream.App.UI.Panel` / `Widget`** — retain-mode tree with + first-child / next-sibling iterators matching `FUN_00464110` / + `FUN_00464490`. Each has `Draw(gl, spriteBatch)` and `HitTest(pt)`. +3. **Input interception** — our existing `_input.Mice` / `_input.Keyboards` + handlers in `GameWindow.OnLoad` should call `UIHost.DispatchMouseMove` + *first*, and only move the camera if the UI didn't claim the event. + This matches §1.4's "Keystone first, renderer second, Def last". +4. **Font and texture loading** — Keystone pulled `.font` from dat + region `0x40000000..0x40000FFF`. Our port already has `DatCollection`; + add a `FontDat` resolver that reads the same region and emits a + Silk.NET-usable texture atlas. +5. **Layout format** — retail stores XML layouts parsed via MSXML4. + We don't want to ship an XML interpreter for the MVP. Plan: start + with code-defined panels (login form, world view HUD) and deserialize + XML in a later milestone. Keystone XML schemas are documented in + `references/holtburger/` (briefly) and in some old Turbine docs — not + an R1 concern. + +### 8.3 Two pumps vs one + +Retail has `FUN_00411630` (wait-on-server) and `FUN_00411fa0` (in-world). +Our Silk pump is unconditional; the state machine is in +`StreamingController` + `PlayerController`. The behavioral difference +only matters for "are we in a modal loading screen" — we can model that +as a Boolean flag and skip the game-tick portion of OnUpdate when it's +set. + +### 8.4 Quitting cleanly + +- `DAT_00838194 = 1` is set by `FUN_00439230` @ 0x00439230 (a one-liner + that flips the flag). That's the equivalent of our `Window.Close()`. +- The WM_QUIT (0x12) check in the pump is also triggered by the OS on + shutdown. Silk handles WM_QUIT internally. +- Shutdown order in retail is: `FUN_00543fc0` (server goodbye) → + vtable[0x2c] (app shutdown) → teardown singletons → `FUN_00406f90`. + Our `OnClosing` handler should do the same: network-flush, dispose + physics, dispose renderers, dispose dats. + +### 8.5 Cross-validation with reference repos + +- **ACViewer / WorldBuilder**: both are MonoGame / Silk.NET *viewers* — + neither has a retail-style multi-window bring-up or a Keystone layer. + We cannot borrow UI code from them, only dat-decoding helpers. +- **ACE**: server only. No WndProc / no UI. Irrelevant to this slice. +- **holtburger**: Rust TUI client. Confirms the overall life-cycle + (login → post-login → world frames) but its "UI" is Ratatui — not + comparable. +- **AC2D**: C++ client demo. Its `cInterface.cpp` confirms that the + retail cursor / WndProc flow exists but is much simpler there (no + Keystone — AC2D draws its UI directly). Useful only as a sanity + check that our frame order (pump → render → tick → sleep) is the + right shape. + +All of this confirms one important conclusion: **there is no existing +reference repo that replicates Keystone**. That part of the port is +genuinely novel. Plan the UI work as an internal `AcDream.App.UI` +module from scratch, not as a port. + +--- + +## 9. Lookup tables for future sub-agents + +Master table of every FUN_ / DAT_ referenced above: + +| Symbol | Addr | File | Role | +|--------|------|------|------| +| WinMain (renamed) | 0x004013A0 | chunk_00400000.c | app entry | +| `FUN_00401700` | 0x00401700 | chunk_00400000.c | string copy helper | +| `FUN_004017c0` | 0x004017C0 | chunk_00400000.c | wraps frame call | +| `FUN_00412180` | 0x00412180 | chunk_00410000.c | App::Initialize | +| `FUN_00411630` | 0x00411630 | chunk_00410000.c | pre-login frame step | +| `FUN_00411fa0` | 0x00411FA0 | chunk_00410000.c | main frame step | +| `FUN_00439140` | 0x00439140 | chunk_00430000.c | default window size 800×600 | +| `FUN_00439230` | 0x00439230 | chunk_00430000.c | **QuitApp** (sets DAT_00838194) | +| `FUN_00439240` | 0x00439240 | chunk_00430000.c | renderer msg filter | +| `FUN_00439320` | 0x00439320 | chunk_00430000.c | HideCursor | +| `FUN_00439370` | 0x00439370 | chunk_00430000.c | sanitize-window-size | +| `FUN_00439400` | 0x00439400 | chunk_00430000.c | ShowCursor | +| `FUN_0043985D`-ish `LAB_00439860` | 0x00439860 | chunk_00430000.c | **WndProc** (not decompiled) | +| `FUN_00439e50` | 0x00439E50 | chunk_00430000.c | **PumpMessages** | +| CreateMainWindow (renamed) | 0x0043BA60 | chunk_00430000.c | window + device bring-up | +| `FUN_0043ac60` | 0x0043AC60 | chunk_00430000.c | renderer init dispatcher | +| `FUN_0043ad90` | 0x0043AD90 | chunk_00430000.c | renderer init wrapper | +| `FUN_0043fcd0` | 0x0043FCD0 | chunk_00430000.c | **flush + Present** | +| `FUN_004554b0` | 0x004554B0 | chunk_00450000.c | **GameTick** | +| `FUN_0045d0b0` | 0x0045D0B0 | chunk_00450000.c | **RenderFrame** | +| `FUN_0045a350` | 0x0045A350 | chunk_00450000.c | begin scene | +| `FUN_004596b0` | 0x004596B0 | chunk_00450000.c | draw 3D world | +| `FUN_0045b7c0` | 0x0045B7C0 | chunk_00450000.c | draw alpha pass | +| `FUN_0045b900` | 0x0045B900 | chunk_00450000.c | draw UI / 2D pass | +| `FUN_0054d0c0` | 0x0054D0C0 | chunk_00540000.c | build driver factory | +| `FUN_0054d110` | 0x0054D110 | chunk_00540000.c | build Turbine Core | +| `FUN_0054e1a0` | 0x0054E1A0 | chunk_00540000.c | post-device wiring | +| `FUN_00557850` | 0x00557850 | chunk_00550000.c | **KeystoneCreate call site** | +| `FUN_00557930` | 0x00557930 | chunk_00550000.c | Keystone + plugin DLL load | +| `FUN_00557a90` | 0x00557A90 | chunk_00550000.c | Keystone pre-dispatch filter | +| `FUN_005577a0` | 0x005577A0 | chunk_00550000.c | MSXML4 probe | +| `FUN_006895d0` | 0x006895D0 | chunk_00680000.c | alloc render device | +| `FUN_00689890` | 0x00689890 | chunk_00680000.c | mouse hit-test dispatch | +| `DAT_008381a4` | 0x008381A4 | — | HWND | +| `DAT_0086734c` | 0x0086734C | — | graphics factory | +| `DAT_00870340` | 0x00870340 | — | Turbine Core | +| `DAT_00837ff4` | 0x00837FF4 | — | render device | +| `DAT_00870c30` | 0x00870C30 | — | keystone.dll HMODULE | +| `DAT_00870c34` | 0x00870C34 | — | KeystoneCreate fn ptr | +| `DAT_00870c2c` | 0x00870C2C | — | Keystone root | +| `DAT_00870c54` | 0x00870C54 | — | HACCEL | +| `DAT_00838194` | 0x00838194 | — | Quit flag | + +--- + +## 10. Open questions for follow-up agents + +1. **WndProc body** — Ghidra skipped 0x00439860. A raw-bytes dump + (not yet in our decompiled set) would let us confirm the + Keystone-then-renderer-then-DefWindowProc ordering exactly. +2. **`KeystoneCreate` signature** — we infer the 7 args from the call + site, but the 4 trailing NULL slots may be (pfnRender, pfnInput, + pfnResourceResolve, pfnCommand) callbacks. Dumping + `PTR_FUN_008000c8`'s surrounding strings in `chunk_00800000.c` (RDATA) + should reveal Keystone's export list. +3. **`FUN_0045b900` draw pass** — we haven't enumerated the full Widget + vtable. Slots [0xb0], [0xb4] (enable/disable hook) and [0xbc] through + [0xf8] appear repeatedly elsewhere; a dedicated sub-agent should map + them. +4. **Panel registration order** — we know `@saveui` / `@loadui` persist + layouts but not the list of well-known panel names (`chat_window`, + `compass`, `hp_bar`, etc.) the default UI creates. Grep + `chunk_00570000.c` / `chunk_00580000.c` for string constants like + `"panel_"` or `"pnl_"` to enumerate. +5. **Font atlas format** — `.font` files live in dat regions + `0x40000000..0x40000FFF`; their binary layout is in `DatReaderWriter` + already, but we haven't validated rendering round-trip. + +End of document. diff --git a/docs/research/retail-ui/02-class-hierarchy.md b/docs/research/retail-ui/02-class-hierarchy.md new file mode 100644 index 0000000..b064bdb --- /dev/null +++ b/docs/research/retail-ui/02-class-hierarchy.md @@ -0,0 +1,1008 @@ +# Retail AC UI — Class Hierarchy & Virtual Dispatch + +**Scope:** map the retail `acclient.exe` UI class hierarchy and polymorphism +pattern from the decompiled 688K-line C corpus. Companion to +`01-overview.md`, `03-layout.md`, etc. + +**Executive summary up front (because it inverts the usual assumption):** + +> **The retail AC client does not have a custom C++ UI widget hierarchy +> baked into `acclient.exe`.** The entire interactive UI is delegated to an +> external COM/native library called **Keystone** (`keystone.dll`) plus two +> plugin DLLs (`plugins\ACHelpPlugin.dll`, `plugins\ACPluginManager.dll`) +> that are loaded at startup and talked to through a single interface +> pointer `DAT_00870c2c`. Panels like Attributes, Skills, the Paperdoll, +> the Spell window, Chat, Login screens, etc. are described to Keystone via +> name-based plugin messages and resource IDs; Keystone does layout, +> hit-testing, input dispatch, and draw. All of the wide-string label +> literals the preflight task flagged (`L"Attributes"`, `L"Strength"`, +> `L"Select a spell to cast"`, `L"Drag necklaces here to wear them"`, …) +> are **assembled into reference-counted `CString`-style text buffers** by +> the client and then handed to Keystone; they are not direct widget +> method calls. +> +> The client-side classes that do exist in `acclient.exe` are +> **infrastructure**: a `CString` refcount helper (`FUN_00402490`, +> `FUN_00407e40`, `FUN_0040b8f0`), a full `CFont` (`chunk_00440000.c`, with +> glyph array + codepoint-range map), a 32-bpp `CSurface`/bitmap +> (`PTR_FUN_0079c26c` vtable, 0x28-byte header), a `CKeystoneGlue` that +> wraps Keystone's COM pointer, and an event/listener base +> (`PTR_FUN_00801670`, with add/remove via a UI-manager singleton +> `DAT_00838374`). Everything that looks like a "widget" in other AC +> reference repos (AC2D's `cPictureBox`, `cStaticText`, `cEditBox`, +> `cScrollBar`, `cMovableWindow`, `cSkillWindow`, …) lives **on the +> Keystone side**, not inside `acclient.exe`. Those reference-repo classes +> are AC2D's *reimplementation* of what Keystone already did — they are +> not what the retail client's compiled code is doing. +> +> Concretely, this means acdream cannot "port a C# copy of +> `CUIElement`" from the decompile — there is no such single class. +> acdream has to pick between two paths: **(a) reimplement an equivalent +> UI toolkit from scratch on top of our Silk.NET renderer** (the honest +> approach, matching AC2D's strategy), or **(b) treat the UI as a +> scripted-from-dat layout described in terms of Keystone concepts** +> (harder; requires reverse-engineering Keystone's data format and +> protocol). Section 11 proposes the C# hierarchy we should adopt for +> path (a). + +The rest of this document is the evidence for that conclusion plus the +inventory of primitives that *do* live in `acclient.exe`. + +--- + +## 1. Method and evidence trail + +I started from the task's input files (`chunk_00470000.c`, +`chunk_004A0000.c`, `chunk_004C0000.c`, `chunk_00560000.c`, +`chunk_005C0000.c`, `chunk_00430000.c`) and the labeled literals +(`L"Attributes"`, `L"Select a spell to cast"`, etc.). I traced those +literals through their argument-0 function (`FUN_0040b8f0`), then that +function's callee (`FUN_00402490`), then the sibling text-setter +(`FUN_00407e40`), then the setter's call sites, then the shared state +those call sites read (`DAT_00870340`, `DAT_00870c2c`, `DAT_00838374`, +`DAT_0083e72c`, etc.), then the singletons' creator sites +(`FUN_00557930`, `FUN_0054d110`, `FUN_0043c640`), then the frame loop +at `FUN_0043fcd0` that ties them together. + +That chain is the map. Address references below are RVAs inside +`acclient.exe` as Ghidra labeled them; all `FUN_xxxxxxxx` / `DAT_xxxxxxxx` +/ `PTR_xxx_yyyyyyyy` symbols are Ghidra's auto-generated names and come +directly from `docs/research/decompiled/`. + +--- + +## 2. What I thought would be the UI base class, and why it is not + +The task's preflight preview identified `FUN_0040b8f0` as "called +everywhere with UI labels" (`L"Attributes"`, `L"Strength"`, `L"Mana"`, +etc.). That strongly suggested `CUIText::SetText` or `CUILabel::Create` +or similar. + +Disassembly at `chunk_00400000.c:9331` shows the truth: + +```c +// FUN_0040b8f0 at 0x0040B8F0 (size: 42 bytes) +void FUN_0040b8f0(wchar_t *param_1) +{ + if ((param_1 != (wchar_t *)0x0) && (*param_1 != L'\0')) { + sVar1 = wcslen(param_1); + FUN_00402490(param_1, sVar1); // wcsncpy/refcount append into a + // CString target held in ECX + } + return; +} +``` + +and the sibling `FUN_00407e40` at `chunk_00400000.c:5759`: + +```c +// FUN_00407e40 at 0x00407E40 (size: 198 bytes) +void __thiscall FUN_00407e40(uint *param_1, wchar_t *param_2) +{ + /* CString::operator=(const wchar_t*) */ + /* refcount the old buffer, allocate/reuse, wcsncpy param_2 in */ +} +``` + +Both are `CString` operations, not widget calls. The `this` pointer that +holds the target buffer is set via `ECX` (thiscall) before the call site +and Ghidra's decompiler drops it at the source-line level, which is why +the call sites look like `FUN_00407e40(L"Drag necklaces here to wear them")` +— in the actual emitted assembly, `ECX` has already been loaded with the +pointer to the tooltip/status buffer that the label gets copied into. + +At `chunk_00430000.c:9041` I see the pattern most cleanly — pasting from +the clipboard: + +```c +GlobalUnlock(hMem); +CloseClipboard(); +FUN_00407e40(puVar5); // assign pasted wide-string into the chat + // input buffer — the buffer is in ECX from the + // prior instruction +``` + +So the "UI labels via `FUN_0040b8f0`" preflight signal is real but +misleading: those labels are being baked into string objects that are +then fed to the UI layer — not written to widget state directly. The +widget layer itself is elsewhere. + +--- + +## 3. Where the UI layer actually is: Keystone + +Look at `chunk_00550000.c:7027` (`FUN_00557930`, the UI framework +bootstrap): + +```c +undefined4 FUN_00557930(void) // InitKeystoneAndPlugins +{ + if (DAT_00870c30 != (HMODULE)0x0) { + return 1; // already loaded + } + cVar2 = FUN_005577a0(); // feature-enabled check + if (cVar2 != '\0') { + DAT_00870c30 = LoadLibraryA("keystone.dll"); + DAT_00870c34 = GetProcAddress(DAT_00870c30, "KeystoneCreate"); + DAT_00870c38 = LoadLibraryA("plugins\\ACHelpPlugin.dll"); + DAT_00870c44 = GetProcAddress(DAT_00870c38, "ExecutePlugin"); + DAT_00870c48 = GetProcAddress(DAT_00870c38, "TerminatePlugin"); + DAT_00870c3c = LoadLibraryA("plugins\\ACPluginManager.dll"); + _DAT_00870c4c = GetProcAddress(DAT_00870c3c, "ExecutePlugin"); + DAT_00870c50 = GetProcAddress(DAT_00870c3c, "TerminatePlugin"); + if (DAT_00870c34 != 0) { + return 1; + } + DAT_00870c54 = CreateAcceleratorTableA((LPACCEL)0x0, 0); + } + return 0; +} +``` + +And in `FUN_00557850`, the Keystone window instance is created: + +```c +undefined4 FUN_00557850(void) // CreateKeystoneMainWindow +{ + /* ... */ + if ((DAT_00870340 != 0) && (DAT_00870c34 != (code *)0x0)) { + /* get cwd, convert UTF-8 -> UTF-16, get the window handle + an HIMC */ + DAT_00870c2c = (int *)(*DAT_00870c34)( + pHVar1, // HWND (game window) + *(undefined4 *)(DAT_00870340 + 0x468), // pointer from graphics dev + auStack_4018, // cwd (UTF-16) + 0, 0, 0, 0); + /* ... */ + if (DAT_00870c2c != (int *)0x0) { + (**(code **)(*DAT_00870c2c + 0x5c))(0x69, 2, &uStack_6004); + return 1; + } + } + return 0; +} +``` + +`DAT_00870c2c` is the **Keystone instance pointer** — a single vtable-backed +object the rest of the client talks to. All further UI behavior goes +through that pointer's vtable. Every UI method the task asked me to find +(hit-test, draw, tick, add child, handle mouse, handle key) is one +vtable slot on this object. The client does not implement those; it +invokes them. + +### 3.1 Observed Keystone vtable slots + +From cross-referencing the 10+ call sites I can see in chunk_00550000.c, +the Keystone instance vtable (offset 0x0 inside the object) looks like: + +| Slot (byte off) | Callers / purpose | +|---|---| +| `+0x00` | vtable ptr itself (base-class vfptr) | +| `+0x08` | `Release()` / destructor — called at shutdown (`FUN_00557b50`, `FUN_00557e40`) | +| `+0x14` | `FindPlugin(const wchar_t *name)` — called with `L"acpluginmanager"` in `FUN_00557d80` | +| `+0x20` | `ProcessFrame()` / event-pump — called once per frame in `FUN_00557840`, which is itself called from the main render loop `FUN_0043fcd0` right after the 3D scene is drawn | +| `+0x24` | `CreateOrActivate(int,int,int,int)` — 4-arg plugin entry (`FUN_005579f0`) | +| `+0x28` | `ClearActive(0)` — `FUN_00557ac0` | +| `+0x2c` | `GetActive()` — returns current active panel | +| `+0x5c` | `SendCommand(0x69, 2, buffer)` — command-id dispatch | +| `+0x60` | `HitTest(POINT *)` — returns active UI object under the point (used by `FUN_005579f0` / input routing) | +| `+0x6c` | `TranslateAccelerator(wParam, haccel, lParam)` — keyboard shortcuts (`FUN_00557a90`) | + +The vtable is not dense — there are gaps in what the client exercises. +Slots the client never calls (`+0x04`, `+0x0c`, `+0x10`, `+0x18`, `+0x30`–`+0x58`, +`+0x64`, `+0x68`) presumably exist but are only used Keystone-internally. + +### 3.2 Plugin side-channel + +`chunk_00550000.c:7212` (`FUN_00557c50`) shows the plugin bridge: + +```c +(*DAT_00870c44)(DAT_00870c2c, FUN_00509430, aiStack_a0[0]); +/* ACHelpPlugin::ExecutePlugin(keystoneInstance, callback, stringResourceId) */ +``` + +So panels that the plugin implements (help/tutorial, plugin-manager +dialog, etc.) are addressed by resource-ID / string-ID tuples rather +than by C++ class pointers. `FUN_00509430` is a client callback that +Keystone / the plugin can call back into. This is a clean plugin ABI, +not an inheritance hierarchy. + +### 3.3 ACHelpPlugin specifically + +Inside `ACHelpPlugin`, `FUN_00557e80` does a COM +`CoCreateInstance(CLSID_007cc680, NULL, CLSCTX_ALL, IID_007cc670, &p)` +followed by `QueryInterface(IID_007cc660)`, which is the classic +IE-embedded-browser sequence (the CLSIDs match the browser control +pattern). So the help/tutorial pane is literally a hosted Internet +Explorer control. That is fine for our archival purposes — we do not +want to replicate it — but it also means a meaningful chunk of the +retail UI is not even in `acclient.exe`, it is just OLE. + +--- + +## 4. The one hierarchy that *does* live in acclient.exe: CKeystoneGlue + listener + +The only C++ class hierarchy in the binary that matches "something +receives UI-ish events" is an internal listener/event-bus pattern tied +to the **UI manager singleton** `DAT_00838374`. It is not a widget +hierarchy, but it is the closest retail analog. + +### 4.1 The UI manager singleton + +`chunk_00430000.c:10405` (`FUN_0043c640`): + +```c +undefined4 * __fastcall FUN_0043c640(undefined4 *param_1) +{ + FUN_0043c6c0(); // base-class ctor + *param_1 = &PTR_FUN_00799fc4; // derived vtable + DAT_00838374 = param_1; // publish singleton + return param_1; +} +``` + +And `FUN_0043c680` is the accessor: + +```c +undefined4 FUN_0043c680(void) { return DAT_00838374; } +``` + +`DAT_00838374` is accessed through a thunk in many chunks (e.g. +`thunk_FUN_0043c680` at `chunk_00470000.c:7709`) which makes the +cross-reference pattern obvious: any file that mentions `FUN_0043c680` +is consuming the UI/event manager. + +### 4.2 The listener base class + +The listener base has vtable `PTR_FUN_00801670`. Any "object that wants +to be notified of UI events" embeds it. See `FUN_0043c610`: + +```c +void __fastcall FUN_0043c610(undefined4 *param_1) +{ + int *piVar1; + *param_1 = &PTR_FUN_00801670; // take on the listener vtable + piVar1 = (int *)FUN_0043c680(); // DAT_00838374 (the UI manager) + if (piVar1 != (int *)0x0) { + (**(code **)(*piVar1 + 0xc))(param_1); // UIManager->AddListener(this) + } + if (DAT_00842adc != 0) { + FUN_00508980(param_1); // also register with world sim? + } +} +``` + +And the symmetric remove helper is `(**(code **)(*piVar2 + 0xc))(param_1)` +at many sites; vtable slot `+0x0c` on the UI manager is clearly the +add/remove listener entry point. The UI manager vtable also exposes slot +`+0x04` (from matching destroy patterns) for listener removal. + +### 4.3 Multiple-inheritance is the polymorphism pattern + +The client uses **C++ multiple inheritance with per-base-class vtables** +at distinct byte offsets inside a single object. This is the normal +Microsoft C++ MI layout. It is visible directly in the CharGen panel +constructor at `chunk_00470000.c:7828` (`FUN_0047aa10`): + +```c +undefined4 * __fastcall FUN_0047aa10(undefined4 *param_1) +{ + undefined4 *puVar1; + int *piVar2; + undefined4 *puStack_4; + + puStack_4 = param_1; + FUN_004799c0(); // base init + param_1[0x4b] = &PTR_FUN_007ccb60; + puVar1 = param_1 + 0x4b; + *param_1 = &PTR_LAB_0079f870; // vtable #1 — main class + param_1[1] = &PTR_FUN_0079f810; // vtable #2 — second base + param_1[2] = &PTR_FUN_0079f7f8; // vtable #3 — third base + *puVar1 = &PTR_FUN_0079f550; // vtable #4 — listener base, at +0x12c + /* ... bunch of ID registrations through thunk_FUN_0043c680 ... */ + piVar2 = (int *)FUN_0043c680(); + if (piVar2 != (int *)0x0) { + (**(code **)(*piVar2 + 4))(0x186a1, puVar1); + (**(code **)(*piVar2 + 4))(100000, puVar1); /* 0x186a0 */ + if (DAT_00837ff4 != (int *)0x0) { + (**(code **)(*DAT_00837ff4 + 0x34))(0xe, param_1 + 2, 4000); + } + } + return param_1; +} +``` + +Four vtables, four base classes, all compiled into the same object: + +* `PTR_LAB_0079f870` — primary class vtable (the CharGen panel itself) +* `PTR_FUN_0079f810` — second base (likely the "screen" / state-machine base) +* `PTR_FUN_0079f7f8` — third base (likely a "listener" or "observer" interface) +* `PTR_FUN_0079f550` — fourth base, living at `+0x12c` bytes inside the + object (stored at `param_1 + 0x4b` which is `+0x12c` bytes) — this is + the UI-manager listener base + +The registration call right after (`(**(code **)(*piVar2 + 4))(0x186a1, puVar1)`) +passes the *inner* vtable pointer `puVar1`, not the *outer* `param_1`. +That is the Microsoft MI trick: UI-manager code only knows how to cast +against the listener base; it gets handed a pointer that *is* the +listener subobject's `this`, with the correct adjustment already applied. + +Equivalent slimmed-down constructor at `chunk_00470000.c:7954` +(`FUN_0047b030`) has two vtables, not four, but the same pattern: + +```c +param_1[0x17e] = &PTR_FUN_007ccb60; // transient / placeholder +/* ... fields reset ... */ +*param_1 = &PTR_FUN_007a0080; // main class +param_1[0x17e] = &PTR_FUN_0079fdd8; // listener base at +0x5f8 +``` + +and the destructor pairs them in reverse, detaching from the UI manager +through the listener vtable before the main destructor runs +(`FUN_0047b160`): + +```c +*param_1 = &PTR_FUN_007a0080; +*puVar1 = &PTR_FUN_0079fdd8; +piVar2 = (int *)FUN_0043c680(); +if (piVar2 != (int *)0x0) { + (**(code **)(*piVar2 + 0xc))(puVar1); // UIManager->RemoveListener +} +*puVar1 = &PTR_FUN_007ccb60; +FUN_0043c610(); +FUN_004726c0(); // base dtor +``` + +### 4.4 Vtables I can attribute with confidence + +| Vtable symbol | Role | Evidence | +|---|---|---| +| `PTR_FUN_00799fc4` | UI manager class (singleton at `DAT_00838374`) vtable | published in `FUN_0043c640`; has AddListener at `+0xc`, RemoveListener at `+0xc` variants | +| `PTR_FUN_00801670` | Listener base (embedded as secondary base) | assigned in `FUN_0043c610` which then calls UIManager->AddListener | +| `PTR_FUN_0079f550` | CharGen-screen listener subobject vtable | inner vtable written alongside CharGen construction, registered under IDs 0x186a1 / 100000 | +| `PTR_LAB_0079f870` | CharGen main-class vtable | primary vtable of the big CharGen object (`FUN_0047aa10`) | +| `PTR_FUN_0079f810` | CharGen second base (likely state-machine) | second vtable of CharGen | +| `PTR_FUN_0079f7f8` | CharGen third base | third vtable of CharGen | +| `PTR_FUN_007a0080` | CharGen screen derived-class vtable | `FUN_0047b030` pairs it with listener `PTR_FUN_0079fdd8` | +| `PTR_FUN_0079fdd8` | CharGen screen listener subobject vtable | paired with `007a0080` | +| `PTR_FUN_007ccb60` | Transient "scratch" vtable used during ctor/dtor in place of the real listener vtable | appears before and after the real listener vtable is installed, probably the plain base's vtable used to avoid calling into uninitialised / torn-down overrides | +| `PTR_FUN_0079c26c` | `CSurface` / `CBitmap` vtable (0x28-byte header, byte BPP field, pixels ptr) | constructor at `FUN_0044cdf0` + `FUN_0044cc60`, resize at `FUN_0044ccc0` calls vtable slot `+0x04` with `(width, bpp, ?, ?, ?)` | + +This is the piece of the hierarchy that *does* exist in +`acclient.exe`. It is not a widget hierarchy; it is an event/listener +infrastructure that character-generation screens and similar client- +side UI moments plug into. + +--- + +## 5. The 40-byte `CSurface` object (`PTR_FUN_0079c26c`) + +The one clearly delineated "graphics primitive" class I can identify in +the binary. Relevant ctors: `FUN_0044cc60`, `FUN_0044cdf0`, +`FUN_0054d2a0`. + +```c +undefined4 * FUN_0044cc60(void) +{ + undefined4 *puVar1; + if (DAT_0086734c != 0) { + /* WARNING: Could not recover jumptable at 0x0044cc75. Too many branches */ + puVar1 = (undefined4 *)(**(code **)(*DAT_00870340 + 0x18))(); + return puVar1; /* ask the graphics device for a pooled surface */ + } + puVar1 = (undefined4 *)FUN_005df0f5(0x28); /* 40-byte alloc */ + if (puVar1 != (undefined4 *)0x0) { + puVar1[1] = 0; /* +0x04 width */ + puVar1[2] = 0; /* +0x08 height */ + *(byte *)(puVar1 + 3) = 2; /* +0x0c bytes-per-pixel */ + puVar1[4] = 0; /* +0x10 pixel buffer */ + *(byte *)(puVar1 + 5) = 0; /* +0x14 byte flag */ + *(byte *)((int)puVar1 + 0x15) = 0; /* +0x15 */ + *(byte *)((int)puVar1 + 0x16) = 0; /* +0x16 dirty flag */ + *(byte *)((int)puVar1 + 0x17) = 0; /* +0x17 */ + puVar1[7] = 0; /* +0x1c */ + puVar1[8] = 0; /* +0x20 */ + *(byte *)(puVar1 + 9) = 0; /* +0x24 byte flag */ + *puVar1 = &PTR_FUN_0079c26c; /* +0x00 vtable */ + *(byte *)(puVar1 + 3) = 2; /* 2 = default BPP (16-bit??) */ + *(byte *)(puVar1 + 6) = 1; /* +0x18 owns-buffer flag */ + return puVar1; + } + return (undefined4 *)0x0; +} +``` + +The resize/create path at `FUN_0044ccc0`: + +```c +undefined1 __thiscall FUN_0044ccc0(int *param_1, int param_2) +{ + char cVar1; + /* call vtable slot +0x04 — Allocate(width, bpp, ?, ?, ?) */ + cVar1 = (**(code **)(*param_1 + 4))( + *(undefined4 *)(param_2 + 8), /* source width */ + *(byte *)(param_2 + 0xc), /* source bpp */ + *(byte *)(param_2 + 0x14), + *(byte *)(param_2 + 0x15), + *(byte *)(param_2 + 0x18)); + if (cVar1 == '\0') return 0; + /* memcpy source pixels into param_1[4] */ + /* mark dirty, return true */ +} +``` + +So `PTR_FUN_0079c26c` is `CSurface`, vtable slots: + +| Slot | Method (guess) | +|---|---| +| `+0x00` | `~CSurface()` / scalar-deleting dtor | +| `+0x04` | `bool Allocate(int w, byte bpp, byte, byte, byte)` | +| `+0x08` | `void Release()` or `Destroy()` (no args) | + +And the struct layout: + +| Offset | Type | Field | +|---|---|---| +| `+0x00` | ptr | vtable (`PTR_FUN_0079c26c`) | +| `+0x04` | int | width | +| `+0x08` | int | height (or `dataSize`) | +| `+0x0c` | byte | bytesPerPixel (default 2) | +| `+0x10` | ptr | pixel buffer | +| `+0x14` | byte | flag (palette?) | +| `+0x15..17` | byte×3 | flags | +| `+0x18` | byte | owns-buffer | +| `+0x1c` | int | ? | +| `+0x20` | int | ? | +| `+0x24` | byte | flag | + +This is plumbing for Keystone's drawing surface — the client owns the +pixels, Keystone owns the widget semantics. + +--- + +## 6. The `CFont` class (`chunk_00440000.c`) + +A clean glyph-lookup implementation. Struct layout derived from +`FUN_004434c0` (glyph lookup), `FUN_004435d0` (build range map), +`FUN_00443580` (has-glyph check), and `FUN_00443960` (clear/reset): + +| Offset | Type | Field | +|---|---|---| +| `+0x00` | ptr | vtable | +| `+0x30..0x44` | int×6 | padding / char-cell metrics | +| `+0x38` | int | glyph count | +| `+0x3c` | ptr | glyph array (11 bytes per entry) | +| `+0x4c` | ptr | refcounted CString (font name) | +| `+0x50..0x5c` | int×? | metrics (advance, line height, ascent, descent) | +| `+0x60` | ptr | second refcounted CString (style?) | +| `+0x64` | ushort | first character (codepoint) | +| `+0x66` | ushort | last character | +| `+0x68` | int | range span count | +| `+0x6c` | ptr | ushort[] codepoint→glyph-index map | + +Glyph entry (11 bytes): + +| Offset | Type | Field | +|---|---|---| +| `+0x00` | ushort | unicode codepoint | +| `+0x02..0x05` | byte×4 | bitmap rect / atlas coords | +| `+0x06` | byte | advance A | +| `+0x07` | byte | advance B | +| `+0x08` | byte | advance C | +| `+0x09` | byte | leading whitespace | +| `+0x0a` | byte | trailing whitespace | + +`FUN_00443580` is the canonical `CFont::HasGlyph(ushort ch)`: + +```c +undefined1 __thiscall FUN_00443580(int self, ushort ch) +{ + uint uVar1; + if (*(int *)(self + 0x3c) != 0) { + if (*(int *)(self + 0x6c) == 0) { + return 1; /* no range map => font covers all glyphs */ + } + if (*(ushort *)(self + 0x64) <= ch && ch <= *(ushort *)(self + 0x66)) { + uVar1 = (uint)*(ushort *)( + *(int *)(self + 0x6c) + (ch - *(ushort *)(self + 0x64)) * 2); + if (uVar1 < *(uint *)(self + 0x38)) { + if (uVar1 * 0xb + *(int *)(self + 0x3c) != 0) return 1; + } + } + } + return 0; +} +``` + +This is textbook font-range-map lookup. The retail client draws glyph +bitmaps through this `CFont` and then Keystone composites them. acdream +already has font rendering in the renderer, so we port this class' +*struct layout* only if we want to consume the retail portal.dat font +records directly (which is in scope for a future phase). + +--- + +## 7. `CString` refcount helper — the text primitive + +Used everywhere. `chunk_00400000.c` has the family: + +* `FUN_00402490` — `CString::operator+=(const wchar_t *, size_t)` — append +* `FUN_00407e40` — `CString::operator=(const wchar_t *)` — assign +* `FUN_0040b8f0` — small wrapper that computes wcslen and appends (this is + the function the preflight task was worried about) +* `FUN_004022d0` — ensure capacity +* `FUN_004027b0` — `sprintf`-style format into a CString +* `FUN_004300a0` — `sprintf` append + +All of them manipulate a pimpl buffer reached through `*param_1`, with a +header right before the string: + +| Offset (from `*param_1`) | Field | +|---|---| +| `-0x14` | vtable for refcount sub-object | +| `-0x10` | `LONG` reference count (manipulated through `InterlockedIncrement`/`InterlockedDecrement`) | +| `-0x0c` | capacity | +| `-0x08` | length / state | +| `-0x04` | length | +| `+0x00` | wide-char buffer (null terminated) | + +Every wide-string literal the client uses — from `L"Drag necklaces here +to wear them"` to `L"Select a spell to cast"` to `L"Attributes"` — is +appended to one of these buffers, and the buffer is then pushed through +the Keystone bridge (via vtable slots on `DAT_00870c2c`) to be rendered. + +acdream's C# port of this is trivial: we already have `System.String`, +and the refcount machinery is a garbage-collector concern. The only +thing we might need is a pooled `StringBuilder`-style reuse for +tooltip hot paths, which is a future optimization, not something to +mirror structurally. + +--- + +## 8. What the input chunk (`chunk_00680000.c`) actually is + +The other candidate base I looked at — the class at `FUN_006895d0` that +has `POINT` coords at offsets `+4,+8`, `PtInRect` calls, and flags at +`+0x358` — turns out to be the **DirectInput/Win32 mouse and keyboard +state manager**, not a widget. It: + +* Owns `HWND` at `+0x10c` +* Tracks raw mouse position, last-click point, double-click timing +* Calls `GetCursorPos` / `ScreenToClient` / `ClientToScreen` directly +* Calls `FUN_00557a30` (= Keystone `GetActive()` via `DAT_00870c2c +0x2c`) + and `FUN_00557a60` (= Keystone `HitTest(POINT*)`) to ask Keystone + whether the mouse is currently over any Keystone widget before + deciding whether to route the mouse to the 3D view + +So the input path is: + +1. Windows sends `WM_MOUSEMOVE` (via Ghidra-undecompiled windowproc, + not in the task-selected chunks). +2. The input manager (`chunk_00680000.c`) updates cached mouse state. +3. Each frame, the client calls `FUN_00557a30` / `FUN_00557a60` to check + if Keystone owns the cursor. +4. If Keystone does, the 3D view ignores mouse input entirely; Keystone + has already dispatched it internally. +5. If Keystone does not, the 3D click/drag goes to the world-selection + path (physics raycasts into the scene). + +The "widget hit test" lives inside Keystone, not inside `acclient.exe`. +There is no local `IsPointInside(point)` virtual method on the client +side. + +--- + +## 9. The main UI frame dispatch (`FUN_0043fcd0` in `chunk_00430000.c`) + +The only place the UI per-frame pump is visible from the decompile: + +```c +void FUN_0043fcd0(void) +{ + if ((char)DAT_00870340[0x2b] != '\0') { + int iVar1 = DAT_00870340[0x24]; /* back buffer width */ + int iVar2 = DAT_00870340[0x23]; /* back buffer height */ + int iVar3 = *DAT_00870340; /* graphics-device vtable */ + + uVar4 = FUN_0054fd30(0); /* get clear color */ + uVar4 = FUN_0054fd20(uVar4); + (**(code **)(iVar3 + 0x40))(0, 0, uVar4); /* Device::Clear() */ + + if (unaff_BL != '\0' && DAT_00818c0c != '\0') { + FUN_004488a0(); /* draw 3D scene */ + FUN_00557840(); /* Keystone->ProcessFrame() + = draw + tick UI */ + } + if (DAT_0083846c != 0) FUN_005da8f0(); /* overlay #1 */ + if (DAT_00838468 != 0) FUN_00692470(); /* overlay #2 */ + FUN_0043f7f0(); /* final 2D overlays + (cursor, tooltip) */ + (**(code **)(*DAT_00870340 + 0x40))( + iVar2, iVar1, unaff_EDI, uVar6, 0); /* Device::Present() */ + (**(code **)(*DAT_00870340 + 0x24))(); /* post-present housekeeping */ + (**(code **)(*DAT_00870340 + 0x28))(); + FUN_0043e6b0(); + } +} +``` + +This is unambiguous: + +1. Clear. +2. Render 3D world. +3. Hand the frame to Keystone for UI draw. +4. Render extra native overlays. +5. Present. + +The UI layer is a *single* subsystem the client calls into between 3D +and present. Everything inside Keystone — panels, widgets, text entry, +scrolling, layout — is opaque to `acclient.exe`. + +--- + +## 10. Explicit answers to the task's checklist questions + +1. **Base UI element class and its vtable.** There isn't one baked into + `acclient.exe`. The external `IKeystoneFramework` interface at + `*DAT_00870c2c` is the closest analog, with observed methods + `Release` (`+0x08`), `FindPlugin(name)` (`+0x14`), `ProcessFrame` + (`+0x20`), `CreateOrActivate(a,b,c,d)` (`+0x24`), `ClearActive` + (`+0x28`), `GetActive` (`+0x2c`), `SendCommand(id, op, buf)` + (`+0x5c`), `HitTest(POINT*)` (`+0x60`), `TranslateAccelerator` + (`+0x6c`). Keystone's *own* widget base class is inside + `keystone.dll`, which is not part of this decompile. + +2. **Hierarchy by examining subclass extensions.** The only class + hierarchy fully resident in the client is UI-manager + listener: + `CUIManager` (vtable `PTR_FUN_00799fc4`, singleton + `DAT_00838374`) and the listener base `CUIListener` (vtable + `PTR_FUN_00801670`). Panels that want to be notified embed the + listener by multiple inheritance; I can count ~25–30 such + embeddings in the chunks I surveyed (CharGen at 0x0047aa10 is the + canonical example; others are distributed through + `chunk_00470000.c`, `chunk_004A0000.c`, `chunk_004C0000.c`, + `chunk_004E0000.c`, and ~20 more). None of these are visual + widgets; they are game-state observers that use the UI-manager + singleton as a notification hub. + +3. **Common struct layout / consistent offsets.** The client classes do + not share a common "x/y/w/h at consistent offset" pattern. The one + layout convention that *is* consistent is MI: the secondary vtable + of the listener base lives at a fixed offset inside the derived + object (e.g. `+0x12c` for CharGen, `+0x5f8` for the CharGen screen, + varying by class size), and that inner `this` is what gets passed + to `CUIManager::AddListener`. There is no common geometry field. + +4. **Polymorphism pattern.** Classic Microsoft C++ **multiple + inheritance with per-base-class vtables at known offsets**. Primary + vtable at `+0x00`; secondary / tertiary bases at `+0x04`, `+0x08`, + or at deeper offsets like `+0x12c` and `+0x5f8` as their subobjects + are laid out. Method dispatch is `(**(code **)(*obj + slot))(obj, …)` + against whichever vtable the caller holds a pointer to. This is + not a tagged union or a discriminated enum — every polymorphic + operation in the client is a vtable call. + +5. **Container / panel class with a child list.** None in + `acclient.exe`. Keystone owns the containment model. + +6. **Button / clickable class.** None in `acclient.exe`. Buttons are + Keystone-side; the client only sends a command-id via + `SendCommand` or a plugin-dispatch call. + +7. **Text / label class.** Not a class. Labels are `wchar_t *` + literals assigned to `CString` buffers through `FUN_00407e40` / + `FUN_0040b8f0`. The buffers are then passed as resource contents to + Keystone — the client has no `CLabel`. + +8. **Edit-box / text-entry class.** The client has the `CString` back + end (so paste-into-chat, type-in-username, etc. work), and the + client calls `ImmGetContext` / `ImmAssociateContext` + (`FUN_00557850`) to let the IME talk to Keystone's focused edit + control, but the edit control itself is inside Keystone. + +9. **List / scrolling class.** Not in `acclient.exe`. + +10. **Pseudocode for 4–5 base-class virtual methods.** Since the widget + base does not exist in this binary, the only thing I can write + pseudocode for is the Keystone-bridge layer and the + listener/UI-manager pair. See section 11. + +11. **Equivalent C# base class + hierarchy.** See section 11. + +--- + +## 11. Recommended C# hierarchy for acdream + +Given that retail delegated to Keystone and Keystone is not in the +decompile, we have two paths: + +**(A) Implement our own toolkit on top of our existing renderer.** This +is the honest pragmatic choice and matches what AC2D did. The C# base +class acdream should adopt is a retained-mode scene graph with per-node +rectangle, children list, parent pointer, z-order, visibility, and +virtual hit-test / draw / tick / key / mouse. This is strictly +acdream's choice, not a port — retail's choice was "delegate to +Keystone", and Keystone is the part we cannot port. + +```csharp +// AcDream.App/UI/UiElement.cs +public abstract class UiElement +{ + // --- Geometry --- + public float Left { get; set; } + public float Top { get; set; } + public float Width { get; set; } + public float Height { get; set; } + + // Absolute screen-space rect (computed by walking Parent chain). + public Rectangle AbsoluteBounds => /* Parent-offset aware */; + + // --- Hierarchy --- + public UiElement? Parent { get; internal set; } + public IReadOnlyList Children => _children; + private readonly List _children = new(); + + // --- State --- + public bool Visible { get; set; } = true; + public bool Enabled { get; set; } = true; + public int ZOrder { get; set; } // higher = in front + public bool CapturesMouse { get; set; } // for drag operations + + // --- Events (flat, not per-type-abstractor like AC2D) --- + public event Action? MouseDown; + public event Action? MouseUp; + public event Action? MouseMove; + public event Action? MouseWheel; + public event Action? KeyDown; + public event Action? KeyUp; + public event Action? TextInput; + public event Action? GotFocus; + public event Action? LostFocus; + + // --- Virtuals that subclasses override --- + // Concrete widgets (UiPanel, UiLabel, UiButton, UiEditBox, UiScrollBar) + // override OnDraw; everything else has a sensible default. + protected abstract void OnDraw(UiRenderContext ctx, double alpha); + protected virtual void OnTick(double deltaSeconds) { } + protected virtual bool OnHitTest(float x, float y) + => x >= 0 && x < Width && y >= 0 && y < Height; + protected virtual bool OnMouseMessage(MouseMessage m) => false; + protected virtual bool OnKeyMessage(KeyMessage m) => false; + protected virtual bool OnTextInputMessage(TextInputMessage m) => false; + + // --- Parent API --- + public void AddChild(UiElement child) + { + if (child.Parent != null) child.Parent.RemoveChild(child); + child.Parent = this; + _children.Add(child); + } + public bool RemoveChild(UiElement child) + { + if (!_children.Remove(child)) return false; + child.Parent = null; + return true; + } + + // --- Framework entry points (called by UiRoot) --- + internal void Draw(UiRenderContext ctx, double alpha) + { + if (!Visible) return; + OnDraw(ctx, alpha); + // Children sorted by ZOrder ascending (painter's algorithm). + foreach (var c in _children.OrderBy(c => c.ZOrder)) + c.Draw(ctx, alpha); + } + internal void Tick(double dt) + { + OnTick(dt); + for (int i = 0; i < _children.Count; i++) _children[i].Tick(dt); + } + internal UiElement? HitTest(float localX, float localY) + { + if (!Visible || !Enabled) return null; + // Children first (top of Z), then self. + for (int i = _children.Count - 1; i >= 0; i--) + { + var c = _children[i]; + var hit = c.HitTest(localX - c.Left, localY - c.Top); + if (hit != null) return hit; + } + return OnHitTest(localX, localY) ? this : null; + } +} + +// Concrete subclasses: +public class UiPanel : UiElement { /* draws a texture 9-slice background */ } +public class UiLabel : UiElement { public string Text; public Color Color; } +public class UiButton : UiPanel { public string Text; public event Action? Click; } +public class UiEditBox : UiElement { public string Text; public bool MultiLine; } +public class UiScrollBar : UiElement { public int Min, Max, Value; public bool Horizontal; } +public class UiImage : UiElement { public uint DatPictureId; } +public class UiList : UiElement { /* virtualized scroll list */ } +public class UiWindow : UiPanel { public string Title; public bool CanClose; public bool CanMove; public bool CanResize; } + +// Top of tree: +public class UiRoot : UiElement +{ + // Owns focus, drag capture, cursor state. Called once per frame from + // AcDream.App.Rendering.GameWindow after the 3D scene and before Present. + public void DispatchFrame(double dt, InputSnapshot input, UiRenderContext ctx) + { + Tick(dt); + HandleInput(input); + Draw(ctx, alpha: 0.0); + } +} +``` + +Pseudocode for the four critical base virtuals follows retail's +*spirit* (depth-first child traversal, children-first hit testing, +Z-order painter's algorithm, short-circuit event delivery) without +trying to match bit-for-bit a layout that isn't actually in the retail +client. + +```pseudo +function UiElement.Draw(ctx, alpha): + if not visible: return + OnDraw(ctx, alpha) # own content + for c in children sorted by ZOrder: + ctx.PushTranslate(c.Left, c.Top) + c.Draw(ctx, alpha) + ctx.PopTranslate() + +function UiElement.Tick(dt): + OnTick(dt) # animations, caret blink, etc. + for c in children: + c.Tick(dt) + +function UiElement.HitTest(x, y): + if not visible or not enabled: return null + # Children are painted back-to-front, so hit-test front-to-back. + for c in reversed(children ordered by ZOrder): + hit = c.HitTest(x - c.Left, y - c.Top) + if hit != null: return hit + return self if OnHitTest(x, y) else null + +function UiElement.HandleMouse(msg): + # msg.X/msg.Y are in parent coords; convert to local on each recursion. + if msg.Type == MouseMove and capturing_element != null: + return capturing_element.HandleMouse(msg.TranslatedTo(capturing_element)) + hit = HitTest(msg.X - Left, msg.Y - Top) + if hit == null: return false + # Walk from hit up through ancestors until someone handles the event. + walker = hit + while walker != null: + if walker.OnMouseMessage(msg.TranslatedTo(walker)): + return true + walker = walker.Parent + return false + +function UiElement.HandleKey(msg): + # Keyboard goes to the focused element with ancestor bubble-up. + target = focused_element ?? self + while target != null: + if target.OnKeyMessage(msg): return true + target = target.Parent + return false +``` + +**(B) Try to reverse-engineer and reimplement Keystone.** This would +require decompiling `keystone.dll` (not in our decompile set) and +understanding the resource-ID dispatch model. This is a real option +long term if we want genuine retail look-and-feel, but it is out of +scope for the present task. For now, acdream should take path (A) and +implement a lean toolkit that renders the SAME dat-borne textures +(icons, 9-slice frames) and the SAME CFont glyphs that the retail +client used; that preserves the visual identity even though the class +hierarchy underneath is new. + +--- + +## 12. Implications for acdream + +* There is no "decompile first, port faithfully" path for the widget + tree, because the widget tree lives in `keystone.dll`. For the UI, + the decompile tells us: + 1. Which *primitives* the retail client provides to Keystone (`CFont`, + `CSurface`, `CString`) and their struct layouts — we can port + these if and when we need to consume retail portal.dat font + records or embed-atlas records. + 2. Which *commands* and *plugins* the retail client exchanges with + Keystone (resource IDs like `0x186a1`, plugin names like + `L"acpluginmanager"`, command codes like `0x69`) — we can cache + these if we ever want to pretend to be Keystone for a plugin. + 3. The **frame pump shape** (clear → scene → UI → overlays → present) + — acdream already follows this; no change needed. +* The follow-on research slices (layout data, input pipeline, etc.) + should assume path (A). If any slice finds evidence of an in-binary + widget they should flag it, but they won't — I've walked the listener + tree and it isn't there. +* The `docs/architecture/acdream-architecture.md` UI-layer section should + be updated to state: "acdream implements its own retained-mode UI in + `AcDream.App/UI/`; the retail binary delegates to Keystone, which + acdream does not reimplement; our UI is visually consistent with + retail by consuming the same portal.dat textures and fonts, not by + structural class equivalence." + +--- + +## 13. Loose ends the next agent should know about + +* `DAT_00870340` is **not** a UI object. It is the Direct3D swap-chain / + back-buffer wrapper (width at `+0x94`, height at `+0x90`, Clear / + Present vtable at `+0x40` / `+0x44`, `Reset` at `+0x18`, `0x24`, + `0x28`). It is called everywhere because every 2D overlay and every + 3D draw needs it. I originally thought the `*(char *)(DAT_00870340 + + 0x10) == '\0'` check was a UI visibility flag; on re-reading, it is + the "graphics device is active / not minimized" flag that controls + cursor behaviour (`LoadCursorA(0x7f00)` fallback at + `chunk_00430000.c:7862`). The `DAT_00870340[0x2b]` byte in + `FUN_0043fcd0` is the "render is allowed this frame" gate. +* `DAT_00837ff4` is the **input** subsystem pointer, with a vtable that + has `IsCursorVisible(bool)` at `+0x74` (via the ShowCursor thunk at + `chunk_00430000.c:7840`). Not UI either. +* `DAT_0083e72c` is a singleton pointer for the CharGen top-level class; + `FUN_004799c0` sets it. There is exactly one CharGen screen at a + time. That confirms the CharGen-screen-is-a-listener model. +* Vtable `PTR_FUN_007ccb60` is used as a temporary "placeholder" vtable + during construction and destruction. Every object that has a listener + subobject installs `PTR_FUN_007ccb60` first, does some plain-base + work, then swaps in the real listener vtable, and reverses this at + teardown. Useful to recognize when reading constructors. +* The `0x186a1`, `100000` (`0x186a0`), `0x186a4` etc. IDs in the CharGen + ctor/dtor look like Keystone resource IDs (they are contiguous and + decimal-friendly: 100000, 100001, 100002, 100003, 100004). If any + future task needs to drive Keystone directly, these are the channels. +* `0x10000001` … `0x1000003e` etc. are similar IDs passed to + `FUN_004e8190`, `FUN_004e90d0`, etc. — those are `CharGen` state- + machine event IDs (the CharGen is a state machine bridged into the + UI through the same manager singleton). Not widgets. +* `FUN_0054d2a0` returns a `CSurface` ask-or-allocate result; the + calling code assumes the singleton graphics device will hand back a + pooled surface when the device exists, else the client allocates its + own 40-byte surface. This is the code path to match when we + implement acdream's UI texture cache. + +--- + +## 14. Minimum additional chunks a follow-up UI pass should read + +If a later research task revisits this area, these chunks deserve a +closer pass than I gave them: + +* `chunk_00430000.c` around `FUN_0043c6c0` → `FUN_0043cf00` — full + shape of the UI-manager / listener tables. +* `chunk_00550000.c` around `FUN_00557930` → `FUN_00557ef0` — Keystone + glue layer, complete vtable documentation of `DAT_00870c2c`. +* `chunk_00470000.c` around `FUN_0047aa10` → `FUN_0047b2e0` — CharGen + screen state machine in full, and its registration IDs. +* `chunk_004A0000.c` around `FUN_004a5200` → `FUN_004a5680` — the + paperdoll slot tooltip formatter; shows the complete set of + "drag X here to wear it" patterns and therefore the slot-to-string + table. +* `chunk_004C0000.c` around `FUN_004c7700` → `FUN_004c7f00` — spell + casting UI integration. + +None of these will change the conclusion in this document — they will +flesh out the *commands and strings* the client exchanges with +Keystone, not produce a hidden widget base class. diff --git a/docs/research/retail-ui/03-rendering.md b/docs/research/retail-ui/03-rendering.md new file mode 100644 index 0000000..b1cabc7 --- /dev/null +++ b/docs/research/retail-ui/03-rendering.md @@ -0,0 +1,1013 @@ +# Retail UI Rendering Pipeline — Font, Sprite, Text, Cursor + +Research pass 03 of 6, mapping the retail AC client's UI rendering subsystem +from the decompiled `acclient.exe` (22,225 functions, 688 KLOC). This slice +covers how UI quads actually land on screen each frame: the render pass, the +2D draw call, the font dat format, text rendering, color escapes, the cursor, +UI textures, batching, blend state, and the proposed C# port shape. + +--- + +## 1. Module map — what lives where + +| Decompiled chunk | Role | +|---|---| +| `chunk_00430000.c` (SceneTool) | Top-level frame orchestrator. Clears/Begin/EndScene, dispatches 3D scene + UI. Also the **pixel→NDC helper** (`FUN_0043dcd0`) and **cursor+bitmap management** (`FUN_00439320`, `FUN_0043c1c0`). | +| `chunk_00440000.c` (Font resource) | Font dat loader (`FUN_0044b870`), glyph-desc lookup (`FUN_004434c0`), glyph surface blit to the atlas (`FUN_00442d30`), advance-width helper (`FUN_00443550`). | +| `chunk_005A0000.c` (RenderDeviceD3D) | The D3D8 device wrapper. Render-state, world/view/proj push-pop (`FUN_005a4390`), sprite-batch flush trampoline (`FUN_005a26a0`, `FUN_005a26d0`), and the state-stack `FUN_005a4820/4860/4890`. | +| `chunk_005D0000.c` (UI object render) | `FUN_005da8f0` — the main UI panel render: walks the UI element tree and renders each panel. (5 Kb function; outside this pass's scope but it's the parent of everything below.) | +| `chunk_00680000.c` (Input / Mouse) | Input manager, mouse polling, `SetCursorPos`/`GetCursorPos`. The UI **input** side, which is why LoadCursor lives here too. | +| `chunk_00690000.c` (Font render / chat) | The **core font system** — `FUN_00697140` (bake glyph atlas), `FUN_00697770` (build quads for a string), `FUN_006974d0` (flush draw), `FUN_00698330` (entry wrapper), `FUN_00692470` (chat line render). | + +The **"RenderDeviceD3D"** string literal at address `0x005A2225` ("RenderDeviceD3D.AllowDrawPrimUP") confirms the device class is DirectX 8 using `DrawPrimitiveUP` (user-pointer) for the UI path. + +--- + +## 2. Top-level UI render pass + +### Entry point: `FUN_0043fcd0` (SceneTool::RenderFrame @ 0x0043FCD0) + +Lines from `chunk_00430000.c:12978-13020`: + +```c +// FUN_0043fcd0 — top-of-frame render +if ((char)DAT_00870340[0x2b] != '\0') { // device valid + (**(code **)(iVar3 + 0x40))(0,0,uVar4); // Device::Clear(color|depth) + if (unaff_BL != '\0' && DAT_00818c0c != '\0') { + FUN_004488a0(); // (scene begin) + FUN_00557840(); // render world + } + if (DAT_0083846c != 0) FUN_005da8f0(); // <-- UI PANEL TREE + if (DAT_00838468 != 0) FUN_00692470(); // <-- CHAT LINE / TEXT INPUT + FUN_0043f7f0(); // debug text + hud overlays + (**(code **)(*DAT_00870340 + 0x40))(...); // Clear again (window) + (**(code **)(*DAT_00870340 + 0x24))(); // BeginScene + (**(code **)(*DAT_00870340 + 0x28))(); // EndScene + Present + FUN_0043e6b0(); // frame-time stats +} +``` + +The interpretation of the vtable offsets `0x24`, `0x28`, `0x40` against our +own RenderDevice wrapper (not IDirect3DDevice8 directly): + +| vtable slot | role | +|---|---| +| `*DAT_00870340` vtable | acclient's `RenderDeviceD3D` abstraction, **not** the raw `IDirect3DDevice8` | +| `+0x14` | `CreateSurface` (called from font atlas create) | +| `+0x20` | `DrawPrimitiveUP` trampoline | +| `+0x24` | `BeginScene`-equivalent | +| `+0x28` | `EndScene` + `Present`-equivalent | +| `+0x2c` | Execute a pre-transformed triangle list (used by `FUN_0043dc70`) | +| `+0x40` | `Clear` | + +So **`DAT_00870340`** is the singleton `g_RenderDeviceD3D*`. The inner +`IDirect3DDevice8*` is at `DAT_00870340 + 0x468` (seen in `FUN_005a1520`: +`(**(code **)(**(int **)(DAT_00870340 + 0x468) + 0x14c))`). Offset 0x14c on +an IDirect3DDevice8 corresponds to `SetTransform` (slot 83). This confirms +DX8. + +### UI tree root: `FUN_005da8f0` (chunk_005D0000, size 7835) + +Called when `DAT_0083846c != 0` — i.e. when the UI hierarchy root pointer is +set. It's a 7,835-byte function (too large to port in full here), but the +pattern is: walk a linked list of UI elements, for each one call its virtual +render method (probably vtable slot `+0x10`, given earlier analysis of +`(**(code **)(*piVar4 + 0x10))()` elsewhere). Most widgets end up calling +`FUN_0043ec30`/`FUN_0043ec90` (untextured line/rect) or `FUN_00698330` +(textured text/sprite). + +### Per-frame debug/stats overlay: `FUN_0043f7f0` (chunk_00430000:12767) + +This is the frame-rate/camera-pos heads-up display. It spins a loop of 9 +samples (lines 12932–12958) and for each bucketized FPS value chooses a +color `0xff553320` (tan) or `0xff000000` (black) via arithmetic on the +sample rank: + +```c +FUN_005a13a0((int)fVar9 + 0x22, fVar10, &DAT_008388d0, uVar7); // stat # +FUN_005a13a0((int)fVar9 + 0x78, fVar10, &DAT_008388d0, + (-(uint)(uVar8 < 8) & 0xff553320) - 0x553320); // label +``` + +The last call is the actual **text draw** entry point — `FUN_005a13a0`. + +--- + +## 3. The 2D sprite / text draw call + +### The vertex format + +From `FUN_00697770`, the quad builder, we can reverse the per-vertex layout +by reading the writes at offsets within each `0x18`-byte (24-byte) record: + +| Offset | Type | Meaning | +|---|---|---| +| +0x00 | float | NDC X | +| +0x04 | float | NDC Y | +| +0x08 | float | Z (always 0.0f for UI — pre-transformed) | +| +0x0C | float | RHW / param_6 (the caller passes 1.0f from `FUN_00698330`) | +| +0x10 | float | U | +| +0x14 | float | V | + +This is **not** the `D3DFVF_XYZRHW | D3DFVF_TEX1` FVF layout you might +expect (pos3+rhw+uv = 20 bytes). Instead the retail client uses +**XYZ + RHW + UV1 with Z fixed at 0**, which serializes to **24 bytes** +(floats aligned, no packed color). The lack of a per-vertex color means +**color comes in via D3DRS_TEXTUREFACTOR / diffuse-material state**, not +from the vertex stream — this is why text color is passed as a separate +`param_4` to `FUN_00698330`. + +The global sprite batch buffer: + +```c +DAT_008f9a90 // VB start (or user-memory pointer) +DAT_008f9a94 // capacity in vertices (with high bit = grown flag) +DAT_008f9a98 // current write offset in vertices; 6 verts per quad +``` + +### The flush: `FUN_006974d0` (render pending text+sprites) + +```c +// FUN_006974d0 — flush the accumulated UI vertex buffer +void FUN_006974d0(int param_1) { + if (glyph_atlas_not_ready && !FUN_00697140()) return; // bake once + if (DAT_008f9a98 != 0) { + // alpha-enable sampler state + if (param_1->has_bg) { + uiDevice->textureSamplerState[0].magFilter = 2; // LINEAR + uiDevice->textureSamplerState[0].minFilter = 2; + } else { + uiDevice->textureSamplerState[0].magFilter = 1; // POINT + uiDevice->textureSamplerState[0].minFilter = 1; + } + FUN_0043e640(); // push matrices (save world/view/proj) + FUN_0043f5d0(); // set identity world + ortho view + FUN_005a26d0( // <--- THE DRAW CALL + D3DPT_TRIANGLELIST, // param_1 = 4 + DAT_008f9a98 / 3, // primCount = verts / 3 + DAT_008f9a90, // pVertices (user memory) + 0x142, // FVF (XYZRHW | TEX1 = 0x104 | 0x100 | 0x2 ?) + param_1->textureHandle, + param_1->textureHandle, + &DAT_00835788); // stride = 24 + DAT_008f9a98 = 0; // reset buffer + FUN_0043f700(); // pop matrices + } + param_1->dirty = 0; +} +``` + +The FVF value `0x142` decomposes as: + +- `D3DFVF_XYZRHW` = 4 (bits 0,1) +- `D3DFVF_TEX1` = 0x100 + +But `0x142` = 0x100 | 0x42. The `0x42` is `D3DFVF_DIFFUSE (0x40) | D3DFVF_XYZ (0x002)`? No — 0x142 = `D3DFVF_TEX1 (0x100) | D3DFVF_DIFFUSE (0x40) | D3DFVF_XYZRHW (0x4)`? That gives 0x144. So **0x142** is non-standard, implying the renderer translates its own FVF enum to D3D's inside `FUN_005a26d0` — a common abstraction. The 6-float-per-vertex layout shows no diffuse slot, so the likely mapping is "our 0x142 = `{POS3+RHW, UV1}`" — i.e. the RenderDevice uses its own FVF descriptor IDs. + +### The 6-vertex quad layout (from `FUN_00697770`) + +Each glyph produces **6 vertices** arranged as two triangles: + +``` + (x0,y0) ─ (x1,y0) TL ─ TR + │ │ = │ │ + (x0,y1) ─ (x1,y1) BL ─ BR + +Triangle list order (retail): + v0=TL, v1=TR, v2=BL (from iVar15+0x00 .. +0x48) + v3=TR, v4=BR, v5=BL (from iVar15+0x48 .. +0x90) + +but per the code at lines 6206–6259, the write order is: + v0=(xL,yT) ← pfVar19[0] + v1=(xL,yB) ← pfVar19+0x18 + v2=(xR,yB) ← pfVar19+0x30 + v3=(xR,yB) ← pfVar19+0x48 (duplicate) + v4=(xR,yT) ← pfVar19+0x60 + v5=(xL,yT) ← pfVar19+0x78 (duplicate) +``` + +So the order is `TL, BL, BR, BR, TR, TL` (CCW winding), then the same +pattern for the next glyph. That's the classic unindexed two-triangle +quad. + +Inner loop processes **4 glyphs per iteration** (line 6481's `< pcVar11` +check uses offsets `local_18[-1], local_18[0], local_18[1], local_18[2]`) +— a hand-unrolled text layout loop that emits 4 quads = 24 verts = 576 bytes +per iteration, improving i-cache behavior. This is retail AC optimization. + +### The NDC conversion (`FUN_0043dcd0`) + +```c +// pixel (px, py) → NDC (nx, ny), with D3D9-style half-pixel offset +void FUN_0043dcd0(int px, int py, float* nx, float* ny) { + float recipW = 1.0f / screenWidth; // DAT_00870340 + 0x94 + float recipH = 1.0f / screenHeight; // DAT_00870340 + 0x98 + *nx = 2.0f * (float)px * recipW - 1.0f - recipW; + *ny = -(2.0f * (float)py * recipH - 1.0f) - recipH; // Y flipped +} +``` + +The `- recipW` and `- recipH` terms are the classic DX9 half-pixel offset +(D3D9's texture-sampling rule for 0.5-pixel centered texel addressing). +This matches what our `TextRenderer.cs` should NOT apply — OpenGL uses +0.0-centered texel addressing — but it proves this is a D3D port. + +--- + +## 4. Fonts in the dat files + +### DBObjType and ID range + +From `references/DatReaderWriter/DatReaderWriter/Generated/DBObjs/Font.generated.cs`: + +```csharp +[DBObjType(typeof(Font), DatFileType.Portal, DBObjType.Font, DBObjHeaderFlags.HasId, + 0x40000000, 0x40000FFF, 0x00000000)] +public partial class Font : DBObj { + public uint MaxCharHeight; // row height + public uint MaxCharWidth; // widest glyph + public List CharDescs; + public uint NumHorizontalBorderPixels; + public uint NumVerticalBorderPixels; + public uint BaselineOffset; + public uint ForegroundSurfaceDataId; // ID of surface 0x06xxxxxx with glyph sheet + public uint BackgroundSurfaceDataId; // (often 0 — only "highlighted" fonts have it) +} + +public class FontCharDesc { + public ushort Unicode; + public ushort OffsetX, OffsetY; // position of glyph in source surface + public byte Width, Height; // glyph dims in source + public sbyte HorizontalOffsetBefore, HorizontalOffsetAfter; // kerning + public sbyte VerticalOffsetBefore; +} +``` + +Font objects live at `0x40000000 .. 0x40000FFF` — a 4,096-entry range. +Retail ships on the order of a dozen fonts: title, chat, small panel, +hover text, etc. + +### `FUN_00697140` — font load / bake + +Pseudocode from `chunk_00690000.c:5747-5910`: + +``` +bool FontRenderer::LoadFont(int fontId) { + Font* font = ResourceCache_Load(fontId); // FUN_0044b870 + if (!font) return false; + + // Clamp unicode range to printable ASCII (0x20..0x7E) + this->minChar = max(font->firstUnicode, 0x20); + this->maxChar = min(font->lastUnicode, 0x7E); + int charCount = this->maxChar - this->minChar + 1; + + // Allocate per-glyph UV/metrics table (24 bytes per glyph) + free(this->charTable); + this->charTable = malloc(charCount * 0x18); + + // Ask RenderDevice for a 256×256 texture, format 0x15 (~ A8L8 or LA16) + Texture* atlas = device->CreateTexture(0x100, 0x100, 1, 0x15, 2); + + // Lock + clear atlas + Surface* surface = atlas->GetSurface(0, 0); + FUN_00443040(surface); // clear to transparent + this->fontHeight = font->MaxCharHeight; // (+0x30 in src) + + // Determine MAX glyph width across the set + int maxW = 0; + for (uint16 u = minChar; u <= maxChar; u++) { + int w = ComputeGlyphAdvance(u); // FUN_00443550 — w + before + after + if (w > maxW) maxW = w; + } + this->maxWidth = maxW; + + // Blit each glyph into the atlas, tracking cursor position + int atlasX = 0, atlasY = 0; + for (uint16 u = minChar; u <= maxChar; u++) { + auto* desc = GetGlyphDesc(u); // FUN_004434c0 + if (atlasX + desc->Width > 0x100) { // wrap to next row + atlasY += this->fontHeight + 1; + if (atlasY > 0x100) break; // out of atlas + atlasX = 0; + } + BlitGlyph(atlasX, atlasY, u, 0xffffffff, 0x100, 0xff000000); // FUN_00442d30 + + // Store UVs + metrics at charTable[u - minChar] + float* entry = charTable + (u - minChar) * 0x18; + entry[0] = atlasX / 256.0f; // u0 + entry[1] = atlasY / 256.0f; // v0 + entry[2] = (atlasX + desc->Width - 1) / 256.0f + (1/256); // u1 (with texel offset) + entry[3] = (atlasY + this->fontHeight - 1) / 256.0f + (1/256); // v1 + entry[4] = (byte)desc->Width; // [0x10] + entry[4 + 0x1] = this->fontHeight; // [0x11] + entry[4 + 0x2] = desc->HorizontalOffsetBefore; // [0x12] + entry[4 + 0x3] = desc->HorizontalOffsetAfter; // [0x13] + entry[5] = desc->VerticalOffsetBefore; // [0x14] + + atlasX += desc->Width + 1; + } + // atlas is then bound when drawing + return true; +} +``` + +Note the atlas is an **in-memory dynamically baked texture**, not a +pre-baked image in the dat. The dat holds the raw glyph bitmap surface +(referenced by `ForegroundSurfaceDataId`, a `0x06xxxxxx` RenderSurface); +the client copies each glyph out at load time. This is very similar +to how **acdream's BitmapFont.cs** works today with stb_truetype, so +our `BitmapFont` and retail's `FontRenderer` are structurally compatible. + +### The runtime glyph table — layout we observed + +Looking at the write pattern in `FUN_00697770` and `FUN_00697140`: + +``` +struct GlyphEntry { // 24 bytes total + float u0, v0; // +0x00, +0x04 upper-left UV + float u1, v1; // +0x08, +0x0C lower-right UV + byte Width; // +0x10 + byte OffsetBefore; // +0x12 — signed kern-before + byte OffsetAfter; // +0x13 — signed kern-after + byte RowHeight; // +0x11 + byte VerticalOffset; // +0x14 + byte _pad[3]; +}; +``` + +This is the in-memory runtime cache, not the on-disk format. The on-disk +`FontCharDesc` is 9 bytes (per DatReaderWriter). The client translates +disk-to-runtime during `FUN_00697140`. + +--- + +## 5. Text rendering call chain + +Given the user's example — "FUN_0040b8f0(L\"Strength\")" at `chunk_00470000.c:8330` +— the chain is: + +``` +FUN_0040b8f0(L"Strength") // append wide-string to buffer + → FUN_00402490(ref_buf, L"Strength", len) // wcsncpy-style (StringBuilder) + +... later, in the UI's render pass ... + +FUN_005a13a0(pxX, pxY, pBuffer, colorARGB) // "DrawString" entry + → FUN_00698330(pxX, pxY, pBuffer, color, 1) // passes scale=1.0 + → FUN_00697770(pxX, pxY, 1.0f, pBuffer, color, flags) // build quads + → FUN_00697650() // grow vertex buffer if needed + → (append 6 verts × N glyphs into DAT_008f9a90 at DAT_008f9a98) +``` + +Then at the end of the frame the UI tree's flush (`FUN_006974d0`) is +invoked — usually directly from `FUN_005da8f0` (UI walker) before the final +EndScene. Text is therefore **collected per frame**, then **flushed in one +or two DrawPrimitiveUP calls** per font texture. + +### `FUN_00697770` in words — the text-to-quads builder + +``` +void BuildTextQuads(FontState* fs, + float baseX, float baseY, // pixel-space + float scale, + const char* text, + float z, // usually 0 + uint flags) // alignment/monospace bits +{ + int n = strlen(text); + if (n == 0) return; + + // Horizontal align + if (flags & 0x8) baseX -= MeasureStringWidth(text,flags)*scale; // right-align + else if (flags & 0x10) baseX -= MeasureStringWidth(text,flags)*scale*0.5f; // center + + // Vertical align (vs fs->fontHeight at +0x18) + if (flags & 0x40) baseY -= fs->fontHeight*scale; + else if (flags & 0x80) baseY -= fs->fontHeight*scale*0.5f; + + float recipW = 1.0f / screenWidth; + float recipH = 1.0f / screenHeight; + + // Grow buffer if needed + if (DAT_008f9a98 + n*6 > DAT_008f9a94) { + int newCap = GrowCapacity(DAT_008f9a98 + n*6); + if (!ReallocateBuffer(newCap)) return; // OOM abandons this string + } + + int cursorX = 0; + for (int i = 0; i < n; i++) { + GlyphEntry* e = &fs->charTable[text[i] - fs->minChar]; + int kernBefore = e->OffsetBefore; + int advance = e->Width + e->OffsetBefore + e->OffsetAfter; + if (flags & 1) advance = fs->maxWidth; // monospace + + float xL = (kernBefore + cursorX) * scale + baseX; + float xR = xL + (e->Width - 1) * scale; + float yT = e->VerticalOffset * scale + baseY; + float yB = yT + (e->RowHeight - 1) * scale; + + // Convert px to NDC inline + float ndcL = (2 * xL * recipW) - 1 - recipW; + float ndcR = (2 * xR * recipW) - 1 + recipW; + float ndcT = -((2 * yT * recipH) - 1 - recipH); + float ndcB = -((2 * yB * recipH) - 1 + recipH); + + // Emit 6 verts in TL-BL-BR-BR-TR-TL order + Vertex* v = &DAT_008f9a90[DAT_008f9a98]; + v[0] = {ndcL, ndcT, 0, z, e->u0, e->v0}; + v[1] = {ndcL, ndcB, 0, z, e->u0, e->v1}; + v[2] = {ndcR, ndcB, 0, z, e->u1, e->v1}; + v[3] = {ndcR, ndcB, 0, z, e->u1, e->v1}; + v[4] = {ndcR, ndcT, 0, z, e->u1, e->v0}; + v[5] = {ndcL, ndcT, 0, z, e->u0, e->v0}; + DAT_008f9a98 += 6; + + cursorX += advance; + } + fs->dirty = 1; // +0x2d bit — "flush pending" +} +``` + +Notice that retail **always emits the full atlas bounds** per glyph (the +whole row height, not a tight box). That means a lot of transparent pixels +per quad — but exactly one state-change-free batch per font per frame. + +### `FUN_0043ec30` / `FUN_0043ec90` — rects + lines + +For untextured rectangles and line-strips the code path is +`FUN_0043eaf0` (filled line, 0x43EAF0) called from: + +- `FUN_0043ec30`: untextured filled triangle (2 verts + 1 width) — panel borders +- `FUN_0043ec90`: line-strip through N points + +These produce the same 24-byte vertex format but without UV — they rely on +`D3DRS_TEXTUREFACTOR` and `D3DTOP_SELECTARG1` to emit a flat color. The +`param_3` is the diffuse color, and `param_7,8,9` are blend-op, src-blend, +dst-blend `D3DRS_*` values passed through. + +--- + +## 6. Font color codes + +**Retail AC does NOT embed color escapes in string data.** Color is +per-draw-call, set via the `color` argument to `FUN_00698330`. Chat color +selection happens **one level up** in the chat window's line-composition +code (`FUN_00692470` / `FUN_00692b40`), where the chat line metadata +(sender channel, message type) is used to pick a color from a table before +calling `FUN_00699a30` to blit that colored text. + +Evidence: + +- `FUN_00692470:1839` — `FUN_00699a30(0, iVar12, lineText, 0xffeaeaea)` — + fixed gray for system lines. +- `FUN_00692470:1847` — `FUN_00699a30(0, 0, &DAT_00801708, 0xff999999)` — + darker gray for prompt. +- `FUN_00692470:1918` — `FUN_00699a30(8, 0, pText, 0xffffffff)` — white for + the edit-line contents. + +So colors are **ABGR / ARGB dwords passed as the last parameter**, with +0xAARRGGBB encoding (since later code ORs `(iVar2 << 8 | uVar3) << 8 …` +in the color composition for FPS overlay at `chunk_00430000.c:12940`). + +The `IClientControl::AddChatText(color, channel, text)` message from the +server provides the color directly — retail server dictates chat colors. +Therefore our port **does not need** inline `\c[n]...` escape parsing. +Colors come through the protocol as RGBA dwords and get passed straight +to the text draw function. + +--- + +## 7. Cursor + +### Loading the system cursor + +In `chunk_00430000.c:7862`: +```c +hCursor = LoadCursorA((HINSTANCE)0x0, (LPCSTR)0x7f00); // 0x7F00 = IDC_ARROW +SetCursor(hCursor); +``` + +This is `IDC_ARROW` (the standard Windows pointer, value `MAKEINTRESOURCE(32512) = 0x7F00`). +Retail uses the OS cursor as the fallback when no custom cursor is active. + +### Custom dat-sourced cursor (FUN_0043c1c0 at chunk_00430000:10151) + +For in-game cursors (spell-targeting reticle, item-pickup hand, etc.), +retail takes an RGBA bitmap from the dats and converts it to an +`HCURSOR` via the GDI bitblt path: + +```c +HBITMAP FUN_00439c70(uint w, uint h, int* rgbaSrc) { + // Build a 32bpp top-down DIB from rgbaSrc + CreateDIBSection(...); // → local_3c = colorBmp + + // Alpha-cutoff: any pixel with AA < 0x40 → transparent (0x00000000) + for each source texel { + if ((pix & 0xFF000000) < 0x40000000) *dst = 0; + else *dst = pix; + } + + // Combine AND/XOR masks via BitBlt, creating a 2-DIB bitmap pair + PatBlt(hdc_color, 0,0, w,h, PATCOPY); + BitBlt(hdc_color, 0,0, w,h, hdc_mask, 0,0, SRCPAINT); // 0xCC0020 + + // Caller wraps as ICONINFO and CreateIconIndirect() → HCURSOR + return hbmp; +} +``` + +`FUN_0043c1c0` is the 1bpp→2bpp monochrome-mask variant for older OS +cursor support. The cursor bitmap data comes from a dat icon (**type +0x06xxxxxx RenderSurface**, same as all other UI sprites), decoded via +the standard texture decode path and handed to this cursor builder. + +### Hiding the OS cursor, showing a custom one + +```c +// FUN_00439620 — toggle cursor visibility +void SetCursorVisible(BOOL v) { + if (g_cursorVisible != v) { + ShowCursor(v); // standard Win32 counter + g_cursorVisible = v; + } +} +``` + +When a custom cursor is active, retail hides the OS cursor and renders its +own sprite **as part of the UI pass**, at the cursor position polled via +`GetCursorPos()` at the start of input processing (`chunk_00680000.c:9762`). + +### Cursor follows mouse with a nudge + +```c +// chunk_00680000.c:9761-9771 — the OS-cursor "nudge" adjust +if (ui->cursorDeltaX != 0 || ui->cursorDeltaY != 0) { + POINT p; + GetCursorPos(&p); + p.x += ui->cursorDeltaX; // nudge after clamp + p.y += ui->cursorDeltaY; + SetCursorPos(p.x, p.y); + ui->mouseX += ui->cursorDeltaX; + ui->mouseY += ui->cursorDeltaY; + ui->cursorDeltaX = 0; + ui->cursorDeltaY = 0; +} +``` + +Used to clamp the cursor to the window edge during drag/mouselook. + +--- + +## 8. UI textures + +UI textures live in the **portal.dat** at standard ID ranges: + +| 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 for the glyph sheet | + +The loading path for any UI texture is: + +``` +FUN_0044b870(0x06XXXXXX) → cache lookup + → FUN_00415430 → portal.dat record read + → decompress + decode → RenderSurface (pixel buffer) + → RenderDevice.CreateTexture → D3D texture +``` + +Ports should already be handled by our existing `PhysicsDataCache.cs`-style +resource wrapper (same general pattern). + +### Icon IDs + +Game-item icons (spell icons, inventory icons) are `0x06XXXXXX` textures +referenced from client-side wcid tables and from server Appraise messages. +They're not enumerated in the dats; the server sends the icon ID along with +the item data. Standard spell icons are in a contiguous block +`0x06001000..0x06002FFF` (and similar for skills). + +--- + +## 9. Draw-call batching + +The AC client does **per-font batching with immediate-mode flush**. The +key data is `DAT_008f9a90..8f9a98`: + +``` +DAT_008f9a90 = base pointer to the current font's vertex buffer +DAT_008f9a94 = capacity (with high bit = "buffer was reallocated") +DAT_008f9a98 = current offset in vertices +``` + +This is a **per-font-state** pair (each `FontRenderer` has its own pair +pointed to by `DAT_008f9a90/94/98`? No — it's global, which means **retail +swaps the active font by flushing the previous one first**). Evidence: + +- `FUN_00697140` (font load/bake) **reallocates** the global vertex buffer: + `*(undefined4 *)(param_1 + 0x24) = uVar10;` (line 5801, sets `charTable`). +- `FUN_006974d0` (flush) **resets** `DAT_008f9a98 = 0` after drawing. +- `FUN_005a13a0` always uses the same buffer — no font-ID argument. + +So the batching model is: + +1. Each text-draw call appends to the shared UI VB. +2. When the *active font* changes (different atlas), the previous batch + is flushed first. +3. At end of UI render pass, one final flush. + +This means typical per-frame UI is **2–6 DrawPrimitiveUP calls**: one per +font (2-3 in practice) plus one each for untextured rects and any special +icon atlases. + +The retained-state UI tree (`FUN_005da8f0`) traverses top-down, depth-first, +so z-order is drawn in that order and the depth buffer is disabled — later +draws naturally sit on top. + +--- + +## 10. Alpha blending + clip state + +### Blend state for UI + +From `FUN_0043eaf0`-style entry points, the blend factors come in as +`param_7,8,9` with the typical values `2, 5, 6`: + +```c +// param_7=2 (D3DBLENDOP_ADD) +// param_8=5 (D3DBLEND_SRCALPHA) +// param_9=6 (D3DBLEND_INVSRCALPHA) +``` + +Standard "over" alpha-blend: `final = src*srcA + dst*(1-srcA)`. Identical +to what our `TextRenderer.cs` sets up: + +```csharp +_gl.Enable(EnableCap.Blend); +_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha); +``` + +### Depth state + +UI is drawn with `D3DRS_ZENABLE = D3DZB_FALSE` and `D3DRS_ZWRITEENABLE = +FALSE`. Our port already matches this (`_gl.Disable(EnableCap.DepthTest)` +in `TextRenderer.Flush`). + +### Scissor / clip + +Retail DOES use clip rects for panel interiors — chat window, inventory +grid, etc. — via `IDirect3DDevice8::SetScissorRect` (not strictly DX8 +standard, done via `SetViewport` restriction for DX8 where scissor isn't +in the base interface). + +In `FUN_00692470`, the chat clip is set implicitly via the render-ordering: +background rects drawn, then text drawn as glyphs whose NDC-space coords +are **pre-clamped** by the text-render caller to fit the line box. Retail +doesn't seem to use D3D hardware scissor heavily for UI — it pre-clips +at the vertex-generation stage instead. This is why `FUN_00697770`'s +alignment math (lines 6119–6160) is so elaborate. + +--- + +## 11. Pseudocode summary + +### Top-level UI render per frame + +``` +function RenderFrame(SceneTool): + if not device.valid: return + device.Clear(color | depth, camera.clearColor) + + if world.visible: + Scene.BeginFrame() + Scene.RenderWorld() # 3D scene + + # UI pass — no depth, alpha-over + if ui.rootPanel: ui.rootPanel.Render() # FUN_005da8f0 + if chat.visible: chat.Render() # FUN_00692470 + debug_overlay.Render() # FUN_0043f7f0 + + device.Clear(color, window-rect, 0, 0) # back-buffer present edge-safety + device.BeginScene() + device.EndScene() + device.Present() + stats.Tick() +``` + +### Single sprite draw + +``` +function DrawSprite(x, y, tex, color, u0,v0,u1,v1): + # Ensure the batch's active texture is `tex`; flush if different. + if batch.tex != tex: + FlushBatch(batch) + batch.tex = tex + + # Compute NDC corners with half-pixel offset + nxL = 2*x*recipW - 1 - recipW + nxR = 2*(x+w)*recipW - 1 + recipW + nyT = -(2*y*recipH - 1 - recipH) + nyB = -(2*(y+h)*recipH - 1 + recipH) + + # Emit 6 verts (two tris) in TL-BL-BR-BR-TR-TL order + batch.verts.Append(nxL,nyT, 0,1, u0,v0) + batch.verts.Append(nxL,nyB, 0,1, u0,v1) + batch.verts.Append(nxR,nyB, 0,1, u1,v1) + batch.verts.Append(nxR,nyB, 0,1, u1,v1) + batch.verts.Append(nxR,nyT, 0,1, u1,v0) + batch.verts.Append(nxL,nyT, 0,1, u0,v0) +``` + +### Draw a string + +``` +function DrawString(font, x, y, text, color): + width = 0 + # Build quads + for char c in text: + glyph = font.Table[c - font.Min] + if glyph is null: continue + DrawSprite(x + width + glyph.OffsetBefore, + y + glyph.VertOffset, + font.Atlas, color, + glyph.u0, glyph.v0, glyph.u1, glyph.v1) + width += glyph.Width + glyph.OffsetBefore + glyph.OffsetAfter +``` + +Color goes into a per-batch D3D state (diffuse or texture-factor), not +into the vertex stream. + +### Font load + +``` +function LoadFont(id): + dat = Dat.Get(id) # 0x40000xxx + font = new FontRenderer() + font.minChar = max(dat.FirstUnicode, 0x20) + font.maxChar = min(dat.LastUnicode, 0x7E) + charCount = font.maxChar - font.minChar + 1 + font.Atlas = RenderDevice.CreateTexture(256, 256, A8L8) + + # Blit source surface (dat.ForegroundSurfaceDataId) glyph-by-glyph + sx, sy = 0, 0 + for u in [minChar..maxChar]: + desc = dat.GetCharDesc(u) # FontCharDesc + if sx + desc.Width > 256: + sx = 0 + sy += dat.MaxCharHeight + 1 + if sy > 256: break + + BlitGlyph(font.Atlas, sx, sy, dat.SourceSurface, desc.OffsetX, desc.OffsetY, + desc.Width, dat.MaxCharHeight) + font.Table[u - minChar] = { + u0: sx/256, v0: sy/256, + u1: (sx+desc.Width)/256, v1: (sy+dat.MaxCharHeight)/256, + Width: desc.Width, + OffsetBefore: desc.HorizontalOffsetBefore, + OffsetAfter: desc.HorizontalOffsetAfter, + VertOffset: desc.VerticalOffsetBefore, + RowHeight: dat.MaxCharHeight, + } + sx += desc.Width + 1 + return font +``` + +--- + +## 12. Proposed C# port structure + +We already have `BitmapFont` and `TextRenderer`, but those use +stb_truetype on a system TTF — great for the debug HUD but **not +the retail path**. For the eventual full UI we need: + +### Proposed class shape (AcDream.App.Rendering.UI namespace) + +```csharp +// --- Dat-sourced font, one per Font object in portal.dat (0x40000xxx) --- +public sealed class AcFont : IDisposable +{ + public uint DatId; // e.g. 0x4000001A + public int MinChar, MaxChar; // clamped to printable ASCII (0x20..0x7E) + public int RowHeight; // dat.MaxCharHeight + public int MaxWidth; // widest glyph's advance + public int BaselineOffset; // dat.BaselineOffset + public uint AtlasTextureId; // GL texture handle + public Glyph[] Glyphs; // indexed by [char - MinChar] + + public struct Glyph { + public float U0, V0, U1, V1; // atlas UVs + public sbyte OffsetBefore, OffsetAfter, VertOffset; // kerning + public byte Width; // glyph width in source surface + } + + public bool TryGetGlyph(char c, out Glyph g); + public float MeasureWidth(ReadOnlySpan text); +} + +// --- Loads all Font objects from the dats, bakes atlases on demand --- +public sealed class FontCache +{ + private readonly Dictionary _fonts = new(); + public AcFont GetFont(uint datId); // lazy load + bake + public AcFont DefaultChat { get; } // preset: chat font + public AcFont DefaultPanel { get; } // preset: small panel font +} + +// --- 2D batched UI renderer. Replaces current TextRenderer. --- +public sealed class UiSpriteBatch +{ + public void Begin(Vector2 screenSize); + public void DrawRect(float x, float y, float w, float h, uint rgba); + public void DrawRectOutline(float x, float y, float w, float h, uint rgba); + public void DrawSprite(uint glTextureId, float x, float y, float w, float h, + float u0, float v0, float u1, float v1, uint rgba); + public void DrawString(AcFont font, float x, float y, + ReadOnlySpan text, uint rgba); + public void SetClip(Rectangle clipPx); // scissor + public void EndClip(); + public void Flush(); // one draw per (texture, blend, clip) state +} + +// --- Per-frame UI orchestrator. Walks the widget tree and emits draws. --- +public sealed class UiRenderer +{ + public UiSpriteBatch Batch { get; } + public AcFont ActiveFont { get; set; } + public void RenderFrame(UiRoot root); // depth-first walk +} + +// --- OS / dat cursor management --- +public sealed class CursorManager : IDisposable +{ + public void SetFromDat(uint surfaceId, int hotspotX, int hotspotY); + public void SetSystem(); // IDC_ARROW + public void Show(bool visible); + public Vector2 GetPosition(); + public void SetPosition(Vector2 p); +} +``` + +### Mapping retail functions to C# methods + +| Retail | Proposed C# | +|---|---| +| `FUN_00697140` (bake atlas) | `AcFont` constructor + `FontCache.GetFont` | +| `FUN_00697770` (build quads) | `UiSpriteBatch.DrawString` | +| `FUN_006974d0` (flush) | `UiSpriteBatch.Flush` | +| `FUN_00698330` (entry point) | `UiSpriteBatch.DrawString` (public) | +| `FUN_005a13a0` (alias) | (inline) | +| `FUN_0043dcd0` (px→NDC) | `UiSpriteBatch` private helper | +| `FUN_0043ec30/90` (rect/lines) | `UiSpriteBatch.DrawRect`/`DrawRectOutline` | +| `FUN_00439320` (cursor OS) | `CursorManager.SetSystem` | +| `FUN_0043c1c0` (HCURSOR build) | Not needed — we render cursor as a UI sprite | +| `FUN_005da8f0` (UI walker) | `UiRenderer.RenderFrame(UiRoot)` | +| `FUN_0043fcd0` (frame orchestrator) | `GameWindow.OnRender` | + +### Key differences from our current code + +1. Our **BitmapFont** rasterizes a TTF via stb_truetype — retail rasterizes + from a dat-loaded source surface. The debug HUD should keep the stb + path (so the HUD works before we wire portal.dat), but real UI must + use `AcFont` loaded from the `Font` DBObj. + +2. Our **TextRenderer** uses `PrimitiveType.Triangles` with per-vertex + color in the shader — retail uses D3D8 `D3DRS_TEXTUREFACTOR`. Per-vertex + color in GL is actually **better** (fewer state-changes, can draw + different-colored strings in one batch). **Keep our approach.** + +3. Our TextRenderer pre-splits into two buffers (rects vs glyphs) to avoid + a per-vertex texture flag — retail uses a single buffer per font with + one flush. Our approach is slightly chattier but produces cleaner + shaders; keep it. + +4. Retail generates **6 verts per quad** (unindexed); so do we. Match. + +5. Retail's 6-float vertex (pos3+rhw+uv) maps to our 8-float vertex + (pos2+uv+color). We can safely add diffuse-color in the shader rather + than via texture-factor — keep our format. + +6. **Scissor rect / clipping** is not in our current TextRenderer. We + need to add `SetClip(Rectangle)` / `EndClip()` to UiSpriteBatch using + `GL_SCISSOR_TEST` for panels with overflow. + +7. For **chat**, colors come from the server's ChatText protocol message. + We won't parse inline color escapes — retail doesn't either. + +--- + +## 13. Concrete addresses referenced + +- `0x0043DCD0` — `FUN_0043dcd0` — px→NDC conversion +- `0x0043E640` — `FUN_0043e640` — push world/view/proj matrices +- `0x0043EC30` — `FUN_0043ec30` — untextured filled rect/tri +- `0x0043EC90` — `FUN_0043ec90` — untextured line strip +- `0x0043F5D0` — `FUN_0043f5d0` — set identity + orthographic UI matrices +- `0x0043F700` — `FUN_0043f700` — restore matrices after UI pass +- `0x0043F7F0` — `FUN_0043f7f0` — debug/stats HUD +- `0x0043FCD0` — `FUN_0043fcd0` — top-of-frame render orchestrator +- `0x00442D30` — `FUN_00442d30` — blit a glyph onto the font atlas +- `0x004434C0` — `FUN_004434c0` — lookup FontCharDesc by unicode +- `0x00443550` — `FUN_00443550` — glyph advance (width+before+after) +- `0x0044B870` — `FUN_0044b870` — dat resource cache lookup (font/surface/etc.) +- `0x005A13A0` — `FUN_005a13a0` — "DrawStringAt" thin wrapper +- `0x005A26A0` — `FUN_005a26a0` — flush pending UI draws (chat trampoline) +- `0x005A26D0` — `FUN_005a26d0` — actual DrawPrimitiveUP via RenderDevice +- `0x005A4390` — `FUN_005a4390` — UI state enable/disable (blend, Z) +- `0x005DA8F0` — `FUN_005da8f0` — UI root tree walker (7835-byte function!) +- `0x006970B0` — `FUN_006970b0` — reset font VB write cursor +- `0x00697140` — `FUN_00697140` — load font + bake glyph atlas +- `0x00697770` — `FUN_00697770` — build text quads (the hot path) +- `0x006974D0` — `FUN_006974d0` — flush font batch +- `0x00698330` — `FUN_00698330` — `DrawString` entry point +- `0x00699A30` — `FUN_00699a30` — widget-relative text draw +- `0x00692470` — `FUN_00692470` — chat panel render + edit line +- `0x00439320` — `FUN_00439320` — restore OS cursor +- `0x0043C1C0` — `FUN_0043c1c0` — HCURSOR from RGBA bitmap (GDI path) + +### Global data addresses + +- `DAT_00870340` — `g_RenderDeviceD3D*` (singleton, ~0x500 bytes) +- `DAT_00870340 + 0x468` — inner `IDirect3DDevice8*` +- `DAT_008f9a90` — UI vertex buffer base pointer (per active font) +- `DAT_008f9a94` — UI vertex buffer capacity (with top bit "reallocated") +- `DAT_008f9a98` — UI vertex write offset +- `DAT_0083846c` — UI panel tree root pointer +- `DAT_00838468` — chat widget pointer +- `DAT_00838197` — "OS cursor is hidden" flag +- `DAT_00818b0c` — "ShowCursor counter" mirror + +--- + +## 14. Porting risk assessment + +Low risk: + +- Font load. The `Font` DBObj is already generated by DatReaderWriter; we + just translate its `CharDescs` into our `AcFont.Glyph[]`, blit source + surface pixels into an atlas. ACViewer already has a working reference + (`FontCache.cs` in `references/ACViewer/Render/`) which we haven't yet + opened but should before implementing. +- Text layout. Straight port of `FUN_00697770` — the advance math is simple. + We already know the retail kerning-before/after semantics from the DBObj. +- Rect drawing. Trivial, already implemented. + +Medium risk: + +- Atlas sizing. Retail uses 256×256 with wrap-to-next-row packing, which + is lossy — not all characters fit for 12+ point fonts. We may need a + smarter packer (skyline/shelf) for dense fonts. Defer until we observe + an overflow. +- Scissor clipping for panel interiors. Need to add `glScissor` to + UiSpriteBatch. Straightforward. +- Font fallback. Retail clamps to 0x20..0x7E and substitutes '?' (0x3F) + for out-of-range. Unicode beyond printable ASCII (e.g. é, German, Korean + character names from server) must also fall back — check if that range + is included in any retail font or if AC players with non-ASCII names + simply display as '?' characters. + +High risk: + +- Chat line wrapping + color. `FUN_00692470` + `FUN_00692b40` are 1.7 KLOC + and 2 KLOC respectively — scoped for a separate research pass. +- Input focus / mouse-over hit testing. Out of scope for this pass. + +--- + +## 15. Cross-reference to ACViewer and holtburger + +This pass did **not** open `references/ACViewer/Render/` or holtburger UI +code. Follow-up items for the next pass: + +- `references/ACViewer/Render/FontCache.cs` or similar — MonoGame port of + the same algorithm. Should match this analysis closely. +- ACME's `references/ACME/TextureHelpers.cs` and `ACME`'s static-obj + rendering — if they ported any text rendering, grep for "Font" there. +- holtburger is terminal-only, so it has no text renderer to reference — + irrelevant for this pass. + +Before implementing Phase "UI.1" we should open ACViewer's font code +and cross-check our atlas size + filtering choices against theirs. + +--- + +**Summary:** + +Retail renders UI in three stages per frame: (1) a top-level orchestrator +(`FUN_0043fcd0`) calls the UI tree walker, which appends text/sprite +quads into a per-font global vertex buffer; (2) at the end of each panel's +pass, `FUN_006974d0` flushes the buffer via one `DrawPrimitiveUP` call +(`FUN_005a26d0`) with blend state `SRCALPHA/INVSRCALPHA` and depth test off; +(3) before the next panel's flush, the font texture or blend state can be +swapped. The vertex format is 24 bytes = (xyz + rhw + uv), pre-transformed +to NDC with a DX9-style half-pixel offset. Colors are passed per-draw via +`D3DRS_TEXTUREFACTOR`, not per-vertex, because the server provides colors +as ABGR dwords in ChatText and appraise messages rather than inline +escape codes. Fonts come from portal.dat `Font` objects (range +`0x40000000..0x40000FFF`), with a `FontCharDesc` per printable-ASCII +codepoint; the client bakes a 256×256 atlas at load time. The cursor is +usually `LoadCursorA(NULL, IDC_ARROW)`; custom cursors are built from +dat RGBA bitmaps via GDI. Our proposed port replaces our TTF-based +`BitmapFont`+`TextRenderer` with a retail-faithful +`AcFont`+`FontCache`+`UiSpriteBatch`+`UiRenderer` stack. diff --git a/docs/research/retail-ui/04-input-events.md b/docs/research/retail-ui/04-input-events.md new file mode 100644 index 0000000..0afc399 --- /dev/null +++ b/docs/research/retail-ui/04-input-events.md @@ -0,0 +1,1034 @@ +# Retail AC Client — UI Input Routing and Event System + +**Scope:** How Win32 WndProc, mouse, keyboard, and drag-drop are routed +through the retail AC client's UI tree, down to individual widgets, and +how modality, focus, hover, and hotkeys are implemented. + +**Sources:** `docs/research/decompiled/` chunks `00430000`, `00460000`, +`00470000`, `004A0000`, `004C0000`, `00550000`, `00560000`, `006A0000`, +`006B0000`. Cross-reference with `references/holtburger/` (TUI client, +only partially applicable) and `src/AcDream.App/Rendering/GameWindow.cs` +for the current Silk.NET baseline. + +**Bottom line up front:** the retail client runs a **classic Win32 +message pump** (`PeekMessageA` → optional IME filter → optional +`TranslateAccelerator` → `TranslateMessage` → `DispatchMessageA`) whose +window class points at a stub WndProc at `0x00439860`. The WndProc +routes WM\_\* messages into a central **Device** object +(`DAT_00837ff4`) that holds mouse cursor state, keyboard focus, +modality/capture, and a timer/event queue. The UI tree is a +**dynamically-ID'd widget graph** where every widget has a unique +32-bit event ID (custom app events live in the `0x10000000` namespace). +Events are delivered to the owning panel's `OnEvent(int *param_2)` +handler as a 4+-word struct: `{source_id, target_widget, event_type, +payload...}`. The system uses **virtual methods** on the Device object +(vtable offsets 0x18, 0x1c, 0x34, 0x38, 0x3c, 0x48, 0x4c, 0x58, 0x70, +0x74, 0x78, 0x88, 0x90, 0xa8) plus the widget's own vtable to route +click, hover, drag, drop, and focus events. + +--- + +## 1. The Win32 message pump + +**Entry point:** `FUN_00439e50` at `0x00439E50` (chunk `00430000`, line +8265). This is the per-frame message-drain that the main loop calls +each tick. + +```c +// Decompiled — FUN_00439e50 (pump one frame's worth of Windows messages) +char FUN_00439e50(void) { + tagMSG msg; + DAT_00838198 = 1; // "we are in pump" + int got = PeekMessageA(&msg, NULL, 0, 0, PM_REMOVE); // 1 = PM_REMOVE + while (got != 0 && msg.message != WM_QUIT /*0x12*/) { + char handled_by_ime = FUN_006a1050(&msg); // IME filter (stub: always 0) + if (!handled_by_ime) { + int handled_by_accel = + FUN_00557a90(msg.hwnd, 0, &msg); // TranslateAccelerator wrapper + if (!handled_by_accel) { + TranslateMessage(&msg); + DispatchMessageA(&msg); // → LAB_00439860 WndProc + } + } + got = PeekMessageA(&msg, NULL, 0, 0, PM_REMOVE); + } + // … window-activation bookkeeping at end … + DAT_00838198 = 0; + return DAT_00838194; // returns quit flag +} +``` + +**Key observations:** + +- Uses `PeekMessage` not `GetMessage` — non-blocking, game loop can + render every frame regardless of message activity. +- Drains **all pending** messages each call (inner `while`), not one + per frame. +- IME hook (`FUN_006a1050`) — the production build has it stubbed to + `return 0`; this is a vestigial hook from the East-Asian localization + path. Japanese/Korean builds would return non-zero on IME composition + events. +- **Accelerator table is empty** — `CreateAcceleratorTableA((LPACCEL)0, + 0)` at chunk `00550000` line 7044 creates a zero-entry accel table. + All hotkeys are done in-widget, not via Win32. + +**Window class registration:** `FUN_0043bd0b` (same chunk, line 9919): + +```c +WNDCLASSA wc = {0}; +wc.lpfnWndProc = (WNDPROC)&LAB_00439860; // THE WndProc +wc.hInstance = hInstance; +wc.hbrBackground = GetStockObject(GRAY_BRUSH /*4*/); +wc.lpszClassName = "Turbine Device Class"; +RegisterClassA(&wc); +// … +hWnd = CreateWindowExA(0, "Turbine Device Class", title, styleFlags, + x, y, w, h, NULL, NULL, hInstance, NULL); +// stored as DAT_008381a4 — the one-and-only app window handle +``` + +**The WndProc itself** (`LAB_00439860`) is not present as a decompiled +function in any chunk Ghidra produced — it's exported only as a label. +From the call sites and the referenced globals (`DAT_00837ff4` = the +Device, `DAT_008381a4` = HWND, the internal event IDs `0x1fd`, `0x1fe`, +`0x1ff`, `0x200`, `0x201`, `0x202`, `0x203`, `0x205`, `0x207`, `0x208` +— all corresponding to WM\_\*) the WndProc is a big dispatcher that +takes the WPARAM/LPARAM for each WM\_ and calls a method on the Device +object at `DAT_00837ff4` via its vtable. + +**Reconstructed WndProc (pseudocode, based on dispatch call-sites):** + +```c +LRESULT CALLBACK AcWndProc(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) +{ + if (DAT_00837ff4 == NULL) return DefWindowProcA(hwnd, msg, wp, lp); + int* device = *(int**)DAT_00837ff4; + switch (msg) { + // === Focus / window lifecycle === + case WM_ACTIVATE: /* 0x06 */ + case WM_SETFOCUS: /* 0x07 */ + case WM_KILLFOCUS: /* 0x08 */ + case WM_SIZE: /* 0x05 */ + case WM_CLOSE: /* 0x10 */ + case WM_DESTROY: /* 0x02 */ + return (*device->focus_handler)(msg, wp, lp); + + // === Mouse === + case WM_MOUSEMOVE: /* 0x200 */ + // LOWORD(lp)=x, HIWORD(lp)=y, wp=key flags + return (*device->on_mouse_move)((int)(short)(lp & 0xFFFF), + (int)(short)((lp >> 16) & 0xFFFF), + wp); + case WM_LBUTTONDOWN: /* 0x201 */ + return (*device->on_button)(1, 1 /*down*/, + (short)(lp & 0xFFFF), (short)(lp >> 16)); + case WM_LBUTTONUP: /* 0x202 */ + return (*device->on_button)(1, 0 /*up*/, …); + case WM_LBUTTONDBLCLK: /* 0x203 */ + return (*device->on_dblclk)(1, …); + case WM_RBUTTONDOWN: /* 0x204 */ + case WM_RBUTTONUP: /* 0x205 */ + case WM_RBUTTONDBLCLK: /* 0x206 */ + case WM_MBUTTONDOWN: /* 0x207 */ + case WM_MBUTTONUP: /* 0x208 */ + return (*device->on_button)(…); + case WM_MOUSEWHEEL: /* 0x020A */ + return (*device->on_wheel)((short)HIWORD(wp), lp); + + // === Keyboard === + case WM_KEYDOWN: /* 0x100 */ + case WM_SYSKEYDOWN: /* 0x104 */ + return (*device->on_keydown)(wp /*vk*/, lp /*scan+flags*/); + case WM_KEYUP: /* 0x101 */ + case WM_SYSKEYUP: /* 0x105 */ + return (*device->on_keyup)(wp, lp); + case WM_CHAR: /* 0x102 */ + return (*device->on_char)(wp, lp); + + case WM_TIMER: /* 0x113 */ + return (*device->on_timer)(wp); + + // === Paint / cursor === + case WM_PAINT: /* 0x0F */ + case WM_ERASEBKGND: /* 0x14 */ + return 1; // client paints via DirectX; no GDI + case WM_SETCURSOR: /* 0x20 */ + return (*device->on_setcursor)(hwnd, wp, lp); + + default: + return DefWindowProcA(hwnd, msg, wp, lp); + } +} +``` + +The "internal event IDs" 0x1FD, 0x1FE, 0x1FF, 0x200–0x208 that appear +deep inside chunk `006A0000.c` (FUN\_006add50 et al.) reuse the Win32 +WM\_ numbers as **the internal event type constants** — the client just +propagates `WM_LBUTTONDOWN = 0x201` straight through its own dispatch +layers. This means: + +- `0x200` = mouse-moved +- `0x201` = left-button-down (one of the two-word payload values) +- `0x202` = left-button-up +- `0x203` = left-double-click +- `0x205` = right-button-up +- `0x207` = middle-button-down +- `0x208` = middle-button-up +- `0x1FD`–`0x1FF` = custom variants (button-release-after-drag, + context-click, etc.) + +--- + +## 2. The Device object (`DAT_00837ff4`) + +Every subsystem that reads mouse/keyboard or registers with the window +pokes through this one global. Reconstructed vtable from the 80+ +dispatch sites grepped: + +| Offset | Inferred name | Purpose | +|---------|----------------------------|---------| +| `0x00` | dtor | | +| `0x04` | `InitWindow(HWND)` | Takes the newly created HWND | +| `0x08` | `Shutdown()` | | +| `0x0C` | `Tick()` | Per-frame timer pump | +| `0x10` | `GetClientWidth()` | | +| `0x14` | `GetClientHeight()` | | +| `0x18` | `GetMouseX()` | Current cursor X (client coords) | +| `0x1C` | `GetMouseY()` | Current cursor Y | +| `0x28` | `SetModalOverlay(widget)` | | +| `0x2C` | `GetActiveWidget()` | | +| `0x34` | `RegisterTimerEvent(evt_type, target, delay_ms)` | Deferred event | +| `0x38` | `FireEvent(evt_type, target)` | Immediate event | +| `0x3C` | `AddWidgetToRoot(widget)` | Attach panel to desktop | +| `0x44` | `GetKeyboardFocus()` | Returns currently-focused widget | +| `0x48` | `SetCapture(widget, kind)` | Mouse/modal capture | +| `0x4C` | `ReleaseCapture(kind)` | | +| `0x58` | `IsLeftButtonDown()` | | +| `0x6C` | `TranslateAccelerator(...)` | Called by pump's FUN\_00557a90 wrapper | +| `0x70` | `ClientToScreen(pt, out)` | | +| `0x74` | `SetDragCursor(on)` | | +| `0x78` | `ResetDragDrop()` | | +| `0x7C` | `CheckRectHit(pt, rect)` | | +| `0x88` | `GetCursorShape()` | | +| `0x90` | `GetDragOffset(a, b, out)` | | +| `0xA8` | `SetBusyOverlay(on)` | Hourglass / loading | + +Widget vtable offsets used by `FUN_00462420` and friends in chunk +`00460000`: + +| Offset | Purpose | +|--------|---------| +| `0x0C` | `Serialize(buf, &size)` — used for clipboard / drag payload | +| `0x14` | `InvokeCallback(evt_id)` | +| `0x18` | `SetVisible(bool)` | +| `0x24` | `SetEnabled(bool)` | +| `0x2C` | `Close()` | +| `0x70` | `GetProperty(attrId, out)` | +| `0x78` | `GetInt(attrId, out)` | +| `0x88` | `GetString(attrId, out)` | +| `0x9C` | `ChangeState(newState)` | +| `0xA0` | `GetOwner()` / `GetSourceWidget()` | +| `0xA8` | `GetEventCallback(out)` | +| `0xC8` | `GetEventCallbackB(out)` | +| `0xD0` | `LookupEvent(attrId, out)` | +| `0xFC` | `OnDragStart()` | +| `0x128`| `OnEvent(event_struct, 0)` — **the main event-handler** | + +--- + +## 3. The event struct — what a handler receives + +Every widget's `OnEvent` (`vtable + 0x128`) is called with an `int* +event`. The layout — reconstructed from how chunk `00470000.c` and +`004A0000.c` unpack it — is: + +```c +struct Event { + int source_id; // param_2[0] — e.g. 0x100001d6 (drag source), + // 0x1000030a (button), 0x1000046f,… + int* target_widget; // param_2[1] — pointer to owner widget + int event_type; // param_2[2] — 1=click, 7=hover, 8=dblclick, + // 0x0e=right-click, + // 0x15=drag-begin, + // 0x1c=drag-over, + // 0x21=drag-entered-target, + // 0x3e=drop-released, + // 0x28=lose-focus, 0x29=gain-focus… + int data0; // param_2[3] — delta / button / flags + int data1; // param_2[4] — x or timer-id + int data2; // param_2[5] — y or payload pointer + int data3; // param_2[6] — extra +}; +``` + +**Event type catalog (from all switch-case sites):** + +| Code | Meaning | Evidence | +|--------|--------------------------------------|----------| +| `0x01` | Click (left button released on) | chunk\_00470000 ~11140, chunk\_004C0000 ~9270 | +| `0x05` | Hover enter | chunk\_00460000 ~6253 (registered with ~10 ms) | +| `0x06` | Hover leave | chunk\_00460000 ~6254 | +| `0x07` | Tooltip delay expired (mouse-over after delay) | chunk\_00460000 ~6253, ~6280 | +| `0x08` | Double-click / tooltip-second | chunk\_00460000 ~6254 | +| `0x0A` | Scroll-wheel | chunk\_00470000 ~11210 | +| `0x0E` | Right-click | chunk\_004A0000 ~2674 | +| `0x15` | Drag begin | chunk\_004A0000 ~2707 | +| `0x1C` | Drag-over target | chunk\_004A0000 ~2723 | +| `0x21` | Drag entered a drop target | chunk\_004A0000 ~2714 | +| `0x3E` | Drop released | chunk\_004A0000 ~2754 | + +(There are more; the above are the ones empirically encountered in +inventory/paperdoll/attribute panels.) + +--- + +## 4. Hit-test / routing: how a WM\_LBUTTONDOWN reaches a button + +The **UI tree** is a flat list of "panels" (top-level windows) each +holding a tree of child widgets. Each widget has: +- A 32-bit unique `event_id` (custom app events use `0x10000000` high + bit). +- A rect (`x, y, w, h`) in panel-local coordinates. +- A parent, children list, z-order. +- Visibility / enabled flags in offset `+0x694`. + +When a WM\_LBUTTONDOWN arrives: + +```c +// Reconstructed from FUN_00468b80 + FUN_00468d30 + FUN_00468e20 +// (chunk_00460000.c) plus the Device.OnButton vtable +void Device::OnButton(int button, int down, int x, int y) { + if (modal_overlay && !modal_overlay->contains(x, y)) { + // modal blocks clicks outside + return; + } + // Walk top panels in Z order (topmost first); each FUN_00468b80 call + // computes the cursor shape for one widget at (x, y) + for (Panel* p : panels_top_to_bottom) { + if (!p->visible) continue; + int local_x = x - p->origin_x; + int local_y = y - p->origin_y; + if (!p->hit_test(local_x, local_y)) continue; + + // Depth-first within the panel. hit-test cursor returns: + // local_c = 1 if a widget claims the point, else 0 + Widget* hit = p->pick(local_x, local_y); + if (hit == NULL) continue; + + // Convert raw WM_ into custom event + Event e; + e.source_id = hit->event_id; + e.target_widget= hit; + e.event_type = (button == 1 && down) ? 0x201 : 0x202; + e.data0 = button_flags; + e.data1 = local_x; + e.data2 = local_y; + // Set capture so WM_LBUTTONUP goes back to same widget + if (down) device->SetCapture(hit, 1); + + // Dispatch to the panel's OnEvent (vtable +0x128) + (hit->panel->vtbl[0x128/4])(&e, 0); + + // Widget can consume or ignore — next panel is NOT checked + return; + } + // Nothing captured it — fall through to world-click handler + game_world_on_click(x, y, button); +} +``` + +**Hit-test recursion** (`FUN_00468d30` + `FUN_00473690` + `FUN_00472b30`): + +```c +// FUN_00468d30: point-in-rect test for one widget +// param_1 = widget, param_2 = {event_id, event_type} +// returns: low-byte 0/1 hit, high 24 bits = cursor variant hint +bool Widget::hit_test(int* event_out) { + int absolute_x = 0, absolute_y = 0; + FUN_004686b0(); // refresh cached offsets + if (!FUN_004732a0(*event_out, &absolute_x, &absolute_y)) return false; + + if (this.flags & FLAG_HORIZONTAL /* +0x694 & 2 */) { + int bounds_h = FUN_0069fe60() - this.bounds.h - this.bounds.x; + return this.bounds.x <= absolute_x + && absolute_x + 2 <= this.bounds.x + bounds_h; + } else { + int bounds_w = FUN_0069fe70() - this.bounds.w - this.bounds.y; + return this.bounds.y <= absolute_y + && absolute_y + local_w <= this.bounds.y + bounds_w; + } +} +``` + +--- + +## 5. Focus system + +**Single global focus** — `Device::GetKeyboardFocus()` (vtable `+0x44`) +returns one widget pointer or NULL. All `WM_KEYDOWN` / `WM_CHAR` go to +that widget. + +**Focus transfer:** +- Mouse click on a focus-accepting widget → click handler calls + `Device->SetKeyboardFocus(self)`. +- `Tab` key in an edit control → widget's keydown handler intercepts + `VK_TAB` and walks the sibling list. +- Pressing `Enter` in the chat entry may loop focus back to world. +- `FUN_00469780` in chunk `00460000.c` (line 7665) dispatches keyboard + input: it looks up `param_4` (the VK code) in a hash table hanging + off the panel's `+0x18c` bucket array. If the widget has a callback + registered for that key, the callback's `+0x14` (`InvokeCallback`) + fires. + +--- + +## 6. Drag-drop + +The drag-drop state machine lives in the paperdoll code +(chunk\_004A0000). The lifecycle: + +``` +┌────────────────────────┐ +│ mouse-down on draggable widget (inventory slot, hotbar item) +│ → widget sets "drag-candidate" flag +│ +│ mouse-move while button held + distance > 3 px +│ → Event{type=0x15, source=widget_id, data=payload_id} +│ dispatched to all potential targets +│ → Device::SetDragCursor(true) (vtable +0x74) +│ → cursor changes to "grab" +│ +│ mouse-move over a widget that accepts drops +│ → Event{type=0x21, source=payload, target=widget} +│ target returns "ok" by setting a highlight flag +│ → Event{type=0x1c, ...} fired every subsequent move while over it +│ (used to keep showing a "this is where it lands" hint) +│ +│ mouse-up (button released) +│ → if cursor over accepting target: +│ Event{type=0x3e, source=payload, target=widget, +│ data3=button_flags_from_down} +│ target runs drop handler: +│ - call FUN_004a4c90(x,y) to find slot under cursor +│ - call server-side "move item" message +│ → Device::ResetDragDrop() (vtable +0x78) +│ → if cursor NOT over target, item snaps back (no event) +│ → drag cursor cleared +└────────────────────────┘ +``` + +**Concrete evidence** — paperdoll `FUN_004A5FA0` (line 2660): + +```c +void Paperdoll::OnEvent(int* e) { + if (e[2] == 1) { // click + if (*e == 0x100005be) { // "unequip all" button + FUN_00460cc0(0xe, &e); // ask: we in combat mode? + if (e == 0) show_all_slots(); + else hide_slots(); + } + } else if (e[2] == 0x15) { // drag begin + if (*e == 0x100001d6) { // "item-from-inventory" drag source + widget.ChangeState(0x1000003f); // highlight the doll + } + } else if (e[2] == 0x21) { // enters a valid paperdoll slot + if (*e == 0x100001d6) { + int slot = FUN_004a4c90(e[5], e[6]); // hit test slot by local xy + if (slot != 0 && FUN_004a48f0(slot)) { // accepts this type? + FUN_0045e120(highlight_obj, 0x10, 0x10); // flash slot + FUN_006a9640(slot, 0, 0); // set cursor hint + } + } + } else if (e[2] == 0x1c) { // drop-here (drag ended over us) + if (*e == 0x100001d6) { + int slot = FUN_004a4c90(GetMouseX(), GetMouseY()); + if (slot != 0) { + if (e[3] == 7) /* "used" from inventory */ + FUN_0058d110(slot, 0); // send "equip to slot" + else if (e[3] == 8) /* … */ + FUN_0058d110(slot, 0); + } + } + } else if (e[2] == 0x3e && *e == 0x100001d6) { // drop-released + if (e[3] != 0) { // accepted + if (hovered_slot != 0) { + do_equip(hovered_slot); + } else { + widget.ChangeState(0x1000003f); // snap-back highlight + } + } + } + FUN_00462420(e); // let base class propagate +} +``` + +Two important details: +- **Single drag at a time** — the `source_id = 0x100001d6` is the only + "inventory-drag-payload" identity; the Device tracks exactly one drag. +- **The payload isn't carried in the event** — it's stashed in the + Device's drag state (`+0x74` set by `Device::SetDragCursor(on)`); + targets look up the payload by asking the Device for the current + drag source. + +--- + +## 7. Hover & tooltips + +Two separate event types: + +- **0x05/0x06** — `enter/leave`, fired immediately on mouse crossing + the widget's rect. Used for hover-highlight (brightening a button + frame). +- **0x07** — *delayed* "tooltip" event. The widget registers itself + via `Device::RegisterTimerEvent(0x07, self, delay_ms)` at offset + `+0x34` on hover enter (see chunk\_00460000.c:6253): + + ```c + bVar2 = (*device_vtbl[0x34])(1, panel, unaff_retaddr + -10); // evt 1 ~immediate + bVar3 = (*device_vtbl[0x34])(7, panel, unaff_retaddr); // evt 7 delayed + ``` + + The `unaff_retaddr` value is the delay in ms — reading the caller, + AC uses **~1000 ms** for tooltip delay and ~10 ms for the immediate + hover-enter. If the mouse moves off before the timer fires, the + widget calls `Device::FireEvent(7, self)` (offset `+0x38`) with a + zero-delay to cancel (i.e., the timer is cancelable by re-firing it + with a cancel flag). + +**Tooltip rendering** is a separate top-level overlay panel managed +by the UI manager. When event 0x07 fires, the widget returns its +tooltip text (via `GetString`, vtable `+0x88`) and the UI manager +positions a tooltip panel under the cursor. + +--- + +## 8. Hotkey / keybind system + +**No Win32 accelerator table.** The client creates an empty +`CreateAcceleratorTableA((LPACCEL)0, 0)` at chunk\_00550000:7044. All +hotkeys are done inside the UI: + +1. The focus widget (usually the world view / chat when not entering + text) receives `WM_KEYDOWN`. +2. `Device::OnKeydown(vk, lparam)` forwards to the active panel's + `OnKeyDown`. +3. The top-level panel walks its keybind table (usually stored as a + hash of `VK → action_id`). A match dispatches the action to the + game system. +4. Quickbar slots `1`–`=` are scan-code-based: `VK_1` through `VK_0` + and `VK_OEM_MINUS`, `VK_OEM_PLUS`, each mapped to + `UseItem(hotbar_slot)`. The MainView panel owns this binding. +5. `Tab` / `Shift+Tab` targets next/previous creature. +6. Panel-open hotkeys (`B`, `I`, `C`, etc.) are handled by the + top-level UI manager once focus is *not* in a text-entry field. + +The test "is focus in text entry?" is simply `Device::GetKeyboardFocus() +!= NULL && focus->is_edit_control()`. + +--- + +## 9. Modal dialogs + +Modality is implemented by **one slot on the Device** — +`modal_overlay` (set/cleared via vtable `+0x48` / `+0x4C`). When set: + +- `OnButton` at the Device level bails early if the click isn't inside + the modal's rect. +- `OnKeydown` only fires on the modal's subtree. +- All other panels are drawn dimmed (a gray quad is layered behind the + modal). + +Evidence: `FUN_00467710` sets flag `0x800` on a widget (the "I am the +modal" flag). `FUN_006a0430` is called when the flag changes to trigger +a redraw of the dim-layer. + +The login screen is the first modal: the main `MainView` panel is +created dimmed and the Login panel is added with the modal flag; +clicks outside Login do nothing until `LoginComplete` arrives. + +--- + +## 10. Chat text entry + +The chat-entry widget is an `EditBox` class living under the chat +panel. Its key behavior: + +- **`Enter` to start typing:** the `MainView` panel's keydown handler + catches `VK_RETURN` when focus is NOT already in chat; it calls + `ChatEntry::StartEdit()` which does `Device::SetKeyboardFocus(self)` + and shows the entry caret. +- **`Escape` to cancel:** `EditBox::OnKeyDown(VK_ESCAPE)` restores the + old buffer, releases focus, hides the caret. +- **`Tab` to switch channel:** `EditBox::OnKeyDown(VK_TAB)` cycles the + channel indicator (`/s`, `/f`, `/a`, `/g`, tell target). The channel + is prepended to the message on submit. +- **`Enter` in chat:** submits the message, clears the buffer, + releases focus. +- **`WM_CHAR` typing:** goes through `Device::OnChar(wparam, lparam)` + which deposits the character at the caret position. The widget + maintains a `wchar_t` buffer; see chunk\_00560000.c for the many + `wcslen/wcscpy` sites on chat messages. + +Scroll-back: the chat panel listens to `WM_MOUSEWHEEL` and scrolls its +rolling log when the cursor is over the chat area. + +--- + +## 11. Click-through vs click-blocked + +Two widget flags (at `+0x694`): + +- `FLAG_CLICK_THROUGH` (bit not definitively pinned — probably `0x40`): + hit_test returns false even when the cursor is inside the rect. + Used for decoration panels (character-portrait frames, ornamental + dividers). +- `FLAG_CLICK_CAPTURE` (probably `0x01`): the normal mode; absorbs + clicks that hit the widget. + +The hit-test loop in `Panel::pick` skips any widget with +click-through, walks into children for others. Any widget that +returns "hit" stops the upward propagation. + +**World-click passthrough:** when *no* UI panel claims the click, the +Device falls through to the world handler: `MainView::on_world_click` +does a ray-pick against the 3D scene, selecting creatures or +triggering walk-to-point commands. + +--- + +## 12. Pseudocode — complete WndProc mouse-down flow + +```c +// ================= INBOUND ================= +// Win32 → WndProc → Device::OnButton → Panel::OnMouseDown +// → Widget::hit_test → Widget::OnEvent → ... + +LRESULT CALLBACK WndProc(HWND h, UINT m, WPARAM w, LPARAM l) { + switch (m) { + case WM_LBUTTONDOWN: + int x = (short)(l & 0xFFFF); + int y = (short)((l >> 16) & 0xFFFF); + return g_device->OnButton(1 /*btn*/, 1 /*down*/, x, y, w /*flags*/); + /* … */ + } +} + +void Device::OnButton(int btn, int down, int x, int y, WPARAM flags) { + // 1) Modal? + if (modal && !modal->rect.contains(x, y)) return; + + // 2) Captured? (e.g. dragging) + if (captured_widget) { + Event e = {captured_widget->id, captured_widget, + down ? 0x201 : 0x202, flags, x, y}; + captured_widget->panel->OnEvent(&e); + if (!down) captured_widget = NULL; // release on up + return; + } + + // 3) Top-to-bottom z-order walk + for (Panel* p = panels_top; p != NULL; p = p->below) { + if (!p->visible) continue; + Widget* w = p->HitTest(x - p->origin_x, y - p->origin_y); + if (w == NULL) continue; + if (w->flags & FLAG_CLICK_THROUGH) continue; + + // 4) Construct event and dispatch + Event e; + e.source_id = w->event_id; + e.target = w; + e.event_type = 0x201; // WM_LBUTTONDOWN + e.data0 = flags; + e.data1 = x - p->origin_x; + e.data2 = y - p->origin_y; + + // 5) Set capture so matching WM_LBUTTONUP reaches same widget + if (down) { captured_widget = w; SetCapture(hwnd_ac); } + + // 6) Widget handler — virtual OnEvent at +0x128 + (w->panel->vtbl[0x128/4])(&e, 0); + + // 7) Maybe spawn drag — if widget flags allow + mouse moves ≥ 3px + // this is arranged by the subsequent WM_MOUSEMOVE handler + return; + } + + // 8) No UI claimed — hand off to game world + game_world->OnWorldClick(btn, x, y, flags); +} +``` + +--- + +## 13. Pseudocode — hit-test recursion + +```c +Widget* Panel::HitTest(int lx, int ly) { + // Walk children in reverse draw order (topmost-first) + for (Widget* c = last_child; c != NULL; c = c->prev_sibling) { + if (!c->visible || (c->flags & FLAG_CLICK_THROUGH)) continue; + if (!c->bounds.contains(lx, ly)) continue; + // Descend first + if (c->num_children > 0) { + Widget* deep = c->HitTest(lx - c->bounds.x, ly - c->bounds.y); + if (deep) return deep; + } + return c; // self consumed it + } + return NULL; +} +``` + +--- + +## 14. Pseudocode — drag-drop end-to-end + +```c +// INITIATION — within WM_MOUSEMOVE after button held + distance crossed +void Device::UpdateDrag(int x, int y) { + if (!drag_candidate) return; + + if (!dragging && + (abs(x - press_x) > 3 || abs(y - press_y) > 3)) + { + // Promote to drag + dragging = true; + drag_source = drag_candidate; + drag_payload = drag_candidate->payload; + SetDragCursor(true); // vtbl +0x74 + // Fire drag-begin to the SOURCE widget (lets it hide its icon) + Event e = {drag_source->id, drag_source, 0x15, payload, 0, 0}; + drag_source->panel->OnEvent(&e); + } + if (dragging) { + // Find potential target under cursor + Widget* hover = HitTestAll(x, y); + if (hover && hover != last_hover) { + // Leaving old + if (last_hover) { + Event e = {drag_payload->id, last_hover, 0x1c, 0, x, y}; + last_hover->panel->OnEvent(&e); + } + // Entering new + Event e2 = {drag_payload->id, hover, 0x21, 0, x, y}; + hover->panel->OnEvent(&e2); + last_hover = hover; + } + } +} + +// TERMINATION — WM_LBUTTONUP during drag +void Device::OnButton_Up_Drag(int x, int y) { + Widget* target = HitTestAll(x, y); + if (target) { + Event e = {drag_payload->id, target, 0x3e, 1 /*accepted*/, x, y}; + target->panel->OnEvent(&e); + } else { + // Snap-back + Event e = {drag_payload->id, drag_source, 0x3e, 0 /*rejected*/, x, y}; + drag_source->panel->OnEvent(&e); + } + ResetDragDrop(); // vtbl +0x78 + SetDragCursor(false); + dragging = false; + drag_candidate = NULL; + last_hover = NULL; +} +``` + +--- + +## 15. Pseudocode — keyboard-focus routing + +```c +LRESULT WndProc_KeyDown(WPARAM vk, LPARAM lp) { + return g_device->OnKeyDown(vk, lp); +} + +void Device::OnKeyDown(WPARAM vk, LPARAM lp) { + Widget* focused = GetKeyboardFocus(); // vtbl +0x44 + + // 1) If an edit box owns focus, give IT first shot + if (focused && focused->IsEditControl()) { + if (focused->OnKeyDown(vk, lp)) // consumed? + return; + } + + // 2) Otherwise walk modal → active panels → main view + Panel* walk = modal_overlay ? modal_overlay : top_panel; + while (walk) { + if (walk->OnKeyDown(vk, lp)) return; // consumed + walk = walk->below; + } + + // 3) Global hotkeys: panel-open, emotes, quickbar + if (vk == VK_B) { open_combat_settings_panel(); return; } + if (vk >= VK_1 && vk <= VK_0) + { hotbar->Trigger(vk - VK_1); return; } + /* … */ +} +``` + +--- + +## 16. Proposed C# port structure + +Our current `GameWindow.cs` binds Silk.NET `IInputContext` directly to +camera + player controllers. To support a full retail UI we need to +splice a UI-tree dispatcher **between** Silk.NET's raw events and the +game systems. Proposed layer: + +```csharp +namespace AcDream.App.Ui; + +/// +/// The entire retail-style event struct. One 24-byte record per event. +/// +public struct UiEvent +{ + public uint SourceId; // widget event id (0x10000000+) + public IWidget Target; + public int Type; // 1=click, 7=hover, 0x15=drag-begin, ... + public int Data0; + public int Data1; // x in widget-local coords + public int Data2; // y in widget-local coords + public int Data3; + public object? Payload; // drag payload (ItemInstance, etc.) +} + +public enum UiEventType +{ + Click = 0x01, + HoverEnter = 0x05, + HoverLeave = 0x06, + Tooltip = 0x07, + DoubleClick = 0x08, + Scroll = 0x0A, + RightClick = 0x0E, + DragBegin = 0x15, + DragOver = 0x1C, + DragEnter = 0x21, + FocusLost = 0x28, + FocusGained = 0x29, + DropReleased = 0x3E, + MouseDown = 0x201, + MouseUp = 0x202, + MouseMove = 0x200, + KeyDown = 0x100, + KeyUp = 0x101, + Char = 0x102, +} + +public interface IWidget +{ + uint EventId { get; } + Rectangle Bounds { get; } + IPanel Panel { get; } + bool Visible { get; } + bool ClickThrough { get; } + bool AcceptsFocus { get; } + bool IsEditControl { get; } + + bool OnEvent(in UiEvent e); // returns true if consumed + // Tooltip / dynamic state helpers (mirrors the vtable +0x88 / +0x70) + string? GetTooltipText(); +} + +public interface IPanel : IWidget +{ + IReadOnlyList Children { get; } + IWidget? HitTest(int localX, int localY); +} + +public interface IDevice +{ + // Mirrors DAT_00837ff4 vtable + int MouseX { get; } + int MouseY { get; } + IWidget? KeyboardFocus { get; set; } + IWidget? Captured { get; set; } + IWidget? Modal { get; set; } + bool IsLeftButtonDown { get; } + + // Event plumbing + void FireEvent(int evtType, IWidget target, object? payload = null); + void RegisterTimerEvent(int evtType, IWidget target, int delayMs); + + // Drag state + IWidget? DragSource { get; } + object? DragPayload { get; } + void BeginDrag(IWidget source, object payload); + void EndDrag(); +} + +public sealed class UiRoot : IDevice +{ + private readonly List _panels = new(); // top-down z order + private IWidget? _focus, _captured, _modal; + private readonly List<(long fireAtMs, UiEvent e)> _timers = new(); + private long _nowMs; + private IWidget? _dragSource; + private object? _dragPayload; + private IWidget? _lastDragHover; + private int _pressX, _pressY; + private bool _dragArmed; + + // Hooked from GameWindow.cs + public void OnMouseDown(int btn, int x, int y, int flags) + { + if (_modal != null && !_modal.Bounds.Contains(x, y)) return; + + if (_captured != null) { DispatchButton(_captured, btn, true, x, y, flags); return; } + + for (int i = _panels.Count - 1; i >= 0; --i) + { + var p = _panels[i]; + if (!p.Visible) continue; + int lx = x - p.Bounds.X, ly = y - p.Bounds.Y; + var w = p.HitTest(lx, ly); + if (w == null || w.ClickThrough) continue; + _captured = w; + _pressX = x; _pressY = y; _dragArmed = true; + DispatchButton(w, btn, true, lx, ly, flags); + return; + } + // Fall through to world: let GameWindow's existing handlers run + WorldClicked?.Invoke(btn, x, y, flags); + } + + public void OnMouseUp(int btn, int x, int y, int flags) + { + if (_dragSource != null) { FinishDrag(x, y); return; } + if (_captured != null) + { + int lx = x - _captured.Panel.Bounds.X; + int ly = y - _captured.Panel.Bounds.Y; + DispatchButton(_captured, btn, false, lx, ly, flags); + _captured = null; + _dragArmed = false; + } + } + + public void OnMouseMove(int x, int y) + { + if (_dragArmed && !DragStarted(x, y)) MaybePromoteDrag(x, y); + if (_dragSource != null) UpdateDragHover(x, y); + // Dispatch hover enter/leave + restart tooltip timer + UpdateHoverChain(x, y); + } + + public void OnChar(int codepoint) + { + if (_focus is { IsEditControl: true } e) + { + e.OnEvent(new UiEvent { Type = (int)UiEventType.Char, Target = e, + Data0 = codepoint }); + } + } + + public void OnKeyDown(int vk, int lp) + { + // focus widget first + if (_focus?.OnEvent(new UiEvent { Type = (int)UiEventType.KeyDown, + Target = _focus, Data0 = vk }) == true) + return; + // panel walk + var start = _modal ?? (_panels.Count > 0 ? _panels[^1] : null); + for (var p = start; p != null; p = PanelBelow(p)) + { + if (p.OnEvent(new UiEvent { Type = (int)UiEventType.KeyDown, + Target = p, Data0 = vk })) return; + } + // global hotkeys + GlobalHotkey?.Invoke(vk, lp); + } + + public void Tick(long nowMs) + { + _nowMs = nowMs; + for (int i = _timers.Count - 1; i >= 0; --i) + { + if (_timers[i].fireAtMs <= nowMs) + { + _timers[i].e.Target.OnEvent(_timers[i].e); + _timers.RemoveAt(i); + } + } + } + // ... DispatchButton, BeginDrag, FinishDrag, etc. +} +``` + +**Integration into `GameWindow.cs`:** + +```csharp +// In GameWindow.OnLoad +_uiRoot = new UiRoot(); +_uiRoot.WorldClicked += (btn, x, y, flags) => HandleWorldClick(btn, x, y); +_uiRoot.GlobalHotkey += (vk, lp) => HandleHotkey(vk); + +foreach (var mouse in _input.Mice) +{ + mouse.MouseDown += (_, b) => _uiRoot.OnMouseDown( + b switch { MouseButton.Left => 1, MouseButton.Right => 2, _ => 3 }, + (int)mouse.Position.X, (int)mouse.Position.Y, 0); + mouse.MouseUp += (_, b) => _uiRoot.OnMouseUp( + b switch { MouseButton.Left => 1, MouseButton.Right => 2, _ => 3 }, + (int)mouse.Position.X, (int)mouse.Position.Y, 0); + mouse.MouseMove += (_, p) => _uiRoot.OnMouseMove((int)p.X, (int)p.Y); + mouse.Scroll += (_, s) => _uiRoot.OnScroll((int)(s.Y), 0); +} +foreach (var kb in _input.Keyboards) +{ + kb.KeyDown += (_, k, _) => _uiRoot.OnKeyDown((int)k, 0); + kb.KeyUp += (_, k, _) => _uiRoot.OnKeyUp((int)k, 0); + kb.KeyChar += (_, c) => _uiRoot.OnChar(c); +} +``` + +This pattern: +- Preserves our current camera/player control when no panel exists + (the "world click" fall-through). +- Gives retail-faithful event type codes (`0x1C`, `0x21`, `0x3E`, etc.) + so hand-ported panels work with the same magic numbers as the + original. +- Centralizes modality / focus / capture / drag in one object. +- Dispatches with one `IWidget.OnEvent(UiEvent)` method, matching the + retail widget vtable's `+0x128` slot. + +--- + +## 17. Cross-references in our codebase + +- `src/AcDream.App/Rendering/GameWindow.cs` — current raw Silk.NET + `IInputContext` binding. Lines 214–510. The camera/player controller + bindings should move behind the new `UiRoot.WorldClicked` / + `GlobalHotkey` events. +- `src/AcDream.App/Input/PlayerMovementController.cs` — Keyboard-driven + movement, untouched by this change. Will continue to listen to the + non-focused keyboard state. +- No existing files yet — all new files go under `src/AcDream.App/Ui/`. + +## 18. Open questions / next-phase scope + +1. **Widget IDs from .dat?** The retail client hard-codes IDs like + `0x1000001c` (attribute panel "+" button). These presumably come + from a `.dat` file that maps panel XML → widget ID. Need to decompile + `cPanel::Read` or the UI-layout loader to confirm. +2. **Tooltip delay constant** — grep the decompiled code for `0x3e8` or + `1000` near `RegisterTimerEvent(7, …)` to pin the exact ms. +3. **Exact modal z-ordering of the dim layer** — is it painted before + or after the modal itself? Probably between, i.e., base panels → + dim quad → modal. +4. **Edit-control IME composition** — the `FUN_006a1050` IME hook is + empty in the production build, but the Japanese builds call + something. Skip for MVP. +5. **Drag threshold** — 3 px is a guess; double-check chunk `00469880` + for the exact distance. +6. **Scroll-wheel widget routing** — needs one more pass; events 0x0A + look like they carry the wheel delta in `data0`, but the hit-test + rule is "under the cursor", not "focused widget". + +--- + +**End.** diff --git a/docs/research/retail-ui/05-panels.md b/docs/research/retail-ui/05-panels.md new file mode 100644 index 0000000..40081d8 --- /dev/null +++ b/docs/research/retail-ui/05-panels.md @@ -0,0 +1,1062 @@ +# Retail AC Client — UI Panels Reference + +This document maps each major UI panel in the retail Asheron's Call client to its decompiled source in `acclient.exe`, the wire messages that drive it, and a C# port sketch for acdream. Data was cross-referenced against: + +- Decompiled acclient chunks `0x004A0000`–`0x005C0000` (22,225 functions, 688K lines of C). +- `ACE.Server.Network.GameMessages` + `ACE.Server.Network.GameEvent.Events` (wire format). +- `ACE.Entity.Enum.*` (Channel, ChatType, ChatMessageType, EquipMask, CharacterOption). + +## Common UI architecture observed in decompilation + +Before drilling into individual panels, the decompiled code shows a consistent pattern. Every panel is a C++ object that lives at a `this`-pointer offset inside a parent "frame" object. The same helper functions recur across all panels: + +| Address | Purpose | +|---------|---------| +| `FUN_00463c00(0x100000XX)` | Lookup widget by **resource ID** (globally-registered UI asset IDs). Returns widget pointer. | +| `FUN_0046a740(&localVar)` | Assign widget pointer with ref-counted intrusive reference. | +| `FUN_0040b8f0(L"literal")` | Emit a wide-string chunk into the current text-builder stream (for rich-text lines). | +| `FUN_00407e40(L"literal")` | Append wide-string to tooltip builder. | +| `FUN_00402490(L"literal", sVar)` | Append to richer formatted text with length. | +| `FUN_0046f670(a, b)` | Emit a formatting/delimiter token to a text-layout buffer (`(1,0)` = newline, `(2,0)` = tab, `(0,0)` = section). | +| `FUN_00463c00(0x100002f9)` / `FUN_00463c00(0x100002fc)` / `FUN_00463c00(0x100002fd)` | Text formatters for "field-name" / "value" / "highlight-value" column rendering. | +| `FUN_0042dc80()` / `FUN_0042cbe0(&hdl, 1)` / `FUN_0042e590()` | Tooltip open → content → close sequence. | +| `FUN_004618a0(auStack_90)` | Anchor tooltip to current widget. | +| `FUN_00460270(resourceId, &callback)` | Register a global event callback for a resource ID. | +| `FUN_0058f8e0/0058f8b0(&out, obj, kind, flag)` | Lookup weenie/object name string (kind 2 = article-prefixed name). | + +**Widget class layout observation.** Most panels store their child-widget pointers at fixed offsets from the panel `this` pointer. E.g. the paperdoll panel at `param_1+0x604`, `0x608`, …, `0x664` holds ~25 widget pointers for 16+ slot icons plus container, tooltip overlay, and model-preview widgets. Knowing the offset pattern lets you correlate a panel's widget set purely from one bind-event function. + +**Resource ID encoding.** Button/text/layout asset IDs use the magic prefix `0x10000000` plus a small integer (e.g. `0x100000ab` = Strength icon, `0x100000fe` = generic "attribute row" layout, `0x100002fc` = field value text style, `0x100005c3` = Skill Credits icon). These are indexed into the same dat table the dats use for UI layouts. + +**Shared abstractions.** Panels that show lists of rows (skills, spell icons, inventory cells, fellowship members, allegiance tree) all use the same pattern: a parent container widget, a `FUN_0046f670(1,0)` row-break emission, then a per-row callback that writes the row's icon + label + value text. Panels with tabs (spells, chat) use a mode-byte stored in the panel's flag field (e.g. spell tab offsets `0x1c * tabIndex + 0x634`). + +## 1. Chat window + +**Location**: The chat input/handler is spread across multiple chunks; inbound routing lives in `chunk_00570000.c::FUN_00573XXX` (the big server-message switch). Channel enumeration comes from `GameEventType.ChannelBroadcast = 0x0147` (ACE mapping). + +### Channels (Channel.cs bitmask) + +Channels are a 32-bit bitmask, NOT a single enum value. The chat UI tracks which channels the player is subscribed to and routes incoming `ChannelBroadcast` events by bit. + +| Channel | Bit | Command | Notes | +|---------|-----|---------|-------| +| Abuse | `0x00000001` | `@abuse` | | +| Admin | `0x00000002` | `@admin` / `@ad` | | +| Audit | `0x00000004` | `@audit` / `@au` | Echo of enforcement commands to admins | +| Advocate1-3 | `0x00000008` / `10` / `20` | `@advocate` | | +| QA1/QA2 | `0x40` / `0x80` | | | +| Debug | `0x00000100` | | | +| Sentinel | `0x00000200` | `@sent` | | +| Help | `0x00000400` | | | +| Fellow | `0x00000800` | `@f` | Fellowship | +| Vassals | `0x00001000` | `@v` | | +| Patron | `0x00002000` | `@p` | | +| Monarch | `0x00004000` | `@m` | | +| AlArqas | `0x00008000` | | Society town channels | +| Holtburg | `0x00010000` | | | +| Lytelthorpe | `0x00020000` | | | +| Nanto | `0x00040000` | | | +| Rithwic | `0x00080000` | | | +| Samsur | `0x00100000` | | | +| Shoushi | `0x00200000` | | | +| Yanshi | `0x00400000` | | | +| Yaraq | `0x00800000` | | | +| CoVassals | `0x01000000` | `@c` | | +| AllegianceBroadcast | `0x02000000` | `@a` | "Tell All Allegiance Members" | +| FellowBroadcast | `0x04000000` | | Leader-to-fellowship | +| SocietyCelHan | `0x08000000` | | Celestial Hand society | +| SocietyEldWeb | `0x10000000` | | Eldrytch Web | +| SocietyRadBlo | `0x20000000` | | Radiant Blood | +| Olthoi | `0x40000000` | | Olthoi-only | + +### ChatType (high-level category sent with each message) + +`ACE.Entity.Enum.ChatType`: Undef, Allegiance, General, Trade, LFG, Roleplay, Society, SocietyCelHan, SocietyEldWeb, SocietyRadBlo, Olthoi. + +### ChatMessageType (color / filter / tab-routing) + +A 32-bit enum used in almost every outbound UI message to colorize and categorize a line: + +| Value | Name | Color/Behavior | +|-------|------|----------------| +| `0x00` | Broadcast | Default — shopkeepers, allegiance MOTD, crafting results, mana-stone messages | +| `0x01` | AllChannels | | +| `0x02` | Speech | "Name says, …" | +| `0x03` | Tell | Incoming /tell | +| `0x04` | OutgoingTell | "You tell …" | +| `0x05` | System | Red warning text | +| `0x06` | Combat | Damage lines | +| `0x07` | Magic | Enchantment applied/resisted | +| `0x08` | Channel | Light pink — @admin, @audit, @av1-3, @sent | +| `0x09` | ChannelSend | | +| `0x0A` | Social | Bright yellow | +| `0x0B` | SocialSend | Light yellow | +| `0x0C` | Emote | Creature emote text (via `HearSpeech`/`HearRangedSpeech`) | +| `0x0D` | Advancement | Level-up / skill-gain | +| `0x0E` | Abuse | Light cyan | +| `0x0F` | Help | Red — urgent-help echo | +| `0x10` | Appraisal | Assess failure | +| `0x11` | Spellcasting | Spell syllable text (`Malar Quaril`, etc) | +| `0x12` | Allegiance | Allegiance chat | +| `0x13` | Fellowship | Bright yellow fellowship chat | +| `0x14` | WorldBroadcast | Green | +| `0x15` | CombatEnemy | Red | +| `0x16` | CombatSelf | Pink | +| `0x17` | Recall | | +| `0x18` | Craft | | +| `0x19` | Salvaging | Green | +| `0x1F` | AdminTell | Bright yellow | + +### Wire messages that drive the chat window + +1. **`GameEventChannelBroadcast` (0xF7B0 / 0x0147)** — 12+ bytes: `uint chatChannel | string16L senderName | string16L messageText`. The sender name is empty when the server is echoing your own message back to you, triggering "You say…" rendering. +2. **`GameEventTell` (0xF7B0 / 0x02BD)** — `string16L messageText | string16L senderName | guid senderID | guid targetID | uint chatMessageType | uint extraZero`. +3. **`GameMessageSystemChat` (0xF7E0)** — `string16L message | int chatMessageType`. The main path for system notifications. +4. **`GameMessageHearSpeech` (0x02BB)** — `string16L messageText | string16L senderName | uint senderID | uint chatMessageType`. Creature/player speech in the 3D world. +5. **`GameMessageHearRangedSpeech` (0x02BC)** — same layout; broadcast to a wider radius (quest giver yell). +6. **`GameMessageTurbineChat` (0xF7DE)** — the Turbine overlay for cross-server chat (General, Trade, LFG, Roleplay, Society). Has a nested blob format: `uint bytesToFollow | ChatNetworkBlobType type | uint dispatchType | uint 1 | uint 0x000B00B5 | uint 1 | uint 0x000B00B5 | uint 0 | uint nestedBytes | uint channel | byteLen-or-u16Len+UTF16 senderName | byteLen-or-u16Len+UTF16 message | uint 0x0C | guid senderID | uint 0 | ChatType`. +7. **`GameEventSetTurbineChatChannels` (0x0295)** — tells the client which Turbine channels to enable (sent at login). + +### Chat window layout (inferred from message types and ACE logging) + +- **Top bar**: tab strip (`General`, `Combat`, `Chat`, `1`, `2`, …). Each tab has a filter mask over ChatMessageType values (ChatDisplayMask / ChatFilterMask enums). +- **Text scroll area**: ring buffer of rendered lines. Each line carries its source ChatMessageType so the tab filter can decide to show it; font color is derived from the ChatMessageType too. +- **Input line** at the bottom: mode byte (`/s` say, `/t name` tell, `/f` fellow, `/a` allegiance, `@channel` for named channels, raw text = last-used mode). The client sends `GameActionChatChannel` on enter. +- **Filters**: Character Options (`ListenToAllegianceChat` 0x1B, `ListenToGeneralChat` 0x23, `ListenToTradeChat` 0x24, `ListenToLFGChat` 0x25, `ListenToRoleplayChat` 0x26, `ListenToSocietyChat` 0x2E, `ListenToPKDeathMessages` 0x34) control what passes through the filter. + +### Strings observed + +Routing strings from the big server-message switch in `chunk_00570000.c`: + +- `L"You have entered the %s channel.\n"` (case `0x51b`) +- `L"You have left the %s channel.\n"` (case `0x51c`) +- `L"That channel doesn\'t exist."` +- `L"You can\'t use that channel."` +- `L"You\'re already on that channel."` +- `L"You\'re not currently on that channel."` +- `L"Message Blocked: %s"` (case `0x51f` — squelched sender) +- `L"%s has been added to the list of people you can hear.\n"` +- `L"You are now deaf to player\'s screams.\n"` +- `L"You can hear all players once again.\n"` + +### C# port sketch — `ChatWindow` + +```csharp +namespace AcDream.UI.Panels; + +public enum ChatTabKind { General, Combat, Chat1, Chat2, Chat3, Chat4 } + +public sealed class ChatTab +{ + public string Label { get; init; } + public ChatDisplayMask VisibleTypes { get; set; } // bitmask over ChatMessageType values 0x00-0x1F + public Channel ChannelFilter { get; set; } // bitmask over Channel enum + public readonly RingBuffer Lines = new(2048); + public bool UnreadBadge { get; set; } +} + +public readonly record struct ChatLine( + ulong Timestamp, + ChatMessageType MessageType, + Channel Channel, + uint SenderGuid, + string SenderName, + string Text, + Color Color); + +public sealed class ChatWindow : IPanel +{ + public IReadOnlyList Tabs { get; } + public int ActiveTabIndex { get; set; } + public ChatInputMode InputMode { get; set; } // Say / Tell / Fellow / Allegiance / Named("AlArqas") + public string PendingTellTarget { get; set; } + public uint SubscribedChannels { get; private set; } // bitmask + + // Drivers (server→UI events) + public void OnChannelBroadcast(Channel ch, string sender, string text); + public void OnTell(string text, string sender, uint senderGuid, uint targetGuid, ChatMessageType type); + public void OnSystemChat(string text, ChatMessageType type); + public void OnHearSpeech(string text, string sender, uint senderGuid, ChatMessageType type); + public void OnTurbineChat(uint channel, string sender, string text, ChatType chatType); + public void OnSetTurbineChannels(uint[] channels); + + // Outbound (UI→server) + public void Send(string text); // routes through InputMode to GameActionChatChannel / Say / Tell etc. +} +``` + +## 2. Character Attributes panel + +**Location**: `chunk_00470000.c::FUN_0047ba70` (the monolithic "character sheet" builder, starting at line ~8280 with "Gender:" and running through skills). Strings rendered via `FUN_0040b8f0`. The sheet body is a single giant switch-style render; each row is emitted via `FUN_0046f670(1,0)` row-break plus a field/value pair. + +### Layout (from the render order in `FUN_0047ba70`) + +The sheet renders top-down: + +1. **Gender** (strings table at `PTR_DAT_0081a1c4` — "Male" / "Female" / "Unknown"). +2. **Heritage** (strings at `PTR_DAT_0081a1d0`, 5 entries — Aluvian, Gharu'ndim, Sho, Viamontian, Umbraen/Empyrean). +3. **Starting Town** (strings at `PTR_u_Holtburg_0081a1e4`, 4 entries — Holtburg, Shoushi, Yaraq, Sanamar/Rithwic per heritage). +4. **"Attributes" header** (row break, layout resource `0x100000fe`). +5. **10 attribute rows** (loop `iVar10 < 10`), each using formatter resource `0x100002fc` (label) + `0x100002fd` (value): + - Case 0 — Strength (value at `player + 0x184`) + - Case 1 — Endurance (value at `+0x188`) + - Case 2 — Coordination (value at `+0x18c`) + - Case 3 — Quickness (value at `+0x190` = 400 dec) + - Case 4 — Focus (value at `+0x194`) + - Case 5 — Self (value at `+0x198`) + - Case 6 — Health (max via `FUN_005df4c4`, curr via `FUN_005c4990(2)`) + - Case 7 — Stamina (`FUN_005c4990(2)` → returns stamina) + - Case 8 — Mana (`FUN_005c4990(6)`) + - Case 9 — Skill Credits (value at `+0x1b8`) +6. **4 skill-category headers** (loop `iStack_10 < 4`): + - Case 0 — "Specialized Skills" (filter rank `iStack_14 = 3`) + - Case 1 — "Trained Skills" (filter rank `2`) + - Case 2 — "Useable Untrained Skills" (filter rank `1`, show only if skill.usableWithoutTraining) + - Case 3 — "Unuseable Untrained Skills" (filter rank `1`, inverse) +7. **Per-skill rows** — walk the skill linked list at `player + 0x1c4`; for each node read skill-id (`+0x20`), category (`FUN_005c4be0`), value (`FUN_005c5b30`), and emit row only if `category == iStack_14`. + +### Short form (from `chunk_005C0000.c::FUN_005c9d70` — tooltip dispatch) + +A small helper that picks an attribute label by 1-based switch: +1 → Strength, 2 → Endurance, 3 → Quickness, 4 → Coordination, 5 → Focus, 6 → Self. + +Descriptions from `FUN_005c9e10`: +- Strength: "Measures your character's muscular power." +- Endurance: "Measures how healthy your character is." +- Quickness: "Measures how fast your character is." +- Coordination: "Measures your character's reflexes" +- Focus: "Measures your character's mind and senses." +- Self: "Measures your character's willpower." + +Vitals from `FUN_005c9eb0`: Maximum Health, Health, Maximum Stamina, Stamina, Maximum Mana, Mana. + +Vital descriptions (`FUN_005c9f50`): +- Health: "(Endurance/2)\nIf you run out of health, you will die!" +- Stamina: "(Endurance)\nAffects your actions and movement." +- Mana: "(Self)\nAffects how much magic you can cast." + +### Wire drivers + +- **`GameMessagePrivateUpdateAttribute` (0x02E3)** — `uint sequence | PropertyAttribute attr | uint ranks | uint startingValue | uint xpSpent`. Sent per-attribute on gain. 21 bytes. +- **`GameMessagePrivateUpdateVital` (0x02E7)** — `uint sequence | PropertyAttribute2ndLevel vital | uint ranks | uint startingValue | uint xpSpent | uint current`. 25 bytes. +- **`GameMessagePrivateUpdateAttribute2ndLevel` (0x02E9)** — max-value refresh for vitals. +- **`GameEventPlayerDescription` (0xF7B0 / 0x0013)** — initial full character dump at login; includes all attributes, vitals, skills, equipped objects, clothing/hair metadata. + +### C# port sketch — `AttributesPanel` + +```csharp +public enum PropertyAttribute { Strength=1, Endurance, Quickness, Coordination, Focus, Self } +public enum PropertyAttribute2ndLevel { MaxHealth=1, Health, MaxStamina, Stamina, MaxMana, Mana } + +public readonly record struct AttributeStats(uint Ranks, uint StartingValue, uint XpSpent) +{ + public uint Current => StartingValue + Ranks; +} + +public readonly record struct VitalStats(uint Ranks, uint StartingValue, uint XpSpent, uint Current) +{ + public uint Max => StartingValue + Ranks; +} + +public sealed class AttributesPanel : IPanel +{ + public Gender Gender { get; set; } + public HeritageGroup Heritage { get; set; } + public StartingTown StartingTown { get; set; } + + public AttributeStats Strength, Endurance, Coordination, Quickness, Focus, Self; + public VitalStats Health, Stamina, Mana; + public uint SkillCredits; + + public void OnUpdateAttribute(PropertyAttribute attr, AttributeStats stats); + public void OnUpdateVital(PropertyAttribute2ndLevel vital, VitalStats stats); + public void OnUpdateSkillCredits(uint credits); // via PrivateUpdatePropertyInt +} +``` + +## 3. Skills panel + +Shares the character sheet with Attributes (both live inside `FUN_0047ba70`). The 4 skill categories ("Specialized Skills", "Trained Skills", "Useable Untrained Skills", "Unuseable Untrained Skills") appear below the attribute block. + +### Skill advancement classes (AdvancementClass enum) + +1 = Untrained, 2 = Trained, 3 = Specialized. + +### Display rules (from the decompile) + +- Each skill row is emitted as: `[category-header]\nskill-namevalue`. +- Specialized skills show in cyan/white, Trained in white, Untrained-useable in gray, Untrained-unuseable hidden by default (still iterated). +- "Useable Untrained" means the skill has `UsableWithoutTraining` property = true (e.g. Run, Jump, Loyalty) AND `AdvancementClass == Untrained`. +- For each skill row the decompile also looks up a hashtable at `iStack_4 + 8` (keyed by `skillId % bucketCount`) — likely the skill hash used for secondary display metadata (icon, current training rank). + +### Buy/Train interaction + +The sheet also has buttons (not in this render function but registered via event callbacks earlier) for Train and Specialize — they cost skill credits (displayed in Attributes section) and XP. Success/failure echoes via `GameMessageSystemChat`. Strings seen in `chunk_00570000.c`: + +- `L"You have failed to alter your skill.\n"` +- `L"Your %s skill is already untrained!\n"` +- `L"Although you cannot untrain your %s skill, you have succeeded in recovering all the experience you had invested in it.\n"` +- `L"Although your augmentation will not allow you to untrain your %s skill, you have succeeded in recovering all the experience you had invested in it.\n"` + +### Wire drivers + +- **`GameMessagePrivateUpdateSkill` (0x02DD)** — `uint sequence | PropertySkill skill | uint ranks | ushort adjustPP | uint advancementClass | uint xpSpent | uint initLevel | uint resistanceAtLastCheck | double lastUsedTime`. 37 bytes. +- **`GameMessagePrivateUpdateSkillLevel` (0x02DF)** — periodic rank update. + +### C# port sketch — `SkillsPanel` + +```csharp +public enum SkillAdvancementClass { Undef=0, Inactive=1, Untrained=2, Trained=3, Specialized=4 } + +public readonly record struct SkillState( + uint Skill, + uint Ranks, + SkillAdvancementClass Class, + uint XpSpent, + uint InitLevel, + uint ResistanceAtLastCheck, + double LastUsedTime) +{ + public uint Current => InitLevel + Ranks; +} + +public sealed class SkillsPanel : IPanel +{ + public readonly Dictionary Skills = new(); + public void OnUpdateSkill(SkillState s); + public void RaiseTrainSkill(Skill s); // sends GameAction TrainSkill + public void RaiseSpecializeSkill(Skill s); + public void RaiseUntrainSkill(Skill s); + + public IEnumerable Specialized => Skills.Values.Where(s => s.Class == SkillAdvancementClass.Specialized); + public IEnumerable Trained => Skills.Values.Where(s => s.Class == SkillAdvancementClass.Trained); + public IEnumerable UntrainedUsable; // needs Skill metadata lookup +} +``` + +## 4. Spell panel + +**Location**: `chunk_004C0000.c` — the spell-bar code. Entry point `FUN_004c68f0` (the spellbar bind function) at address `0x004C68F0`. + +### Spell tabs (7 tabs) + +From `FUN_004c6500` (returns the active tab index from a mode byte at `spellbar + 0x5fc.+0x6d8`): + +| Tab | Value | Icon resource ID | Represents | +|-----|-------|------------------|------------| +| 0 | Life | `0x100000aa` (button) / `0x100000a3` (icon) | Life magic | +| 1 | Creature | `0x100000ab` / `0x100000a4` | Creature Enchantment | +| 2 | Item | `0x100000ac` / `0x100000a5` | Item Enchantment | +| 3 | War | `0x100000ad` / `0x100000a6` | War Magic | +| 4 | Portal | `0x100000ae` / `0x100000a7` | Portal Magic (recall/summon) | +| 5 | (unused retail) | `0x100000af` / `0x100000a8` | | +| 6 | (unused retail) | `0x100000b0` / `0x100000a9` | | +| 7 | Void | `0x100005c3` / `0x100005c2` | Void Magic (post-ToD) | + +Each tab is registered into the spellbar via `FUN_004c6680(spellbar, buttonId, iconId, tabIndex)`. + +### Per-tab spell slot storage + +The spellbar keeps 7 slots per tab. Slot storage lives at `spellbar + 0x634 + (tabIndex * 0x1c)` (28 bytes per tab: 7 slots × 4-byte spell IDs = 28). + +### Ready/selected spell + +- `spellbar + 0x620` = currently-selected spell's container object ID. +- `spellbar + 0x624` = spell ID of the selected spell. +- `iVar4 * 0x1c + 0x640 + spellbar` = per-tab "is this tab the current ready tab?" flag byte. + +### Cast interaction (`FUN_004c78XX` region, around line 5040-5210) + +- User double-clicks a spell slot → `FUN_004c7c50` fires. +- Single-click just highlights via `FUN_006a9640` and shows "Double-click to cast this spell" tooltip (`L"%hs\nDouble-click to cast this spell"`). +- On cast button press: validates target via `DAT_00871e54` (current selection), checks spell-can-target-self via `FUN_00588350`, and if valid formats `L"CAST %hs"` + optional `L" on %s"` and sends via `FUN_005abb30`. +- Error paths: + - `L"Select a spell to cast"` (no slot filled, but spells known) — `FUN_00407e40` tooltip bubble. + - `L"You have no spells ready to cast"` (no tab populated at all). + - `L"You must select a target for %hs"` (no selection). + - `L"You must select an appropriate target for %hs"` (wrong target type). + - `L"You cannot cast this spell upon yourself"` (self-cast when not allowed). + - `L"This spell would require a target"` / `L"This spell would require no target"` (target-present/absent mismatch). + - `L"Cannot cast spell on a stack of items."` + - `L"This spell cannot be cast on %s"`. + +### Wire drivers + +- **`GameEventMagicUpdateSpell` (0x02C1)** — adds a known spell to the spellbook. +- **`GameEventMagicRemoveSpell` (0x01A8)** — removes. +- **`GameEventMagicUpdateEnchantment` (0x02C2)** — active enchantment on player (for the spell-bar enchantment overlay). +- **`GameEventMagicRemoveEnchantment` (0x02C3)** / **Multiple** (0x02C5) / **Purge** (0x02C6) / **Dispel** (0x02C7–8) / **PurgeBad** (0x0312). +- **`GameAction` CastUnTargetedSpell / CastTargetedSpell** (outbound) — triggers the cast. + +### C# port sketch — `SpellBarPanel` + +```csharp +public enum SpellSchool { Life, CreatureEnchantment, ItemEnchantment, War, Portal, Slot5, Slot6, Void } +public const int SlotsPerTab = 7; + +public sealed class SpellBarPanel : IPanel +{ + public readonly Dictionary Slots = new(); + public SpellSchool ActiveTab { get; set; } + public int SelectedSlot { get; set; } + public uint? ReadySpellId { get; private set; } + public uint? ReadySpellContainerId { get; private set; } + + public SpellBarPanel() + { + foreach (var s in Enum.GetValues()) + Slots[s] = new uint?[SlotsPerTab]; + } + + public void OnLearnedSpell(uint spellId, SpellSchool school); + public void OnUnlearnedSpell(uint spellId); + public void AssignToSlot(SpellSchool tab, int slot, uint spellId); + + public bool TryCast(uint? targetId, out string error); // emits "Select a spell to cast" etc. +} + +public sealed class SpellbookPanel : IPanel +{ + // Full known-spell list (filterable by school / level) + public readonly HashSet KnownSpells = new(); + public void OnUpdateSpell(uint spellId) => KnownSpells.Add(spellId); + public void OnRemoveSpell(uint spellId) => KnownSpells.Remove(spellId); +} + +public sealed class EnchantmentPanel : IPanel +{ + // Active buffs/debuffs with countdown + public readonly Dictionary Active = new(); + public void OnAdd(Enchantment e); + public void OnRemove(uint spellId); + public void OnPurgeAll(); + public void OnPurgeBadOnly(); +} +``` + +## 5. Paperdoll / Equipment panel + +**Location**: `chunk_004A0000.c::FUN_004a5200` is the slot-router that emits the correct "Drag X here to wear them" tooltip and handles drag-drop assignment. Slot widget pointers are stored at fixed offsets from the panel `this` pointer (`param_1`). + +### Slot table (derived from `FUN_004a5200` offsets) + +| Offset | Slot | Empty-tooltip string | EquipMask flag | +|--------|------|----------------------|----------------| +| `+0x604` | Necklace | `L"Drag necklaces here to wear them"` | `NeckWear 0x00008000` | +| `+0x608` | Left Bracelet | `L"Drag bracelets here to wear them"` | `WristWearLeft 0x00010000` | +| `+0x610` | Right Bracelet | `L"Drag bracelets here to wear them"` | `WristWearRight 0x00020000` | +| `+0x60C` | Left Ring | `L"Drag rings here to wear them"` | `FingerWearLeft 0x00040000` | +| `+0x614` | Right Ring | `L"Drag rings here to wear them"` | `FingerWearRight 0x00080000` | +| `+0x618` | Weapon | `L"Drag weapons here to wield them"` | `MeleeWeapon 0x00100000` | +| `+0x61C` | Missile Ammo | `L"Drag missile ammunition here to wield it"` | `MissileAmmo 0x00800000` | +| `+0x620` | Shield | `L"Drag shields here to wield them"` | `Shield 0x00200000` | +| `+0x624` | Clothing (Shirt) | `L"Drag clothing items here to wear them"` | `ChestWear 0x00000002` | +| `+0x628` | Clothing (Pants) | `L"Drag clothing items here to wear them"` | `AbdomenWear 0x00000004` | +| `+0x62C` | Trinket | `L"Drag trinkets here to activate them"` | `TrinketOne 0x04000000` | +| `+0x630` | Cloak | `L"Drag cloaks here to activate them"` | `Cloak 0x08000000` | +| `+0x634` | Aetheria Blue (Sigil1 — Lyr) | `L"Drag a Blue Aetheria sigil here to activate it"` | `SigilOne 0x10000000` | +| `+0x638` | Aetheria Yellow (Sigil2 — Kor) | `L"Drag a Yellow Aetheria sigil here to activate it"` | `SigilTwo 0x20000000` | +| `+0x63C` | Aetheria Red (Sigil3 — Tem) | `L"Drag a Red Aetheria sigil here to activate it"` | `SigilThree 0x40000000` | +| `+0x640` | Head | `L"Drag head items here to wear them"` | `HeadWear 0x00000001` | +| `+0x644` | Chest Armor | `L"Drag chest items here to wear them"` | `ChestArmor 0x00000200` | +| `+0x648` | Abdomen Armor | `L"Drag abdomen items here to wear them"` | `AbdomenArmor 0x00000400` | +| `+0x64C` | Upper Arm Armor | `L"Drag upper arm items here to wear them"` | `UpperArmArmor 0x00000800` | +| `+0x650` | Lower Arm Armor | `L"Drag lower arm items here to wear them"` | `LowerArmArmor 0x00001000` | +| `+0x654` | Glove Armor | `L"Drag glove items here to wear them"` | `HandWear 0x00000020` | +| `+0x658` | Upper Leg Armor | `L"Drag upper leg items here to wear them"` | `UpperLegArmor 0x00002000` | +| `+0x65C` | Lower Leg Armor | `L"Drag lower leg items here to wear them"` | `LowerLegArmor 0x00004000` | +| `+0x660` | Foot Armor | `L"Drag foot coverings here to wear them"` | `FootWear 0x00000100` | +| `+0x664` | (cross-slot state) | — | — | + +Additional layout widgets around `+0x668` (toggle to armor-view), `+0x674` (toggle to clothing-view), `+0x670` (paperdoll 3D model preview), `+0x678` (sidebar with stats). + +### Armor/Clothing view toggle + +`FUN_004a5fa0` at line 2660: the paperdoll has a mode toggle between "clothing view" (shows shirt/pants/jewelry/weapons) and "armor view" (shows all 10 armor slots). The mode is set by event `0x100005be` (button id). When toggled, slot widget visibility is flipped via `(**)(slotWidget + 0x18))(showFlag)` calls — classic "hide 8 widgets, show 10 widgets" pattern. + +### Drag-drop behavior + +- Drop onto an empty slot: tooltip cycles through the "Drag X here to wear them" messages. +- Drop onto a filled slot OR double-click on the paperdoll slot: emits "(item name) (%s)\nDouble-click to %s" where the inserted strings are either ("worn", "take off") for armor/clothing or ("wielded", "unwield") for weapons/shield (line 2619-2624 branch `bVar1`). +- Failed drag (wrong item type for slot): `L"You can\'t put that item there"` via `FUN_004a4de0`. +- Error branches come from the server: `L"You're already wearing a helm."`, `L"You're already wearing chest armor."`, …, and similar for every body region (chunk_00560000.c line 1199-1394). + +### 3D paperdoll model preview + +`FUN_004a4c70` (starting line ~2150) — loads a preview scene at widget `+0x68c` (a rendered 3D object overlay), placing a scaled copy of the character's visual (ObjDesc) for live armor/weapon visualization. When an item is dragged onto a slot, the server sends an updated `GameMessageObjDescEvent` which triggers a repaint here. + +### Wire drivers + +- **`GameMessageObjDescEvent` (0xF625)** — full object-description (clothing base + palette swatches + hair) refresh. Any slot change triggers this on every nearby player. +- **`GameEventWieldObject` (0x0023)** — confirms that an equip request succeeded. Contains the item's guid + new wield location. +- **`GameEventInventoryPutObjInContainer` (0x0022)** — inverse: item moved back to backpack. +- **`GameActionPutItemInContainer` / `GameActionWieldItem`** — outbound equip/unequip requests. + +### C# port sketch — `PaperdollPanel` + +```csharp +public sealed class PaperdollPanel : IPanel +{ + // 25 slots, keyed by EquipMask bit + public readonly Dictionary Equipment = new(); + + // The visual 3D model + public ObjDescSnapshot PaperdollObjDesc { get; set; } + + public PaperdollViewMode View { get; set; } = PaperdollViewMode.Clothing; + + public void OnWieldObject(uint itemGuid, EquipMask location); + public void OnUnwieldObject(uint itemGuid); + public void OnObjDescUpdate(ObjDescSnapshot desc); + + public bool TryDragEquip(uint itemGuid, EquipMask targetSlot, out string errorString); + public bool TryDoubleClickUnequip(EquipMask slot); +} + +public enum PaperdollViewMode { Clothing, Armor } +``` + +The slot table MUST match the offsets above exactly when porting drag-drop dispatch — the retail client dispatches via offset comparisons, not enum values. + +## 6. Inventory panel + +The inventory panel is heavily intertwined with the paperdoll (same window in retail) and with containers (sub-windows when you open a pack). Inventory code is scattered across `chunk_004B0000.c` (container list renderer), `chunk_004E0000.c` (container placement), and `chunk_00580000.c` (item-use and pickup dispatch). + +### Layout + +- **Side tabs**: Main Pack + 6 numbered Side Packs (up to 7 packs total, each up to 24 items). Main pack = character's base container. +- **Grid**: fixed-size icon grid inside each pack. Items beyond visible range use a scroll-arrow or "Page X" header (see `L"Page %d"` format at chunk_004B0000.c lines 2881/2903). +- **Footer**: Burden / capacity text. Burden status strings come from `chunk_00560000.c`: `L"You are encumbered!"` (line 157), `L"You are severely encumbered!"` (163). + +### Key interactions (from `chunk_00580000.c` dispatch) + +- **Right-click**: context menu with Use / Examine / Split / Drop / Give options. +- **Double-click**: `GameActionUseItem` (equips or activates). +- **Left-click drag**: move between slots. If cross-container, sends `GameActionPutItemInContainer`. +- **Drag to paperdoll**: equip. +- **Drag to ground**: drop (`GameActionDropItem`). +- **Drag to NPC**: give (`GameActionGiveObjectRequest`). +- **Shift-click on stack**: split (`GameActionStackableSplit`). + +### Error strings (partial) + +- `L"You can't pick that up!"`, `L"You are too encumbered to carry that!"`, `L"You cannot pick up more of that item!"` (chunk_00570000 line 1849-3018). +- `L"You must first pick up the %s"`, `L"The %s is locked"`, `L"You must open the %s first"`, `L"Cannot give %s to %s"`, `L"Move cancelled"`, `L"You cannot do that in mid air"`. +- `L"The destination stack is already full."`, `L"You cannot merge different types of items."`, `L"You cannot merge items while they are being traded."`. +- `L"Cannot place container in item list"`, `L"Cannot place item in container list"`, `L"Already attempting to place %s here"`, `L"The %s cannot accept items"`. +- `L"You are not carrying the %s"` (line 4826 in chunk_004B0000). + +### Stack / split + +The split UI uses a numeric entry dialog. Split-related errors: +- `L"Cannot split the stack to sell it"`, `L"Cannot split the stack for dwelling costs"`, `L"The %s can't be split"`, `L"You must split the stack before selling it."`. + +### Wire drivers + +- **`GameMessageCreateObject` (0xF745)** — the universal "object exists, here's its data" — inventory items arrive through this. +- **`GameEventInventoryPutObjInContainer` (0x0022)**, **`GameEventItemServerSaysContainId`**, **`GameEventItemServerSaysMoveItem`** — confirm server-side state after an inventory action. +- **`GameMessageSetStackSize` (0x0197)** — stack merged/split result. +- **`GameMessageInventoryRemoveObject` (0x0024)** — item deleted. +- **`GameMessagePickupEvent` (0xF74A)** — outbound, client picking up a ground object. +- **`GameEventViewContents` (0x0196)** — sent when client opens a container (includes full item list). + +### C# port sketch — `InventoryPanel` + +```csharp +public sealed class InventoryItem +{ + public uint Guid; + public uint IconResourceId; + public string Name; + public uint StackSize; + public uint Burden; + public uint Value; + public EquipMask ValidLocations; + public ItemType Type; + public uint ContainerGuid; // who holds us + public int PlacementIndex; + public IsEquipped Equipped; +} + +public sealed class InventoryPanel : IPanel +{ + public uint CharacterGuid { get; } + public readonly Dictionary Items = new(); // by GUID + public readonly List OpenContainerStack = new(); // Main + side packs currently viewed + + public int SelectedTab { get; set; } + public int BurdenCurrent { get; private set; } + public int BurdenMax { get; private set; } + public BurdenStatus Status { get; private set; } // Fine, Encumbered, SeverelyEncumbered + + public void OnCreateObject(InventoryItem item); + public void OnRemoveObject(uint guid); + public void OnStackSize(uint guid, uint newSize); + public void OnPutInContainer(uint itemGuid, uint newContainer, int placement); + public void OnViewContents(uint containerGuid, InventoryItem[] contents); + + public void RaiseMoveItem(uint guid, uint destContainer, int placement); + public void RaiseSplitItem(uint guid, uint newSize); + public void RaiseDropItem(uint guid); + public void RaiseGiveItem(uint guid, uint targetGuid); +} + +public enum BurdenStatus { Fine, Encumbered, SeverelyEncumbered } +``` + +## 7. Quickbar + +The retail client has a 10-slot quickbar bound to **1–0 keys**, visible along the bottom-center of the screen. The decompiled binding code resides near the main-window input dispatch; I did not find the exact function address during this sweep (would be in a chunk around `0x004D0000` given the `"ID_InputMap_*"` strings in `chunk_004D0000.c` line 4808, e.g. `"ID_InputMap_CameraAlternateControls"`). + +### Behaviors + +- Slots accept any item from inventory or any spell from the spellbar. +- On slot-press: if item → `GameActionUseItem(guid)`; if spell → equivalent of double-clicking that spell slot. +- Shift+number rearranges; ctrl+number clears. (Standard AC layout.) + +### C# port sketch — `Quickbar` + +```csharp +public enum QuickbarAction { None, UseItem, CastSpell, PlayMacro } + +public sealed class QuickbarSlot +{ + public QuickbarAction Action; + public uint Target; // item-guid, spell-id, or macro-id + public uint IconId; +} + +public sealed class Quickbar : IPanel +{ + public readonly QuickbarSlot[] Slots = new QuickbarSlot[10]; + public int ActiveBar { get; set; } // retail has only one, but plan for plugin expansion + public void Press(int slotIndex); // resolve action → dispatch through Inventory/SpellBar + public void Assign(int slotIndex, QuickbarSlot slot); +} +``` + +## 8. Allegiance panel + +**Location**: rendering lives in ACE code as an `AllegianceProfile` struct sent inside `GameEventAllegianceUpdate (0x0020)`; the client side is in a chunk I did not fully decompile in this sweep (would be around `0x004B`/`0x004C`). + +### Content rendered + +- **Monarch** name + title. +- **Patron** (your direct superior) — may be null. +- **Vassals** — list of direct reports with level, gender, online/offline. +- Rank (your depth in the tree), allegiance name, MOTD (if set), officer level. +- Recruited count, percentage breakdown. +- Bans and officer list (if you're monarch). + +### Events / strings + +- `L"You're already sworn your Allegiance"` / `L"You are not in an allegiance!"` (chunk_00570000 lines 1527/1541). +- `L"No patron from which to break!"`, `L"Your Allegiance has been dissolved!"`. +- `L"You have been teleported too recently!"` (recall-related). +- `L"That is an invalid officer level."`. +- `L"Your allegiance is currently: %s."` / `L"Your allegiance is now: %s."`. +- `L"Your allegiance name has been cleared."`. +- `L"Banned Characters: "` (list header). +- `L"You are banned from %s's allegiance!"`. +- `L"Allegiance information for %hs%s:\n"` (chunk_00560000 line 7245 — the `@allegiance info` dump). + +### Wire drivers + +- **`GameEventAllegianceUpdate` (0x0020)** — full allegiance tree + rank + profile. +- **`GameEventAllegianceLoginNotification` (0x027A)** — "Vassal X is online" popups. +- **`GameEventAllegianceInfoResponse` (0x027C)** — response to `@allegiance info`. +- **`GameEventAllegianceAllegianceUpdateDone` (0x01C8)** — action-complete marker. +- **`GameEventAllegianceUpdateAborted` (0x0003)**. + +### C# port sketch — `AllegiancePanel` + +```csharp +public sealed class AllegianceMember +{ + public uint Guid; + public string Name; + public int Level; + public Gender Gender; + public HeritageGroup Heritage; + public bool Online; + public uint PatronGuid; + public int Rank; // depth + public AllegianceOfficerLevel OfficerLevel; + public List Vassals = new(); +} + +public sealed class AllegiancePanel : IPanel +{ + public AllegianceMember Monarch; + public AllegianceMember Patron; + public List Vassals; + public string AllegianceName; + public string AllegianceMotd; + public uint MyRank; + public AllegianceOfficerLevel MyOfficerLevel; + public void OnAllegianceUpdate(AllegianceMember root, AllegianceMember self); + public void OnLoginNotification(string name, bool online); +} +``` + +## 9. Fellowship panel + +### Content rendered (from `GameEventFellowshipFullUpdate 0x02BE`) + +Each fellow line includes: + +- **Name** (string16L). +- **Level**. +- **Health/Stamina/Mana** max + current (shown as bars). +- **cpCached / lumCached** (undistributed exp and luminance shares). +- **ShareLoot flag** (0 or 0x10). + +Fellowship-level fields: + +- FellowshipName (up to 256 chars). +- FellowshipLeaderGuid. +- ShareXP / EvenShare / Open flags. +- IsLocked flag. +- DepartedMembers list (with cooldown timers). +- FellowshipLocks (post-ToD locks). + +### Interaction + +- Leader can recruit (drag player onto fellowship UI or `@fellow recruit PlayerName`). +- Leader can dismiss (`@fellow dismiss name`). +- Any member can quit (`@fellow quit`). +- Leader can disband the fellowship. + +### Strings observed + +- `L"You are unprepared to cast a spell"` (unrelated but appears in same switch). +- `L"You must be the leader of a Fellowship"`. +- `L"Your Fellowship is full"`. +- `L"That Fellowship name is not permitted"`. +- `L"You do not belong to a Fellowship."`. +- `L"This fellowship is locked; "` + name + `" cannot be recruited into the fellowship."` (line 2543). +- `L"The fellowship is locked, you were not added to the fellowship."`. +- `L"The fellowship is locked; you cannot open locked fellowships."`. + +### Wire drivers + +- **`GameEventFellowshipFullUpdate` (0x02BE)**. +- **`GameEventFellowshipUpdateFellow` (0x02C0)** — per-fellow incremental. +- **`GameEventFellowshipDisband` (0x02BF)**. +- **`GameEventFellowshipQuit` (0x00A3)**. +- **`GameEventFellowshipDismiss` (0x00A4)**. +- **`GameEventFellowshipFellowUpdateDone` (0x01C9)** / **`FellowshipFellowStatsDone` (0x01CA)**. + +### C# port sketch — `FellowshipPanel` + +```csharp +public sealed class Fellow +{ + public uint Guid; + public string Name; + public int Level; + public uint MaxHealth, MaxStamina, MaxMana; + public uint CurrentHealth, CurrentStamina, CurrentMana; + public uint CpCached; + public uint LumCached; + public bool ShareLoot; +} + +public sealed class FellowshipPanel : IPanel +{ + public string FellowshipName; + public uint LeaderGuid; + public bool ShareXP, EvenShare, Open, IsLocked; + public readonly Dictionary Members = new(); + public readonly Dictionary Departed = new(); + + public bool IAmLeader(uint myGuid) => myGuid == LeaderGuid; + + public void OnFullUpdate(FellowshipSnapshot snap); + public void OnUpdateFellow(Fellow f); + public void OnDisband(); + public void RaiseRecruit(string name); + public void RaiseDismiss(uint guid); + public void RaiseQuit(); + public void RaiseToggleShareXP(); +} +``` + +## 10. Macros panel + +Retail macros were stored client-side in `~/Documents/Asheron's Call/settings/(character).ppf` or equivalent, not sent through the wire protocol. The decompiled code I scanned did not contain a dedicated macro UI function in the chunks explicitly assigned here; it lives in a higher chunk (around `0x005D`–`0x005F` based on the config file saver strings). + +### Behaviors (from in-game retail memory) + +- `@save name` and `@load name` for macros (`L"Please use @help saveui for proper usage."` and `L"Please use @help loadui for proper usage."` are the help prompts, chunk_00570000 lines 208/270). +- A macro is a short script of actions: text chats, cast spell, use item, delay N seconds. +- Bound to quickbar slots or standalone hotkeys. +- Also included "Automation" — auto-select nearest monster and attack. + +### C# port sketch — `MacrosPanel` + +This is one of the panels where acdream's plugin API becomes crucial (the "scripting/macros" requirement in CLAUDE.md). + +```csharp +public enum MacroStepKind { Chat, CastSpell, UseItem, Delay, Wait, Attack, Emote, Custom } + +public sealed class MacroStep +{ + public MacroStepKind Kind; + public string Text; // for Chat/Emote + public uint TargetId; // for CastSpell/UseItem + public TimeSpan Duration; // for Delay/Wait + public string CustomId; // for plugin-provided step +} + +public sealed class Macro +{ + public string Name; + public int HotkeyIndex; + public List Steps = new(); +} + +public sealed class MacrosPanel : IPanel +{ + public readonly Dictionary Macros = new(); + public void Save(Macro m); + public void Delete(string name); + public void Execute(string name, IPluginHost host); // dispatches through plugin API +} +``` + +## 11. Options panel + +**Location**: the options panel writes into `GameActionSetSingleCharacterOption` (see `CharacterOption.cs`). The rendering chunk is around `0x005D`–`0x005F` (config / saved-UI layout chunks), not fully scanned here. + +### Options available + +From `CharacterOption.cs`, 55 named options split across **CharacterOptions1** (gameplay) and **CharacterOptions2** (display / filters). Some highlights: + +| Option | Bit group | Description | +|--------|-----------|-------------| +| AutoRepeatAttacks | 1 | Auto-attack | +| IgnoreAllegianceRequests | 1 | | +| IgnoreFellowshipRequests | 1 | | +| IgnoreAllTradeRequests | 1 | | +| DisableMostWeatherEffects | 1 | Graphics | +| AlwaysDaylightOutdoors | 2 | Graphics | +| LetOtherPlayersGiveYouItems | 1 | | +| KeepCombatTargetsInView | 1 | | +| Display3dTooltips | 1 | | +| AttemptToDeceiveOtherPlayers | 1 | | +| RunAsDefaultMovement | 1 | | +| StayInChatModeAfterSendingMessage | 1 | | +| AdvancedCombatInterface | 1 | | +| AutoTarget | 1 | | +| VividTargetingIndicator | 1 | | +| ShareFellowshipExpAndLuminance | 1 | | +| AcceptCorpseLootingPermissions | 1 | | +| ShareFellowshipLoot | 1 | | +| AutomaticallyAcceptFellowshipRequests | 1 | | +| SideBySideVitals | 1 | UI layout | +| ShowCoordinatesByTheRadar | 1 | | +| DisplaySpellDurations | 1 | | +| DisableHouseRestrictionEffects | 1 | | +| DragItemToPlayerOpensTrade | 1 | | +| ShowAllegianceLogons | 1 | | +| UseChargeAttack | 1 | | +| UseCraftingChanceOfSuccessDialog | 1 | | +| ListenToAllegianceChat | 1 | | +| AllowOthersToSeeYourDateOfBirth | 2 | | +| AllowOthersToSeeYourAge | 2 | | +| AllowOthersToSeeYourChessRank | 2 | | +| AllowOthersToSeeYourFishingSkill | 2 | | +| AllowOthersToSeeYourNumberOfDeaths | 2 | | +| DisplayTimestamps | 2 | | +| SalvageMultipleMaterialsAtOnce | 2 | | +| ListenToGeneralChat | 2 | | +| ListenToTradeChat | 2 | | +| ListenToLFGChat | 2 | | +| ListenToRoleplayChat | 2 | | +| AppearOffline | 2 | | +| AllowOthersToSeeYourNumberOfTitles | 2 | | +| UseMainPackAsDefaultForPickingUpItems | 2 | | +| LeadMissileTargets | 2 | | +| UseFastMissiles | 2 | | +| FilterLanguage | 2 | | +| ConfirmUseOfRareGems | 2 | | +| ListenToSocietyChat | 2 | | +| ShowYourHelmOrHeadGear | 2 | | +| DisableDistanceFog | 2 | Graphics | +| UseMouseTurning | 2 | | +| ShowYourCloak | 2 | | +| LockUI | 2 | | +| ListenToPKDeathMessages | 2 | | + +### Additional graphics/audio options (retail, not in protocol — client-local) + +- Resolution + windowed/fullscreen. +- Draw distance (both landblock chunks loaded and scenery density). +- Texture detail / vertex lighting / environment lighting toggles. +- Gamma / contrast. +- Master volume, music volume, speech volume, effects volume. +- Radar color theme, UI scale. + +### Wire drivers + +- **`GameActionSetSingleCharacterOption`** outbound on toggle. +- **`GameActionSetCharacterOption1Flag` / `Option2Flag`** bulk. +- The server persists and replays at login via `PlayerDescription`. + +### C# port sketch — `OptionsPanel` + +```csharp +public sealed class OptionsPanel : IPanel +{ + public readonly Dictionary Options = new(); + + public GraphicsOptions Graphics { get; set; } // client-local + public AudioOptions Audio { get; set; } // client-local + + public bool Get(CharacterOption opt) => Options.GetValueOrDefault(opt, false); + public void Set(CharacterOption opt, bool value); // writes back to server + persists locally + public void LoadFromDescription(uint options1, uint options2); +} +``` + +## 12. Login screen / server select + +### Login screen layout (from retail behavior) + +- Title banner (AC logo). +- Username field (masked). +- Password field (masked). +- Server selection dropdown (loaded from a `ServerList.ini`-style file). +- Play / Create Account / Exit buttons. +- Error strings: varying dialogs surface rejections from `GameMessageAccountBanned (0xF7C1)`, `CharacterError (0xF659)`. + +### Wire drivers + +- Initial **LoginRequest** → **ConnectRequest** → **ConnectResponse** (UDP handshake, see `docs/research/2026-04-12-movement-deep-dive.md` and `holtburger` session code). +- **`GameMessageCharacterList` (0xF658)** arrives after encrypted handshake — contains the account's 11-slot character list. +- **`GameMessageServerName` (0xF7E1)** — displayed at top of character select. +- **`GameMessageAccountBanned` (0xF7C1)** + **`GameMessageBootAccount`** — rejection paths. + +### C# port sketch — `LoginScreen` + +```csharp +public sealed class ServerEntry +{ + public string Name; + public string Host; + public int Port; + public int Population; + public bool Online; +} + +public sealed class LoginScreen : IPanel +{ + public List Servers; + public ServerEntry SelectedServer { get; set; } + public string Username { get; set; } + public string PasswordSecure { get; set; } // kept in pinned SecureString + + public event Action OnSubmit; + public void ShowError(string message); +} +``` + +## 13. Character select screen + +### Layout + +- Slot-list on left: 11 character slots (paid accounts), 1 free character always unlocked. +- 3D preview of selected character rotating on a pedestal (server sends appearance + equipped ObjDesc). +- Play / Create / Delete buttons. +- Server name at top. + +### Wire drivers + +- **`GameMessageCharacterList` (0xF658)** — the list. +- **`GameMessageCharacterError` (0xF659)** — generic error (name taken, account suspended, etc.). +- **`GameMessageCharacterDelete` (0xF655)** outbound. +- **`GameMessageCharacterCreate` (0xF656)** outbound. +- **`GameMessageCharacterCreateResponse` (0xF643)** server's answer. +- **`GameMessageCharacterEnterWorldRequest` (0xF7C8)** outbound when Play clicked. +- **`GameMessageCharacterEnterWorldServerReady` (0xF7DF)** when world server takes over. +- **`GameMessageCharacterRestore` (0xF7D9)** used for restoring deleted characters within the 1-hour grace window. + +### Strings seen + +From chunk_00470000 line 8280-8315 — this is the CREATION screen render, NOT select: +- `L"Gender: "` +- `L"Heritage: "` +- `L"Starting Town: "` +- Then the attribute preview as documented in panel #2. + +### C# port sketch — `CharacterSelectScreen` + +```csharp +public sealed class CharacterSlot +{ + public uint Guid; + public string Name; + public int Level; + public int SecondsRemainingToDelete; // for post-delete grace period + public ObjDescSnapshot Appearance; + public bool IsFreeSlot; +} + +public sealed class CharacterSelectScreen : IPanel +{ + public string ServerName; + public List Slots; + public CharacterSlot Selected; + + public event Action OnPlay; + public event Action OnDelete; + public event Action OnCreateNew; + + public void OnCharacterList(CharacterSlot[] slots); + public void OnError(string errorMessage); +} +``` + +## Cross-panel patterns for acdream + +Based on the decompilation sweep above, the client uses a small set of recurring abstractions. When porting, implement these once and reuse: + +1. **`IPanel`** interface with `Bind()` / `Unbind()` / `OnPacketReceived()` hooks. Every panel registers to its relevant `GameMessageOpcode` / `GameEventType` values; the dispatcher routes to them. +2. **`WidgetLookup`** — resource-ID → widget pointer. Mirrors retail's `FUN_00463c00` pattern. Each panel stores its child widgets in a record type with named fields. +3. **`RichTextBuilder`** — builds formatted rows of `[label][tab][value]\n` with style tokens. Replaces the `FUN_0040b8f0 / FUN_0046f670 / FUN_00402490` trio. +4. **`TooltipBuilder`** — sequence `Open → Emit → Close → Anchor`. Replaces `FUN_0042dc80 / FUN_0040b8f0 / FUN_0042cbe0 / FUN_004618a0 / FUN_0042e590`. +5. **`DragDropRouter`** — single entry point for pick/drop events with a slot-kind dispatch table. The paperdoll's 25-slot switch and inventory grid both route through this. +6. **`RowList`** (for skills, spells, fellows, allegiance vassals) — generic panel-internal renderer that iterates a filtered collection, emits one row per item, supports sorting/scrolling. +7. **`PropertyUpdateBus`** — the `PrivateUpdateProperty{Int,Int64,Bool,Float,String,DataID}` family of 0x02CD–0x02D8 messages all go through one bus that panels subscribe to by `PropertyId`. This is cleaner than 6 separate wiring paths. + +## Summary table — panel ↔ chunk ↔ wire message + +| Panel | Primary chunk / function | Main wire input | +|-------|--------------------------|-----------------| +| Chat window | `chunk_00570000.c` big switch | `ChannelBroadcast 0x0147`, `Tell 0x02BD`, `SystemChat 0xF7E0`, `HearSpeech 0x02BB`, `TurbineChat 0xF7DE` | +| Character Attributes | `chunk_00470000.c::FUN_0047ba70` + `chunk_005C0000.c::FUN_005c9d70/e10/eb0` | `PrivateUpdateAttribute 0x02E3`, `PrivateUpdateVital 0x02E7`, `PlayerDescription 0x0013` | +| Skills | shared with Attributes | `PrivateUpdateSkill 0x02DD`, `PrivateUpdateSkillLevel 0x02DF` | +| Spell bar | `chunk_004C0000.c::FUN_004c68f0 + 004c6500/6680/7c50` | `MagicUpdateSpell 0x02C1`, `MagicRemoveSpell 0x01A8`, enchantment events 0x02C2-8 | +| Paperdoll | `chunk_004A0000.c::FUN_004a5200 + 004a5fa0` | `ObjDescEvent 0xF625`, `WieldObject 0x0023`, `InventoryPutObjInContainer 0x0022` | +| Inventory | `chunk_004B0000.c` + `chunk_004E0000.c` + `chunk_00580000.c` | `ObjectCreate 0xF745`, `SetStackSize 0x0197`, `InventoryRemoveObject 0x0024`, `ViewContents 0x0196` | +| Quickbar | around `chunk_004D0000.c` (input map) | client-local (actions dispatch back to inventory/spell) | +| Allegiance | not scanned here | `AllegianceUpdate 0x0020`, `AllegianceInfoResponse 0x027C`, `AllegianceLoginNotification 0x027A` | +| Fellowship | not scanned here | `FellowshipFullUpdate 0x02BE`, `FellowshipUpdateFellow 0x02C0`, `FellowshipDisband 0x02BF` | +| Macros | not scanned here | client-local | +| Options | not fully scanned | `GameActionSetSingleCharacterOption` outbound | +| Login | pre-world chunks | UDP handshake + `ServerName 0xF7E1` | +| Character select | post-handshake pre-world | `CharacterList 0xF658`, `CharacterError 0xF659` | + +### What was NOT fully mapped in this sweep + +Honest gaps to flag for the next research pass: + +- **Macros panel** — needs a scan of `0x005D`–`0x005F` chunks for the config save/load handlers and macro execution tokenizer. +- **Options panel rendering** — needs a scan for the actual layout function (where the 55 character options get paired with checkboxes + sliders). +- **Allegiance tree widget** — the actual tree-layout widget with expand/collapse arrows; it's probably in a chunk adjacent to fellowship. +- **Fellowship member-list widget** — same; only the data side is in ACE. The render function is in retail's client and I did not find its address in this sweep. +- **Quickbar widget class offsets** — known it exists from input-map strings but the widget-storage offsets were not identified. +- **Chat window widget IDs** — the tab-button resource IDs, tab-filter storage offsets, and input-box widget ID need a dedicated pass through the chunk that owns `chat_window` or similar identifier. + +These gaps are documented here so a follow-up research task can pick them up rather than re-discover them. diff --git a/docs/research/retail-ui/06-hud-and-assets.md b/docs/research/retail-ui/06-hud-and-assets.md new file mode 100644 index 0000000..4ec62b5 --- /dev/null +++ b/docs/research/retail-ui/06-hud-and-assets.md @@ -0,0 +1,1093 @@ +# 06 — HUD Elements and Dat-File UI Assets + +**Slice 6 of 6: Heads-Up Display + the catalog of every UI-related DBObj.** + +This document covers the always-on-screen HUD elements (vital orbs, radar, compass, +quickbar, selection indicator, damage numbers, cursors, announcements, hover +name) and maps the complete set of dat-file data-types used by the AC UI. + +The retail client's in-game HUD is built on top of the same UI layer used by +all dialogs and panels (see slices 01–05). The defining property of the HUD is +that it is always rendered last, over the 3D world, with transparency and the +camera-cursor coupling that distinguishes "in-game" from "in a dialog". + +Sources: + +- `chunk_00400000.c` – options/settings UI wiring (tooltip, font-face, chat + size). Every control references a `LanguageString` by name, routed through + `FUN_004016b0` (a localized-string lookup). +- `chunk_00410000.c` – **the DBObj ID-range dispatcher** (`FUN_0041xxxx`), + which maps a 32-bit DataId prefix to an internal DBObjType constant. This + is the ground truth for every range below. +- `chunk_00430000.c` – **Win32 cursor plumbing** (`SetCursor`, `ShowCursor`, + `SetClassLongA`, HCURSOR ownership), plus `BitBlt` fallback paths for + software rendering. +- `chunk_005A0000.c` – D3D render-state toggles including + `"RenderDeviceD3D.AllowDrawPrimUP"` — the legacy 2D UI primitive-up path. +- `chunk_005C0000.c` – vital-name string resolution (`Strength`, `Health`, + `Maximum Stamina`, …), used to populate tooltip text and attribute labels. +- `chunk_00680000.c` – cursor mode switching: `SetCursorPos`, + `ClientToScreen`, `ScreenToClient`, mouse-look mode (cursor recenter) — + the boundary between HUD-cursor and look-cursor states. +- `references/AC2D/cInterface.cpp` + `cCustomWindows.h` — a contemporaneous + C++ reimplementation of the AC UI with **exact retail dat icon IDs baked + into the code**. Treated as reference for which assets are which. +- `references/DatReaderWriter/` — the canonical C# model of every portal and + local DBObj with ID ranges from the generator XML. + +Related retail / cross-check: + +- `references/Chorizite.ACProtocol/protocol.xml` – the authoritative + `VitalId` (0x01, 0x03, 0x05) and `CurVitalId` (0x02, 0x04, 0x06) enums + plus the `SecondaryAttributeInfo` packet shape. +- `references/ACE/Source/ACE.Server/Network/GameMessages/Messages/GameMessagePublicUpdateVital.cs` + – server side of the vital-update path. +- `references/holtburger/apps/holtburger-cli/src/pages/game/hud/` – a + working Rust client's HUD data model (what it actually pulls from game + state). + +--- + +## Part A — HUD Elements + +AC's HUD is composed of a fixed set of **movable, transparent windows** +stacked on top of the 3D render. In retail they are: the three vital orbs +(health/stamina/mana), the radar dish, the compass arc, the hotbar, the chat +panel, the selection/target name plate, announcement strip, 3D world hover +name, damage floaters, and the status icon strip (combat mode, "connection +good" indicator, encumbrance state). + +The underlying "movable window" UI is driven by the ElementDesc / StateDesc / +LayoutDesc data described in slices 01–04. Each HUD element has a +`LayoutDesc` in the `0x21xxxxxx` range and swaps its child Sprite IDs +(`0x06xxxxxx`) through `UIStateId` transitions. + +### A.1 Health / Stamina / Mana globes + +**Purpose:** bottom-left (or bottom-center, depending on the UI profile) +three vertical orbs that show the fraction `current / max` for each vital. +Clicking one opens the vital's detail panel; hovering shows the numeric +`current/max` and rate-of-regen tooltip. + +**Data sources** (from `protocol.xml`): + +| Enum | Value | Meaning | +|---|---|---| +| `VitalId.MaximumHealth` | 0x01 | Max HP (from `Qualities_UpdateAttribute2nd`) | +| `CurVitalId.CurrentHealth` | 0x02 | Current HP (from `PrivateUpdateAttribute2nd`) | +| `VitalId.MaximumStamina` | 0x03 | Max stamina | +| `CurVitalId.CurrentStamina` | 0x04 | Current stamina | +| `VitalId.MaximumMana` | 0x05 | Max mana | +| `CurVitalId.CurrentMana` | 0x06 | Current mana | + +Each vital arrives as a `SecondaryAttributeInfo` structure: + +```text +SecondaryAttributeInfo { + AttributeInfo Attribute; // ranks, base, investment + uint Current; // current value +} +``` + +The client keeps a local `cCharInfo` store (see AC2D's `cCharInfo.cpp` for +naming). The orb renderer subscribes to changes and recomputes a fill +fraction `f = min(1, current / buffed_max)`. + +**Rendering — how the partial fill is drawn** + +Retail uses a **textured quad with a clip rectangle** (scissor rect), not a +colored gradient. AC2D's `cProgressBar` (which `cVitalsWindow` uses) +implements the pattern: + +1. Draw the full "empty" background sprite (the globe outline, fixed image). +2. Set a `glScissor` (or a D3D8 sub-rect when bitblitting) that covers only + the bottom `f * height` pixels of the orb. +3. Draw the "full" colored sprite clipped to the scissor. +4. Restore scissor. + +Retail's exact IDs (per AC2D's `cInterface.cpp::cInterface()`): + +- `0x060013B2` — icon for the "Vitals" window titlebar. +- Interior globe colors: + - Blue `0x0000FF` (mana) + - Cyan `0x10F0F0` (stamina; AC2D uses this, retail appears similar) + - Red `0xFF0000` (health) + +In retail the orbs are visually three-dimensional (specular highlight + +shaped alpha). They are not procedural fills — they are pre-rendered RGBA +sprites loaded from `RenderSurface` (`0x06xxxxxx`) through `SurfaceTexture` +(`0x05xxxxxx`). + +**Text overlay.** A `Font` (`0x40000000`–`0x40000FFF`) renders the numeric +value centered over the orb when the mouse hovers (or when the user enables +"always show values" in settings). + +**Render-path pseudocode:** + +```text +function DrawVitalOrb(vital_type, current, buffed_max, x, y, w, h): + sprite_empty = GetOrbSpriteForVital(vital_type, state = Empty) + sprite_filled = GetOrbSpriteForVital(vital_type, state = Filled) + + DrawSprite(sprite_empty, x, y, w, h) + + fill_fraction = clamp(current / buffed_max, 0, 1) + fill_px = round(fill_fraction * h) + + PushScissor(x, y + h - fill_px, w, fill_px) + DrawSprite(sprite_filled, x, y, w, h) + PopScissor() + + if mouse_over or always_show: + text = format("{0}/{1}", current, buffed_max) + DrawTextCentered(font = GetUIFont(), text, x + w/2, y + h/2, color = #FFFFFF) +``` + +**C# port sketch:** + +```csharp +public sealed class VitalOrb +{ + readonly uint _spriteFrameId; // 0x06xxxxxx + readonly uint _spriteFilledId; // 0x06xxxxxx + readonly VitalKind _kind; + + public void Draw(IUiRenderer r, Rect bounds, uint current, uint buffedMax) + { + r.DrawSprite(_spriteFrameId, bounds); + + var fill = buffedMax == 0 ? 0f : MathF.Min(1f, (float)current / buffedMax); + var fillPx = (int)MathF.Round(fill * bounds.Height); + var clipRect = new Rect(bounds.X, bounds.Y + bounds.Height - fillPx, bounds.Width, fillPx); + + using (r.PushScissor(clipRect)) + r.DrawSprite(_spriteFilledId, bounds); + + if (r.HoverContains(bounds)) + r.DrawText(_uiFont, $"{current}/{buffedMax}", + bounds.Center, Color.White, TextAlign.Center); + } +} +``` + +### A.2 Radar / compass + +The radar is the canonical "small circular polar plot of nearby creatures +with the player at the center". The compass is a thin bar across the top of +the screen showing the 16 cardinal directions as the camera rotates. + +**Radar — data sources** + +- Player world position (`Position` packet, 24-byte LandCell + XYZ). +- Every nearby object's `CreateObject` / `UpdatePosition` with heading. +- Per-object `RadarColor` override (hostile = red, green = friendly NPC, + etc.) + `ObjectFlags2` bits (`0x08` = item, `0x10` = blue-book NPC). + +**Radar — retail dat IDs** (AC2D, `cInterface.cpp:139-144`): + +- `0x06001388` — radar window titlebar/toolbar icon. +- `0x06004CC1` — radar background art (the circular bezel). + +**Radar — the player arrow + blip placement** + +AC2D `cRadar::OnRender` (`cCustomWindows.h:1004-1070`) is the clearest +retail-equivalent. The math: + +```text +for each nearby object obj: + delta = obj.pos - player.pos + delta = delta.RotateAround(Z, -player.heading) // align to radar-up = camera-forward + screen.x = radar.left + radar.w/2 + (delta.x / (1.18 * range)) * (radar.w/2) + screen.y = radar.top + radar.h/2 - (delta.y / (1.18 * range)) * (radar.h/2) + color = PickRadarColor(obj.radar_override, obj.flags2) + DrawQuad2x2(screen, color) +``` + +The `1.18` factor is retail-observed — it shrinks the effective range +slightly so blips near the edge stay visible before the bezel clips them. +Player's own arrow is NOT in the blip loop; it is drawn as a fixed +centered sprite (the "player dot") rotated by `player.heading`. + +**Compass — data sources** + +- `player.heading` in radians. + +**Compass — how the rose is drawn** + +The rose is a **seamless horizontal strip texture** where 360° is tiled +across some multiple of the screen width. The U-offset is +`heading_normalized * strip_u_period`, with the visible portion cropped to +a narrow strip at the top of the screen. This is the classic "scrolling +texture" approach used by most 3D clients; retail AC follows it. + +Holtburger's TUI compass (`hud/status.rs:11-18`) enumerates the 16 +cardinal-direction labels that retail paints onto the strip: + +```text +["W", "WNW", "NW", "NNW", "N", "NNE", "NE", "ENE", + "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW"] +``` + +22.5° per segment, first label centered on 11.25°. + +**Render-path pseudocode:** + +```text +function DrawCompassStrip(heading_rad, bar_x, bar_y, bar_w, bar_h): + heading_deg = (heading_rad * 180 / PI) mod 360 + // Texture is 360° wide in world-space; we crop to bar_w centered on heading + u_center = heading_deg / 360 // 0..1 + u_half = bar_w / strip_texture_w / 2 + u_left = u_center - u_half + u_right = u_center + u_half + DrawSpriteUV(compass_strip_tex, bar_x, bar_y, bar_w, bar_h, + u0=u_left, u1=u_right, v0=0, v1=1) +``` + +**C# port sketch:** + +```csharp +public void DrawRadar(IUiRenderer r, Rect bounds, float playerHeading, + Vector3 playerPos, IEnumerable nearby, float range) +{ + r.DrawSprite(/*0x06004CC1*/ _radarBgId, bounds); + + var cx = bounds.X + bounds.Width * 0.5f; + var cy = bounds.Y + bounds.Height * 0.5f; + + foreach (var e in nearby) + { + var d = e.Position - playerPos; + d = Vector3.Transform(d, Matrix4x4.CreateRotationZ(-playerHeading)); + var sx = cx + (d.X / (1.18f * range)) * (bounds.Width * 0.5f); + var sy = cy - (d.Y / (1.18f * range)) * (bounds.Height * 0.5f); + var col = PickRadarColor(e); + r.DrawFilledQuad(sx - 1, sy - 1, 2, 2, col); + } + + // Player arrow - always on top + r.DrawSprite(_playerArrowId, cx - 5, cy - 5, 10, 10, rotation: playerHeading); +} +``` + +### A.3 Quickbar / hotbar + +The hotbar is the horizontal strip of spell-and-item slots (traditionally 7 +main bars with 8–12 slots each) at the bottom of the screen. + +**Data:** each slot holds either: + +- An `ObjectId` (for items: a potion, a healer kit), OR +- A spell `uint` (for spells). + +The client persists the mapping in `acclient.cfg` plus server-side character +options. + +**Dat IDs in retail** (AC2D `cCustomWindows.h:395-509`): + +- `0x060011D2` — selection highlight ring (drawn under selected slot). +- `0x06001AB0` / `0x06001AB2` — spell-bar tab in "unselected" / "selected" + state. +- Spell-level icons (7 tiers): `0x060013F4`, `0x060013F5`, `0x060013F6`, + `0x060013F7`, `0x060013F8`, `0x060013F9`, `0x06001F63`. +- Slot background `0x06001AB2` (48px wide slot frame). + +**Slot interaction:** + +1. Drag from inventory → hover over slot → `UIStateId.Drag_rollover_accept` + (0x09) if droppable, `Drag_rollover_reject` (0x0A) if not. This is a + per-slot StateDesc transition in the LayoutDesc. +2. Drop: `ItemSlot_DragOver_DropIn` (0x10000046) fires, followed by + `ItemSlot_Filled` (0x1000001D). +3. Click: the slot's icon is looked up, and the client dispatches either: + - `C2S_UseItem` if it's an object, or + - `C2S_CastSpell` if it's a spell. +4. Keybind: F1-F12 and 1-0 map to slot indices via `MasterInputMap` + (`0x14000000`-`0x1400FFFF`). + +**Per-slot draw order:** + +```text +for each slot: + DrawSprite(slot.bg, rect) # 0x06001AB2 (frame) + if slot.occupant: + DrawSprite(slot.occupant.icon, rect) # the item's 0x06xxxxxx icon + if slot.occupant is Spell: + DrawSprite(spell_tier_icon[slot.occupant.level-1], corner_rect) + if slot.is_focused: + DrawSprite(0x060011D2, rect) # selection ring + if slot.keybind: + DrawText(ui_font, slot.keybind_label, corner) +``` + +### A.4 Selection target indicator + +When the player clicks a creature or another player, the client draws: + +1. A **floating name plate** above their head in 3D space (billboarded). +2. An **over-the-head health bar** showing `current_health / max_health` of + the selected target. +3. Below the name plate: extra text for target state (e.g. "Selected", + "Talking to", monster level if allowed). + +**Data sources:** + +- `selected_object_id` local UI state. +- The target's `CreateObject` gave the client its name, level, and visible + health fraction (NOT the exact current HP — servers usually obfuscate + that). +- `Qualities_UpdateAttribute2nd` for the target's vitals (when selected; the + server streams public vital updates for the selected target). + +**Retail UIStateId transitions:** + +- `UIStateId.ObjectSelected` (0x1000000B) — when a world object becomes + the primary selection. +- `UIStateId.Unselected` / `Selected` (0x10000016 / 0x10000017) — for UI + chip and menu items referencing the same object. + +**Render path:** + +```text +function DrawSelectionHealthBar(target, world_to_screen, camera): + if target == null: return + + head_world = target.pos + (0, 0, target.height + 0.2) + head_screen = world_to_screen(head_world) + if head_screen.z < 0 or head_screen.off_screen: return + + // Fixed 96x8 bar + bar_x = head_screen.x - 48 + bar_y = head_screen.y - 24 + + // Full bar background + DrawFilledRect(bar_x, bar_y, 96, 8, color = #40202020) + + // Health fill + frac = clamp(target.health_pct, 0, 1) + fill_w = round(96 * frac) + fill_color = health_color_for_fraction(frac) // green→yellow→red + DrawFilledRect(bar_x, bar_y, fill_w, 8, fill_color) + + // Name below the bar + DrawTextCentered(ui_font, target.display_name, + head_screen.x, bar_y + 12, target.name_color) +``` + +`health_color_for_fraction`: retail interpolates linearly between +#00FF00 (100%) → #FFFF00 (50%) → #FF0000 (0%). + +### A.5 Damage numbers (floating text) + +When the player or the currently-selected target takes damage, retail shows +a short-lived floating text at the head of the hit target: red for damage, +yellow-green for heal. + +**Data source:** + +- `GameEventCombatDamage` (AC `GameEventType.CombatDamage` = 0x01AE). The + packet carries `attacker_id`, `victim_id`, damage amount, damage type, + and location. +- Derived client-side: the client decides whose head to anchor to. + +**Lifecycle per floater:** + +```text +FloatingNumber { + world_anchor: Vector3, + offset_y: float, // starts at 0, grows over life + alpha: float, // 1 → 0 over life + life_remaining: float, // typically 1.5s + text: string, // "127" or "Heal 40" + color: RGBA +} +``` + +**Update:** + +```text +per frame: + for each f in floaters: + f.offset_y += 40 * dt # pixels per second, upward + f.life_remaining -= dt + f.alpha = clamp(f.life_remaining / 0.4, 0, 1) + if f.life_remaining <= 0: remove + + for each f: + head_screen = world_to_screen(f.world_anchor) + DrawTextOutlined(font, f.text, + head_screen.x, head_screen.y - f.offset_y, + f.color with alpha = f.alpha, + outline = black) +``` + +Retail does the outline as a 4-corner black stamp then white fill, not +SDF — the pixel-perfect AC font system pre-dates SDF. + +### A.6 Cursor customization + +**Decompiled evidence** (`chunk_00430000.c:7854-8024`): + +```c +// FUN_00439320: restore default system cursor +HCURSOR hCursor = LoadCursorA(0, (LPCSTR)0x7f00); // IDC_ARROW +SetCursor(hCursor); + +// FUN_004395d0: install a custom cursor on the window class +HICON hIcon = (HICON)GetClassLongA(hwnd, -0xc /* GCL_HCURSOR */); +if (hIcon != param_1) DestroyIcon(hIcon); +SetClassLongA(hwnd, -0xc, (LONG)param_1); +SetCursor(param_1); +``` + +Retail uses Win32 cursors for the base arrow and custom-shaped cursors for +contextual modes. The cursor shape is **driven by the HUD hit-test** of the +current frame: moving over an NPC yields the "talk" cursor, over a +monster with the melee cursor, over inventory with the "drag" cursor. + +**Cursor asset source (dat):** retail stores custom cursors as +`MediaDescCursor` sub-records inside StateDesc.Media for specific UI +states. Per `MediaDescCursor.generated.cs`: + +```csharp +public class MediaDescCursor : MediaDesc { + public uint File; // underlying SurfaceTexture (0x05xxxxxx) + public uint XHotspot; // pixels from image top-left + public uint YHotspot; +} +``` + +The client converts these into Win32 HCURSOR at load time (CreateIconIndirect +on a monochrome mask + color bitmap pair), then swaps them via +`SetCursor(hcursor)` as the UI state transitions happen. + +**Mouse-look mode** (`chunk_00680000.c:9789-9804`) reveals the cursor-recenter +pattern retail uses to implement "right-click-hold to mouse-look": + +```c +// Pseudocode of FUN_0068a930 mouse-look +center_x = client_width / 2; +center_y = client_height / 2; +client_to_screen(hwnd, ¢er_x, ¢er_y); +SetCursorPos(center_x, center_y); +``` + +Each frame, mouse delta = `actual_pos - last_center`; then snap back to +center. The HUD cursor sprite is hidden during this (ShowCursor(FALSE)). + +**UIStateId cursor contexts** (from `UIStateId.generated.cs`): + +| State | Use | +|---|---| +| `Drag_rollover_accept` | green-tinted drag cursor | +| `Drag_rollover_reject` | red X cursor | +| `ObjectSelected` | selection pointer | +| `JumpMode` | jump cursor (space held) | +| `MeleeMode` | combat crosshair | +| `MissileMode` | ranged combat crosshair | +| `DDDMode` | dialog-pointing cursor | +| `Csm_highlight` / `Csm_normal` / `Csm_ghosted` | context-sensitive movement cursor | + +### A.7 Announcement / status bar + +The announcement strip appears near the top-center when "big" events happen: +death messages, level-up, server-wide broadcasts ("Server will shut down in +15 minutes"), and the MOTD on first login. + +**Data source:** `GameEventEvent` with subtype SystemBroadcast / +AdminBroadcast / CombatDeath, plus the server's `GameMessageMOTD` on +entering-the-world. + +**Visible lifecycle:** + +1. Fade in over 0.3s (alpha 0 → 1). +2. Hold for message_duration (default 5s, scales with message length). +3. Fade out over 0.5s. + +The strip is a single horizontal `LayoutDesc`-backed panel; it loads the +bordered-panel background sprite and lays one or two `cStaticText`-style +children centered. + +**Retail dat ID:** the connecting/MOTD panel uses `0x06004CB2` (AC2D +`cInterface.cpp:196`) as the "Enter Game" button/panel art. Most +announcement strips use the same bordered-panel sprite family. + +### A.8 3D item hover name + +When the mouse hovers over a world object (without clicking), a floating +text tag appears above the object: the object's display name, colored by +faction/allegiance. + +**Hit test:** the client does a picking ray from the screen cursor, runs it +through the physics BSP (see `src/AcDream.Core/Physics/BSPQuery.cs`), finds +the hit object, and checks its `ObjectDescriptionFlag` to decide whether +hover names should be shown. The `"Maximum Tooltip Distance"` setting +(`chunk_00400000.c`, around the `ID_Misc_TooltipDelay` binding) gates the +display. + +**Render:** + +```text +function DrawHoverName(target, world_to_screen): + if target == null: return + if distance(player, target) > hover_max_range: return + + head = target.pos + (0, 0, target.height + 0.15) + ss = world_to_screen(head) + if ss.z < 0 or off_screen: return + + text = target.display_name + color = hover_color_for_relation(target) + size = measure_text(font, text) + + DrawFilledRect(ss.x - size.x/2 - 3, ss.y - size.y - 2, + size.x + 6, size.y + 4, color = #C0000000) + DrawTextCentered(font, text, ss.x, ss.y - size.y, color) +``` + +The `size + 6` padding is for the pill-shaped background. `ss.y - size.y` +stacks the name above the object's head; the health bar (if target is +selected) sits below. + +--- + +## Part B — Dat-File UI Assets + +This is the catalog of every DBObj type used by the UI subsystem, derived +from `references/DatReaderWriter/DatReaderWriter/Generated/DBObjs/*.cs` and +verified against the decompiled ID-range dispatcher in `chunk_00410000.c`. + +### B.1 Master ID-range table + +The retail client's `FUN_0041ccc0` dispatcher (which returns a +DBObjType-equivalent integer from a DataId prefix) confirms the following +ranges. **Every table entry is load-bearing — do not paraphrase.** + +| DataId prefix | Name | DBObjType | Dat file | Notes | +|---|---|---|---|---| +| `0x01xxxxxx` | GfxObj | 2 | Portal | 3D mesh/geometry (not UI) | +| `0x02xxxxxx` | Setup | 3 | Portal | Multi-part rig (not UI) | +| `0x03xxxxxx` | Animation | 4 | Portal | Keyframed anim (not UI) | +| **`0x04xxxxxx`** | **Palette** | 5 | Portal | **ARGB lookup; UI tinted sprites** | +| **`0x05xxxxxx`** | **SurfaceTexture** | 6 | Portal | **Mip chain of RenderSurface** | +| **`0x06xxxxxx`** | **RenderSurface (Icon)** | 7 | Portal | **THE UI icon/sprite space** | +| **`0x07xxxxxx`** | **RenderSurface** | 7 | Portal | **HiRes dat overflow for icons** | +| **`0x08xxxxxx`** | **Surface** | 8 | Portal | **Material pointing at tex+palette** | +| `0x09xxxxxx` | MotionTable | 9 | Portal | (not UI) | +| `0x0Axxxxxx` | Wave | 10 | Portal | Audio (not UI but UI *plays* them) | +| `0x0Dxxxxxx` | Environment | 11 | Portal | (not UI) | +| `0x0Exxxxxx` | Table singletons | mixed | Portal | CharGen, ChatPoseTable, etc. | +| `0x0Fxxxxxx` | PaletteSet / PalSet | 17 | Portal | Subpalette swaps for heritage tinting | +| `0x10xxxxxx` | Clothing / ClothingTable | 18 | Portal | (not UI directly) | +| `0x11xxxxxx` | GfxObjDegradeInfo | 19 | Portal | (not UI) | +| `0x12xxxxxx` | Scene | 20 | Portal | (not UI) | +| `0x13xxxxxx` | Region | 21 | Portal | Skybox — UI reads daylight from it | +| `0x14xxxxxx` | **MasterInputMap** | 22 | Portal | **UI keybinding map** | +| `0x15xxxxxx` | RenderTexture | 23 | Portal | Material-system texture (not UI) | +| `0x16xxxxxx` | RenderMaterial | 24 | Portal | Material system (not UI) | +| `0x17xxxxxx` | MaterialModifier | 25 | Portal | | +| `0x18xxxxxx` | MaterialInstance | 26 | Portal | | +| `0x20xxxxxx` | SoundTable | 27 | Portal | UI sounds (click, error) | +| **`0x21xxxxxx`** | **LayoutDesc** | 47 | **Local** | **THE UI LAYOUT FILE** | +| `0x22xxxxxx` | EnumMapper | 28 | Portal | Id→string helpers | +| **`0x23xxxxxx` / `0x24xxxxxx`** | **StringTable** | 48 | **Local** | **Localized strings for UI** | +| `0x25xxxxxx` | EnumIDMap | 29 | Portal | | +| `0x27xxxxxx` | DualEnumIDMap / DualDataIdMapper | 32 | Portal | | +| `0x30xxxxxx` | CombatTable | 34 | Portal | | +| **`0x31xxxxxx`** | **LanguageString** | 33 | Portal | **Loose localized string** | +| `0x32xxxxxx` | ParticleEmitter(Info) | 35 | Portal | Spell FX particles (seen in UI previews) | +| `0x33xxxxxx` | PhysicsScript | 36 | Portal | | +| `0x34xxxxxx` | PhysicsScriptTable | 37 | Portal | | +| **`0x40000000`–`0x40000FFF`** | **Font** | 38 | Portal | **BITMAP FONTS for the UI** | +| **`0x41xxxxxx`** | **LanguageInfo** | 49 | **Local** | **IME + text formatting config** | + +Legend: bolded rows are what a UI layer needs. + +### B.2 UI-critical DBObj types in depth + +#### Font (0x40000000 – 0x40000FFF) + +```csharp +public class Font : DBObj { + public uint MaxCharHeight; + public uint MaxCharWidth; + public List CharDescs; + public uint NumHorizontalBorderPixels; + public uint NumVerticalBorderPixels; + public uint BaselineOffset; + public uint ForegroundSurfaceDataId; // -> 0x06xxxxxx RenderSurface + public uint BackgroundSurfaceDataId; // -> 0x06xxxxxx RenderSurface (outline/shadow) +} + +public class FontCharDesc { + public ushort Unicode; // codepoint + public ushort OffsetX, OffsetY; // position within atlas image + public byte Width, Height; // glyph bbox + public sbyte HorizontalOffsetBefore; // pre-advance (kerning-ish) + public sbyte HorizontalOffsetAfter; // post-advance + public sbyte VerticalOffsetBefore; // baseline adjust +} +``` + +Retail has **two surfaces per font**: `ForegroundSurfaceDataId` (the glyph +pixels, typically white A8) and `BackgroundSurfaceDataId` (an outline +stroke). The renderer blits background first with the text color shifted +darker, then foreground with the fill color, giving AC's characteristic +outlined UI text. + +**Glyph lookup is linear in `CharDescs`**, sorted by `Unicode`. Retail +does a binary search. For modern C# the port should build a +`Dictionary` at load time. + +**Typical font IDs** (observed in retail settings dialog via +`chunk_00400000.c::FUN_004037b0`): the chat font is chosen from a fixed +list hardcoded by face name — "Arial", "CourierNew", "PalatinoLinotype", +"Tahoma", "TimesNewRoman". Each face maps to a Font DataId at runtime via +a mapping stored in `StringTable`. + +#### LayoutDesc (0x21000000 – 0x21FFFFFF, Local dat) + +**This is the single most important UI dat type.** Every HUD panel, every +dialog, every chat window layout, is a `LayoutDesc`. The file resides in +the **Local dat** (`client_local_English.dat`), not the Portal dat. + +```csharp +public class LayoutDesc : DBObj { + public uint Width; + public uint Height; + public HashTable Elements; // top-level elements by ElementId +} + +public class ElementDesc { + public StateDesc StateDesc; // default state + public uint ReadOrder; // render order within parent + public uint ElementId; // unique per LayoutDesc + public uint Type; // element type (button/text/panel/etc.) + public uint BaseElement; // inheritance ref + public uint BaseLayoutId; // parent LayoutDesc for inheritance + public UIStateId DefaultState; + public uint X, Y; // relative to parent + public uint Width, Height; + public uint ZLevel; + public uint LeftEdge, TopEdge, RightEdge, BottomEdge; // anchor margins + public Dictionary States; // per-state visuals + public Dictionary Children; +} + +public class StateDesc { + public uint StateId; + public bool PassToChildren; + public IncorporationFlags IncorporationFlags; // which fields the child overrides + public Dictionary Properties; + public List Media; // Image/Cursor/Sound/Anim per state +} +``` + +An ElementDesc has a **default StateDesc** + a **dictionary of per-state +StateDescs**. When the UI's logical state changes (e.g. mouse enters a +button → UIStateId.Normal_rollover), the renderer looks up the matching +StateDesc and re-reads Properties + Media. This is how buttons change +their sprite on hover, how vital orbs switch color when the player is +poisoned, and how the drag-drop target shows a green/red highlight. + +**The `IncorporationFlags` enum** determines whether the state override +applies X, Y, Width, Height, ZLevel etc. Most per-state overrides only +change Media (the sprite behind the element), not geometry. + +**ElementDesc.Type is an integer code.** From protocol-adjacent evidence +and ACViewer naming, the known values are: +- 0 = Root (panel) +- 1 = Picture (sprite blitter) +- 2 = Text (string label) +- 3 = Button (picture + label + state) +- 4 = EditBox +- 5 = ScrollBar +- 6 = ListBox +- ... + +These are the primitive widget kinds the UI renderer dispatches on. + +#### StringTable (0x23000000 – 0x24FFFFFF, Local dat) + +```csharp +public class StringTable : DBObj { + public uint Language; // 1 = English + public HashTable Strings; +} +``` + +Every localized UI string lives here, keyed by a stable internal ID. The +decompiled settings-UI wiring in `chunk_00400000.c` shows the retail +access pattern: + +```c +// FUN_004037b0 (the settings dialog construction) +uVar2 = FUN_004016b0("ID_Sound_DisableSound_Help"); // look up help text +uVar2 = FUN_004016b0("ID_Sound_DisableSound", uVar2); // bind label + help +FUN_005dee50(&DAT_008375b0, 4, 0x10000003, uVar2); // install into control +``` + +`FUN_004016b0` is the **StringTable lookup by name-hash**. It returns a +StringId that points into the current StringTable. When the user switches +language, a different StringTable is loaded but the same hash still +resolves. + +**Retail ID prefixes observed:** + +| Prefix | Domain | +|---|---| +| `ID_UI_*` | UI widget labels | +| `ID_Misc_*` | misc UI (tooltips) | +| `ID_Sound_*` | audio settings | +| `ID_Graphics_*` | graphics settings | +| `ID_Chat_*` | chat system | +| `ID_Inventory_*` | inventory panel | + +The hash function (from ACE's `DatLoader`) is a variant of Pearson hashing +over the lowercased byte string. Port this exactly; computed IDs are +compared against dat values. + +#### LanguageString (0x31000000 – 0x3100FFFF, Portal dat) + +```csharp +public class LanguageString : DBObj { + public PStringBase Value; // raw ASCII/UTF-8 string +} +``` + +A free-standing single string, separate from StringTable. Retail uses this +for very large strings or for strings that don't belong to a table (help +text, tutorial content, quest descriptions shown in panels). + +#### Palette (0x04000000 – 0x0400FFFF) + +```csharp +public class Palette : DBObj { + public List Colors; // up to 256 entries +} +``` + +Paired with `RenderSurface.Format = PFID_INDEX16 | PFID_P8`. UI uses this +to tint a single icon by swapping palettes — e.g. heritage-colored UI +buttons in character creation cycle between Aluvian / Gharundim / Sho +palettes. + +#### PaletteSet / PalSet (0x0F000000 – 0x0F00FFFF) + +```csharp +public class PaletteSet : DBObj { public List Palettes; } +``` + +A list of `Palette` DataIds. Used when a UI element can be in multiple +tinted variants (e.g. the "connection quality" traffic light cycling +between good/uncertain/bad by swapping the active palette index). + +#### RenderSurface (0x06000000 – 0x07FFFFFF) + +```csharp +public class RenderSurface : DBObj { + public int Width, Height; + public PixelFormat Format; + public byte[] SourceData; + public uint DefaultPaletteId; // only if Format is INDEX16 or P8 +} +``` + +This is **the bytes behind every UI icon in the game**. A few properties +matter for UI: + +- `Format = PFID_INDEX16` — 16-bit indexed, paired with DefaultPaletteId. + Typical for tintable icons. +- `Format = PFID_A8R8G8B8` — 32-bit ARGB. Direct blit, premultiplied if + the owning Surface has nonzero Translucency. +- `Format = PFID_CUSTOM_RAW_JPEG` — compressed raw JPEG bytes. Retail's + portrait-rendered images (e.g. splash art). +- `Format = PFID_DXT1`/`PFID_DXT5` — compressed. Usually world textures + but occasionally used for high-res UI backdrops. + +Width/Height for UI icons are predominantly: +- 16×16 (hotbar slot icon, small skill icon) +- 32×32 (inventory icon) +- 64×64 (spell book icon) +- Various (panel background sprites, titlebar icons, 9-slice edges) + +#### SurfaceTexture (0x05000000 – 0x05FFFFFF) + +```csharp +public class SurfaceTexture : DBObj { + public TextureType Type; + public List> Textures; // mip chain +} +``` + +Wraps one or more `RenderSurface` into a mipmapped texture. UI usually uses +**only mip 0** (no mips for 2D pixels), but the wrapper is still required. + +#### Surface (0x08000000 – 0x0800FFFF) + +```csharp +public class Surface : DBObj { + public SurfaceType Type; // bitfield + public QualifiedDataId OrigTextureId; // set if Base1Image or Base1ClipMap + public QualifiedDataId OrigPaletteId; + public ColorARGB ColorValue; // set if no texture + public float Translucency; + public float Luminosity; + public float Diffuse; +} +``` + +A Surface is a **material descriptor**: "use this SurfaceTexture with this +Palette, at this translucency, with this luminosity glow". UI-side, +`Translucency` controls the window transparency setting the player picks +in AC ("move the slider to make chat window 60% opaque" sets the +Translucency on the chat LayoutDesc's backing Surface). + +#### MasterInputMap (0x14000000 – 0x1400FFFF) + +Stores the keybinding map: "F1 → action Cast", "I → open inventory", etc. +Port this verbatim; it's a lookup table the UI event handler walks on +every key press. + +#### SoundTable (0x20000000 – 0x2000FFFF) + +UI sound cues (click, error, level-up, unlock) live here. Not strictly +"visual UI" but the HUD dispatches them alongside draw calls. See +`MediaDescSound` inside StateDesc.Media for the per-state sound triggers. + +### B.3 How the client chooses which dat asset to load + +For a given HUD element the data-flow is: + +1. **UI layer starts with a known LayoutDesc DataId.** For example the + "main game HUD" layout is a fixed DataId the client knows at compile + time — it's a 0x21xxxxxx constant baked in. +2. **LayoutDesc.Elements iterates children.** Each ElementDesc's default + StateDesc.Media list contains a `MediaDescImage` or + `MediaDescCursor` — these hold a `File` field which is a 0x06xxxxxx + RenderSurface DataId (icon), a 0x08xxxxxx Surface DataId (material), + or similar. +3. **State transitions swap media.** When `UIStateId` changes on an + element, the client re-reads the per-state StateDesc, pulls its Media + list, and re-binds sprites. +4. **Text is resolved via hash.** Every text label's string-content isn't + stored in the layout; the layout stores a `StringId` (hash), which is + passed to `FUN_004016b0` (`StringTable.Lookup(id)`) and returns the + string pointer in the currently-active StringTable. +5. **Icons-from-server vs icons-from-dats.** Most HUD icons are + **hardcoded dat IDs** (see AC2D's table). But inventory icons and spell + icons are **data-driven**: each WorldObject's CreateObject packet + carries its `IconId` (0x06xxxxxx) from the server, which the client + blits at its designated slot. This is the one case where server drives + icon choice; for the HUD chrome itself (frame art, globe art, compass + strip, selection ring) the IDs are fixed. + +### B.4 Pseudocode: loading a UI icon and blitting it + +This is the reference flow for "take a 0x06xxxxxx DataId and draw it into +a rect on screen". Follow it exactly when porting. + +```text +function DrawUIIcon(iconDataId, rect): + assert (iconDataId & 0xFF000000) in {0x06000000, 0x07000000} + + // 1. Resolve RenderSurface + rs = PortalDat.Read(iconDataId) + if rs == null: + rs = HighResDat.Read(iconDataId) // optional fallback + if rs == null: return Error + + // 2. Decode pixels into a GPU-uploadable byte[] (BGRA8) + pixels = DecodeToBGRA8(rs, rs.Format, rs.SourceData, rs.DefaultPaletteId) + + // 3. Upload (or reuse from cache) + tex = TextureCache.GetOrUpload(iconDataId, rs.Width, rs.Height, pixels) + + // 4. Draw textured quad at rect, with straight alpha-blending + PushBlendMode(AlphaBlend) + DrawQuad(tex, rect, uv = (0, 0, 1, 1), tint = White) + PopBlendMode() + +function DecodeToBGRA8(rs, format, raw, defaultPaletteId): + switch format: + case PFID_A8R8G8B8: + out = copy raw + SwapRedAndBlue(out) # AC stores ARGB, GL wants BGRA + return out + case PFID_R5G6B5: + return Expand565ToBGRA(raw) + case PFID_A4R4G4B4: + return Expand4444ToBGRA(raw) + case PFID_INDEX16: + pal = PortalDat.Read(defaultPaletteId).Colors + out = new byte[rs.Width * rs.Height * 4] + for each 16-bit index i in raw: + color = pal[i & 0x7FF] # 11-bit index; high bits mean 'paletted' + out.AppendBGRA(color) + return out + case PFID_P8: + pal = PortalDat.Read(defaultPaletteId).Colors + out = new byte[rs.Width * rs.Height * 4] + for each 8-bit index i in raw: + out.AppendBGRA(pal[i]) + return out + case PFID_DXT1, PFID_DXT3, PFID_DXT5: + return DecodeDXT(raw, format, rs.Width, rs.Height) + case PFID_A8: + # grayscale alpha - expand with white RGB + return ExpandA8ToBGRA(raw, fill = #FFFFFF) + case PFID_CUSTOM_RAW_JPEG: + bmp = DecodeJPEG(raw) + return BitmapToBGRA(bmp) + default: + return Error("unsupported format 0x%X" % format) +``` + +Cache key: `(iconDataId, paletteOverrideId)`. Palette overrides are needed +when a UI subsystem wants to tint an indexed icon differently (e.g. the +same base sprite shown in green for "trained" and red for "specialized" +skills — retail uses Palette overrides, not color-mul). + +**C# port sketch (Silk.NET + .NET 10):** + +```csharp +public sealed class UiIconCache +{ + readonly PortalDat _portal; + readonly PortalDat? _highRes; + readonly GL _gl; + readonly Dictionary<(uint Id, uint Palette), GpuTexture> _cache = new(); + + public GpuTexture Load(uint iconId, uint paletteOverride = 0) + { + var key = (iconId, paletteOverride); + if (_cache.TryGetValue(key, out var tex)) return tex; + + var rs = _portal.Read(iconId) + ?? _highRes?.Read(iconId) + ?? throw new UiAssetMissing(iconId); + + var paletteId = paletteOverride != 0 ? paletteOverride : rs.DefaultPaletteId; + var pixels = DecodeToBgra8(rs, paletteId); + + tex = GpuTexture.Upload(_gl, rs.Width, rs.Height, pixels); + _cache[key] = tex; + return tex; + } + + byte[] DecodeToBgra8(RenderSurface rs, uint paletteId) + => rs.Format switch + { + PixelFormat.PFID_A8R8G8B8 => SwapArgbToBgra(rs.SourceData), + PixelFormat.PFID_R5G6B5 => Expand565(rs.SourceData), + PixelFormat.PFID_A4R4G4B4 => Expand4444(rs.SourceData), + PixelFormat.PFID_INDEX16 => ExpandIndex16(rs, _portal.Read(paletteId)!), + PixelFormat.PFID_P8 => ExpandP8(rs, _portal.Read(paletteId)!), + PixelFormat.PFID_DXT1 + or PixelFormat.PFID_DXT3 + or PixelFormat.PFID_DXT5 => DxtDecoder.Decode(rs), + PixelFormat.PFID_A8 => ExpandA8(rs.SourceData), + PixelFormat.PFID_CUSTOM_RAW_JPEG => JpegToBgra(rs.SourceData), + _ => throw new NotSupportedException($"UI format {rs.Format}") + }; +} +``` + +### B.5 Known hardcoded HUD dat IDs (from AC2D) + +These are the specific DataIds AC2D hardcodes for each HUD window, copied +verbatim from the retail `.dat` files. Use them as **golden values** for +conformance tests — if acdream can load these IDs and render them, the +HUD is wired. + +| Element | IconId (titlebar) | Other IDs | +|---|---|---| +| Radar | `0x06001388` | background `0x06004CC1` | +| Minimap | `0x06001065` | map tile `0x06000261`, cursor `0x060011F9`, sub-cursor `0x06001377` | +| Chat window | `0x0600137D` | | +| Vitals window | `0x060013B2` | | +| Stats window | `0x0600138C` | | +| Skills window | `0x0600138E` | | +| Selection spell highlight | `0x060011D2` | | +| Spell-tier icons (levels I–VII) | — | `0x060013F4`, `0x060013F5`, `0x060013F6`, `0x060013F7`, `0x060013F8`, `0x060013F9`, `0x06001F63` | +| Hotbar slot frame | — | `0x06001AB2` (selected), `0x06001AB0` (unselected) | +| Skill level headers | — | `0x06000F90` (Specialized), `0x06000F86` (Trained), `0x06000F89` (Untrained + Unusable) | +| Skill line row bg | `0x06000F98` | | +| Stats line row bg | `0x06000F98` | | +| "Enter Game" button art | `0x06004CB2` | | +| Character-selection highlight | `0x06001125` | | + +The UI-critical dat IDs can be verified by opening the retail +`client_portal.dat` and searching for these DataIds — they should all +be `RenderSurface` records in `0x06xxxxxx` with sensible dimensions (16×16 +to 256×256). + +--- + +## Integration notes for acdream + +**Architecture:** + +- Implement `IUiRenderer` over Silk.NET with a single per-frame sprite + batcher (similar to `Chorizite.OpenGLSDLBackend/FontRenderer.cs`) — + 4 verts per sprite, flush on texture change or reach of + `MAX_SPRITES = 10048`. +- Use a single 2D orthographic projection matrix sized to the swapchain + extent. Update on window resize. +- Implement `UiIconCache` keyed on `(DataId, PaletteOverride)` with LRU + eviction; HUD working-set is small (<1 MB VRAM total). +- Parse LayoutDesc lazily. When the HUD is opened, read the top-level + LayoutDesc, recurse Elements, resolve default state's Media, submit + sprite draws in `ReadOrder`. + +**State changes:** + +- Every element stores its current `UIStateId`. On mouse/keyboard/game-state + change, recompute the target state, swap media, reissue draws. +- Vital updates arrive as `Qualities_UpdateAttribute2nd` packets. Store + into a `PlayerVitals` component; the orb renderer subscribes and + recomputes fill fractions (no allocation per frame). + +**Ordering:** + +- HUD renders **after** the 3D world. Use a separate framebuffer or simply + a clear-depth / disable-depth pass. Retail draws HUD last into the same + swapchain buffer, which is what we should do. + +**Text:** + +- Port Font decoding into `UiFont` using Font.ForegroundSurfaceDataId + + Font.BackgroundSurfaceDataId. Build a glyph dictionary at load time. + Use atlas textures the first time the font is used. +- Outline = background blitted first at the color-darkened-by-0.5; fill = + foreground blitted at the requested color. + +**Cursor:** + +- Build HCURSOR (Windows) / SDL cursors at UI state load time; swap via + the GLFW / Silk.NET window callback when the hit-tested UI element's + active state specifies a MediaDescCursor. + +**Phase ordering:** + +- Fonts, icon cache, LayoutDesc loader — implement as a shared UI core + library. +- Vitals HUD comes first (simplest, clear user-visible acceptance). +- Radar second (needs world-to-screen; simple polar plot). +- Chat panel + hotbar third (need proper widget primitives: scrollbox, + button with state). +- Selection indicator + hover names + damage floaters integrate last + (depend on the world renderer's camera and the physics picking ray). + +**Conformance test targets:** + +- Load `Font 0x40000001` (retail's primary UI font), decode its + ForegroundSurfaceDataId, verify glyph count and baselineoffset match + recorded golden values from retail dat (can be extracted with ACME). +- Load `RenderSurface 0x06001388` (radar icon), decode to BGRA, + byte-compare against known-good BGRA from ACME. +- Load `LayoutDesc 0x21000001` (or whatever the main HUD layout is), + parse, verify element count + default-state media IDs. + +Once these three pass, the HUD foundation is stable and the rest of the +slices (01–05) plug in on top of the same ElementDesc machinery. diff --git a/src/AcDream.App/UI/README.md b/src/AcDream.App/UI/README.md new file mode 100644 index 0000000..8dacd46 --- /dev/null +++ b/src/AcDream.App/UI/README.md @@ -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. +``` diff --git a/src/AcDream.App/UI/UiElement.cs b/src/AcDream.App/UI/UiElement.cs new file mode 100644 index 0000000..ae9a0a7 --- /dev/null +++ b/src/AcDream.App/UI/UiElement.cs @@ -0,0 +1,203 @@ +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// Base class for every UI widget in the retained-mode tree. +/// +/// Design notes: +/// - Retail AC delegates widget semantics to the external +/// keystone.dll library (see +/// docs/research/retail-ui/02-class-hierarchy.md — there is no +/// widget hierarchy inside acclient.exe itself). We implement +/// our own retained-mode toolkit here, matching the behavior +/// described in the decompile without trying to byte-match Keystone's +/// internal class layout. +/// - Events use the retail-faithful struct and +/// the constants so that hand-ported panel +/// code can use the same magic numbers the decompiled C uses +/// (e.g. if (e.Type == 0x15) ... 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 screen pixels, origin top-left. +/// is in the parent's local coordinate space. +/// +public abstract class UiElement +{ + // ── Identity ───────────────────────────────────────────────────────── + /// + /// Unique 32-bit event ID. Retail uses the range 0x10000000+ + /// for custom app events (see + /// docs/research/retail-ui/04-input-events.md §3). Assigned + /// by when the element is added to the tree. + /// + public uint EventId { get; internal set; } + + /// Human-readable name for debugging / FindByName. + public string? Name { get; init; } + + // ── Geometry ──────────────────────────────────────────────────────── + /// X in the parent's local pixel space. + public float Left { get; set; } + public float Top { get; set; } + public float Width { get; set; } + public float Height { get; set; } + + /// Absolute (screen-space) top-left, computed by walking Parent. + 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; + + /// + /// If true, skips this element — the event + /// passes through to whatever is behind. Used by decoration widgets + /// (portrait frames, ornamental dividers). + /// + public bool ClickThrough { get; set; } + + /// + /// If true, will set focus here on click, + /// routing WM_KEYDOWN / WM_CHAR to as + /// / . + /// + public bool AcceptsFocus { get; set; } + + /// + /// True if this is a text-entry (edit box); used by focus routing + /// to suppress global hotkeys while typing. + /// + public bool IsEditControl { get; set; } + + /// Painter's-algorithm z-order within siblings. Higher = on top. + public int ZOrder { get; set; } + + // ── Tree structure ────────────────────────────────────────────────── + public UiElement? Parent { get; private set; } + + private readonly List _children = new(); + public IReadOnlyList 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 ─────────────────────────────────────────────── + + /// + /// Draw THIS element (not its children). Children are composited by + /// after this returns. + /// + protected virtual void OnDraw(UiRenderContext ctx) { } + + /// Per-frame tick (animations, timers, caret blink). + protected virtual void OnTick(double deltaSeconds) { } + + /// + /// Custom hit-test override. Default is a rectangle containment + /// check on (, ). + /// + protected virtual bool OnHitTest(float localX, float localY) + => localX >= 0f && localX < Width && localY >= 0f && localY < Height; + + /// + /// Event handler. Return true to consume the event (the + /// will stop propagation). Return false + /// to let ancestors / fall-through handle it. + /// + public virtual bool OnEvent(in UiEvent e) => false; + + /// + /// 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. + /// + 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); + } + + /// + /// Top-down, children-first hit-test. / + /// are in THIS element's local space. + /// Returns the topmost descendant (or this) at the point, or null. + /// + 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; + } +} diff --git a/src/AcDream.App/UI/UiEvent.cs b/src/AcDream.App/UI/UiEvent.cs new file mode 100644 index 0000000..61245a8 --- /dev/null +++ b/src/AcDream.App/UI/UiEvent.cs @@ -0,0 +1,90 @@ +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// Per-event payload delivered to . +/// Mirrors the retail AC client's 24-byte event struct that is passed to +/// every widget's vtable slot +0x128 (OnEvent(int* event)). +/// +/// Layout from decompiled chunk_004A0000.c paperdoll handler +/// FUN_004A5FA0: +/// +/// 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] +/// +/// +public readonly record struct UiEvent( + uint SourceId, + UiElement? Target, + int Type, // see + int Data0 = 0, + int Data1 = 0, + int Data2 = 0, + int Data3 = 0, + object? Payload = null); + +/// +/// 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 +/// +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; +} + +/// +/// Mouse button enum matching retail's 1/2/3 encoding. +/// +public enum UiMouseButton +{ + Left = 1, + Right = 2, + Middle = 3, +} diff --git a/src/AcDream.App/UI/UiHost.cs b/src/AcDream.App/UI/UiHost.cs new file mode 100644 index 0000000..5f697cf --- /dev/null +++ b/src/AcDream.App/UI/UiHost.cs @@ -0,0 +1,102 @@ +using System.Numerics; +using AcDream.App.Rendering; +using Silk.NET.Input; +using Silk.NET.OpenGL; + +namespace AcDream.App.UI; + +/// +/// Packages the , the 2D sprite batcher +/// (), and a default font so +/// GameWindow can wire the retail-style UI in with one +/// construction and a handful of input callbacks. +/// +/// Usage (from GameWindow.OnLoad): +/// +/// _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); +/// +/// +/// And per frame (from GameWindow.OnRender): +/// +/// _uiHost.Tick(deltaSeconds); +/// _uiHost.Draw(new Vector2(_window!.Size.X, _window.Size.Y)); +/// +/// +/// Retail analog: the trio of DAT_00870340 (Core, owns fonts/atlases), +/// DAT_00837ff4 (Device, owns input state), DAT_00870c2c +/// (Keystone root, widget tree). We fuse them into a single host class +/// because we're not linking to Keystone. +/// +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(); + } +} diff --git a/src/AcDream.App/UI/UiPanel.cs b/src/AcDream.App/UI/UiPanel.cs new file mode 100644 index 0000000..9f941da --- /dev/null +++ b/src/AcDream.App/UI/UiPanel.cs @@ -0,0 +1,93 @@ +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// 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 0x06xxxxxx RenderSurface range, and composed via +/// LayoutDesc (0x21xxxxxx) trees. Until our +/// AcFont/UiSpriteBatch consumes those directly, we draw a +/// simple translucent rectangle so panels are visible during development. +/// +public class UiPanel : UiElement +{ + /// Background fill color. Set to skip. + public Vector4 BackgroundColor { get; set; } = new(0f, 0f, 0f, 0.55f); + + /// Border color. Set to skip. + 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); + } +} + +/// +/// 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 +/// FUN_0040b8f0 then drawn by the widget's draw method through +/// FUN_00698330. +/// +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); +} + +/// +/// Simple clickable button: panel background + centered label + click +/// callback. Retail equivalent is Keystone's button widget, driven by +/// a StateDesc per UIStateId (normal / hot / pressed / +/// disabled) from the panel layout. +/// +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); + } +} diff --git a/src/AcDream.App/UI/UiRenderContext.cs b/src/AcDream.App/UI/UiRenderContext.cs new file mode 100644 index 0000000..51ce7b8 --- /dev/null +++ b/src/AcDream.App/UI/UiRenderContext.cs @@ -0,0 +1,62 @@ +using System.Numerics; +using AcDream.App.Rendering; + +namespace AcDream.App.UI; + +/// +/// Per-frame drawing context passed through the +/// tree. Wraps a (our 2D sprite batcher) and a +/// transform stack so elements can draw in local coordinates. +/// +/// Retail equivalent: the implicit context FUN_005da8f0 walks with +/// when iterating the UI tree. Our version is explicit so it plugs +/// cleanly into Silk.NET. +/// +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 _stack = new(); + private Vector2 _current; + + public UiRenderContext(TextRenderer tr, Vector2 screenSize, BitmapFont? defaultFont = null) + { + TextRenderer = tr; + ScreenSize = screenSize; + DefaultFont = defaultFont; + } + + /// Push a relative translate. Must be paired with . + 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); + } +} diff --git a/src/AcDream.App/UI/UiRoot.cs b/src/AcDream.App/UI/UiRoot.cs new file mode 100644 index 0000000..7df4173 --- /dev/null +++ b/src/AcDream.App/UI/UiRoot.cs @@ -0,0 +1,473 @@ +using System; +using System.Collections.Generic; +using System.Numerics; + +namespace AcDream.App.UI; + +/// +/// 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 +/// semantics. +/// +/// Retail analog: the DAT_00837ff4 Device object (see +/// docs/research/retail-ui/04-input-events.md §2). That object has +/// a ~20-slot vtable; the methods we emulate here are: +/// +/// +/// +0x18 / +0x1C : / +/// +0x34 : (tooltip delay) +/// +0x38 : +/// +0x44 : +/// +0x48 / +0x4C : / +/// +0x74 / +0x78 : drag cursor set / reset +/// +/// +/// When no widget consumes an event, the +/// or event fires so the game world +/// (camera, player controller) still receives input. +/// +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; } + + /// Widget currently receiving keyboard events. + public UiElement? KeyboardFocus { get; private set; } + + /// + /// Single modal overlay; while set, mouse clicks outside its rect + /// are ignored. Retail sets this via Device vtable +0x48. + /// + public UiPanel? Modal { get; set; } + + /// Widget with mouse capture (during click-drag). + public UiElement? Captured { get; private set; } + + /// Current drag source (set between drag-begin and drop/cancel). + 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; + + /// Raised when an event was not consumed by any widget. + public event Action? WorldMouseFallThrough; + + /// Raised when a key was not consumed by any widget. + public event Action? WorldKeyFallThrough; + + /// Raised when mouse moved and no widget captured. + public event Action? WorldMouseMoveFallThrough; + + /// Raised on scroll fall-through (world zoom, etc.). + public event Action? 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); + } + + /// + /// Call on ; + /// if it returns false, walk the Parent chain. + /// + 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. + } +}