docs+feat(ui): retail UI deep-dive research + C# port scaffold

Deep investigation of the retail AC client's GUI subsystem, driven by 6
parallel Opus research agents, plus the first cut of a retail-faithful
retained-mode widget toolkit that scaffolds Phase D.

Research (docs/research/retail-ui/):
- 00-master-synthesis.md        — cross-slice synthesis + port plan
- 01-architecture-and-init.md   — WinMain, CreateMainWindow, frame loop,
                                  Keystone bring-up (7 globals mapped)
- 02-class-hierarchy.md         — key finding: UI lives in keystone.dll,
                                  not acclient.exe; CUIManager + CUIListener
                                  MI pattern, CFont + CSurface + CString
- 03-rendering.md               — 24-byte XYZRHW+UV verts, per-font
                                  256x256 atlas baked from RenderSurface,
                                  TEXTUREFACTOR coloring, DrawPrimitiveUP
- 04-input-events.md            — Win32 WndProc → Device (DAT_00837ff4)
                                  → widget OnEvent(+0x128); full event-type
                                  table (0x01 click, 0x07 tooltip ~1000ms,
                                  0x15 drag-begin, 0x21 enter, 0x3E drop)
- 05-panels.md                  — chat, attributes, skills, spells, paperdoll
                                  (25-slot layout), inventory, fellowship,
                                  allegiance — with wire-message bindings
- 06-hud-and-assets.md          — vital orbs (scissor fill), radar
                                  (0x06001388/0x06004CC1, 1.18× shrink),
                                  compass strip, dat asset catalog

Key insight: keystone.dll owns the actual widget toolkit — we cannot
port a class hierarchy from the decompile because it's not there.
Instead we implement our own retained-mode toolkit with retail-faithful
behavior (event codes, focus/modal/capture, drag-drop state machine)
and will consume the same portal.dat fonts + sprites so the visual
identity is preserved.

C# scaffold (src/AcDream.App/UI/):
- UiEvent          — 24-byte event struct + retail event-type constants
                     (0x01 click, 0x15 drag-begin, 0x201 WM_LBUTTONDOWN,
                     etc.) matching retail decompile switches
- UiElement        — base widget: children, ZOrder, focus/capture flags,
                     virtual OnDraw/OnEvent/OnHitTest/OnTick; children-
                     first hit test + back-to-front composite
- UiPanel          — panel, label, button primitives
- UiRenderContext  — 2D draw context with translate stack
- UiRoot           — top-of-tree + Device responsibilities (mouse/
                     keyboard state, focus, modal, capture, drag-drop,
                     tooltip timer); WorldMouseFallThrough/
                     WorldKeyFallThrough preserves existing camera
                     controls when no widget consumes
- UiHost           — packages UiRoot + TextRenderer + input wiring
                     helpers for one-line integration into GameWindow
- README.md        — orientation for future agents

Roadmap (docs/plans/2026-04-11-roadmap.md):
- D.1 marked shipped (debug overlay from 2026-04-17)
- D.2 expanded to include the retail UI framework landed here
- D.3-D.7 added: AcFont, dat sprites, core panels, HUD, CursorManager
- D.8 remains sound

All existing 470 tests pass. 0 warnings, 0 errors.
This commit is contained in:
Erik 2026-04-17 19:13:02 +02:00
parent ff325abd7b
commit 7230c1590f
15 changed files with 8041 additions and 5 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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