docs+feat(ui): retail UI deep-dive research + C# port scaffold
Deep investigation of the retail AC client's GUI subsystem, driven by 6
parallel Opus research agents, plus the first cut of a retail-faithful
retained-mode widget toolkit that scaffolds Phase D.
Research (docs/research/retail-ui/):
- 00-master-synthesis.md — cross-slice synthesis + port plan
- 01-architecture-and-init.md — WinMain, CreateMainWindow, frame loop,
Keystone bring-up (7 globals mapped)
- 02-class-hierarchy.md — key finding: UI lives in keystone.dll,
not acclient.exe; CUIManager + CUIListener
MI pattern, CFont + CSurface + CString
- 03-rendering.md — 24-byte XYZRHW+UV verts, per-font
256x256 atlas baked from RenderSurface,
TEXTUREFACTOR coloring, DrawPrimitiveUP
- 04-input-events.md — Win32 WndProc → Device (DAT_00837ff4)
→ widget OnEvent(+0x128); full event-type
table (0x01 click, 0x07 tooltip ~1000ms,
0x15 drag-begin, 0x21 enter, 0x3E drop)
- 05-panels.md — chat, attributes, skills, spells, paperdoll
(25-slot layout), inventory, fellowship,
allegiance — with wire-message bindings
- 06-hud-and-assets.md — vital orbs (scissor fill), radar
(0x06001388/0x06004CC1, 1.18× shrink),
compass strip, dat asset catalog
Key insight: keystone.dll owns the actual widget toolkit — we cannot
port a class hierarchy from the decompile because it's not there.
Instead we implement our own retained-mode toolkit with retail-faithful
behavior (event codes, focus/modal/capture, drag-drop state machine)
and will consume the same portal.dat fonts + sprites so the visual
identity is preserved.
C# scaffold (src/AcDream.App/UI/):
- UiEvent — 24-byte event struct + retail event-type constants
(0x01 click, 0x15 drag-begin, 0x201 WM_LBUTTONDOWN,
etc.) matching retail decompile switches
- UiElement — base widget: children, ZOrder, focus/capture flags,
virtual OnDraw/OnEvent/OnHitTest/OnTick; children-
first hit test + back-to-front composite
- UiPanel — panel, label, button primitives
- UiRenderContext — 2D draw context with translate stack
- UiRoot — top-of-tree + Device responsibilities (mouse/
keyboard state, focus, modal, capture, drag-drop,
tooltip timer); WorldMouseFallThrough/
WorldKeyFallThrough preserves existing camera
controls when no widget consumes
- UiHost — packages UiRoot + TextRenderer + input wiring
helpers for one-line integration into GameWindow
- README.md — orientation for future agents
Roadmap (docs/plans/2026-04-11-roadmap.md):
- D.1 marked shipped (debug overlay from 2026-04-17)
- D.2 expanded to include the retail UI framework landed here
- D.3-D.7 added: AcFont, dat sprites, core panels, HUD, CursorManager
- D.8 remains sound
All existing 470 tests pass. 0 warnings, 0 errors.
This commit is contained in:
parent
ff325abd7b
commit
7230c1590f
15 changed files with 8041 additions and 5 deletions
473
src/AcDream.App/UI/UiRoot.cs
Normal file
473
src/AcDream.App/UI/UiRoot.cs
Normal file
|
|
@ -0,0 +1,473 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Top-level UI container. Implements the retail "Device" responsibilities
|
||||
/// (mouse cursor tracking, keyboard focus, modal overlay, mouse capture,
|
||||
/// drag-drop state machine, tooltip timer). Routes Silk.NET input events
|
||||
/// into the widget tree with retail-faithful <see cref="UiEvent"/>
|
||||
/// semantics.
|
||||
///
|
||||
/// Retail analog: the <c>DAT_00837ff4</c> Device object (see
|
||||
/// <c>docs/research/retail-ui/04-input-events.md §2</c>). That object has
|
||||
/// a ~20-slot vtable; the methods we emulate here are:
|
||||
///
|
||||
/// <list type="bullet">
|
||||
/// <item>+0x18 / +0x1C : <see cref="MouseX"/> / <see cref="MouseY"/></item>
|
||||
/// <item>+0x34 : <see cref="RegisterTimerEvent"/> (tooltip delay)</item>
|
||||
/// <item>+0x38 : <see cref="FireEvent"/></item>
|
||||
/// <item>+0x44 : <see cref="KeyboardFocus"/></item>
|
||||
/// <item>+0x48 / +0x4C : <see cref="SetCapture"/> / <see cref="ReleaseCapture"/></item>
|
||||
/// <item>+0x74 / +0x78 : drag cursor set / reset</item>
|
||||
/// </list>
|
||||
///
|
||||
/// When no widget consumes an event, the <see cref="WorldMouseFallThrough"/>
|
||||
/// or <see cref="WorldKeyFallThrough"/> event fires so the game world
|
||||
/// (camera, player controller) still receives input.
|
||||
/// </summary>
|
||||
public sealed class UiRoot : UiElement
|
||||
{
|
||||
// ── Device-level state ───────────────────────────────────────────────
|
||||
public int MouseX { get; private set; }
|
||||
public int MouseY { get; private set; }
|
||||
public bool LeftButtonDown { get; private set; }
|
||||
public bool RightButtonDown { get; private set; }
|
||||
public bool MiddleButtonDown { get; private set; }
|
||||
|
||||
/// <summary>Widget currently receiving keyboard events.</summary>
|
||||
public UiElement? KeyboardFocus { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Single modal overlay; while set, mouse clicks outside its rect
|
||||
/// are ignored. Retail sets this via Device vtable +0x48.
|
||||
/// </summary>
|
||||
public UiPanel? Modal { get; set; }
|
||||
|
||||
/// <summary>Widget with mouse capture (during click-drag).</summary>
|
||||
public UiElement? Captured { get; private set; }
|
||||
|
||||
/// <summary>Current drag source (set between drag-begin and drop/cancel).</summary>
|
||||
public UiElement? DragSource { get; private set; }
|
||||
public object? DragPayload { get; private set; }
|
||||
private UiElement? _lastDragHoverTarget;
|
||||
private int _pressX, _pressY;
|
||||
private bool _dragCandidate;
|
||||
private const int DragDistanceThreshold = 3; // pixels, retail-observed
|
||||
|
||||
// Hover / tooltip tracking.
|
||||
private UiElement? _hoverWidget;
|
||||
private long _hoverStartedMs;
|
||||
private const int TooltipDelayMs = 1000; // retail typical
|
||||
private bool _tooltipFired;
|
||||
|
||||
private long _nowMs;
|
||||
|
||||
/// <summary>Raised when an event was not consumed by any widget.</summary>
|
||||
public event Action<UiMouseButton, int, int, uint>? WorldMouseFallThrough;
|
||||
|
||||
/// <summary>Raised when a key was not consumed by any widget.</summary>
|
||||
public event Action<int /*vk*/, uint /*lparam*/>? WorldKeyFallThrough;
|
||||
|
||||
/// <summary>Raised when mouse moved and no widget captured.</summary>
|
||||
public event Action<int, int>? WorldMouseMoveFallThrough;
|
||||
|
||||
/// <summary>Raised on scroll fall-through (world zoom, etc.).</summary>
|
||||
public event Action<int /*dy*/>? WorldScrollFallThrough;
|
||||
|
||||
private uint _nextEventId = 0x10000001u;
|
||||
|
||||
public override void AddChild(UiElement child)
|
||||
{
|
||||
if (child.EventId == 0) child.EventId = _nextEventId++;
|
||||
base.AddChild(child);
|
||||
}
|
||||
|
||||
// ── Per-frame pumping ────────────────────────────────────────────────
|
||||
|
||||
public void Tick(double dt, long nowMs)
|
||||
{
|
||||
_nowMs = nowMs;
|
||||
|
||||
// Tooltip timer: once mouse has hovered over the same widget for
|
||||
// TooltipDelayMs, fire a Tooltip event on it exactly once.
|
||||
if (_hoverWidget is not null && !_tooltipFired
|
||||
&& _nowMs - _hoverStartedMs >= TooltipDelayMs)
|
||||
{
|
||||
var e = new UiEvent(_hoverWidget.EventId, _hoverWidget, UiEventType.Tooltip);
|
||||
_hoverWidget.OnEvent(in e);
|
||||
_tooltipFired = true;
|
||||
}
|
||||
|
||||
TickSelfAndChildren(dt);
|
||||
}
|
||||
|
||||
public void Draw(UiRenderContext ctx)
|
||||
{
|
||||
// Render children (panels) sorted by z-order — modal last so it
|
||||
// sits on top.
|
||||
DrawSelfAndChildren(ctx);
|
||||
}
|
||||
|
||||
// ── Input entry points (called from GameWindow's Silk.NET handlers) ──
|
||||
|
||||
public void OnMouseMove(int x, int y)
|
||||
{
|
||||
int dx = x - MouseX;
|
||||
int dy = y - MouseY;
|
||||
MouseX = x;
|
||||
MouseY = y;
|
||||
|
||||
// If we have capture, deliver MouseMove to the captured widget
|
||||
// AND drive drag state machine; do NOT fall through.
|
||||
if (Captured is not null)
|
||||
{
|
||||
DispatchMouseMove(Captured, x, y);
|
||||
|
||||
// Promote to drag if candidate and moved far enough.
|
||||
if (_dragCandidate && DragSource is null)
|
||||
{
|
||||
if (Math.Abs(x - _pressX) > DragDistanceThreshold
|
||||
|| Math.Abs(y - _pressY) > DragDistanceThreshold)
|
||||
{
|
||||
BeginDrag(Captured, payload: null);
|
||||
}
|
||||
}
|
||||
if (DragSource is not null)
|
||||
UpdateDragHover(x, y);
|
||||
return;
|
||||
}
|
||||
|
||||
// Not captured: track hover for tooltips + fall through.
|
||||
UpdateHover(x, y);
|
||||
WorldMouseMoveFallThrough?.Invoke(x, y);
|
||||
}
|
||||
|
||||
public void OnMouseDown(UiMouseButton btn, int x, int y, uint flags = 0)
|
||||
{
|
||||
MouseX = x; MouseY = y;
|
||||
UpdateButtonFlag(btn, down: true);
|
||||
_pressX = x; _pressY = y;
|
||||
|
||||
// Modal blocks clicks outside its bounds.
|
||||
if (Modal is not null && !ContainsAbsolute(Modal, x, y))
|
||||
return;
|
||||
|
||||
var (target, lx, ly) = HitTestTopDown(x, y);
|
||||
if (target is null)
|
||||
{
|
||||
WorldMouseFallThrough?.Invoke(btn, x, y, flags);
|
||||
return;
|
||||
}
|
||||
|
||||
// Set keyboard focus if target accepts it.
|
||||
if (target.AcceptsFocus) SetKeyboardFocus(target);
|
||||
|
||||
// Capture + arm drag candidate (drag promotes on subsequent MouseMove > threshold).
|
||||
SetCapture(target);
|
||||
_dragCandidate = true;
|
||||
|
||||
// Dispatch raw MouseDown event (retail uses WM_LBUTTONDOWN = 0x201).
|
||||
int rawType = btn switch
|
||||
{
|
||||
UiMouseButton.Left => UiEventType.MouseDown,
|
||||
UiMouseButton.Right => UiEventType.RightDown,
|
||||
UiMouseButton.Middle => UiEventType.MiddleDown,
|
||||
_ => UiEventType.MouseDown,
|
||||
};
|
||||
var e = new UiEvent(target.EventId, target, rawType,
|
||||
Data0: (int)flags, Data1: (int)lx, Data2: (int)ly);
|
||||
BubbleEvent(target, in e);
|
||||
}
|
||||
|
||||
public void OnMouseUp(UiMouseButton btn, int x, int y, uint flags = 0)
|
||||
{
|
||||
MouseX = x; MouseY = y;
|
||||
UpdateButtonFlag(btn, down: false);
|
||||
|
||||
if (DragSource is not null)
|
||||
{
|
||||
FinishDrag(x, y);
|
||||
ReleaseCapture();
|
||||
_dragCandidate = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (Captured is not null)
|
||||
{
|
||||
int rawType = btn switch
|
||||
{
|
||||
UiMouseButton.Left => UiEventType.MouseUp,
|
||||
UiMouseButton.Right => UiEventType.RightUp,
|
||||
UiMouseButton.Middle => UiEventType.MiddleUp,
|
||||
_ => UiEventType.MouseUp,
|
||||
};
|
||||
|
||||
var sp = Captured.ScreenPosition;
|
||||
var raw = new UiEvent(Captured.EventId, Captured, rawType,
|
||||
Data0: (int)flags,
|
||||
Data1: (int)(x - sp.X), Data2: (int)(y - sp.Y));
|
||||
BubbleEvent(Captured, in raw);
|
||||
|
||||
// If left-up over the same element that received the down, emit Click.
|
||||
if (btn == UiMouseButton.Left && ContainsAbsolute(Captured, x, y))
|
||||
{
|
||||
var click = new UiEvent(Captured.EventId, Captured, UiEventType.Click,
|
||||
Data0: (int)flags,
|
||||
Data1: (int)(x - sp.X), Data2: (int)(y - sp.Y));
|
||||
BubbleEvent(Captured, in click);
|
||||
}
|
||||
else if (btn == UiMouseButton.Right && ContainsAbsolute(Captured, x, y))
|
||||
{
|
||||
var click = new UiEvent(Captured.EventId, Captured, UiEventType.RightClick,
|
||||
Data0: (int)flags);
|
||||
BubbleEvent(Captured, in click);
|
||||
}
|
||||
|
||||
ReleaseCapture();
|
||||
_dragCandidate = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// No capture — give the world a chance.
|
||||
WorldMouseFallThrough?.Invoke(btn, x, y, flags);
|
||||
}
|
||||
|
||||
public void OnScroll(int dy)
|
||||
{
|
||||
// Scroll goes to the widget under the cursor (not the focused one).
|
||||
var (target, lx, ly) = HitTestTopDown(MouseX, MouseY);
|
||||
if (target is null)
|
||||
{
|
||||
WorldScrollFallThrough?.Invoke(dy);
|
||||
return;
|
||||
}
|
||||
var e = new UiEvent(target.EventId, target, UiEventType.Scroll, Data0: dy,
|
||||
Data1: (int)lx, Data2: (int)ly);
|
||||
BubbleEvent(target, in e);
|
||||
}
|
||||
|
||||
public void OnKeyDown(int vk, uint lparam = 0)
|
||||
{
|
||||
// Focus widget first.
|
||||
if (KeyboardFocus is not null)
|
||||
{
|
||||
var e = new UiEvent(KeyboardFocus.EventId, KeyboardFocus, UiEventType.KeyDown,
|
||||
Data0: vk, Data1: (int)lparam);
|
||||
if (BubbleEvent(KeyboardFocus, in e)) return;
|
||||
}
|
||||
|
||||
// If the focused widget is NOT an edit control, also consult the modal /
|
||||
// top panel. Edit controls absorb all keys (prevents hotkeys while typing).
|
||||
if (KeyboardFocus is null || !KeyboardFocus.IsEditControl)
|
||||
{
|
||||
var root = Modal ?? (UiElement)this;
|
||||
var e = new UiEvent(root.EventId, root, UiEventType.KeyDown,
|
||||
Data0: vk, Data1: (int)lparam);
|
||||
if (BubbleEvent(root, in e)) return;
|
||||
}
|
||||
|
||||
WorldKeyFallThrough?.Invoke(vk, lparam);
|
||||
}
|
||||
|
||||
public void OnKeyUp(int vk, uint lparam = 0)
|
||||
{
|
||||
if (KeyboardFocus is not null)
|
||||
{
|
||||
var e = new UiEvent(KeyboardFocus.EventId, KeyboardFocus, UiEventType.KeyUp,
|
||||
Data0: vk, Data1: (int)lparam);
|
||||
if (BubbleEvent(KeyboardFocus, in e)) return;
|
||||
}
|
||||
// Key up rarely falls through; game logic generally keys off KeyDown.
|
||||
}
|
||||
|
||||
public void OnChar(int codepoint)
|
||||
{
|
||||
if (KeyboardFocus is null || !KeyboardFocus.IsEditControl) return;
|
||||
var e = new UiEvent(KeyboardFocus.EventId, KeyboardFocus, UiEventType.Char,
|
||||
Data0: codepoint);
|
||||
BubbleEvent(KeyboardFocus, in e);
|
||||
}
|
||||
|
||||
// ── Focus + capture ─────────────────────────────────────────────────
|
||||
|
||||
public void SetKeyboardFocus(UiElement? e)
|
||||
{
|
||||
if (KeyboardFocus == e) return;
|
||||
if (KeyboardFocus is not null)
|
||||
{
|
||||
var lost = new UiEvent(KeyboardFocus.EventId, KeyboardFocus, UiEventType.FocusLost);
|
||||
KeyboardFocus.OnEvent(in lost);
|
||||
}
|
||||
KeyboardFocus = e;
|
||||
if (e is not null)
|
||||
{
|
||||
var gained = new UiEvent(e.EventId, e, UiEventType.FocusGained);
|
||||
e.OnEvent(in gained);
|
||||
}
|
||||
}
|
||||
|
||||
public void SetCapture(UiElement e) => Captured = e;
|
||||
public void ReleaseCapture() => Captured = null;
|
||||
|
||||
// ── Drag-drop (retail event chain 0x15 → 0x21 → 0x1C → 0x3E) ────────
|
||||
|
||||
private void BeginDrag(UiElement source, object? payload)
|
||||
{
|
||||
DragSource = source;
|
||||
DragPayload = payload;
|
||||
var e = new UiEvent(source.EventId, source, UiEventType.DragBegin, Payload: payload);
|
||||
source.OnEvent(in e);
|
||||
}
|
||||
|
||||
private void UpdateDragHover(int x, int y)
|
||||
{
|
||||
var (t, lx, ly) = HitTestTopDown(x, y);
|
||||
if (ReferenceEquals(t, _lastDragHoverTarget)) return;
|
||||
|
||||
// Leave old target.
|
||||
if (_lastDragHoverTarget is not null)
|
||||
{
|
||||
var eLeave = new UiEvent(DragSource!.EventId, _lastDragHoverTarget,
|
||||
UiEventType.DragOver, Data1: x, Data2: y,
|
||||
Payload: DragPayload);
|
||||
_lastDragHoverTarget.OnEvent(in eLeave);
|
||||
}
|
||||
|
||||
// Enter new target.
|
||||
if (t is not null)
|
||||
{
|
||||
var eEnter = new UiEvent(DragSource!.EventId, t, UiEventType.DragEnter,
|
||||
Data1: (int)lx, Data2: (int)ly,
|
||||
Payload: DragPayload);
|
||||
t.OnEvent(in eEnter);
|
||||
}
|
||||
_lastDragHoverTarget = t;
|
||||
}
|
||||
|
||||
private void FinishDrag(int x, int y)
|
||||
{
|
||||
var (t, lx, ly) = HitTestTopDown(x, y);
|
||||
var target = t ?? DragSource!;
|
||||
var accepted = t is not null && t != DragSource;
|
||||
var e = new UiEvent(DragSource!.EventId, target, UiEventType.DropReleased,
|
||||
Data0: accepted ? 1 : 0,
|
||||
Data1: (int)lx, Data2: (int)ly,
|
||||
Payload: DragPayload);
|
||||
target.OnEvent(in e);
|
||||
|
||||
DragSource = null;
|
||||
DragPayload = null;
|
||||
_lastDragHoverTarget = null;
|
||||
}
|
||||
|
||||
// ── Hover / tooltip ─────────────────────────────────────────────────
|
||||
|
||||
private void UpdateHover(int x, int y)
|
||||
{
|
||||
var (w, _, _) = HitTestTopDown(x, y);
|
||||
if (ReferenceEquals(w, _hoverWidget)) return;
|
||||
|
||||
if (_hoverWidget is not null)
|
||||
{
|
||||
var leave = new UiEvent(_hoverWidget.EventId, _hoverWidget, UiEventType.HoverLeave);
|
||||
_hoverWidget.OnEvent(in leave);
|
||||
}
|
||||
_hoverWidget = w;
|
||||
_hoverStartedMs = _nowMs;
|
||||
_tooltipFired = false;
|
||||
if (w is not null)
|
||||
{
|
||||
var enter = new UiEvent(w.EventId, w, UiEventType.HoverEnter);
|
||||
w.OnEvent(in enter);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
public void FireEvent(int type, UiElement target, object? payload = null)
|
||||
{
|
||||
var e = new UiEvent(target.EventId, target, type, Payload: payload);
|
||||
target.OnEvent(in e);
|
||||
}
|
||||
|
||||
public void RegisterTimerEvent(int type, UiElement target, int delayMs,
|
||||
object? payload = null)
|
||||
{
|
||||
_timers.Add((_nowMs + delayMs, new UiEvent(target.EventId, target, type, Payload: payload)));
|
||||
}
|
||||
|
||||
private readonly List<(long fireAt, UiEvent e)> _timers = new();
|
||||
|
||||
private void UpdateButtonFlag(UiMouseButton b, bool down)
|
||||
{
|
||||
switch (b)
|
||||
{
|
||||
case UiMouseButton.Left: LeftButtonDown = down; break;
|
||||
case UiMouseButton.Right: RightButtonDown = down; break;
|
||||
case UiMouseButton.Middle: MiddleButtonDown = down; break;
|
||||
}
|
||||
}
|
||||
|
||||
private (UiElement? element, float localX, float localY) HitTestTopDown(int x, int y)
|
||||
{
|
||||
// Modal gets exclusive hit-test.
|
||||
if (Modal is not null)
|
||||
{
|
||||
var mp = Modal.ScreenPosition;
|
||||
var mh = Modal.HitTest(x - mp.X, y - mp.Y);
|
||||
if (mh is not null) return (mh, x - mp.X, y - mp.Y);
|
||||
return (null, 0, 0);
|
||||
}
|
||||
|
||||
// Walk top-level children in reverse Z-order (topmost first).
|
||||
var kids = new UiElement[Children.Count];
|
||||
for (int i = 0; i < Children.Count; i++) kids[i] = Children[i];
|
||||
Array.Sort(kids, static (a, b) => b.ZOrder.CompareTo(a.ZOrder));
|
||||
foreach (var c in kids)
|
||||
{
|
||||
var cp = c.ScreenPosition;
|
||||
var hit = c.HitTest(x - cp.X, y - cp.Y);
|
||||
if (hit is not null)
|
||||
return (hit, x - cp.X, y - cp.Y);
|
||||
}
|
||||
return (null, 0, 0);
|
||||
}
|
||||
|
||||
private static bool ContainsAbsolute(UiElement e, int x, int y)
|
||||
{
|
||||
var sp = e.ScreenPosition;
|
||||
return x >= sp.X && x < sp.X + e.Width
|
||||
&& y >= sp.Y && y < sp.Y + e.Height;
|
||||
}
|
||||
|
||||
private void DispatchMouseMove(UiElement target, int x, int y)
|
||||
{
|
||||
var sp = target.ScreenPosition;
|
||||
var e = new UiEvent(target.EventId, target, UiEventType.MouseMove,
|
||||
Data1: (int)(x - sp.X), Data2: (int)(y - sp.Y));
|
||||
BubbleEvent(target, in e);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Call <see cref="UiElement.OnEvent"/> on <paramref name="start"/>;
|
||||
/// if it returns false, walk the Parent chain.
|
||||
/// </summary>
|
||||
private bool BubbleEvent(UiElement start, in UiEvent e)
|
||||
{
|
||||
var w = start;
|
||||
while (w is not null)
|
||||
{
|
||||
if (w.OnEvent(in e)) return true;
|
||||
w = w.Parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
// Root itself draws nothing; children do.
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue