DatWidgetFactory.Create now accepts an optional fontResolve: Func<uint,UiDatFont?> parameter. When supplied and the element has a non-zero FontDid, the element receives its own dat font instead of the shared global datFont fallback. Null = original single-font behavior (the live GameWindow path passes null — provably unchanged). LayoutImporter.Build/BuildFromInfos/Import all thread the optional resolver down to the factory. RenderStack gains a lazy font cache (ConcurrentDictionary, pre-seeded with VitalsDatFont + LargeDatFont) and a ResolveDatFont(uint) method. StudioWindow wires stack.ResolveDatFont into LayoutSource so every studio import gets per-element fonts. GameWindow import calls left passing null (follow-up todo). CharacterStatController font-hack cleanup (diagnosed via one-shot console dump then removed): - Name (0x10000231): dat FontDid = 18px font — remove datFont override (null) - Heritage/PkStatus: dat FontDid = 14px fonts — remove override - LevelCaption: dat FontDid = 16px — remove override (same font, no visual change) - Level (0x1000023B): dat FontDid = 36px (the big retail gold font) — was forced to rowDatFont/LargeDatFont (18px); now drops to null so the dat 36px font drives - TotalXpLabel/TotalXp: dat FontDid = 16px — remove override - FooterTitle (0x1000024E): dat FontDid = 20px — remove datFont override - KEEP: synthesized elements (XP meter overlays, 9 attribute rows, tab sprites) still use datFont directly since they have no dat origin All Label/LabelTwoLine/LabelLeft/LabelProvider helpers updated: null = keep build-time dat font; non-null = controller explicit override (backward-compat). 8 new tests in DatWidgetFactoryFontResolveTests: - null resolver → DatFont == global datFont - FontDid=0 → resolver not called - resolver returns null → fallback to global datFont - resolver called with element's FontDid - controller DatFont override wins after build - LayoutImporter.Build threads fontResolve to factory - meter element fires resolver for non-zero FontDid - BuildFromInfos without fontResolve param = original behavior Build + all 710 App tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
458 lines
19 KiB
C#
458 lines
19 KiB
C#
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;
|
||
|
||
/// <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]
|
||
///
|
||
/// 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 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<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;
|
||
|
||
// 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 = 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 <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;
|
||
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 <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
|
||
{
|
||
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<Rgba32>(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();
|
||
}
|
||
|
||
/// <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.
|
||
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;
|
||
}
|
||
}
|