acdream/src/AcDream.App/Studio/StudioWindow.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

528 lines
21 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Numerics;
using AcDream.Content;
using AcDream.App.Rendering;
using AcDream.App.UI;
using DatReaderWriter;
using Silk.NET.Input;
using Silk.NET.Maths;
using Silk.NET.OpenGL;
using Silk.NET.Windowing;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using static Silk.NET.OpenGL.ClearBufferMask;
namespace AcDream.App.Studio;
/// <summary>
/// Standalone Silk.NET window that boots the production render stack
/// (<see cref="RenderBootstrap"/>) and previews a single UI panel
/// identified by a <see cref="LayoutSource"/>.
///
/// Usage: dotnet run -- ui-studio [dat-dir] [--layout 0xNNNN] [--markup path] [--mockup]
///
/// Task 3 adds an ImGui IDE on top of the panel FBO:
/// <list type="bullet">
/// <item>Canvas pane — the panel rendered off-screen via <see cref="PanelFbo"/>.</item>
/// <item>Tree pane — the element hierarchy; click-to-select.</item>
/// <item>Properties pane — geometry/anchors/flags of the selected element.</item>
/// <item>Click-to-inspect — a left-click in the canvas selects the topmost
/// element under the cursor via <see cref="UiRoot.Pick"/>.</item>
/// </list>
///
/// The window is intentionally thin: no game world, no physics, no streaming —
/// just GL + UiHost + the layout under test, identical to how the panel
/// appears inside <c>GameWindow</c>.
/// </summary>
public sealed class StudioWindow : IDisposable
{
private readonly StudioOptions _opts;
// Created in OnLoad, released in OnClosing.
private IWindow? _window;
private IDatReaderWriter? _dats;
private RenderStack? _stack;
private LayoutSource? _source;
// Task 3 additions.
private AcDream.UI.ImGui.ImGuiBootstrapper? _imgui;
private PanelFbo? _panelFbo;
private StudioInspector? _inspector;
private UiElement? _panelRoot; // top-level element added to UiRoot (for hit-test + tree)
// UX-pass additions: panel picker + current-slug tracking.
private string? _currentSlug; // slug of the panel currently displayed (null = non-dump mode)
private string? _dumpFile; // resolved dump file path (once, in OnLoad)
private IReadOnlyList<string> _dumpSlugs = Array.Empty<string>(); // all slugs from the dump
// Task 4: sample data table — built once in OnLoad and kept alive for the window's lifetime
// so the controller subscriptions (ObjectAdded/ObjectMoved etc.) fire correctly.
private AcDream.Core.Items.ClientObjectTable? _objects;
private IRetainedPanelController? _fixtureController;
// Headless screenshot mode: set when --screenshot was passed.
// True after the first OnRender fires the screenshot (guard against repeat).
private bool _screenshotDone;
public StudioWindow(StudioOptions opts)
{
_opts = opts ?? throw new ArgumentNullException(nameof(opts));
}
/// <summary>
/// Open the window and block until it is closed.
/// Mirrors <c>GameWindow.Run()</c>.
/// </summary>
public void Run()
{
// Resolve quality settings the same way GameWindow.Run() does
// (SettingsStore → QualitySettings.From → WithEnvOverrides).
var startupStore = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore(
AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
var startupDisplay = startupStore.LoadDisplay();
var startupBase = AcDream.UI.Abstractions.Settings.QualitySettings.From(startupDisplay.Quality);
var startupQuality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(startupBase);
var options = WindowOptions.Default with
{
Size = new Vector2D<int>(1280, 720),
Title = "acdream UI Studio",
API = new GraphicsAPI(
ContextAPI.OpenGL,
ContextProfile.Core,
ContextFlags.ForwardCompatible,
new APIVersion(4, 3)),
VSync = false,
// MSAA from quality preset — must be baked into the GL context at creation.
Samples = startupQuality.MsaaSamples,
PreferredStencilBufferBits = 8,
// Headless screenshot mode: hide the window so no desktop flash occurs.
// The GL context is still fully valid on a hidden window; FBO rendering
// is off-screen and independent of window visibility.
IsVisible = _opts.ScreenshotPath is null,
};
_window = Window.Create(options);
_window.Load += OnLoad;
_window.Update += OnUpdate;
_window.Render += OnRender;
_window.Closing += OnClosing;
_window.Run();
}
private void OnLoad()
{
var gl = GL.GetApi(_window!);
_dats = RuntimeDatCollectionFactory.OpenReadOnly(_opts.DatDir);
// Build QualitySettings for RenderBootstrap (same as Run() above — re-read
// after the GL context is confirmed, mirroring GameWindow.OnLoad).
var store = new AcDream.UI.Abstractions.Panels.Settings.SettingsStore(
AcDream.UI.Abstractions.Panels.Settings.SettingsStore.DefaultPath());
var display = store.LoadDisplay();
var quality = AcDream.UI.Abstractions.Settings.QualitySettings.WithEnvOverrides(
AcDream.UI.Abstractions.Settings.QualitySettings.From(display.Quality));
_stack = RenderBootstrap.Create(gl, _dats, new RenderBootstrapOptions(quality));
if (_opts.Mockup)
MockupDesktop.Load(_dats, _stack);
// Load the panel described by options and add it to the UI tree.
// Task 4b: --dump <slug> uses DumpLayout (static retail mockup, no controllers).
// All other modes use LayoutSource + FixtureProvider (production path).
//
// Fix C: pass the per-element font resolver into LayoutSource so that elements
// with a non-zero FontDid get their own dat font at build time. This is wired
// ONLY in the studio path; GameWindow's Import calls continue to pass null so the
// live game path is provably unchanged (follow-up: GameWindow font-resolver wire-up).
_source = new LayoutSource(_dats, _stack.ResolveChrome, _stack.VitalsDatFont,
fontResolve: _stack.ResolveDatFont);
// Resolve the dump file once (used by OnLoad + by LoadDumpPanel at runtime).
_dumpFile = _opts.ResolveDumpFile();
if (_dumpFile is not null)
_dumpSlugs = UiDumpModel.ListSlugs(_dumpFile)
.OrderBy(s => s, StringComparer.OrdinalIgnoreCase).ToList();
UiElement? root = null;
if (!_opts.Mockup && _opts.DumpSlug is not null)
{
if (_dumpFile is null)
{
Console.Error.WriteLine("[studio] --dump: retail UI dump file not found. " +
"Expected docs/research/2026-06-25-retail-ui-layout-dump.json in the source tree, " +
"or pass --dump-file <path>.");
root = null;
}
else
{
root = DumpLayout.Load(_dumpFile, _opts.DumpSlug, _stack.ResolveChrome, out var dumpErr);
if (root is null)
Console.Error.WriteLine($"[studio] dump load failed: {dumpErr}");
else
_currentSlug = _opts.DumpSlug;
}
}
else if (!_opts.Mockup)
{
root = _source.Load(_opts);
if (root is null)
Console.Error.WriteLine($"[studio] panel load failed: {_source.LastError}");
else
NormalizeSinglePanelRoot(root);
}
_panelRoot = root;
if (root is not null)
{
_stack.UiHost.Root.AddChild(root);
// Task 4: populate the panel with sample data via production controllers,
// so inventory / vitals / toolbar panels render with plausible content.
// Dump source is static — no FixtureProvider needed.
if (_opts.DumpSlug is null && _source.CurrentLayout is not null)
{
uint layoutId = _opts.LayoutId ?? 0x2100006Cu;
_objects = SampleData.BuildObjectTable();
_fixtureController = FixtureProvider.Populate(
layoutId, _source.CurrentLayout, _stack, _objects, _dats);
}
}
// Task 3: ImGui IDE — interactive mode only.
// Headless screenshot mode needs only PanelFbo; ImGui/inspector/input are skipped.
_panelFbo = new PanelFbo(gl);
if (_opts.ScreenshotPath is null)
{
var input = _window!.CreateInput();
// Mockup mode draws UiHost directly to the window, so raw Silk mouse coordinates
// already match UI pixels. Inspector mode draws UiHost inside an ImGui canvas, so
// mouse is forwarded manually from DrawCanvas with canvas-local remapping.
if (_opts.Mockup)
foreach (var mouse in input.Mice)
_stack.UiHost.WireMouse(mouse);
foreach (var kb in input.Keyboards)
_stack.UiHost.WireKeyboard(kb);
if (_opts.Mockup)
return;
_imgui = new AcDream.UI.ImGui.ImGuiBootstrapper(gl, _window!, input);
_inspector = new StudioInspector();
}
}
private void OnUpdate(double dt) { }
private void OnRender(double dt)
{
if (_stack is null || _panelFbo is null) return;
_stack.BeginFrame();
Exception? renderFailure = null;
try
{
// ── HEADLESS SCREENSHOT PATH ──────────────────────────────────────────────
if (_opts.ScreenshotPath is not null)
{
if (_screenshotDone) return; // fire exactly once
_screenshotDone = true;
// Pick render size from the loaded root's bounds (clamped to sane limits).
// Fall back to 1280×720 when the root has no explicit size.
int w = 1280, h = 720;
if (!_opts.Mockup && _panelRoot is not null)
{
float rw = _panelRoot.Width;
float rh = _panelRoot.Height;
if (rw >= 1f && rh >= 1f)
{
w = Math.Clamp((int)rw, 256, 2048);
h = Math.Clamp((int)rh, 256, 2048);
}
}
// Tick once so widget state is initialised (e.g. bar fills).
_stack.UiHost.Tick(dt);
// Render the panel into the FBO.
_panelFbo.Render(w, h, _stack.UiHost);
// Read back RGBA pixels (FBO origin = bottom-left).
byte[] pixels = _panelFbo.ReadColorRgba(w, h);
if (pixels.Length == 0)
{
Console.Error.WriteLine("[studio-screenshot] FBO readback returned no pixels.");
_window?.Close();
return;
}
// Flip rows vertically: FBO bottom-left → PNG top-left.
int stride = w * 4;
byte[] flipped = new byte[pixels.Length];
for (int row = 0; row < h; row++)
{
System.Buffer.BlockCopy(pixels, row * stride, flipped, (h - 1 - row) * stride, stride);
}
// Build ImageSharp image and save as PNG.
var path = _opts.ScreenshotPath;
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
using var img = Image.LoadPixelData<Rgba32>(flipped, w, h);
img.SaveAsPng(path);
Console.WriteLine($"[studio-screenshot] wrote {path} ({w}x{h})");
_window?.Close();
return;
}
// ── INTERACTIVE PATH ──────────────────────────────────────────────────────
if (_opts.Mockup)
{
var mockupGl = _stack.Gl;
int mockupW = _window!.Size.X;
int mockupH = _window!.Size.Y;
_stack.UiHost.Tick(dt);
mockupGl.BindFramebuffer(FramebufferTarget.Framebuffer, 0);
mockupGl.Viewport(0, 0, (uint)mockupW, (uint)mockupH);
mockupGl.ClearColor(0.08f, 0.08f, 0.08f, 1f);
mockupGl.Clear(ColorBufferBit | DepthBufferBit);
_stack.UiHost.Draw(new Vector2(mockupW, mockupH));
return;
}
if (_imgui is null || _inspector is null) return;
var gl = _stack.Gl;
int iw = _window!.Size.X;
int ih = _window!.Size.Y;
// 1. Tick the UI widgets (OnRender's own dt — Update + Render fire with the same delta).
_stack.UiHost.Tick(dt);
// 2. Render the panel into the off-screen FBO; get the color texture.
// The FBO is the same logical size as the window, so element rects map 1:1 to
// FBO pixels — no scale factor needed when displaying the canvas at full size.
uint panelTex = _panelFbo.Render(iw, ih, _stack.UiHost);
// 3. Clear the window back-buffer (the dark ImGui background shows behind panes).
gl.ClearColor(0.1f, 0.1f, 0.1f, 1f);
gl.Clear(ColorBufferBit | DepthBufferBit);
// 4. Begin the ImGui frame.
_imgui.BeginFrame((float)dt);
// ── Layout constants (fixed pane arrangement, FirstUseEver) ──────────────
// MenuBar: always-on-top main menu bar (~22px) — panel picker lives here so it
// is never covered by the floating panes (replaces the old 40px toolbar).
// Tree: 280px wide on the left, below menu bar.
// Canvas: centre strip between tree and properties.
// Props: 340px wide on the right, below menu bar.
const int kMenuBarH = 22; // ImGui default main menu bar height
const int kTreeW = 280;
const int kPropsW = 340;
int canvasX = kTreeW;
int canvasW = Math.Max(1, iw - kTreeW - kPropsW);
int propsX = iw - kPropsW;
int paneY = kMenuBarH;
int paneH = Math.Max(1, ih - kMenuBarH);
// 5. Main menu bar — panel picker combo pinned to the window top.
// BeginMainMenuBar returns true when the bar is visible (always is); the combo
// inside it is always-on-top and is never occluded by Tree/Canvas/Props panes.
string? pickedSlug = null;
if (ImGuiNET.ImGui.BeginMainMenuBar())
{
ImGuiNET.ImGui.SetNextItemWidth(300f);
string preview = _currentSlug ?? "(none)";
if (ImGuiNET.ImGui.BeginCombo("Panel", preview))
{
foreach (var slug in _dumpSlugs)
{
bool selected = string.Equals(slug, _currentSlug,
System.StringComparison.OrdinalIgnoreCase);
if (ImGuiNET.ImGui.Selectable(slug, selected) && !selected)
pickedSlug = slug;
if (selected)
ImGuiNET.ImGui.SetItemDefaultFocus();
}
ImGuiNET.ImGui.EndCombo();
}
ImGuiNET.ImGui.EndMainMenuBar();
}
if (pickedSlug is not null)
LoadDumpPanel(pickedSlug);
// 6. Canvas pane — show the FBO texture; gather canvas mouse events.
var canvasEvt = default(CanvasInputEvent);
if (panelTex != 0)
canvasEvt = _inspector.DrawCanvas(
(nint)panelTex, iw, ih,
canvasX, canvasW, paneY, paneH);
// 7. Forward canvas mouse events to the panel UiHost or the inspector tree.
//
// Coordinate mapping (see StudioInspector.DrawCanvas summary):
// panel-local pixel = raw_mouse - ImGui.GetItemRectMin() (1:1 scale, no Y flip)
// The image is drawn V-flipped (uv0.Y=1, uv1.Y=0) so screen top = panel Y=0.
//
// Interact mode (default): canvas mouse events go directly to UiRoot so elements respond.
// OnMouseMove + OnMouseDown/Up + OnScroll are all forwarded.
// A Console.WriteLine confirms each forwarded left-click for live verification.
//
// Inspect mode: left-click hit-tests and selects the element in the tree (old behavior).
// OnMouseMove is still forwarded so hover/tooltip state in the panel stays live.
if (canvasEvt.IsHovered)
{
int mx = canvasEvt.MouseX;
int my = canvasEvt.MouseY;
var root = _stack.UiHost.Root;
// Always forward mouse-move so hover highlights / tooltips in the panel work.
root.OnMouseMove(mx, my);
if (_inspector.InteractMode)
{
// ── Interact: live panel interaction ──────────────────────────────
if (canvasEvt.LeftDown)
{
Console.WriteLine($"[studio] canvas click → panel ({mx}, {my})");
root.OnMouseDown(UiMouseButton.Left, mx, my);
}
if (canvasEvt.LeftUp)
root.OnMouseUp(UiMouseButton.Left, mx, my);
if (canvasEvt.ScrollDelta != 0)
root.OnScroll(canvasEvt.ScrollDelta);
}
else
{
// ── Inspect: click selects an element in the tree ─────────────────
if (canvasEvt.LeftDown)
{
var hit = root.Pick(mx, my);
if (hit is not null)
_inspector.Selected = hit;
}
}
}
// 8. Element tree pane.
if (_panelRoot is not null)
_inspector.DrawTree(_panelRoot, 0, paneY, kTreeW, paneH);
// 9. Properties pane.
_inspector.DrawProperties(propsX, paneY, kPropsW, paneH);
// 9. Finalise ImGui and flush draw data to the window.
_imgui.Render();
}
catch (Exception ex)
{
renderFailure = ex;
throw;
}
finally
{
try
{
_stack.EndFrame();
}
catch (Exception closeFailure) when (renderFailure is not null)
{
throw new AggregateException(
"UI Studio rendering failed and its GPU frame could not be closed.",
renderFailure,
closeFailure);
}
}
}
/// <summary>
/// Load a different dump panel at runtime (no relaunch required).
/// Removes the current <see cref="_panelRoot"/> from the UI tree,
/// loads the named slug from the dump, and installs the new root.
/// Resets <see cref="StudioInspector.Selected"/> to null.
/// No-op when the dump file is not available or the slug fails to load.
/// </summary>
public void LoadDumpPanel(string slug)
{
if (_stack is null || _inspector is null) return;
if (_dumpFile is null)
{
Console.Error.WriteLine("[studio] LoadDumpPanel: dump file not available.");
return;
}
// Remove the existing panel root from the tree. Fixture controllers
// own subscriptions into the sample table and end with their panel.
_fixtureController?.Dispose();
_fixtureController = null;
if (_panelRoot is not null)
{
_stack.UiHost.Root.RemoveChild(_panelRoot);
_panelRoot = null;
}
// Load the new panel.
var newRoot = DumpLayout.Load(_dumpFile, slug, _stack.ResolveChrome, out var err);
if (newRoot is null)
{
Console.Error.WriteLine($"[studio] LoadDumpPanel('{slug}') failed: {err}");
_currentSlug = null;
return;
}
_stack.UiHost.Root.AddChild(newRoot);
_panelRoot = newRoot;
_currentSlug = slug;
_inspector.Selected = null;
}
/// <summary>
/// Studio dat-layout previews show one panel in isolation, not at its live-game
/// screen position. Some top-level layouts (inventory: x=500,y=138) otherwise
/// draw entirely outside the root-sized screenshot FBO.
/// </summary>
internal static void NormalizeSinglePanelRoot(UiElement root)
{
root.Left = 0f;
root.Top = 0f;
root.Anchors = AnchorEdges.None;
}
private void OnClosing()
{
_fixtureController?.Dispose();
_fixtureController = null;
_imgui?.Dispose();
_panelFbo?.Dispose();
_imgui = null;
_panelFbo = null;
_stack?.Dispose(); // whole render stack: dispatcher/mesh/textures/shader/ubo/uihost
_dats?.Dispose();
_dats = null;
_stack = null;
}
public void Dispose()
{
_fixtureController?.Dispose();
_fixtureController = null;
_imgui?.Dispose();
_panelFbo?.Dispose();
_imgui = null;
_panelFbo = null;
_window?.Dispose();
_window = null;
// If OnClosing wasn't called (e.g. an exception before Run() completed), dispose the FULL
// stack anyway — the review flagged that disposing only UiHost here leaked the rest.
_stack?.Dispose();
_dats?.Dispose();
_dats = null;
_stack = null;
}
}