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.
102 lines
3.6 KiB
C#
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();
|
|
}
|
|
}
|