acdream/docs/superpowers/plans/2026-06-25-ui-studio-previewer.md
Erik f55650fdcd docs(studio): implementation plan — acdream UI Studio (8 staged tasks)
Bite-sized, gated build order: (1) RenderBootstrap [new code, no GameWindow
edit → zero game-regression risk], (2) StudioWindow + LayoutSource + ui-studio
subcommand, (3) ImGui inspector (canvas FBO + tree + props + click-inspect),
(4) FixtureProvider sample data, (5) live 3-D doll, (6) markup + hot-reload,
(7) editable props + write-back, (8) doll-camera sliders. Pure-logic units
(LayoutSource/FixtureProvider/MarkupWriteBack) unit-tested; GL/window tasks
visual-gated. Spec: docs/superpowers/specs/2026-06-25-ui-studio-previewer-design.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 14:13:27 +02:00

332 lines
26 KiB
Markdown
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.

# acdream UI Studio Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build a standalone Silk.NET dev tool that renders any acdream UI panel through the production render stack, with an ImGui click-to-inspect inspector, sample-data fixtures, the live 3-D doll, markup hot-reload + write-back, and doll-camera sliders.
**Architecture:** A new `StudioWindow` (Silk GL app, launched via a `ui-studio` subcommand in `Program.cs`) builds its render stack through a new `RenderBootstrap` (a clean construction of just the subset the studio needs — GL, `DatCollection`, `TextureCache`, the WB mesh pipeline, `UiHost` — leaving `GameWindow` untouched). A `LayoutSource` loads a panel (dat `LayoutDesc` id or markup file) into a `UiElement` tree; a `FixtureProvider` populates it via the production controllers; a `StudioInspector` (ImGui) renders the tool chrome with the panel shown as an FBO image.
**Tech Stack:** C# .NET 10, Silk.NET (Windowing/Input/OpenGL), ImGui.NET 1.91.6.1 + Silk.NET.OpenGL.Extensions.ImGui 2.23.0 (via the existing `AcDream.UI.ImGui` project), the existing `AcDream.App.UI` retained-mode tree + `LayoutImporter`/`DatWidgetFactory`, `DatReaderWriter`.
**Spec:** `docs/superpowers/specs/2026-06-25-ui-studio-previewer-design.md`
**Subagent note (project policy):** implementers are Sonnet and INHERIT CLAUDE.md; they read the cited code themselves. Tasks give the contract, the key code, the files to read, the test, the gate, and the commit message — not every line. GL/window tasks verify via a visual gate (the studio is itself the visual tool); pure-logic tasks (LayoutSource infos, FixtureProvider, MarkupWriteBack) use real unit tests.
**Branch:** `claude/hopeful-maxwell-214a12`. Commit after every task. Full build (`dotnet build src/AcDream.App/AcDream.App.csproj`) green before any commit; relevant tests green.
**Launch (for every visual gate):**
```powershell
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug -- ui-studio "$env:USERPROFILE\Documents\Asheron's Call" --layout 0x2100006C
```
Do NOT auto-kill a running client; if a rebuild is locked, ask the user to close it ([[feedback_dont_kill_clients_before_launch]]).
---
## File structure
New, all under `src/AcDream.App/`:
- `Rendering/RenderBootstrap.cs``RenderStack Create(GL, RenderBootstrapOptions)`; the shared render-stack builder + the `RenderStack` record.
- `Studio/StudioWindow.cs` — the Silk window + frame loop + owns the stack/inspector/sources.
- `Studio/StudioOptions.cs` — parsed `ui-studio` args (layout id | markup path | dat dir).
- `Studio/LayoutSource.cs` — load/reload a panel from a dat id or a markup file.
- `Studio/FixtureProvider.cs` — sample data + per-layout controller binding.
- `Studio/SampleData.cs` — the canned items / vitals / creature fixture constants.
- `Studio/StudioInspector.cs` — the ImGui chrome (canvas/tree/properties/render-config).
- `Studio/PanelFbo.cs` — render-to-texture target for the previewed panel (mirrors `PaperdollViewportRenderer`'s FBO).
- `Studio/MarkupWriteBack.cs` — targeted in-place markup attribute edits.
- `Studio/HotReloadWatcher.cs` — debounced `FileSystemWatcher`.
- Modify: `src/AcDream.App/Program.cs``ui-studio` subcommand dispatch.
Tests under `tests/AcDream.App.Tests/Studio/`:
- `LayoutSourceTests.cs`, `FixtureProviderTests.cs`, `MarkupWriteBackTests.cs`.
---
## Task 1: RenderBootstrap (shared render stack)
**Files:**
- Create: `src/AcDream.App/Rendering/RenderBootstrap.cs`
- Read first: `src/AcDream.App/Rendering/GameWindow.cs:1026-1815,2313-2408` (the construction order), `src/AcDream.App/UI/UiHost.cs`, `src/AcDream.App/Rendering/Wb/WbDrawDispatcher.cs` (ctor), `src/AcDream.App/UI/Layout/UiDatFont.cs`.
**Contract:**
```csharp
namespace AcDream.App.Rendering;
public sealed record RenderStack(
GL Gl,
DatReaderWriter.DatCollection Dats,
string ShaderDir,
BindlessSupport Bindless,
TextureCache TextureCache,
Shader MeshShader,
Wb.WbMeshAdapter MeshAdapter,
Wb.EntitySpawnAdapter EntitySpawnAdapter,
Wb.WbDrawDispatcher DrawDispatcher,
SceneLightingUboBinding LightingUbo,
AcDream.App.UI.UiHost UiHost,
AcDream.App.UI.Layout.UiDatFont? VitalsDatFont)
{
/// <summary>sprite id (0x06xxxxxx) → (GL handle, width, height) for the importer's resolve func.</summary>
public (uint, int, int) ResolveChrome(uint spriteId)
=> TextureCache.GetOrUploadRenderSurface(spriteId, out var w, out var h) is var h2
? (h2, w, h) : (0u, 0, 0); // match GameWindow.ResolveChrome — read its exact body first
}
public sealed record RenderBootstrapOptions(QualitySettings Quality);
public static class RenderBootstrap
{
/// <summary>Build the subset of the production render stack the UI Studio needs — same classes,
/// same construction order as GameWindow.OnLoad, minus terrain/sky/physics/streaming. Throws
/// NotSupportedException if bindless/draw-params are missing (same as the game).</summary>
public static RenderStack Create(GL gl, DatReaderWriter.DatCollection dats, RenderBootstrapOptions opts);
}
```
- [ ] **Step 1:** Read the cited GameWindow construction lines and `ResolveChrome` (grep it) so the order + the exact `GetOrUploadRenderSurface`/sequencer-factory signatures are copied, not guessed.
- [ ] **Step 2:** Write `RenderBootstrap.Create` constructing, in order: `BindlessSupport.TryCreate` (throw if absent/no draw-params, copy the GameWindow message), `shadersDir = Path.Combine(AppContext.BaseDirectory,"Rendering","Shaders")`, `SceneLightingUboBinding(gl)`, `MeshShader = new Shader(gl, mesh_modern.vert/.frag)`, `TextureCache(gl, dats, bindless)`, `WbMeshAdapter(gl, dats, NullLogger<WbMeshAdapter>.Instance)`, the `SequencerFactory` lambda (copy from GameWindow:2335-2361, capturing `dats` + an `AnimLoader`), `EntitySpawnAdapter(textureCache, sequencerFactory, meshAdapter)`, `WbDrawDispatcher(gl, meshShader, textureCache, meshAdapter, entitySpawnAdapter, bindless, new <classificationCache>)` with `AlphaToCoverage = opts.Quality.AlphaToCoverage`, the vitals dat font (copy GameWindow's load of `0x40000000` via `UiDatFont`), and `UiHost(gl, shadersDir, defaultFont)`. Return the `RenderStack`.
- [ ] **Step 3:** Build: `dotnet build src/AcDream.App/AcDream.App.csproj -c Debug`. Expected: green (no test yet — GL construction is verified by Task 2's boot gate).
- [ ] **Step 4:** Commit.
**Gate:** Build green. (Functional gate is Task 2.)
**Commit:** `feat(studio): RenderBootstrap — shared render stack for the UI previewer`
---
## Task 2: StudioWindow + LayoutSource (dat id) + Program dispatch
**Files:**
- Create: `src/AcDream.App/Studio/StudioOptions.cs`, `src/AcDream.App/Studio/LayoutSource.cs`, `src/AcDream.App/Studio/StudioWindow.cs`
- Modify: `src/AcDream.App/Program.cs` (before `new GameWindow`)
- Test: `tests/AcDream.App.Tests/Studio/LayoutSourceTests.cs`
- Read first: `src/AcDream.App/UI/Layout/LayoutImporter.cs:57-190`, `src/AcDream.App/Rendering/GameWindow.cs:972-1017` (window+Run), `src/AcDream.App/UI/UiHost.cs:65-96` (Draw/WireMouse/WireKeyboard).
**`StudioOptions` contract:**
```csharp
namespace AcDream.App.Studio;
public sealed record StudioOptions(string DatDir, uint? LayoutId, string? MarkupPath)
{
public static StudioOptions Parse(string[] args); // args AFTER the "ui-studio" token
}
```
Parse: positional dat dir (or `ACDREAM_DAT_DIR`), `--layout 0xNNNN` (hex), `--markup <path>`. Default layout when neither given: `0x2100006Cu` (vitals).
**`LayoutSource` contract:**
```csharp
public enum LayoutSourceKind { DatLayout, Markup }
public sealed class LayoutSource
{
public LayoutSource(DatCollection dats, Func<uint,(uint,int,int)> resolve, UiDatFont? datFont);
public LayoutSourceKind Kind { get; }
public uint? LayoutId { get; }
public string? MarkupPath { get; }
public string? LastError { get; private set; }
/// <summary>Imported tree, or null on error (LastError set). Dat → LayoutImporter.Import; markup → MarkupDocument.Build with an empty binding.</summary>
public ImportedLayout? CurrentLayout { get; private set; }
public UiElement? Load(StudioOptions opts); // sets Kind/LayoutId/MarkupPath + CurrentLayout
public UiElement? Reload(); // re-run the current source; markup re-reads the file
}
```
- [ ] **Step 1 (test, GL-free):** `LayoutSourceTests.LoadsDatLayout_byId`. Open real dats via `ConformanceDats.ResolveDatDir()` (skip if null), build a `LayoutSource` with a STUB resolve `_ => (1u,1,1)` + null font, `Load(new StudioOptions(dir, 0x2100006Cu, null))`, assert `CurrentLayout` non-null and `FindElement(0x2100006Cu) != null` and `Kind == DatLayout`. (Use `LayoutImporter.ImportInfos` is GL-free; `Import`'s resolve is only called for sprites — the stub is fine for tree-shape.)
- [ ] **Step 2:** Run it — expect FAIL (LayoutSource missing). `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter LayoutSourceTests`.
- [ ] **Step 3:** Implement `StudioOptions.Parse` + `LayoutSource` (dat path only; markup path returns LastError "markup unsupported" for now — wired in Task 6). Dat path = `LayoutImporter.Import(dats, id, resolve, datFont)`.
- [ ] **Step 4:** Run the test — expect PASS.
- [ ] **Step 5:** Implement `StudioWindow`: mirror `GameWindow.Run()` window creation (1280×720, GL 4.3 Core, stencil 8). On `Load`: `gl = GL.GetApi(window)`, `input = window.CreateInput()`, `dats = new DatCollection(opts.DatDir, DatAccessType.Read)`, `stack = RenderBootstrap.Create(gl, dats, new(QualitySettings...))`, wire `stack.UiHost.WireMouse/WireKeyboard` for each mouse/keyboard, `source = new LayoutSource(dats, stack.ResolveChrome, stack.VitalsDatFont)`, `root = source.Load(opts)`, and if non-null add it to `stack.UiHost.Root` (use the same add-child API GameWindow uses — grep `_uiHost.Root` adds). On `Render`: `gl.ClearColor` mid-grey, `Clear`, `stack.UiHost.Tick(dt)` + `stack.UiHost.Draw(new Vector2(window.Size.X, window.Size.Y))`. On `Closing`: dispose.
- [ ] **Step 6:** `Program.cs`: before `new GameWindow`, add `if (args.Length >= 1 && args[0] == "ui-studio") { var so = AcDream.App.Studio.StudioOptions.Parse(args[1..]); using var sw = new AcDream.App.Studio.StudioWindow(so); sw.Run(); return 0; }`.
- [ ] **Step 7:** Build green, then VISUAL GATE (launch command above). Expect: a window showing the vitals panel chrome (three empty bars + frame) rendered by the real UiHost.
- [ ] **Step 8:** Commit.
**Gate (visual):** `ui-studio --layout 0x2100006C` opens a window showing the vitals panel.
**Commit:** `feat(studio): StudioWindow + LayoutSource — render a dat panel standalone`
---
## Task 3: StudioInspector (ImGui) + canvas FBO + tree + props + click-to-inspect
**Files:**
- Create: `src/AcDream.App/Studio/PanelFbo.cs`, `src/AcDream.App/Studio/StudioInspector.cs`
- Modify: `StudioWindow.cs` (init ImGui, render panel→FBO, draw inspector)
- Read first: `src/AcDream.UI.ImGui/ImGuiBootstrapper.cs:35-46`, `src/AcDream.App/Rendering/PaperdollViewportRenderer.cs` (the FBO RTT pattern + `GLStateScope`), `src/AcDream.App/UI/UiRoot.cs` (grep `HitTestTopDown`), `src/AcDream.App/UI/UiElement.cs` (Id/Type/Left/Top/Width/Height/Anchors/ZOrder/Children accessors).
**`PanelFbo`:** mirror `PaperdollViewportRenderer`'s FBO (RGBA8 + Depth24Stencil8, lazy resize). `uint Render(int w, int h, UiHost host)` = bind FBO, viewport, clear transparent, `host.Draw(new Vector2(w,h))` inside a `GLStateScope`, return color tex. Reuse the exact FBO setup from `PaperdollViewportRenderer.EnsureFramebuffer`.
**`StudioInspector`:** ImGui windows, drawn between `BeginFrame`/`Render`:
```csharp
public sealed class StudioInspector
{
public UiElement? Selected { get; set; }
// Canvas window: ImGui.Image(panelTex) sized to the panel; capture click → return panel-local (x,y)
public (int x,int y)? DrawCanvas(nint panelTex, int w, int h);
public void DrawTree(UiElement root); // recursive ImGui.TreeNode; click sets Selected
public void DrawProperties(); // Selected's Id/Type/Rect/Anchors/ZOrder/sprites (read-only here)
}
```
Click-to-inspect: when `DrawCanvas` returns a coord, call `root` `HitTestTopDown(x,y)` (grep its exact signature/return) → set `Selected`.
- [ ] **Step 1:** Implement `PanelFbo` (copy `PaperdollViewportRenderer` FBO lifecycle; swap the doll draw for `host.Draw`).
- [ ] **Step 2:** `StudioWindow.Load`: construct `new ImGuiBootstrapper(gl, window, input)` (no `DevToolsEnabled` gate — the studio is always devtools) + `new PanelFbo(gl)` + `new StudioInspector()`.
- [ ] **Step 3:** `StudioWindow.Render`: `panelTex = panelFbo.Render(w,h, stack.UiHost)`; `imgui.BeginFrame(dt)`; `var click = inspector.DrawCanvas((nint)panelTex, w, h); if (click is {} c) inspector.Selected = root.HitTestTopDown(c.x, c.y); inspector.DrawTree(root); inspector.DrawProperties();`; `imgui.Render()`.
- [ ] **Step 4:** Implement `StudioInspector` windows (ImGui.NET: `ImGui.Begin`, `ImGui.Image`, `ImGui.TreeNodeEx`, `ImGui.Text`). Tree recurses `element.Children`; each node label = `$"{name} 0x{Id:X8} [{Type}]"`. Properties: Id, Type, Rect `(Left,Top,Width,Height)`, Anchors, ZOrder, and each state sprite id (grep how `UiDatElement`/`UiButton` expose their sprite ids; if not trivially exposed, list "—" for v1 and note follow-up).
- [ ] **Step 5:** Build green, VISUAL GATE: window shows a Canvas pane (the panel) + a Tree pane + a Properties pane; clicking the panel or a tree node selects an element and shows its id/rect.
- [ ] **Step 6:** Commit.
**Gate (visual):** click an element in the canvas or tree → its id / Type / rect appear in Properties.
**Commit:** `feat(studio): ImGui inspector — canvas FBO, element tree, props, click-to-inspect`
---
## Task 4: FixtureProvider — populate 2-D panels with sample data
**Files:**
- Create: `src/AcDream.App/Studio/SampleData.cs`, `src/AcDream.App/Studio/FixtureProvider.cs`
- Modify: `StudioWindow.cs` (call `FixtureProvider.Populate` after `Load`)
- Test: `tests/AcDream.App.Tests/Studio/FixtureProviderTests.cs`
- Read first: `src/AcDream.Core/Items/ClientObjectTable.cs:41-420` (`AddOrUpdate`/`Get`/`GetContents`/`ClientObject` fields), the four controllers' `Bind` (signatures in the plan header research), `src/AcDream.Core/Items/ItemType.cs` + `EquipMask`.
**`SampleData`:** static factory building a `ClientObjectTable` seeded with: a player object (guid `0xPLAYER`, `ItemsCapacity=102`, `ContainersCapacity=7`), ~6 loose items in the main pack (varied `Type`/`IconId` — pick real icon ids from existing tests or a known wcid; `ContainerId = player`, gapless `ContainerSlot`), ~2 side bags (`Type=Container`, `ItemsCapacity>0`), and a few equipped items (`CurrentlyEquippedLocation` set to Head/Chest/etc). Plus sample vitals (`health=0.8f, stam=0.6f, mana=0.9f` + text). Constants only — no wire.
**`FixtureProvider.Populate(uint layoutId, ImportedLayout layout, RenderStack stack)`** switch on `layoutId`:
- `0x2100006C``VitalsController.Bind(layout, ()=>0.8f, ()=>0.6f, ()=>0.9f, ()=>"80/100", ()=>"60/100", ()=>"90/100")`.
- `0x21000016``ToolbarController.Bind(layout, sampleTable, ()=>PLAYER, IconIds, _=>{}, ...digits from GameWindow's load...)`.
- `0x21000023``InventoryController.Bind(layout, sampleTable, ()=>PLAYER, IconIds, ()=>100, stack.VitalsDatFont, contents/sideBag/mainPack sprites, ...)`.
- default → no-op (structural preview).
`IconIds` = reuse GameWindow's `iconIds` lambda (grep it — `IconComposer`-backed); if heavy, a stub returning the passed base id is acceptable for v1 (note it).
- [ ] **Step 1 (test):** `FixtureProviderTests.SampleTable_hasPackContents`. Build `SampleData.BuildObjectTable()`, assert `table.GetContents(PLAYER).Count >= 6` and a known item's `Type`/`IconId` set.
- [ ] **Step 2:** Run — FAIL (missing).
- [ ] **Step 3:** Implement `SampleData` + the table seeding.
- [ ] **Step 4:** Run — PASS.
- [ ] **Step 5:** Implement `FixtureProvider.Populate`; call it from `StudioWindow` after `source.Load`.
- [ ] **Step 6:** Build green, VISUAL GATE: `--layout 0x21000023` shows the inventory with sample items in the grid + a non-zero burden bar; `--layout 0x21000016` shows toolbar slots populated; `--layout 0x2100006C` shows filled vital bars.
- [ ] **Step 7:** Commit.
**Gate (visual):** inventory / toolbar / vitals panels render POPULATED (sample items, filled bars).
**Commit:** `feat(studio): FixtureProvider — sample data populates the 2-D panels`
---
## Task 5: Live doll in the paperdoll viewport
**Files:**
- Modify: `FixtureProvider.cs` (build a sample creature entity for `0x21000023`/paperdoll), `StudioWindow.cs` (wire `PaperdollViewportRenderer` + the pre-UI doll render, mirroring GameWindow:9148-9160)
- Read first: `src/AcDream.App/Rendering/GameWindow.cs:3786-3897` (`RefreshPaperdollDoll`/`ApplyPaperdollPose`), `9148-9160` (the pre-UI doll hook), `src/AcDream.App/Rendering/DollEntityBuilder.cs` (`Build`), `src/AcDream.App/Rendering/PaperdollViewportRenderer.cs` (ctor + `SetDoll`/`Render`), `EntitySpawnAdapter.OnCreate`.
**Sample creature:** a base human Setup id (grep the test character / a known humanoid Setup `0x0200....`; or reuse the player Setup constant if one exists in tests). Build a `WorldEntity { SourceGfxObjOrSetupId = setupId, MeshRefs = <resolved from Setup via WbMeshAdapter>, ... }`, run it through `stack.EntitySpawnAdapter.OnCreate` to resolve MeshRefs (same path GameWindow uses), then `DollEntityBuilder.Build` + `ApplyPaperdollPose`-equivalent. Keep a small `StudioDoll` helper if this grows.
- [ ] **Step 1:** In `StudioWindow.Load`, construct `new PaperdollViewportRenderer(gl, stack.DrawDispatcher, stack.LightingUbo)`.
- [ ] **Step 2:** When the layout is the inventory/paperdoll, have `FixtureProvider` build the sample creature, call `RefreshPaperdollDoll`-equivalent (clone → `ApplyPaperdollPose`) → `paperdollRenderer.SetDoll(doll)`; expose the doll-viewport widget (FindElement `0x100001D5`).
- [ ] **Step 3:** In `StudioWindow.Render`, BEFORE the panel FBO draw, render the doll into the viewport widget's texture exactly as GameWindow:9156-9159 (`widget.TextureHandle = paperdollRenderer.Render(w,h)`), gated on the viewport widget being present + visible.
- [ ] **Step 4:** Build green, VISUAL GATE: `--layout 0x21000023` shows the paperdoll with a DRESSED 3-D doll in the held pose (reuses the camera/heading/pose shipped in `8fa66c2`).
- [ ] **Step 5:** Commit.
**Gate (visual):** the paperdoll viewport shows the sample creature as a posed 3-D doll.
**Commit:** `feat(studio): live 3-D doll in the paperdoll preview`
---
## Task 6: Markup source + hot-reload
**Files:**
- Create: `src/AcDream.App/Studio/HotReloadWatcher.cs`
- Modify: `LayoutSource.cs` (markup path via `MarkupDocument.Build`), `StudioWindow.cs` (watch + reload), `StudioOptions` already parses `--markup`
- Read first: `src/AcDream.App/UI/MarkupDocument.cs:21-82`, an existing first-party markup file (grep `assets/*.xml`, e.g. the retired `vitals.xml` in git history or a current plugin panel) for the schema.
- Test: extend `LayoutSourceTests`.
**LayoutSource markup path:** `Kind=Markup`; `Load` reads the file → `MarkupDocument.Build(xml, EmptyBinding, resolve, null)` → wrap its `UiNineSlicePanel` as the root; `Reload` re-reads the file. `EmptyBinding` = `new object()` (no `{Binding}` resolution needed for preview; unresolved bindings render literally or empty — acceptable).
**HotReloadWatcher:**
```csharp
public sealed class HotReloadWatcher : IDisposable
{
public HotReloadWatcher(string filePath, Action onChanged); // debounce ~150ms; marshal onChanged via a thread-safe flag the window polls
}
```
The watcher sets a `volatile bool _dirty`; `StudioWindow.Render` checks it and calls `source.Reload()` + `FixtureProvider.Populate` + re-add to the UiRoot on the GL thread (FileSystemWatcher fires on a worker thread — never touch GL off-thread).
- [ ] **Step 1 (test):** `LayoutSourceTests.LoadsMarkupFile`. Write a temp `.xml` with a minimal `UiNineSlicePanel`, `Load(new StudioOptions(dir, null, path))`, assert non-null root + `Kind==Markup`.
- [ ] **Step 2:** Run — FAIL.
- [ ] **Step 3:** Implement the markup path in `LayoutSource` + `HotReloadWatcher`.
- [ ] **Step 4:** Run — PASS.
- [ ] **Step 5:** Wire the watcher in `StudioWindow` (poll `_dirty` in Render; reload on the GL thread).
- [ ] **Step 6:** Build green, VISUAL GATE: launch `--markup <path>`; edit the file in an editor + save → the studio reloads the panel within ~1s.
- [ ] **Step 7:** Commit.
**Gate (visual):** edit a markup file externally → studio re-renders it live.
**Commit:** `feat(studio): markup source + hot-reload`
---
## Task 7: Editable markup props + write-back
**Files:**
- Create: `src/AcDream.App/Studio/MarkupWriteBack.cs`
- Modify: `StudioInspector.cs` (editable rect/anchor fields when `Kind==Markup`), `StudioWindow.cs` (apply edit live + persist)
- Test: `tests/AcDream.App.Tests/Studio/MarkupWriteBackTests.cs`
**`MarkupWriteBack`:** targeted in-place attribute edits — NOT a full re-serialize (preserve formatting/comments):
```csharp
public static class MarkupWriteBack
{
/// <summary>In the markup text, find the element whose id attribute == elementId and set attr=value
/// (add the attribute if absent). Returns the new text. Leaves all other bytes untouched.</summary>
public static string SetAttribute(string markup, uint elementId, string attr, string value);
}
```
Implement by locating the element's opening tag (match the id attribute literal `Id="0x...."` — grep the markup schema for the exact id attribute name), then regex-replace just that attribute within that tag span.
- [ ] **Step 1 (test):** `MarkupWriteBackTests.SetAttribute_updatesInPlace`. Given a 2-element markup string, `SetAttribute(m, id, "Width", "200")` → re-parse, assert the target's Width==200 AND the sibling element + any comment are byte-identical.
- [ ] **Step 2:** Run — FAIL.
- [ ] **Step 3:** Implement `MarkupWriteBack.SetAttribute`.
- [ ] **Step 4:** Run — PASS (add an absent-attribute case + a not-found case returning the input unchanged).
- [ ] **Step 5:** `StudioInspector.DrawProperties`: when `Kind==Markup`, render rect (`Left/Top/Width/Height`) + anchors as ImGui `InputInt`/combo; on change, set the live `UiElement` field AND call back to the window to `MarkupWriteBack.SetAttribute(...)` + write the file (the watcher then reloads, or skip the watcher for self-writes via a guard flag).
- [ ] **Step 6:** Build green, VISUAL GATE: select an element in a markup panel, change its Width in Properties → the panel updates AND the file on disk shows the new value.
- [ ] **Step 7:** Commit.
**Gate (visual):** edit a rect in the inspector → live panel updates + the markup file is rewritten.
**Commit:** `feat(studio): editable markup props with in-place write-back`
---
## Task 8: Render-config sliders (doll camera)
**Files:**
- Modify: `src/AcDream.App/Rendering/DollCamera.cs` (make Eye/Target/Fov instance-settable — currently `static readonly`), `StudioInspector.cs` (sliders), `PaperdollViewportRenderer.cs` (expose its `DollCamera` or accept overrides)
- Read first: `DollCamera.cs` (current static fields), `PaperdollViewportRenderer.cs:35` (`private readonly DollCamera _camera = new();`).
**DollCamera change:** convert the `static readonly` Eye/Target/Up/FovRadians to instance properties with the retail defaults as initializers (keep the decomp citations). This is a safe, behavior-preserving change (same defaults) — verify the existing `DollCameraTests` still pass (they read `cam.View`).
**Inspector:** a "Render config" ImGui window shown when a doll viewport is selected: `SliderFloat3("eye", ...)`, `SliderFloat("fov", ...)`, `SliderFloat("heading", ...)` bound to the `PaperdollViewportRenderer`'s camera (expose it) + the doll's heading. A "Copy to clipboard" button emits the C# literals.
- [ ] **Step 1:** Make `DollCamera` Eye/Target/Up/FovRadians instance properties (defaults = current retail values). Build + run `DollCameraTests` — expect PASS (defaults unchanged).
- [ ] **Step 2:** Expose the camera from `PaperdollViewportRenderer` (a `public DollCamera Camera => _camera;`).
- [ ] **Step 3:** `StudioInspector`: render the Render-config window with sliders mutating `renderer.Camera.{Eye,FovRadians}` + the doll heading; a copy button.
- [ ] **Step 4:** Build green, VISUAL GATE: select the doll, drag the eye/FOV sliders → the doll re-frames live; copy emits the literals.
- [ ] **Step 5:** Commit.
**Gate (visual):** dragging the camera sliders re-frames the doll in real time.
**Commit:** `feat(studio): render-config sliders for the doll camera`
---
## Wrap-up (after Task 8)
- [ ] Update `claude-memory/project_d2b_retail_ui.md` (or a new `project_ui_studio.md`) with the studio's architecture + the launch command + DO-NOT-RETRY notes; add a `MEMORY.md` pointer.
- [ ] Update the roadmap: note the UI Studio tool shipped (it's tooling, not a milestone phase — a one-line ledger entry).
- [ ] Full suite green; final commit.
## Self-review notes
- **Spec coverage:** both sources (Tasks 2 dat / 6 markup) ✓; full fixtures (Task 4 + 5) ✓; inspector read (3) + editable markup (7) + render sliders (8) ✓; hot-reload (6) ✓; live doll (5) ✓; RenderBootstrap (1) ✓; ImGui chrome (3) ✓.
- **GL-untestable tasks** (1,2-window,3,5,6-window,8) use visual gates by design — the studio is a visual tool; pure-logic units (LayoutSource infos, FixtureProvider, MarkupWriteBack) have unit tests.
- **Type consistency:** `RenderStack`/`ImportedLayout`/`StudioOptions`/`LayoutSource`/`ClientObjectTable` member names match the research reference; `ResolveChrome` body must be copied from GameWindow (flagged in Task 1 Step 1).
- **Risk:** Task 1 builds NEW code (no GameWindow edit) → zero game-regression risk, the de-risk decision from the spec's open risk. Task 5's sample Setup id is the one unknown literal — Task 5 Step reads the test fixtures to find a real humanoid Setup before coding.