acdream/src/AcDream.App/UI/UiHost.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

102 lines
3.6 KiB
C#

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