using System.Numerics; using AcDream.App.Rendering; using AcDream.App.UI; using DatReaderWriter; using DatReaderWriter.Options; 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; /// /// Standalone Silk.NET window that boots the production render stack /// () and previews a single UI panel /// identified by a . /// /// Usage: dotnet run -- ui-studio [dat-dir] [--layout 0xNNNN] [--markup path] /// /// Task 3 adds an ImGui IDE on top of the panel FBO: /// /// Canvas pane — the panel rendered off-screen via . /// Tree pane — the element hierarchy; click-to-select. /// Properties pane — geometry/anchors/flags of the selected element. /// Click-to-inspect — a left-click in the canvas selects the topmost /// element under the cursor via . /// /// /// 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 GameWindow. /// public sealed class StudioWindow : IDisposable { private readonly StudioOptions _opts; // Created in OnLoad, released in OnClosing. private IWindow? _window; private DatCollection? _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 _dumpSlugs = Array.Empty(); // 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; // 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)); } /// /// Open the window and block until it is closed. /// Mirrors GameWindow.Run(). /// 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(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 = new DatCollection(_opts.DatDir, DatAccessType.Read); // 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)); // Load the panel described by options and add it to the UI tree. // Task 4b: --dump 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; if (_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 ."); 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 { root = _source.Load(_opts); if (root is null) Console.Error.WriteLine($"[studio] panel load failed: {_source.LastError}"); } _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(); 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(); // Wire KEYBOARD input into UiHost (keyboard coords are not spatial, so no remapping needed). // Do NOT wire mouse here — raw Silk window coords would be offset by the canvas sub-window's // position (tree pane width + ImGui chrome) and land in the wrong panel-local location. // Mouse is forwarded manually from DrawCanvas with correct panel-local mapping below. foreach (var kb in input.Keyboards) _stack.UiHost.WireKeyboard(kb); _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; // ── 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 (_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(flipped, w, h); img.SaveAsPng(path); Console.WriteLine($"[studio-screenshot] wrote {path} ({w}x{h})"); _window?.Close(); return; } // ── INTERACTIVE PATH ────────────────────────────────────────────────────── 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(); } /// /// Load a different dump panel at runtime (no relaunch required). /// Removes the current from the UI tree, /// loads the named slug from the dump, and installs the new root. /// Resets to null. /// No-op when the dump file is not available or the slug fails to load. /// 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. 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; } private void OnClosing() { _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() { _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; } }