Merge claude/hopeful-maxwell-214a12 — D.2b UI Studio + faithful importer + Character window
UI Studio (preview panels through the production renderer), importer dat-fidelity (Fix A/B/C/4/5: the importer carries dat font/justification/color; boundary look=importer / state=runtime), and the Character window Attributes tab (reads as retail). 3062 tests green. Handoff: docs/research/2026-06-26-mockup-stage-handoff.md. # Conflicts: # docs/ISSUES.md # docs/architecture/retail-divergence-register.md
This commit is contained in:
commit
ca94b479bf
61 changed files with 125881 additions and 44 deletions
671
docs/superpowers/plans/2026-06-23-d2b-paperdoll-slice2.md
Normal file
671
docs/superpowers/plans/2026-06-23-d2b-paperdoll-slice2.md
Normal file
|
|
@ -0,0 +1,671 @@
|
|||
# D.2b Sub-phase C Slice 2 — Paperdoll Doll + Slots Toggle — 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.
|
||||
>
|
||||
> **⚠ Execution split (project rule — CLAUDE.md "Do not integrate via subagent unless the subagent has full context"):** Tasks 1-4 are isolated/testable → safe for fresh subagents. **Tasks 5-7 are GL/animation/GameWindow integration → do INLINE** (the session that has the render-pipeline context), build **static-doll-first then animate**, verify at build + screenshot/visual-gate. Task 8 is wrap-up.
|
||||
|
||||
**Goal:** Render the local player as a live 3-D "doll" inside the inventory paperdoll panel and wire the "Slots" button to toggle between doll-view (doll + 12 non-armor slots) and slot-view (9 armor slots).
|
||||
|
||||
**Architecture:** Render-to-texture — a `PaperdollViewportRenderer` draws a re-dressed clone of the player (a dedicated `WorldEntity`) through the existing `WbDrawDispatcher` into an off-screen `ManagedGLFramebuffer` with a fixed doll camera + one distant light, inside a `GLStateScope`; the new `UiViewport` widget (dat Type `0xD`) blits that texture as a normal 2-D sprite so it lands in correct painter order. The shipped `PaperdollController` is extended (not rewritten) with the armor/non-armor partition + the toggle.
|
||||
|
||||
**Tech Stack:** C# .NET 10, Silk.NET OpenGL, the acdream `AcDream.App.UI` retained-mode toolkit + `AcDream.App.Rendering.Wb` mesh pipeline.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-23-d2b-paperdoll-slice2-design.md`. Read it first.
|
||||
|
||||
**Reference call sites (verified this session — line numbers drift, grep the symbol):**
|
||||
- World single-entity draw to mirror: `GameWindow.cs:8756` `_wbDrawDispatcher.Draw(camera, _worldState.LandblockEntries, frustum, neverCullLandblockId: playerLb, visibleCellIds: null, animatedEntityIds: animatedIds)`.
|
||||
- `animatedIds` built from `_animatedEntities.Keys`: `GameWindow.cs:8369-8375`.
|
||||
- Spawn animation idle setup (`SetCycle`): `GameWindow.cs:3514-3588`. `MotionCommand.Ready = 0x41000003u` (`AcDream.Core.Physics.MotionInterpreter`).
|
||||
- `WorldEntity` build template (palette/part overrides): `GameWindow.cs:3390-3431`.
|
||||
- Player data: `_entitiesByServerGuid[_playerServerGuid]` (live entity), `_lastSpawnByGuid[_playerServerGuid]` (spawn). Player appearance update hook: `OnLiveAppearanceUpdated` `GameWindow.cs:3733`.
|
||||
- Lighting UBO: `AcDream.Core.Lighting.SceneLightingUbo` + `AcDream.App.Rendering.SceneLightingUboBinding.Upload` (binding=1); world build at `GameWindow.cs:8306`.
|
||||
- FBO: `AcDream.App.Rendering.Wb.ManagedGLFramebuffer` (ctor `(OpenGLGraphicsDevice, ITexture, w, h, hasDepthStencil)`). GL save/restore: `AcDream.App.Rendering.Wb.GLStateScope` (RAII).
|
||||
- `WbDrawDispatcher.Draw` signature: `WbDrawDispatcher.cs:881`. `ICamera`: `ICamera.cs:5` (`View`, `Projection`, `Aspect`).
|
||||
- `EntitySpawnAdapter.OnCreate(WorldEntity)`: `EntitySpawnAdapter.cs:100` (requires `ServerGuid != 0`).
|
||||
- `UiButton.OnClick` (Action): `UiButton.cs:39`; wiring pattern `ChatWindowController.cs:283-289,322`.
|
||||
- Window register: `_uiHost.RegisterWindow(WindowNames.Inventory, inventoryFrame)` `GameWindow.cs:2214`; `WindowNames.Inventory = "inventory"`.
|
||||
- `DatWidgetFactory` Type switch: `DatWidgetFactory.cs:~63`.
|
||||
- `PaperdollController.Bind` call site in GameWindow: grep `PaperdollController.Bind`.
|
||||
- Camera convention to mirror: `src/AcDream.App/Rendering/ChaseCamera.cs` (`View`/`Projection` construction).
|
||||
|
||||
**Decomp anchors:** spec §2. The 9 armor element-ids (the toggle set): `0x100005ab, 0x100005ac, 0x100005ad, 0x100005ae, 0x100005af, 0x100005b0, 0x100005b1, 0x100005b2, 0x100005b3`. Camera eye `(0.12, −2.4, 0.88)` → origin. Light `DISTANT_LIGHT` dir `(0.3, 1.9, 0.65)` intensity `2.0`. Heading `191.367905°`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Armor/non-armor partition + Slots toggle (`PaperdollController`)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/UI/Layout/PaperdollController.cs`
|
||||
- Test: `tests/AcDream.App.Tests/UI/Layout/PaperdollToggleTests.cs` (create)
|
||||
|
||||
This task is pure logic. The doll viewport is injected as a plain `UiElement?` (the concrete `UiViewport` arrives in Task 3/6); the toggle just flips `Visible`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```csharp
|
||||
// tests/AcDream.App.Tests/UI/Layout/PaperdollToggleTests.cs
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using Xunit;
|
||||
|
||||
public class PaperdollToggleTests
|
||||
{
|
||||
// The 9 armor element-ids that gmPaperDollUI::ListenToElementMessage flips
|
||||
// (decomp 175674-175706). Doll-view hides these; slot-view shows them.
|
||||
private static readonly uint[] ArmorIds =
|
||||
{
|
||||
0x100005abu, 0x100005acu, 0x100005adu, 0x100005aeu, 0x100005afu,
|
||||
0x100005b0u, 0x100005b1u, 0x100005b2u, 0x100005b3u,
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void ArmorSlotIds_match_the_decomp_nine()
|
||||
{
|
||||
Assert.Equal(ArmorIds, PaperdollController.ArmorSlotElementIds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DollView_default_shows_doll_hides_armor()
|
||||
{
|
||||
var v = new PaperdollViewState();
|
||||
// default = doll-view (checkbox attr 0xe = 0)
|
||||
Assert.False(v.SlotView);
|
||||
Assert.True(v.DollVisible);
|
||||
Assert.False(v.ArmorSlotsVisible);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Toggle_to_slotview_hides_doll_shows_armor_and_back()
|
||||
{
|
||||
var v = new PaperdollViewState();
|
||||
v.Toggle();
|
||||
Assert.True(v.SlotView);
|
||||
Assert.False(v.DollVisible);
|
||||
Assert.True(v.ArmorSlotsVisible);
|
||||
v.Toggle();
|
||||
Assert.False(v.SlotView);
|
||||
Assert.True(v.DollVisible);
|
||||
Assert.False(v.ArmorSlotsVisible);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter PaperdollToggleTests`
|
||||
Expected: FAIL — `PaperdollController.ArmorSlotElementIds` and `PaperdollViewState` don't exist.
|
||||
|
||||
- [ ] **Step 3: Add the partition + a testable view-state struct to `PaperdollController.cs`**
|
||||
|
||||
Add the armor-id set as a public static (the test pins it). Add a small `PaperdollViewState` class that holds the boolean state + `Toggle()` (testable without UI). The controller will own a `PaperdollViewState` and project it onto the real widgets in `ApplyView`.
|
||||
|
||||
```csharp
|
||||
// In PaperdollController.cs, inside the AcDream.App.UI.Layout namespace.
|
||||
|
||||
/// <summary>The 9 equip slots the Slots button toggles — the body-covering set that
|
||||
/// gmPaperDollUI::PostInit starts hidden and ListenToElementMessage (idMessage==1,
|
||||
/// 0x100005be) flips (decomp 175412-175508 / 175674-175706). NOTE: this is the exact
|
||||
/// element-id set, NOT an EquipMask heuristic — it includes HeadWear/HandWear/FootWear
|
||||
/// and excludes the shirt/pants clothing slots.</summary>
|
||||
public static readonly uint[] ArmorSlotElementIds =
|
||||
{
|
||||
0x100005abu, 0x100005acu, 0x100005adu, 0x100005aeu, 0x100005afu,
|
||||
0x100005b0u, 0x100005b1u, 0x100005b2u, 0x100005b3u,
|
||||
};
|
||||
|
||||
/// <summary>Pure boolean view-state for the doll/slot toggle (testable without widgets).
|
||||
/// Default = doll-view, mirroring retail SetAttribute_Bool(m_SlotCheckbox, 0xe, 0).</summary>
|
||||
public sealed class PaperdollViewState
|
||||
{
|
||||
public bool SlotView { get; private set; } // false = doll-view (default)
|
||||
public bool DollVisible => !SlotView; // doll shown only in doll-view
|
||||
public bool ArmorSlotsVisible => SlotView; // 9 armor slots shown only in slot-view
|
||||
public void Toggle() => SlotView = !SlotView;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter PaperdollToggleTests`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Wire the state onto the real widgets (find armor slots + button + viewport, apply on click)**
|
||||
|
||||
In `PaperdollController`, after the existing slot-binding loop in the constructor: collect the 9 armor-slot `UiItemList`s by element-id; find the Slots button (`0x100005BE`) and the doll viewport element (`0x100001D5`); store a `PaperdollViewState`; on button click, `Toggle()` then `ApplyView()`. `ApplyView()` sets `viewport.Visible = state.DollVisible` and each armor slot `.Visible = state.ArmorSlotsVisible`. Apply once at the end of construction so the initial state matches default (doll-view).
|
||||
|
||||
```csharp
|
||||
// Fields:
|
||||
private readonly PaperdollViewState _viewState = new();
|
||||
private readonly List<UiItemList> _armorSlots = new();
|
||||
private AcDream.App.UI.UiElement? _dollViewport; // set in ctor; the UiViewport (Task 3/6)
|
||||
|
||||
// In the constructor, after the SlotMap binding loop:
|
||||
foreach (var id in ArmorSlotElementIds)
|
||||
if (layout.FindElement(id) is UiItemList armor) _armorSlots.Add(armor);
|
||||
|
||||
_dollViewport = layout.FindElement(0x100001D5u); // the UiViewport widget (may be null pre-Task 6)
|
||||
|
||||
if (layout.FindElement(0x100005BEu) is AcDream.App.UI.UiButton slotsBtn)
|
||||
slotsBtn.OnClick = () => { _viewState.Toggle(); ApplyView(); };
|
||||
|
||||
ApplyView(); // initial: doll-view
|
||||
|
||||
// Method:
|
||||
private void ApplyView()
|
||||
{
|
||||
if (_dollViewport is not null) _dollViewport.Visible = _viewState.DollVisible;
|
||||
foreach (var a in _armorSlots) a.Visible = _viewState.ArmorSlotsVisible;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Build + commit**
|
||||
|
||||
```bash
|
||||
dotnet build src/AcDream.App/AcDream.App.csproj
|
||||
git add src/AcDream.App/UI/Layout/PaperdollController.cs tests/AcDream.App.Tests/UI/Layout/PaperdollToggleTests.cs
|
||||
git commit -m "feat(D.2b): Slice 2 — paperdoll armor/non-armor partition + Slots toggle state"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: `DollCamera` — the fixed `ICamera`
|
||||
|
||||
**Files:**
|
||||
- Create: `src/AcDream.App/Rendering/DollCamera.cs`
|
||||
- Test: `tests/AcDream.App.Tests/Rendering/DollCameraTests.cs` (create)
|
||||
|
||||
Mirror `ChaseCamera`'s `View`/`Projection` construction convention (read `src/AcDream.App/Rendering/ChaseCamera.cs` first) so winding/culling match the world. Fixed eye/target from the decomp; up = AC `+Z`.
|
||||
|
||||
- [ ] **Step 1: Write the failing test** (convention-independent: recover the eye from the inverse view; check aspect)
|
||||
|
||||
```csharp
|
||||
// tests/AcDream.App.Tests/Rendering/DollCameraTests.cs
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using Xunit;
|
||||
|
||||
public class DollCameraTests
|
||||
{
|
||||
[Fact]
|
||||
public void Eye_position_is_the_retail_camera_offset()
|
||||
{
|
||||
var cam = new DollCamera { Aspect = 100f / 214f };
|
||||
Assert.True(Matrix4x4.Invert(cam.View, out var inv));
|
||||
var eye = inv.Translation;
|
||||
Assert.Equal(0.12f, eye.X, 3);
|
||||
Assert.Equal(-2.4f, eye.Y, 3);
|
||||
Assert.Equal(0.88f, eye.Z, 3);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Projection_is_finite_and_uses_aspect()
|
||||
{
|
||||
var cam = new DollCamera { Aspect = 1.5f };
|
||||
// A perspective projection has a non-zero [3][2] (w = -z) term and finite entries.
|
||||
Assert.True(float.IsFinite(cam.Projection.M11));
|
||||
Assert.NotEqual(0f, cam.Projection.M34);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter DollCameraTests`
|
||||
Expected: FAIL — `DollCamera` does not exist.
|
||||
|
||||
- [ ] **Step 3: Implement `DollCamera`** (mirror ChaseCamera's matrix calls; substitute the fixed eye/target/up)
|
||||
|
||||
```csharp
|
||||
// src/AcDream.App/Rendering/DollCamera.cs
|
||||
using System.Numerics;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>Fixed camera for the paperdoll mini-scene. Eye/target from
|
||||
/// gmPaperDollUI::PostInit (decomp 175521-175527): eye (0.12,-2.4,0.88) looking at the
|
||||
/// origin, AC up = +Z. Uses the same View/Projection convention as ChaseCamera so the
|
||||
/// doll's triangle winding + back-face culling match the world pass. Per-race camera
|
||||
/// distance (UpdateForRace) is deferred polish.</summary>
|
||||
public sealed class DollCamera : ICamera
|
||||
{
|
||||
private static readonly Vector3 Eye = new(0.12f, -2.4f, 0.88f);
|
||||
private static readonly Vector3 Target = Vector3.Zero;
|
||||
private static readonly Vector3 Up = new(0f, 0f, 1f);
|
||||
|
||||
// Match ChaseCamera: <copy its handedness + FOV/near/far helper calls here>.
|
||||
// Default FOV/near/far below are starting values — tune at the visual gate.
|
||||
public float FovRadians { get; set; } = 0.6f; // ~34°; refine vs CreatureMode::SetFOVRad
|
||||
public float Near { get; set; } = 0.1f;
|
||||
public float Far { get; set; } = 50f;
|
||||
public float Aspect { get; set; } = 1f;
|
||||
|
||||
public Matrix4x4 View => Matrix4x4.CreateLookAt(Eye, Target, Up);
|
||||
public Matrix4x4 Projection =>
|
||||
Matrix4x4.CreatePerspectiveFieldOfView(FovRadians, Aspect <= 0 ? 1f : Aspect, Near, Far);
|
||||
}
|
||||
```
|
||||
|
||||
> **Note:** if `ChaseCamera` uses a left-handed / custom matrix (not `CreateLookAt`/`CreatePerspectiveFieldOfView`), copy ITS exact calls instead — the doll must share the world's winding so culling isn't inverted. The test only pins the eye + aspect, which both conventions satisfy.
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter DollCameraTests`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/Rendering/DollCamera.cs tests/AcDream.App.Tests/Rendering/DollCameraTests.cs
|
||||
git commit -m "feat(D.2b): Slice 2 — DollCamera (fixed paperdoll ICamera)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: `UiViewport` widget + Type `0xD` registration
|
||||
|
||||
**Files:**
|
||||
- Create: `src/AcDream.App/UI/UiViewport.cs`
|
||||
- Create: `src/AcDream.App/UI/IUiViewportRenderer.cs`
|
||||
- Modify: `src/AcDream.App/UI/Layout/DatWidgetFactory.cs` (the Type switch)
|
||||
- Test: `tests/AcDream.App.Tests/UI/Layout/UiViewportFactoryTests.cs` (create)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
```csharp
|
||||
// tests/AcDream.App.Tests/UI/Layout/UiViewportFactoryTests.cs
|
||||
using AcDream.App.UI;
|
||||
using AcDream.App.UI.Layout;
|
||||
using Xunit;
|
||||
|
||||
public class UiViewportFactoryTests
|
||||
{
|
||||
[Fact]
|
||||
public void Factory_builds_UiViewport_for_dat_type_0xD()
|
||||
{
|
||||
var info = TestElementInfo.WithType(0xD); // helper: minimal ElementInfo, Type=0xD
|
||||
var widget = DatWidgetFactory.Create(info, _ => 0u); // resolve stub
|
||||
Assert.IsType<UiViewport>(widget);
|
||||
Assert.True(widget.ConsumesDatChildren);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> If a `TestElementInfo` helper / the exact `DatWidgetFactory.Create` signature differs, read `DatWidgetFactory.cs` + an existing factory test (e.g. the UiMeter/UiScrollbar tests under `tests/AcDream.App.Tests/UI/Layout/`) and match their construction exactly.
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter UiViewportFactoryTests`
|
||||
Expected: FAIL — no `0xD` case / no `UiViewport`.
|
||||
|
||||
- [ ] **Step 3: Define the seam interface**
|
||||
|
||||
```csharp
|
||||
// src/AcDream.App/UI/IUiViewportRenderer.cs
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>Renders a 3-D mini-scene into an off-screen buffer and returns the GL color-
|
||||
/// texture handle. Called by the per-frame pre-UI hook (GameWindow), NOT from
|
||||
/// UiViewport.OnDraw. Implemented by PaperdollViewportRenderer in AcDream.App.Rendering.
|
||||
/// Intra-App decoupling so the UI widget doesn't depend on WbDrawDispatcher/GameWindow.</summary>
|
||||
public interface IUiViewportRenderer
|
||||
{
|
||||
/// <summary>Render at (width,height); return the color-texture GL handle, or 0 if nothing rendered.</summary>
|
||||
uint Render(int width, int height);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Implement the widget**
|
||||
|
||||
```csharp
|
||||
// src/AcDream.App/UI/UiViewport.cs
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
/// <summary>Leaf widget for dat Type 0xD (UIElement_Viewport). Blits the texture produced
|
||||
/// by its IUiViewportRenderer (run in the pre-UI hook) as a single sprite at its own rect.
|
||||
/// The 3-D render does NOT happen here (OnDraw only has a 2-D context).</summary>
|
||||
public sealed class UiViewport : UiElement
|
||||
{
|
||||
public override bool ConsumesDatChildren => true;
|
||||
|
||||
/// <summary>Set by GameWindow wiring (Task 6). When null, the widget draws nothing.</summary>
|
||||
public IUiViewportRenderer? Renderer { get; set; }
|
||||
|
||||
/// <summary>Last color-texture handle the pre-UI hook produced (0 = none).</summary>
|
||||
public uint TextureHandle { get; set; }
|
||||
|
||||
protected override void OnDraw(UiRenderContext ctx)
|
||||
{
|
||||
if (!Visible || TextureHandle == 0) return;
|
||||
var p = ScreenPosition;
|
||||
ctx.DrawSprite(TextureHandle, p.X, p.Y, Width, Height);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Confirm the exact `UiElement` virtual + `ConsumesDatChildren`/`OnDraw`/`ScreenPosition`/`DrawSprite` signatures against `UiElement.cs`/`UiRenderContext.cs` and an existing leaf widget (`UiItemSlot`/`UiMeter`); match them. The RTT texture is a normal GL texture id — `DrawSprite` already takes a `uint texture` (see SSOT chrome-sprite path).
|
||||
|
||||
- [ ] **Step 5: Register Type `0xD` in `DatWidgetFactory`**
|
||||
|
||||
In the Type switch (`DatWidgetFactory.cs:~63`), add: `0xD => new UiViewport(),`. Match the surrounding arm style (some arms pass `info`/`resolve`; `UiViewport` needs neither for construction — it's wired post-build).
|
||||
|
||||
- [ ] **Step 6: Run test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter UiViewportFactoryTests`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 7: Build + commit**
|
||||
|
||||
```bash
|
||||
dotnet build src/AcDream.App/AcDream.App.csproj
|
||||
git add src/AcDream.App/UI/UiViewport.cs src/AcDream.App/UI/IUiViewportRenderer.cs src/AcDream.App/UI/Layout/DatWidgetFactory.cs tests/AcDream.App.Tests/UI/Layout/UiViewportFactoryTests.cs
|
||||
git commit -m "feat(D.2b): Slice 2 — UiViewport widget (dat Type 0xD) + IUiViewportRenderer seam"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Doll-entity builder (player Setup + ObjDesc → a `WorldEntity`)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/AcDream.App/Rendering/DollEntityBuilder.cs`
|
||||
- Test: `tests/AcDream.App.Tests/Rendering/DollEntityBuilderTests.cs` (create)
|
||||
|
||||
Extract the palette/part-override mapping from `GameWindow.cs:3390-3431` into a reusable pure helper that turns a player's spawn-derived inputs into the doll `WorldEntity` (synthetic guid, scene-origin position, heading 191.37°). Tests feed plain inputs (no dats).
|
||||
|
||||
- [ ] **Step 1: Read** `GameWindow.cs:3390-3431` (the `PaletteOverride` + `PartOverride[]` construction) and `WorldEntity.cs` (the record's settable members) so the builder's output matches what the dispatcher consumes.
|
||||
|
||||
- [ ] **Step 2: Write the failing test**
|
||||
|
||||
```csharp
|
||||
// tests/AcDream.App.Tests/Rendering/DollEntityBuilderTests.cs
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.App.Rendering;
|
||||
using AcDream.Core.World;
|
||||
using Xunit;
|
||||
|
||||
public class DollEntityBuilderTests
|
||||
{
|
||||
[Fact]
|
||||
public void Builds_doll_entity_with_synthetic_guid_and_player_setup()
|
||||
{
|
||||
var meshRefs = new List<MeshRef>(); // empty is fine for this mapping test
|
||||
var doll = DollEntityBuilder.Build(
|
||||
setupId: 0x0200_0001u,
|
||||
meshRefs: meshRefs,
|
||||
basePaletteId: 0x04000ABCu,
|
||||
subPalettes: new (uint Id, uint Off, uint Len)[] { (0x0F00_0001u, 0, 8) },
|
||||
partOverrides: new (byte Part, uint GfxObj)[] { (2, 0x0100_0042u) });
|
||||
|
||||
Assert.Equal(0x0200_0001u, doll.SourceGfxObjOrSetupId);
|
||||
Assert.NotEqual(0u, doll.ServerGuid); // synthetic non-zero (EntitySpawnAdapter requires it)
|
||||
Assert.Single(doll.PartOverrides);
|
||||
Assert.Equal((byte)2, doll.PartOverrides[0].PartIndex);
|
||||
Assert.Equal(0x0100_0042u, doll.PartOverrides[0].GfxObjId);
|
||||
Assert.NotNull(doll.PaletteOverride);
|
||||
Assert.Equal(0x04000ABCu, doll.PaletteOverride!.BasePaletteId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Heading_faces_the_viewer()
|
||||
{
|
||||
var doll = DollEntityBuilder.Build(0x0200_0001u, new List<MeshRef>(), null, null, null);
|
||||
// 191.367905° about +Z → quaternion; just assert it's set (non-identity) + normalized.
|
||||
Assert.True(System.MathF.Abs(doll.Rotation.LengthSquared() - 1f) < 1e-3f);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Match `MeshRef`, `PaletteOverride` (ctor `(BasePaletteId, SubPaletteRange[])`), `PartOverride` (`(byte PartIndex, uint GfxObjId)`), and `WorldEntity`'s members to their real definitions (read `WorldEntity.cs`, `PaletteOverride.cs`, `PartOverride.cs`). Adjust the test's tuple shapes to whatever signature you give `Build`.
|
||||
|
||||
- [ ] **Step 3: Run test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter DollEntityBuilderTests`
|
||||
Expected: FAIL — `DollEntityBuilder` does not exist.
|
||||
|
||||
- [ ] **Step 4: Implement `DollEntityBuilder`** (mirror GameWindow.cs:3390-3431; add the fixed pose + synthetic guid)
|
||||
|
||||
```csharp
|
||||
// src/AcDream.App/Rendering/DollEntityBuilder.cs
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.World;
|
||||
|
||||
namespace AcDream.App.Rendering;
|
||||
|
||||
/// <summary>Builds the dedicated paperdoll WorldEntity (retail makeObject(player) clone):
|
||||
/// the player's Setup + current ObjDesc, posed at the scene origin facing the viewer.
|
||||
/// Mirrors the palette/part-override mapping at GameWindow.cs:3390-3431. The synthetic
|
||||
/// guid keeps it distinct from the live player entity and lets EntitySpawnAdapter.OnCreate
|
||||
/// accept it (ServerGuid != 0).</summary>
|
||||
public static class DollEntityBuilder
|
||||
{
|
||||
/// <summary>Reserved synthetic guid for the paperdoll clone (high, collision-free).</summary>
|
||||
public const uint DollServerGuid = 0xDA11_D011u;
|
||||
|
||||
// 191.367905° about +Z (retail set_heading), in radians.
|
||||
private static readonly float HeadingRad = 191.367905f * (MathF.PI / 180f);
|
||||
|
||||
public static WorldEntity Build(
|
||||
uint setupId,
|
||||
IReadOnlyList<MeshRef> meshRefs,
|
||||
uint? basePaletteId = null,
|
||||
IReadOnlyList<(uint Id, uint Off, uint Len)>? subPalettes = null,
|
||||
IReadOnlyList<(byte Part, uint GfxObj)>? partOverrides = null)
|
||||
{
|
||||
PaletteOverride? pal = null;
|
||||
if (subPalettes is { Count: > 0 })
|
||||
{
|
||||
var ranges = new PaletteOverride.SubPaletteRange[subPalettes.Count];
|
||||
for (int i = 0; i < subPalettes.Count; i++)
|
||||
ranges[i] = new PaletteOverride.SubPaletteRange(
|
||||
subPalettes[i].Id, subPalettes[i].Off, subPalettes[i].Len);
|
||||
pal = new PaletteOverride(basePaletteId ?? 0, ranges);
|
||||
}
|
||||
|
||||
PartOverride[] parts = Array.Empty<PartOverride>();
|
||||
if (partOverrides is { Count: > 0 })
|
||||
{
|
||||
parts = new PartOverride[partOverrides.Count];
|
||||
for (int i = 0; i < partOverrides.Count; i++)
|
||||
parts[i] = new PartOverride(partOverrides[i].Part, partOverrides[i].GfxObj);
|
||||
}
|
||||
|
||||
return new WorldEntity
|
||||
{
|
||||
Id = 0, // assigned by the caller if it needs a render id
|
||||
ServerGuid = DollServerGuid,
|
||||
SourceGfxObjOrSetupId = setupId,
|
||||
Position = Vector3.Zero, // scene origin
|
||||
Rotation = Quaternion.CreateFromAxisAngle(new Vector3(0, 0, 1), HeadingRad),
|
||||
MeshRefs = meshRefs,
|
||||
PaletteOverride = pal,
|
||||
PartOverrides = parts,
|
||||
ParentCellId = 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Adjust property names/types to `WorldEntity`'s real definition (it's `required`-init in GameWindow's usage — match exactly). If `MeshRefs` must be a concrete `List`/array, convert.
|
||||
|
||||
- [ ] **Step 5: Run test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter DollEntityBuilderTests`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/Rendering/DollEntityBuilder.cs tests/AcDream.App.Tests/Rendering/DollEntityBuilderTests.cs
|
||||
git commit -m "feat(D.2b): Slice 2 — DollEntityBuilder (player Setup+ObjDesc -> doll WorldEntity)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: `PaperdollViewportRenderer` — the RTT pass (INLINE; static doll first)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/AcDream.App/Rendering/PaperdollViewportRenderer.cs`
|
||||
|
||||
This is GL integration — **do inline**, build a **static doll first** (no live animation), verify with a screenshot, THEN Task 7 adds idle motion. Implements `IUiViewportRenderer`.
|
||||
|
||||
**Responsibilities:**
|
||||
1. Own a `ManagedGLFramebuffer` (color `ITexture` + depth) sized to the widget rect; lazily (re)create when `(w,h)` changes. Read how `ManagedGLFramebuffer` is constructed elsewhere (grep `new ManagedGLFramebuffer`) for the `OpenGLGraphicsDevice` + `ITexture` creation pattern; reuse it.
|
||||
2. Hold the doll `WorldEntity` (from `DollEntityBuilder`, refreshed by Task 6) + its mesh-resolved `MeshRefs`, a `DollCamera`, and refs to `WbDrawDispatcher` + `SceneLightingUboBinding` (injected).
|
||||
3. `Render(w,h)`:
|
||||
|
||||
```csharp
|
||||
public uint Render(int width, int height)
|
||||
{
|
||||
if (_dollEntity is null || width <= 0 || height <= 0) return 0;
|
||||
EnsureFramebuffer(width, height); // (re)create FBO on size change
|
||||
_camera.Aspect = width / (float)height;
|
||||
|
||||
using var _ = new GLStateScope(_gl); // RAII: restores ALL gl state on dispose
|
||||
_gl.BindFramebuffer(FramebufferTarget.Framebuffer, (uint)_fbo.NativeHandle.ToInt32());
|
||||
_gl.Viewport(0, 0, (uint)width, (uint)height);
|
||||
_gl.Disable(EnableCap.ScissorTest);
|
||||
_gl.ClearColor(0f, 0f, 0f, 0f); // transparent — window backdrop shows through
|
||||
_gl.ClearDepth(1f);
|
||||
_gl.Clear(ClearBufferMask.ColorBufferBit | ClearBufferMask.DepthBufferBit);
|
||||
_gl.Enable(EnableCap.DepthTest);
|
||||
_gl.DepthMask(true);
|
||||
// cull/front-face: match the world object pass (read GameWindow.cs ~8098). The doll
|
||||
// shares mesh winding, so use the SAME CullFace + FrontFace the world sets.
|
||||
|
||||
UploadDollLight(); // overwrite binding=1 contents (safe — rebuilt next frame)
|
||||
|
||||
_wbDrawDispatcher.Draw(
|
||||
_camera,
|
||||
new[] { (_dollLandblockId, _aabbMin, _aabbMax,
|
||||
(IReadOnlyList<WorldEntity>)new[] { _dollEntity },
|
||||
(IReadOnlyDictionary<uint, WorldEntity>?)_animatedById) },
|
||||
frustum: null,
|
||||
neverCullLandblockId: _dollLandblockId,
|
||||
visibleCellIds: null,
|
||||
animatedEntityIds: _animatedEntityIds); // empty for static doll; {dollId} after Task 7
|
||||
|
||||
return _fboColorTextureHandle;
|
||||
}
|
||||
```
|
||||
|
||||
```csharp
|
||||
private void UploadDollLight()
|
||||
{
|
||||
var ubo = new AcDream.Core.Lighting.SceneLightingUbo();
|
||||
var dir = System.Numerics.Vector3.Normalize(new System.Numerics.Vector3(0.3f, 1.9f, 0.65f));
|
||||
ubo.Light0 = new AcDream.Core.Lighting.UboLight
|
||||
{
|
||||
PosAndKind = new System.Numerics.Vector4(0, 0, 0, 0), // kind 0 = directional
|
||||
DirAndRange = new System.Numerics.Vector4(dir, 1e9f), // huge range = no cutoff
|
||||
ColorAndIntensity = new System.Numerics.Vector4(1f, 1f, 1f, 2.0f), // white @ retail intensity 2.0
|
||||
ConeAngleEtc = System.Numerics.Vector4.Zero,
|
||||
};
|
||||
ubo.CellAmbient = new System.Numerics.Vector4(0.35f, 0.35f, 0.35f, 1f); // 1 active light; ambient tunable
|
||||
_sceneLightingUbo.Upload(ubo);
|
||||
}
|
||||
```
|
||||
|
||||
**Steps:**
|
||||
|
||||
- [ ] **Step 1:** Read `new ManagedGLFramebuffer` usage + `OpenGLGraphicsDevice`/`ITexture` creation; the world object-pass GL state at `GameWindow.cs:~8098` (cull/front-face); confirm `WbDrawDispatcher.Draw`'s tuple element types. Confirm whether a Setup-backed entity renders WITHOUT an `animatedEntityIds` entry (static pose) — if the dispatcher needs an animation entry to pose parts, note it for Task 7 and use a minimal pose; otherwise static is fine.
|
||||
- **⚠ Known interaction — the doll pass is a SECOND `Draw()` per frame.** `WbDrawDispatcher.cs:951-959` explicitly warns that its indoor-probe `IndoorProbeState` construction assumes "Draw() is called exactly once per frame" and "a second pass within the same frame needs revisiting." This only bites when an `ACDREAM_PROBE_*`/indoor diagnostic is active (not normal play), but verify: (a) the Tier-1 cache (#53) is keyed by entity+owning-landblock, so the doll's synthetic guid/landblock must NOT collide with any world entity/landblock (use a reserved `_dollLandblockId` unused by streaming, e.g. `0`); (b) the world Draw runs BEFORE the doll pass each frame and `_groups` is cleared at the top of every `Draw()` (`:926`), so the doll pass doesn't corrupt the world's next frame. If probes are on and you see doubled probe lines, that's expected/benign for this slice — note it, don't chase it.
|
||||
- [ ] **Step 2:** Implement `PaperdollViewportRenderer` (ctor injects `GL`, `OpenGLGraphicsDevice`, `WbDrawDispatcher`, `SceneLightingUboBinding`, `DollCamera`; `EnsureFramebuffer`, `UploadDollLight`, `Render`, `SetDoll(WorldEntity, meshRefs)`, `Dispose`). `_dollLandblockId` = a synthetic constant (e.g. `0`); `_aabbMin/Max` = the doll entity's local bounds (or a generous fixed box).
|
||||
- [ ] **Step 3:** Build: `dotnet build src/AcDream.App/AcDream.App.csproj` — green.
|
||||
- [ ] **Step 4:** Commit (renderer compiles; wiring is Task 6):
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/Rendering/PaperdollViewportRenderer.cs
|
||||
git commit -m "feat(D.2b): Slice 2 — PaperdollViewportRenderer RTT pass (static doll)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: GameWindow wiring + pre-UI hook + re-dress (INLINE)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/Rendering/GameWindow.cs`
|
||||
|
||||
**Steps:**
|
||||
|
||||
- [ ] **Step 1 — construct the renderer + give the controller the viewport.** At the `PaperdollController.Bind` call site (grep `PaperdollController.Bind`): after binding, `FindElement(0x100001D5)` for the `UiViewport`; construct `PaperdollViewportRenderer` (inject `_gl`, the `OpenGLGraphicsDevice`, `_wbDrawDispatcher`, `_sceneLightingUbo`, a `new DollCamera()`); set `uiViewport.Renderer = renderer`; store the renderer in a field `_paperdollViewport`. (The controller already finds `0x100001D5` for its toggle in Task 1 — the same element; ensure both see it.)
|
||||
|
||||
- [ ] **Step 2 — build the doll entity from the player + resolve its meshes.** Add `RefreshPaperdollDoll()`: read `_lastSpawnByGuid[_playerServerGuid]` (+ `_entitiesByServerGuid[_playerServerGuid]` for already-resolved `MeshRefs`); call `DollEntityBuilder.Build(...)`; resolve `MeshRefs` the same way the spawn path does (reuse the player entity's `MeshRefs` if present — cheapest); route the doll through `EntitySpawnAdapter.OnCreate(doll)` (so its meshes background-load + a per-instance `AnimatedEntityState` exists); `renderer.SetDoll(doll, meshRefs)`. Call it once when the inventory opens and on re-dress.
|
||||
|
||||
- [ ] **Step 3 — the pre-UI hook.** Immediately before `_uiHost.Tick/Draw` (`GameWindow.cs:9009`), add:
|
||||
|
||||
```csharp
|
||||
// Paperdoll doll RTT pass — only when the inventory window is open AND in doll-view.
|
||||
if (_options.RetailUi && _paperdollViewport is not null && _inventoryFrame is { Visible: true }
|
||||
&& _paperdollDollViewport is { Visible: true } uvp) // the UiViewport; Visible == doll-view
|
||||
{
|
||||
if (_lastSpawnByGuid.ContainsKey(_playerServerGuid))
|
||||
{
|
||||
if (_paperdollDollDirty) { RefreshPaperdollDoll(); _paperdollDollDirty = false; }
|
||||
uvp.TextureHandle = _paperdollViewport.Render((int)uvp.Width, (int)uvp.Height);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Capture `_inventoryFrame` (the registered window root) + `_paperdollDollViewport` (the `UiViewport`) as fields at the Bind site. `Visible` on the `UiViewport` is set by the controller's toggle (Task 1) — so the gate "doll-view" == `uvp.Visible`.
|
||||
|
||||
- [ ] **Step 4 — re-dress hook.** In `OnLiveAppearanceUpdated` (`GameWindow.cs:3733`), when `update.Guid == _playerServerGuid`, set `_paperdollDollDirty = true` (rebuild lazily next doll pass). Also set it true on inventory-open.
|
||||
|
||||
- [ ] **Step 5 — teardown.** In the logout/teleport `Clear()` path, dispose `_paperdollViewport` (FBO) + drop the doll's `EntitySpawnAdapter` state; null the fields so they rebuild on next login.
|
||||
|
||||
- [ ] **Step 6 — build + launch + screenshot (visual checkpoint).** Build green. Launch (`dotnet run`, plain — do NOT kill a running client; if the rebuild is locked, ASK the user to close it). Open the inventory; confirm a (static) re-dressed doll renders in the paperdoll panel and the Slots button toggles doll ↔ armor-slots.
|
||||
|
||||
- [ ] **Step 7 — commit.**
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/Rendering/GameWindow.cs
|
||||
git commit -m "feat(D.2b): Slice 2 — wire paperdoll doll RTT pass + re-dress into GameWindow"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Idle animation (INLINE refinement)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/AcDream.App/Rendering/PaperdollViewportRenderer.cs`, `src/AcDream.App/Rendering/GameWindow.cs`
|
||||
|
||||
Make the doll idle-animate, mirroring the spawn path.
|
||||
|
||||
- [ ] **Step 1:** Read `GameWindow.cs:3514-3588` (the `SetCycle` + `_animatedEntities[entity.Id]` setup) and how `TickAnimations` (`GameWindow.cs:8114`) advances them. Decide the doll's tick owner: either (a) add the doll to `_animatedEntities` (free ticking, its Id flows into the world `animatedIds` harmlessly since it's not in `_worldState.LandblockEntries`), or (b) tick the doll sequencer in the doll pass. Prefer (a) — least new machinery.
|
||||
- [ ] **Step 2:** In `RefreshPaperdollDoll`, after `OnCreate`, give the doll an `_animatedEntities[doll.Id]` entry with `sequencer.SetCycle(style, MotionCommand.Ready)` (style = the player's MotionTable `DefaultStyle`, or `0x8000003D` NonCombat — match the spawn path). Pass the doll's `Id` in `renderer`'s `_animatedEntityIds` set + the `_animatedById` dict so `WbDrawDispatcher.Draw` poses it.
|
||||
- [ ] **Step 3:** Build + launch + screenshot: the doll idles (subtle breathing/sway).
|
||||
- [ ] **Step 4:** Commit.
|
||||
|
||||
```bash
|
||||
git add src/AcDream.App/Rendering/PaperdollViewportRenderer.cs src/AcDream.App/Rendering/GameWindow.cs
|
||||
git commit -m "feat(D.2b): Slice 2 — paperdoll doll idle animation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Wrap-up — divergence rows, SSOT, full suite, visual gate
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/architecture/retail-divergence-register.md`
|
||||
- Modify: `claude-memory/project_d2b_retail_ui.md` (the D.2b SSOT) + `MEMORY.md` if needed
|
||||
- Modify: `docs/plans/2026-04-11-roadmap.md` (mark Slice 2)
|
||||
|
||||
- [ ] **Step 1:** Reword **AP-66** (empty-slot frame stays in both views — slots ring the doll, no overlay/transparent). Add rows: per-race camera deferred; idle-DID per-race deferred; `IUiViewportRenderer` seam in App.UI not Core (intentional adaptation, rationale = spec §4C); doll lighting = one hand-built distant-light UBO (approximation iff it differs from retail `CreatureMode` lighting). Gate-verify AP-62 (MissileAmmo) opportunistically.
|
||||
- [ ] **Step 2:** Run the FULL suite (not filtered): `dotnet test` across Core / Core.Net / App / UI. All green. (PROCESS lesson: a filtered subset can hide a cross-file regression.)
|
||||
- [ ] **Step 3:** Update the D.2b SSOT with a "SUB-PHASE C SLICE 2 SHIPPED" entry (the doll + toggle + RTT seam + the key DO-NOT-RETRY facts) once the visual gate passes.
|
||||
- [ ] **Step 4 — VISUAL GATE (stop for the user):** the user compares to retail — doll renders the re-dressed local player (race/gender/gear; naked if bare), faces the viewer, idles; Slots toggles doll-view ↔ armor-slots; live re-dress on wield/unwield. **This is the acceptance test; do not declare done before it passes.**
|
||||
- [ ] **Step 5:** Commit the docs.
|
||||
|
||||
```bash
|
||||
git add docs/architecture/retail-divergence-register.md claude-memory/project_d2b_retail_ui.md docs/plans/2026-04-11-roadmap.md
|
||||
git commit -m "docs(D.2b): Slice 2 shipped — paperdoll doll + Slots toggle; divergence + SSOT"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes (author)
|
||||
|
||||
- **Spec coverage:** §4A→T1, §4B→T3, §4C→T3+T5, §4D→T4+T6+T7, §4E→T6, §4F→T1-4 tests + T8 gate. §7 pin-items resolved: UiButton (T1), inventory-open (T6 `_inventoryFrame.Visible`), animation contract (T5 step1 + T7), lighting UBO (T5 `UploadDollLight`), camera (T2 mirrors ChaseCamera), idle motion (T7 `MotionCommand.Ready`). All covered.
|
||||
- **Type consistency:** `PaperdollViewState`, `ArmorSlotElementIds`, `DollCamera`, `UiViewport`, `IUiViewportRenderer.Render(w,h)→uint`, `DollEntityBuilder.Build`, `PaperdollViewportRenderer.Render/SetDoll`, `DollServerGuid` used consistently across tasks.
|
||||
- **Known soft spots (resolved inline, by design):** the exact `WbDrawDispatcher.Draw` tuple types, `WorldEntity` `required` members, `MeshRef`/`PaletteOverride` ctors, and `ChaseCamera`'s matrix convention are confirmed against source DURING Tasks 4-5 (each task's step 1 says "read X first") — they're "mirror this exact existing code" directives, not open requirements. GL output is verified at the visual gate (Tasks 6-7), which is the acceptance test per the spec.
|
||||
332
docs/superpowers/plans/2026-06-25-ui-studio-previewer.md
Normal file
332
docs/superpowers/plans/2026-06-25-ui-studio-previewer.md
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
# 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue