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