acdream/src/AcDream.App/UI/UiElement.cs
Erik 7230c1590f 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.
2026-04-17 19:13:02 +02:00

203 lines
7.9 KiB
C#

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;
}
}