diff --git a/src/AcDream.App/Studio/PanelFbo.cs b/src/AcDream.App/Studio/PanelFbo.cs
new file mode 100644
index 00000000..91270d97
--- /dev/null
+++ b/src/AcDream.App/Studio/PanelFbo.cs
@@ -0,0 +1,128 @@
+using System;
+using System.Numerics;
+using AcDream.App.Rendering.Wb;
+using AcDream.App.UI;
+using Silk.NET.OpenGL;
+
+namespace AcDream.App.Studio;
+
+///
+/// Renders a into an off-screen FBO each frame and
+/// returns the color texture handle for display in ImGui.
+///
+/// Pattern lifted verbatim from :
+/// RGBA8 color texture + Depth24Stencil8 renderbuffer, lazily (re)created on
+/// size change. The entire 2-D UI pass is sealed in a
+/// so it cannot disturb the surrounding ImGui GL state.
+///
+/// FBO origin is bottom-left (GL convention). The caller must flip V when
+/// displaying the texture in ImGui (pass uv0=(0,1), uv1=(1,0) to ImGui.Image)
+/// so the image appears right-side-up in ImGui's top-left coordinate system.
+///
+public sealed unsafe class PanelFbo : IDisposable
+{
+ private readonly GL _gl;
+
+ // Off-screen target — lazily (re)created when the requested size changes.
+ private uint _fbo;
+ private uint _colorTex;
+ private uint _depthRbo;
+ private int _fbW;
+ private int _fbH;
+
+ public PanelFbo(GL gl)
+ {
+ _gl = gl ?? throw new ArgumentNullException(nameof(gl));
+ }
+
+ ///
+ /// Render (a full draw pass) into a
+ /// private FBO at × pixels.
+ /// Returns the GL color texture handle (0 on failure). The texture is valid until
+ /// the next call to with a different size, or until .
+ ///
+ public uint Render(int width, int height, UiHost host)
+ {
+ if (width <= 0 || height <= 0 || host is null) return 0u;
+
+ EnsureFramebuffer(width, height);
+ if (_fbo == 0) return 0u;
+
+ // Seal the entire pass: GLStateScope saves + restores every GL state the
+ // UI draw touches (viewport, blend, FBO binding, etc.) so ImGui's own state
+ // — set up by BeginFrame and expected intact by Render — is untouched.
+ using var scope = new GLStateScope(_gl);
+
+ _gl.BindFramebuffer(FramebufferTarget.Framebuffer, _fbo);
+ _gl.Viewport(0, 0, (uint)width, (uint)height);
+ _gl.Disable(EnableCap.ScissorTest);
+ _gl.ClearColor(0.18f, 0.18f, 0.18f, 1f); // dark bg matching the old direct draw
+ _gl.ClearDepth(1.0);
+ _gl.DepthMask(true);
+ _gl.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
+
+ host.Draw(new Vector2(width, height));
+
+ // FBO stays bound here; GLStateScope.Dispose() restores the previous binding.
+ return _colorTex;
+ }
+
+ // ── FBO lifecycle (mirrors PaperdollViewportRenderer.EnsureFramebuffer) ──────
+
+ private void EnsureFramebuffer(int width, int height)
+ {
+ if (_fbo != 0 && width == _fbW && height == _fbH) return;
+ DeleteFramebuffer();
+
+ _fbW = width;
+ _fbH = height;
+
+ _colorTex = _gl.GenTexture();
+ _gl.BindTexture(TextureTarget.Texture2D, _colorTex);
+ _gl.TexImage2D(TextureTarget.Texture2D, 0, InternalFormat.Rgba8,
+ (uint)width, (uint)height, 0,
+ PixelFormat.Rgba, PixelType.UnsignedByte, (void*)0);
+ _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMinFilter,
+ (int)TextureMinFilter.Linear);
+ _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureMagFilter,
+ (int)TextureMinFilter.Linear);
+ _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapS,
+ (int)TextureWrapMode.ClampToEdge);
+ _gl.TexParameter(TextureTarget.Texture2D, TextureParameterName.TextureWrapT,
+ (int)TextureWrapMode.ClampToEdge);
+ _gl.BindTexture(TextureTarget.Texture2D, 0);
+
+ _depthRbo = _gl.GenRenderbuffer();
+ _gl.BindRenderbuffer(RenderbufferTarget.Renderbuffer, _depthRbo);
+ _gl.RenderbufferStorage(RenderbufferTarget.Renderbuffer,
+ InternalFormat.Depth24Stencil8, (uint)width, (uint)height);
+ _gl.BindRenderbuffer(RenderbufferTarget.Renderbuffer, 0);
+
+ _fbo = _gl.GenFramebuffer();
+ _gl.BindFramebuffer(FramebufferTarget.Framebuffer, _fbo);
+ _gl.FramebufferTexture2D(FramebufferTarget.Framebuffer,
+ FramebufferAttachment.ColorAttachment0,
+ TextureTarget.Texture2D, _colorTex, 0);
+ _gl.FramebufferRenderbuffer(FramebufferTarget.Framebuffer,
+ FramebufferAttachment.DepthStencilAttachment,
+ RenderbufferTarget.Renderbuffer, _depthRbo);
+
+ var status = _gl.CheckFramebufferStatus(FramebufferTarget.Framebuffer);
+ _gl.BindFramebuffer(FramebufferTarget.Framebuffer, 0);
+ if (status != GLEnum.FramebufferComplete)
+ {
+ Console.WriteLine($"[studio] PanelFbo incomplete: {status} ({width}x{height})");
+ DeleteFramebuffer();
+ }
+ }
+
+ private void DeleteFramebuffer()
+ {
+ if (_fbo != 0) { _gl.DeleteFramebuffer(_fbo); _fbo = 0; }
+ if (_colorTex != 0) { _gl.DeleteTexture(_colorTex); _colorTex = 0; }
+ if (_depthRbo != 0) { _gl.DeleteRenderbuffer(_depthRbo); _depthRbo = 0; }
+ _fbW = _fbH = 0;
+ }
+
+ public void Dispose() => DeleteFramebuffer();
+}
diff --git a/src/AcDream.App/Studio/StudioInspector.cs b/src/AcDream.App/Studio/StudioInspector.cs
new file mode 100644
index 00000000..43cc03b0
--- /dev/null
+++ b/src/AcDream.App/Studio/StudioInspector.cs
@@ -0,0 +1,158 @@
+using System.Numerics;
+using AcDream.App.UI;
+using ImGuiNET;
+
+namespace AcDream.App.Studio;
+
+///
+/// Three-pane ImGui IDE for the acdream UI Studio:
+///
+/// - Canvas — shows the panel FBO texture; click-to-inspect returns the
+/// panel-local pixel coordinate of the click.
+/// - Tree — recursive ImGui tree of the element hierarchy; clicking a node
+/// sets .
+/// - Properties — shows the element's geometry,
+/// anchors, and z-order.
+///
+///
+/// Coordinate mapping for the canvas:
+/// The FBO is rendered at the same logical size as the window, so a click at image-local
+/// pixel (ix, iy) maps directly to panel coord (ix, iy) — no scale factor needed when
+/// the image is drawn 1:1. We draw it 1:1 if it fits; if the Canvas ImGui window is
+/// smaller we let ImGui clip it (the click coordinates are still image-local pixels so
+/// no scale correction is needed in v1).
+///
+/// V-flip: the FBO origin is bottom-left (GL convention), but ImGui images use
+/// top-left. We pass uv0=(0,1), uv1=(1,0) to ImGui.Image, which flips V so
+/// the panel renders right-side-up in the canvas. Because V is flipped, image-local
+/// pixel y = 0 is the TOP of the panel, matching the UiRoot's top-left origin — so
+/// click y maps directly without further inversion.
+///
+public sealed class StudioInspector
+{
+ /// Currently selected element (set by tree-click or canvas-click).
+ public UiElement? Selected { get; set; }
+
+ // ── Canvas ────────────────────────────────────────────────────────────────────
+
+ ///
+ /// Draw the "Canvas" ImGui window containing the panel FBO texture.
+ /// If the user left-clicks inside the image, returns the panel-local pixel
+ /// coordinate of the click (origin top-left, matching UiRoot's space); else null.
+ ///
+ /// The texture is drawn with uv0=(0,1) / uv1=(1,0) to flip V, correcting
+ /// the GL bottom-left FBO origin to the ImGui top-left convention.
+ ///
+ public (int x, int y)? DrawCanvas(nint panelTex, int width, int height)
+ {
+ ImGui.Begin("Canvas");
+
+ var imageSize = new Vector2(width, height);
+ // V-flip: FBO origin is bottom-left; ImGui images expect top-left.
+ // uv0 = bottom-left of texture = top of the panel in screen space.
+ // uv1 = top-right of texture = bottom of the panel in screen space.
+ var uv0 = new Vector2(0f, 1f);
+ var uv1 = new Vector2(1f, 0f);
+ ImGui.Image(panelTex, imageSize, uv0, uv1);
+
+ (int x, int y)? result = null;
+
+ // Detect a left-click on the image.
+ if (ImGui.IsItemHovered() && ImGui.IsMouseClicked(ImGuiMouseButton.Left))
+ {
+ // Image item rect: top-left of the drawn image in screen (display) coords.
+ var rectMin = ImGui.GetItemRectMin();
+ var mousePos = ImGui.GetMousePos();
+
+ // Panel-local pixel = mouse offset from image top-left.
+ // Because we flipped V (uv0.Y=1, uv1.Y=0), image row 0 IS the top of the panel,
+ // so y needs no further inversion.
+ int ix = (int)(mousePos.X - rectMin.X);
+ int iy = (int)(mousePos.Y - rectMin.Y);
+ // Clamp to image bounds.
+ if (ix >= 0 && ix < width && iy >= 0 && iy < height)
+ result = (ix, iy);
+ }
+
+ ImGui.End();
+ return result;
+ }
+
+ // ── Tree ──────────────────────────────────────────────────────────────────────
+
+ /// Draw the "Tree" ImGui window. Clicking a node sets .
+ public void DrawTree(UiElement root)
+ {
+ ImGui.Begin("Tree");
+ DrawTreeNode(root);
+ ImGui.End();
+ }
+
+ private void DrawTreeNode(UiElement el)
+ {
+ // Label: EventId (hex) + C# type name, e.g. "0x10000001 [UiDatElement]"
+ string label = $"0x{el.EventId:X8} [{el.GetType().Name}]";
+
+ bool isSelected = ReferenceEquals(el, Selected);
+ bool hasChildren = el.Children.Count > 0;
+
+ ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags.OpenOnArrow
+ | ImGuiTreeNodeFlags.SpanAvailWidth;
+ if (!hasChildren)
+ flags |= ImGuiTreeNodeFlags.Leaf;
+ if (isSelected)
+ flags |= ImGuiTreeNodeFlags.Selected;
+
+ bool open = ImGui.TreeNodeEx(label, flags);
+
+ // Click on the node label (not the arrow) selects it.
+ if (ImGui.IsItemClicked(ImGuiMouseButton.Left))
+ Selected = el;
+
+ if (open)
+ {
+ foreach (var child in el.Children)
+ DrawTreeNode(child);
+ ImGui.TreePop();
+ }
+ }
+
+ // ── Properties ───────────────────────────────────────────────────────────────
+
+ /// Draw the "Properties" ImGui window for .
+ public void DrawProperties()
+ {
+ ImGui.Begin("Properties");
+
+ var el = Selected;
+ if (el is null)
+ {
+ ImGui.TextUnformatted("(nothing selected)");
+ ImGui.End();
+ return;
+ }
+
+ ImGui.TextUnformatted($"Id (EventId): 0x{el.EventId:X8}");
+ ImGui.TextUnformatted($"Type: {el.GetType().Name}");
+ ImGui.TextUnformatted($"Name: {el.Name ?? "(null)"}");
+ ImGui.Separator();
+ ImGui.TextUnformatted($"Rect: ({el.Left}, {el.Top}, {el.Width} x {el.Height})");
+ ImGui.TextUnformatted($"Anchors: {el.Anchors}");
+ ImGui.TextUnformatted($"ZOrder: {el.ZOrder}");
+ ImGui.Separator();
+ ImGui.TextUnformatted($"Visible: {el.Visible}");
+ ImGui.TextUnformatted($"Enabled: {el.Enabled}");
+ ImGui.TextUnformatted($"ClickThrough: {el.ClickThrough}");
+ ImGui.TextUnformatted($"Draggable: {el.Draggable}");
+ ImGui.TextUnformatted($"Resizable: {el.Resizable}");
+ ImGui.TextUnformatted($"IsDragSource: {el.IsDragSource}");
+ ImGui.TextUnformatted($"HandlesClick: {el.HandlesClick}");
+ ImGui.TextUnformatted($"Opacity: {el.Opacity:F2}");
+ ImGui.Separator();
+ var sp = el.ScreenPosition;
+ ImGui.TextUnformatted($"ScreenPos: ({sp.X:F1}, {sp.Y:F1})");
+ ImGui.TextUnformatted($"Children: {el.Children.Count}");
+
+ ImGui.End();
+ }
+}
diff --git a/src/AcDream.App/Studio/StudioWindow.cs b/src/AcDream.App/Studio/StudioWindow.cs
index d55064b4..b77d1420 100644
--- a/src/AcDream.App/Studio/StudioWindow.cs
+++ b/src/AcDream.App/Studio/StudioWindow.cs
@@ -1,5 +1,6 @@
using System.Numerics;
using AcDream.App.Rendering;
+using AcDream.App.UI;
using DatReaderWriter;
using DatReaderWriter.Options;
using Silk.NET.Input;
@@ -17,6 +18,15 @@ namespace AcDream.App.Studio;
///
/// 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.
@@ -31,6 +41,12 @@ public sealed class StudioWindow : IDisposable
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)
+
public StudioWindow(StudioOptions opts)
{
_opts = opts ?? throw new ArgumentNullException(nameof(opts));
@@ -99,10 +115,16 @@ public sealed class StudioWindow : IDisposable
// Load the panel described by options and add it to the UI tree.
_source = new LayoutSource(_dats, _stack.ResolveChrome, _stack.VitalsDatFont);
var root = _source.Load(_opts);
+ _panelRoot = root;
if (root is not null)
_stack.UiHost.Root.AddChild(root);
else
Console.Error.WriteLine($"[studio] panel load failed: {_source.LastError}");
+
+ // Task 3: ImGui IDE. The studio is always "devtools" — no gate flag needed.
+ _imgui = new AcDream.UI.ImGui.ImGuiBootstrapper(gl, _window!, input);
+ _panelFbo = new PanelFbo(gl);
+ _inspector = new StudioInspector();
}
private double _dt;
@@ -114,18 +136,61 @@ public sealed class StudioWindow : IDisposable
private void OnRender(double dt)
{
- if (_stack is null) return;
+ if (_stack is null || _imgui is null || _panelFbo is null || _inspector is null)
+ return;
var gl = _stack.Gl;
- gl.ClearColor(0.18f, 0.18f, 0.18f, 1f);
+ int w = _window!.Size.X;
+ int h = _window!.Size.Y;
+
+ // 1. Tick the UI widgets.
+ _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(w, h, _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);
- _stack.UiHost.Tick(_dt);
- _stack.UiHost.Draw(new Vector2(_window!.Size.X, _window!.Size.Y));
+ // 4. Begin the ImGui frame.
+ _imgui.BeginFrame((float)dt);
+
+ // 5. Canvas pane — show the FBO texture; detect clicks.
+ (int x, int y)? click = null;
+ if (panelTex != 0)
+ click = _inspector.DrawCanvas((nint)panelTex, w, h);
+
+ // 6. If the user clicked inside the canvas, hit-test the panel tree and select.
+ if (click is { } c && _panelRoot is not null)
+ {
+ // UiElement.HitTest is internal — accessible from AcDream.App.Studio (same assembly).
+ // We call it on the panel root with coords relative to its top-left.
+ var screenPos = _panelRoot.ScreenPosition;
+ var hit = _panelRoot.HitTest(c.x - screenPos.X, c.y - screenPos.Y);
+ if (hit is not null)
+ _inspector.Selected = hit;
+ }
+
+ // 7. Element tree pane.
+ if (_panelRoot is not null)
+ _inspector.DrawTree(_panelRoot);
+
+ // 8. Properties pane.
+ _inspector.DrawProperties();
+
+ // 9. Finalise ImGui and flush draw data to the window.
+ _imgui.Render();
}
private void OnClosing()
{
+ _imgui?.Dispose();
+ _panelFbo?.Dispose();
+ _imgui = null;
+ _panelFbo = null;
_stack?.DrawDispatcher.Dispose();
_stack?.MeshAdapter.Dispose();
_stack?.TextureCache.Dispose();
@@ -139,6 +204,10 @@ public sealed class StudioWindow : IDisposable
public void Dispose()
{
+ _imgui?.Dispose();
+ _panelFbo?.Dispose();
+ _imgui = null;
+ _panelFbo = null;
_window?.Dispose();
_window = null;
// If OnClosing wasn't called (e.g. exception before Run()), clean up anyway.