acdream/src/AcDream.App/UI/UiHost.cs
Erik 749e8ceeb1 fix(rendering): bound portal resource lifetime
Separate logical ownership, render publication, and GPU retirement across live entities, landblocks, particles, textures, mesh arenas, portal/UI teardown, and per-frame scratch storage. Add bounded DAT/texture caches, upload budgets, three-frame fence retirement, exact-incarnation appearance reconciliation, frame pacing, and extensive lifetime conformance coverage.\n\nThe seven-destination connected route now cuts peak working/private memory roughly in half, returns Caul to 125-153 FPS locally, and produces no WER or AMD reset.\n\nCo-authored-by: OpenAI Codex <codex@openai.com>
2026-07-18 21:35:16 +02:00

218 lines
8.6 KiB
C#

using System.Collections.Generic;
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 RetailWindowManager WindowManager => Root.WindowManager;
public TextRenderer TextRenderer { get; }
public BitmapFont? DefaultFont { get; set; }
/// <summary>The last wired keyboard. Exposed so widgets that need clipboard
/// access (<see cref="IKeyboard.ClipboardText"/>) or modifier-key state
/// (<see cref="IKeyboard.IsKeyPressed"/>) — e.g. <see cref="UiText"/>'s
/// Ctrl+C copy — can reach the device. One-keyboard desktop: last wins.</summary>
public IKeyboard? Keyboard { get; private set; }
private long _startTicks = System.Environment.TickCount64;
private readonly List<System.Action> _inputUnsubscribers = new();
private ResourceShutdownTransaction? _shutdown;
private bool _disposeRequested;
private bool _disposed;
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)
{
System.ObjectDisposedException.ThrowIf(_disposeRequested || _disposed, this);
System.ArgumentNullException.ThrowIfNull(mouse);
void OnMouseDown(IMouse sender, MouseButton button) =>
Root.OnMouseDown(MapButton(button), (int)sender.Position.X, (int)sender.Position.Y);
void OnMouseUp(IMouse sender, MouseButton button) =>
Root.OnMouseUp(MapButton(button), (int)sender.Position.X, (int)sender.Position.Y);
void OnMouseMove(IMouse sender, Vector2 position) =>
Root.OnMouseMove((int)position.X, (int)position.Y);
void OnScroll(IMouse sender, ScrollWheel scroll) => Root.OnScroll((int)scroll.Y);
mouse.MouseDown += OnMouseDown;
mouse.MouseUp += OnMouseUp;
mouse.MouseMove += OnMouseMove;
mouse.Scroll += OnScroll;
_inputUnsubscribers.Add(() =>
{
mouse.MouseDown -= OnMouseDown;
mouse.MouseUp -= OnMouseUp;
mouse.MouseMove -= OnMouseMove;
mouse.Scroll -= OnScroll;
});
}
public void WireKeyboard(IKeyboard kb)
{
System.ObjectDisposedException.ThrowIf(_disposeRequested || _disposed, this);
System.ArgumentNullException.ThrowIfNull(kb);
Keyboard = kb; // last wired keyboard wins (one-keyboard desktop)
void OnKeyDown(IKeyboard sender, Key key, int scanCode) => Root.OnKeyDown((int)key);
void OnKeyUp(IKeyboard sender, Key key, int scanCode) => Root.OnKeyUp((int)key);
void OnKeyChar(IKeyboard sender, char value) => Root.OnChar(value);
kb.KeyDown += OnKeyDown;
kb.KeyUp += OnKeyUp;
kb.KeyChar += OnKeyChar;
_inputUnsubscribers.Add(() =>
{
kb.KeyDown -= OnKeyDown;
kb.KeyUp -= OnKeyUp;
kb.KeyChar -= OnKeyChar;
});
}
private static UiMouseButton MapButton(MouseButton b) => b switch
{
MouseButton.Left => UiMouseButton.Left,
MouseButton.Right => UiMouseButton.Right,
MouseButton.Middle => UiMouseButton.Middle,
_ => UiMouseButton.Left,
};
// ── Window manager forwarders (delegate to UiRoot) ─────────────────
/// <summary>Register a top-level window for Show/Hide/Toggle. See <see cref="UiRoot.RegisterWindow"/>.</summary>
public RetailWindowHandle RegisterWindow(
string name,
UiElement window,
UiElement? contentRoot = null,
IRetainedPanelController? controller = null,
IRetainedWindowStateController? stateController = null)
=> Root.RegisterWindow(name, window, contentRoot, controller, stateController);
public bool UnregisterWindow(string name) => Root.UnregisterWindow(name);
/// <summary>Show a registered window; returns false if the name is unknown.</summary>
public bool ShowWindow(string name) => Root.ShowWindow(name);
/// <summary>Hide a registered window; returns false if the name is unknown.</summary>
public bool HideWindow(string name) => Root.HideWindow(name);
public bool CloseWindow(string name) => Root.CloseWindow(name);
/// <summary>Return the current visibility of a registered window.</summary>
public bool IsWindowVisible(string name) => Root.IsWindowVisible(name);
/// <summary>Toggle a registered window's visibility; returns the new IsVisible.</summary>
public bool ToggleWindow(string name) => Root.ToggleWindow(name);
public void Dispose()
{
if (_disposed)
return;
_disposeRequested = true;
_shutdown ??= CreateShutdownTransaction(
_inputUnsubscribers.AsEnumerable().Reverse().ToArray(),
() =>
{
_inputUnsubscribers.Clear();
Keyboard = null;
},
WindowManager.Dispose,
TextRenderer.Dispose);
_shutdown.CompleteOrThrow();
_disposed = _shutdown.IsComplete;
}
internal static ResourceShutdownTransaction CreateShutdownTransaction(
IReadOnlyList<Action> inputUnsubscribers,
Action releaseInputState,
Action disposeWindowManager,
Action disposeTextRenderer)
{
ArgumentNullException.ThrowIfNull(inputUnsubscribers);
ArgumentNullException.ThrowIfNull(releaseInputState);
ArgumentNullException.ThrowIfNull(disposeWindowManager);
ArgumentNullException.ThrowIfNull(disposeTextRenderer);
return new ResourceShutdownTransaction(
new ResourceShutdownStage(
"retained UI input subscriptions",
inputUnsubscribers
.Select((unsubscribe, index) => new ResourceShutdownOperation(
$"input subscription {index}",
unsubscribe ?? throw new ArgumentException(
"Input unsubscriber entries cannot be null.",
nameof(inputUnsubscribers))))
.ToArray()),
new ResourceShutdownStage("retained UI input state",
[
new("input state", releaseInputState),
]),
new ResourceShutdownStage("retained UI windows",
[
new("window manager", disposeWindowManager),
]),
new ResourceShutdownStage("retained UI renderer",
[
new("text renderer", disposeTextRenderer),
]));
}
}