diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..d27a33d0 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,31 @@ +# Agent Instructions + +`CLAUDE.md` is the source of truth for this repository. + +Before doing project work, read `CLAUDE.md` and follow it. If anything in this +file appears to conflict with `CLAUDE.md`, treat `CLAUDE.md` as authoritative +and update this file rather than carrying a second version of the rules. + +## Codex Notes + +- Keep this file as a thin bridge for agent tooling that discovers + `AGENTS.md`. Do not duplicate the full project state, roadmap, memory + protocol, launch workflow, or domain reference tables here. +- The project goal is `acdream`: a modern open-source C#/.NET 10 Asheron's + Call client whose behavior faithfully tracks the retail client while using a + modern architecture. +- Align all code and planning work with the architecture document named in + `CLAUDE.md`, the current milestones/roadmap docs, and the relevant memory + digests before touching a subsystem. +- Treat `GameWindow.cs` as runtime wiring. New substantial behavior belongs in + dedicated controllers, sinks, orchestrators, or Core/UI abstractions according + to the layer rules in `CLAUDE.md`. +- For AC-specific algorithms, wire formats, constants, coordinates, rendering, + physics, movement, UI, audio, chat, plugin behavior, or dat handling, read the + references named in `CLAUDE.md` before implementing. Do not guess. +- Fix root causes. Do not ship workaround guards, retry loops, grace periods, + suppressions, or symptom masks without explicit approval. +- Continue autonomously through implementation, build/test verification, docs, + memory updates, and commits when appropriate. The main reason to stop is when + visual confirmation from the user is the actual acceptance test. + diff --git a/docs/ISSUES.md b/docs/ISSUES.md index de8b7dd0..6349bce1 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -59,32 +59,6 @@ Copy this block when adding a new issue: **Files:** `src/AcDream.App/UI/Layout/CharacterStatController.cs`, `src/AcDream.App/UI/Layout/LayoutImporter.cs`. -## #157 — Live game (GameWindow) doesn't thread the per-element dat-font resolver (Fix C is studio-only) - -**Status:** OPEN -**Severity:** LOW -**Filed:** 2026-06-26 -**Component:** ui - -**Description:** Fix C (commit `a0d3395`) added a per-element dat `FontDid` resolver to `LayoutImporter.Import` + `DatWidgetFactory` — each text element gets its own dat font instead of one global font. Wired into the STUDIO path (StudioWindow → LayoutSource → LayoutImporter), but GameWindow's four `LayoutImporter.Import`/`Build` calls explicitly pass `null`, so the LIVE game still uses a single global font. - -**Fix:** Build a `Func` closure over a `ConcurrentDictionary` near where `vitalsDatFont`/`largeDatFont` load (~`GameWindow.cs:1820`), reusing `UiDatFont.Load` via `GetOrAdd` (same pattern as `RenderStack.ResolveDatFont`), and pass it to the four Import sites: vitals `0x2100006C` (~1820-1835), chat (~1875), toolbar `0x21000016` (~2019), inventory `0x21000023` (~2157). `CharacterStatController.Bind` already has null-means-keep-dat-font semantics, so it benefits automatically. Backward-compat: resolver applies the dat font as a default; explicit `DatFont` still overrides. - -**Files:** `src/AcDream.App/Rendering/GameWindow.cs` (4 import sites), `src/AcDream.App/Rendering/RenderBootstrap.cs` (`RenderStack.ResolveDatFont` = pattern to mirror). **Relates to:** Fix C `a0d3395`; register row AP-75. - -## #156 — Studio: inventory window (0x21000023) renders all-black in preview - -**Status:** OPEN -**Severity:** MEDIUM -**Filed:** 2026-06-25 -**Component:** ui (studio) - -**Description:** `AcDream.App ui-studio "" --layout 0x21000023 --screenshot out.png` renders a flat ~1.2 KB dark-canvas image — the inventory frame / grid / paperdoll don't draw. No exception, no console output. Other studio panels render fine; the inventory works in the live game (F12). Matters for the planned multi-window "full mockup" studio mode. - -**Root cause / status:** Pre-existing (identical at commits 9021600 + eccacc5; NOT a regression). The binding does not throw. Likely the studio host/render-stack omits state the inventory's whole-window Draggable frame + sub-window mounts need, or a sprite-resolve / z-order gap specific to the studio host. - -**Files:** `src/AcDream.App/Studio/FixtureProvider.cs` (case 0x21000023u), `src/AcDream.App/Studio/StudioWindow.cs`, `src/AcDream.App/Studio/PanelFbo.cs`. - ## #155 — Outdoor terrain textures look stretched + blurry vs retail (missing landscape DETAIL-texture overlay) **Status:** OPEN — **root cause VERIFIED (oracle-first); fix attempt implemented + REVERTED** (rendered the ground black). Ready for a focused fresh implementation pass. @@ -5651,6 +5625,22 @@ outdoors at the angle that previously erased it. # Recently closed +## #157 — [DONE 2026-06-26] Live game threads per-element dat-font resolver + +**Closed:** 2026-06-26 +**Commit:** `feat(studio): #156 #157 mockup desktop and font resolver` +**Resolution:** Mirrored the studio `RenderStack.ResolveDatFont` pattern in `GameWindow`: a per-window `ConcurrentDictionary` seeded with the default dat font, loaded lazily under `_datLock`, and passed to the live vitals, chat, toolbar, and inventory `LayoutImporter.Import`/`Build` calls. Live panels now get dat-authored `FontDid` defaults the same way the studio does; explicit controller-set fonts still override. + +--- + +## #156 — [DONE 2026-06-26] Studio inventory preview draws off-canvas + +**Closed:** 2026-06-26 +**Commit:** `feat(studio): #156 #157 mockup desktop and font resolver` +**Resolution:** The inventory layout imported correctly, but its top-level dat rect is the live-game screen position (`Left=500`, `Top=138`). Screenshot mode sized the FBO to the panel itself (`300x362`), so the whole tree rendered outside the off-screen canvas and only the dark clear color remained. `StudioWindow` now normalizes isolated dat-layout previews to `(0,0)` and disables root-level anchoring; the inventory screenshot changed from a flat 1.2 KB image to a rendered panel. + +--- + ## #150 — Open doors still blocked at the threshold (ethereal target not skipped in the step-down pass) **Status:** DONE (2026-06-25) — after the retail collision sweep, an OPEN door diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 824c9362..00f7d5b4 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -171,8 +171,6 @@ accepted-divergence entries (#96, #49, #50). | AP-69 | On a landblock (re)load, acdream re-projects its retained server-object spawns (`GameWindow._lastSpawnByGuid` — our `weenie_object_table` for world objects, carrying position + Setup + appearance) into the render world via `LandblockEntityRehydrator`. The dungeon collapse (#133/#135) and Near→Far demote drop a landblock's RENDER entities for FPS but keep the parsed spawns; ACE will NOT re-broadcast objects whose guid is still in its per-player `KnownObjects` set (never cleared on a normal teleport — ACE relies on the client retaining its table + doing its own visibility cull). Re-projecting from our own table is the retail "client keeps its weenie_object_table and re-renders from it" model. DIVERGENCE: acdream has NO retail 25 s / 384 m visibility cull — the retained spawn table is pruned ONLY by an explicit server `DeleteObject` (0xF747) or a spawn de-dup, never by distance/time. (#138) | `src/AcDream.App/Streaming/LandblockEntityRehydrator.cs` + `GameWindow.RehydrateServerEntitiesForLandblock` + `StreamingController` (`onLandblockLoaded`, Loaded + Promoted) | Re-projection from our own table is retail-faithful AND the only reliable path (ACE won't re-send a known object); fixes the #138 "doors/NPCs/portals gone after a dungeon exit" symptom independent of any server re-broadcast | An object the server silently stopped tracking WITHOUT a `DeleteObject` (e.g. a creature that wandered out of PVS) is re-hydrated at its last-known position on reload, where retail's 25 s cull would have dropped it — a stale ghost until a real `DeleteObject` or the next session. Close it by porting holtburger's 25 s/384 m `ACE_DESTRUCTION_TIMEOUT` self-cull | ACE `ObjectMaint.KnownObjects` never cleared on teleport (`Physics/Common/ObjectMaint.cs`, `WorldObjects/Player_Tracking.cs`); holtburger client 25 s cull `liveness.rs` `ACE_DESTRUCTION_TIMEOUT_SECS`; retail client weenie_object_table re-render | | AP-70 | `TeleportAnimSequencer.ComputeFadeAlpha` uses a smoothstep curve for all fade states; retail's `gmSmartBoxUI::UseTime` drives fade alpha through a 1024-entry `GetAnimLevel` lookup table whose contents are unrecovered | `src/AcDream.Core/World/TeleportAnimSequencer.cs` (`ComputeFadeAlpha`) | `GetAnimLevel` table address and contents not yet extracted via cdb; smoothstep is a perceptually-reasonable S-curve that closely approximates a typical gamma-corrected fade; the visual difference is imperceptible under normal teleport conditions | Fade timing differs subtly from retail — ramp-in or ramp-out may be slightly too fast/slow at the black-fade edges; retire by reading the `GetAnimLevel` table via cdb and replacing smoothstep with a direct 1024-entry lookup (spec §8) | `gmSmartBoxUI::UseTime` 0x004d6e30; `GetAnimLevel` 1024-entry table (address unrecovered — spec §8 cdb trace) | | AP-71 | **`CEnvCell::find_env_collisions` omits the `CObjCell::check_entry_restrictions` gate** — retail's `CEnvCell::find_env_collisions` (pc:309576) calls `CObjCell::check_entry_restrictions` (pc:309576) FIRST and returns `COLLIDED` when an access-locked cell's `restriction_obj` entity rejects the mover (`CanMoveInto` fails AND `CanBypassMoveRestrictions` is false). acdream's `FindEnvCollisions` (`src/AcDream.Core/Physics/TransitionTypes.cs:2088`) goes straight to BSP collision. | `src/AcDream.Core/Physics/TransitionTypes.cs:2088` | **No access-restriction cell data exists in acdream.** `CellPhysics` (`src/AcDream.Core/Physics/PhysicsDataCache.cs:540`) has no `restriction_obj` field; `DatReaderWriter` does not model per-cell access locks; no weenie-object-table exists on the client for the gate's `CanMoveInto` / `CanBypassMoveRestrictions` dispatch. The gap is **inert** in all dev content (ACE starter area has no access-locked env cells). | If access-locked dungeon cells are ever modeled (dat + wire), the player will walk through the restriction barrier without being blocked — wrong PK/housing gating. | `CObjCell::check_entry_restrictions` pc:309576; `CEnvCell::find_env_collisions` pc:309573–309597 | -| AP-72 | **Per-element dat-font resolver (Fix C) wired into the STUDIO path only** — `LayoutImporter.Import` + `DatWidgetFactory` support a `Func` resolver that gives each text element its own dat font; `StudioWindow` supplies it. `GameWindow`'s four `Import`/`Build` call sites pass `null`, so the live game still uses a single global font. **Retire with #157.** | `src/AcDream.App/Rendering/RenderBootstrap.cs` (`RenderStack.ResolveDatFont` — pattern to mirror into GameWindow) + `src/AcDream.App/Studio/StudioWindow.cs` (resolver wired) + `src/AcDream.App/Rendering/GameWindow.cs` (~4 import sites pass null) | Studio is the proving-ground path; GameWindow threading is Issue #157 (small, safe, orthogonal to M5 gameplay). The live panels still render with the global dat font (same as before Fix C), so behavior is unchanged in the shipped game. | Text elements whose dat `FontDid` differs from the global font render with the wrong size/face in the live game — cosmetic; the studio shows the correct dat font. | `DatWidgetFactory.BuildText`; `LayoutImporter.Import(fontResolver)`; commit `a0d3395` (Fix C) | - --- ## 4. Temporary stopgap (TS) — 32 rows diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 0cf19a71..731d815c 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1861,6 +1861,15 @@ public sealed class GameWindow : IDisposable AcDream.App.UI.UiDatFont? vitalsDatFont; lock (_datLock) vitalsDatFont = AcDream.App.UI.UiDatFont.Load(_dats!, _textureCache!); + var datFontCache = new System.Collections.Concurrent.ConcurrentDictionary(); + if (vitalsDatFont is not null) + datFontCache.TryAdd(AcDream.App.UI.UiDatFont.DefaultFontId, vitalsDatFont); + AcDream.App.UI.UiDatFont? ResolveDatFont(uint fontDid) + => datFontCache.GetOrAdd(fontDid, id => + { + lock (_datLock) + return AcDream.App.UI.UiDatFont.Load(_dats!, _textureCache!, id); + }); Console.WriteLine(vitalsDatFont is not null ? "[D.2b] vitals dat-font 0x40000000 loaded for numeric overlay." : "[D.2b] vitals dat-font 0x40000000 unavailable — falling back to debug font."); @@ -1872,7 +1881,7 @@ public sealed class GameWindow : IDisposable AcDream.App.UI.Layout.ImportedLayout? imported; lock (_datLock) imported = AcDream.App.UI.Layout.LayoutImporter.Import( - _dats!, 0x2100006Cu, ResolveChrome, vitalsDatFont); + _dats!, 0x2100006Cu, ResolveChrome, vitalsDatFont, ResolveDatFont); if (imported is not null) { AcDream.App.UI.Layout.VitalsController.Bind(imported, @@ -1916,7 +1925,7 @@ public sealed class GameWindow : IDisposable chatRootInfo = AcDream.App.UI.Layout.LayoutImporter.ImportInfos( _dats!, AcDream.App.UI.Layout.ChatWindowController.LayoutId); chatLayout = chatRootInfo is null ? null - : AcDream.App.UI.Layout.LayoutImporter.Build(chatRootInfo, ResolveChrome, vitalsDatFont); + : AcDream.App.UI.Layout.LayoutImporter.Build(chatRootInfo, ResolveChrome, vitalsDatFont, ResolveDatFont); } if (chatRootInfo is not null && chatLayout is not null) { @@ -2058,7 +2067,7 @@ public sealed class GameWindow : IDisposable AcDream.App.UI.Layout.ImportedLayout? toolbarLayout; lock (_datLock) toolbarLayout = AcDream.App.UI.Layout.LayoutImporter.Import( - _dats!, 0x21000016u, ResolveChrome, vitalsDatFont); + _dats!, 0x21000016u, ResolveChrome, vitalsDatFont, ResolveDatFont); if (toolbarLayout is not null) { _toolbarController = AcDream.App.UI.Layout.ToolbarController.Bind( @@ -2196,7 +2205,7 @@ public sealed class GameWindow : IDisposable AcDream.App.UI.Layout.ImportedLayout? invLayout; lock (_datLock) invLayout = AcDream.App.UI.Layout.LayoutImporter.Import( - _dats!, 0x21000023u, ResolveChrome, vitalsDatFont); + _dats!, 0x21000023u, ResolveChrome, vitalsDatFont, ResolveDatFont); if (invLayout is not null) { var inventoryRoot = invLayout.Root; diff --git a/src/AcDream.App/Studio/MockupDesktop.cs b/src/AcDream.App/Studio/MockupDesktop.cs new file mode 100644 index 00000000..ed2fe1ab --- /dev/null +++ b/src/AcDream.App/Studio/MockupDesktop.cs @@ -0,0 +1,251 @@ +using AcDream.App.Rendering; +using AcDream.App.UI; +using AcDream.App.UI.Layout; +using AcDream.Core.Chat; +using AcDream.UI.Abstractions; +using AcDream.UI.Abstractions.Panels.Chat; +using DatReaderWriter; + +namespace AcDream.App.Studio; + +/// +/// Builds a multi-window UI desktop for the studio. It mounts several real +/// imported panels into one production so drag, resize, +/// click, focus, and z-order behavior all run through the same UI root as the +/// game. +/// +internal static class MockupDesktop +{ + public static void Load(DatCollection dats, RenderStack stack) + { + var objects = SampleData.BuildObjectTable(); + + MountVitals(dats, stack); + MountToolbar(dats, stack, objects); + MountCharacter(dats, stack, objects); + MountInventory(dats, stack, objects); + MountChat(dats, stack); + } + + private static ImportedLayout? Import(DatCollection dats, uint layoutId, RenderStack stack) + => LayoutImporter.Import(dats, layoutId, stack.ResolveChrome, stack.VitalsDatFont, + fontResolve: stack.ResolveDatFont); + + private static void MountVitals(DatCollection dats, RenderStack stack) + { + var layout = Import(dats, 0x2100006Cu, stack); + if (layout is null) return; + + FixtureProvider.Populate(0x2100006Cu, layout, stack, SampleData.BuildObjectTable(), dats); + + var root = layout.Root; + root.Left = 12f; + root.Top = 18f; + root.ClickThrough = false; + root.Anchors = AnchorEdges.None; + root.Draggable = true; + root.Resizable = true; + root.ResizeX = true; + root.ResizeY = false; + root.MinWidth = 40f; + stack.UiHost.Root.AddChild(root); + } + + private static void MountToolbar(DatCollection dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects) + { + var layout = Import(dats, 0x21000016u, stack); + if (layout is null) return; + + FixtureProvider.Populate(0x21000016u, layout, stack, objects, dats); + + const int border = RetailChromeSprites.Border; + var toolbarRoot = layout.Root; + float contentW = toolbarRoot.Width > 0f ? toolbarRoot.Width : 300f; + float contentH = toolbarRoot.Height; + var frame = new UiCollapsibleFrame(stack.ResolveChrome) + { + Left = 12f, + Top = 220f, + Width = contentW + 2 * border, + Height = contentH + 2 * border, + Opacity = 1f, + }; + + toolbarRoot.Left = border; + toolbarRoot.Top = border; + toolbarRoot.Width = contentW; + toolbarRoot.Height = contentH; + toolbarRoot.Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right; + toolbarRoot.ClickThrough = true; + toolbarRoot.Draggable = false; + toolbarRoot.Resizable = false; + frame.AddChild(toolbarRoot); + + ConfigureToolbarCollapse(layout, frame, contentH, border); + stack.UiHost.Root.AddChild(frame); + } + + private static void ConfigureToolbarCollapse(ImportedLayout layout, UiCollapsibleFrame frame, float contentH, int border) + { + uint[] row2Ids = + { + 0x100006B6u, + 0x100006B7u, 0x100006B8u, 0x100006B9u, 0x100006BAu, 0x100006BBu, + 0x100006BCu, 0x100006BDu, 0x100006BEu, 0x100006BFu, + 0x100006C0u, + }; + + var row2 = new List(); + float minRow2Top = float.MaxValue; + foreach (var id in row2Ids) + { + if (layout.FindElement(id) is not { } element) continue; + row2.Add(element); + if (element.Top < minRow2Top) minRow2Top = element.Top; + } + + if (row2.Count == 0) return; + + float expandedH = contentH + 2 * border; + float collapsedH = minRow2Top + 2 * border; + frame.CollapsedHeight = collapsedH; + frame.ExpandedHeight = expandedH; + frame.SecondRow = row2; + frame.Resizable = true; + frame.ResizableEdges = ResizeEdges.Bottom; + frame.MinHeight = collapsedH; + frame.MaxHeight = expandedH; + } + + private static void MountCharacter(DatCollection dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects) + { + var layout = Import(dats, 0x2100002Eu, stack); + if (layout is null) return; + + FixtureProvider.Populate(0x2100002Eu, layout, stack, objects, dats); + + var root = layout.Root; + root.Left = 540f; + root.Top = 18f; + root.Anchors = AnchorEdges.None; + root.ClickThrough = false; + root.Draggable = true; + root.Resizable = true; + root.MinWidth = root.Width; + root.MinHeight = root.Height; + stack.UiHost.Root.AddChild(root); + } + + private static void MountInventory(DatCollection dats, RenderStack stack, AcDream.Core.Items.ClientObjectTable objects) + { + var layout = Import(dats, 0x21000023u, stack); + if (layout is null) return; + + FixtureProvider.Populate(0x21000023u, layout, stack, objects, dats); + + const int border = RetailChromeSprites.Border; + var inventoryRoot = layout.Root; + float contentW = inventoryRoot.Width; + float contentH = inventoryRoot.Height; + var frame = new UiNineSlicePanel(stack.ResolveChrome) + { + Left = 900f, + Top = 18f, + Width = contentW + 2 * border, + Height = contentH + 2 * border, + Opacity = 1f, + Visible = true, + Anchors = AnchorEdges.None, + Draggable = true, + Resizable = true, + ResizeX = false, + ResizableEdges = ResizeEdges.Bottom, + MinHeight = contentH + 2 * border, + MaxHeight = 560f, + }; + + inventoryRoot.Left = border; + inventoryRoot.Top = border; + inventoryRoot.Width = contentW; + inventoryRoot.Height = contentH; + inventoryRoot.Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom; + inventoryRoot.Draggable = false; + frame.AddChild(inventoryRoot); + + StretchV(layout, 0x100001D0u); + StretchV(layout, 0x100001CFu); + StretchV(layout, 0x100001C6u); + StretchV(layout, 0x100001C7u); + PinTopLeft(layout, 0x100001CDu); + PinTopLeft(layout, 0x100001CEu); + + stack.UiHost.Root.AddChild(frame); + stack.UiHost.RegisterWindow(WindowNames.Inventory, frame); + } + + private static void StretchV(ImportedLayout layout, uint id) + { + if (layout.FindElement(id) is { } element) + element.Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Bottom; + } + + private static void PinTopLeft(ImportedLayout layout, uint id) + { + if (layout.FindElement(id) is { } element) + element.Anchors = AnchorEdges.Left | AnchorEdges.Top; + } + + private static void MountChat(DatCollection dats, RenderStack stack) + { + var rootInfo = LayoutImporter.ImportInfos(dats, ChatWindowController.LayoutId); + if (rootInfo is null) return; + + var layout = LayoutImporter.Build(rootInfo, stack.ResolveChrome, stack.VitalsDatFont, + fontResolve: stack.ResolveDatFont); + + var chatLog = new ChatLog(); + chatLog.SetLocalPlayerGuid(SampleData.PlayerGuid); + chatLog.OnSystemMessage("Welcome to the acdream UI mockup.", 0); + chatLog.OnLocalSpeech("You", "This desktop is running through UiHost.", SampleData.PlayerGuid, isRanged: false); + chatLog.OnChannelBroadcast(1, "Caith", "Drag and resize the windows here.", "General"); + chatLog.OnTellReceived("Alicia", "The old UI is starting to wake up.", 0x5000ABCDu); + + var vm = new ChatVM(chatLog, displayLimit: 200); + var controller = ChatWindowController.Bind( + rootInfo, + layout, + vm, + () => NullCommandBus.Instance, + stack.VitalsDatFont, + debugFont: null, + stack.ResolveChrome); + if (controller is null) return; + + const int border = RetailChromeSprites.Border; + var chatRoot = controller.Root; + float contentW = 490f; + float contentH = chatRoot.Height; + var frame = new UiNineSlicePanel(stack.ResolveChrome) + { + Left = 12f, + Top = 390f, + Width = contentW + 2 * border, + Height = contentH + 2 * border, + MinWidth = 200f, + MinHeight = 90f, + Opacity = 0.75f, + }; + + chatRoot.Left = border; + chatRoot.Top = border; + chatRoot.Width = contentW; + chatRoot.Height = contentH; + chatRoot.Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right | AnchorEdges.Bottom; + chatRoot.Draggable = false; + chatRoot.Resizable = false; + frame.AddChild(chatRoot); + + stack.UiHost.Root.AddChild(frame); + stack.UiHost.Root.DefaultTextInput = controller.Input; + } +} diff --git a/src/AcDream.App/Studio/StudioOptions.cs b/src/AcDream.App/Studio/StudioOptions.cs index 2a715b01..f60e7fb4 100644 --- a/src/AcDream.App/Studio/StudioOptions.cs +++ b/src/AcDream.App/Studio/StudioOptions.cs @@ -11,7 +11,8 @@ public sealed record StudioOptions( string? MarkupPath, string? DumpSlug = null, string? DumpFile = null, - string? ScreenshotPath = null) + string? ScreenshotPath = null, + bool Mockup = false) { /// /// Parse studio options from the args that come AFTER the ui-studio token. @@ -39,6 +40,7 @@ public sealed record StudioOptions( string? dumpSlug = null; string? dumpFile = null; string? screenshotPath = null; + bool mockup = false; for (int i = 0; i < args.Length; i++) { @@ -67,6 +69,10 @@ public sealed record StudioOptions( { screenshotPath = args[++i]; } + else if (args[i] == "--mockup") + { + mockup = true; + } else if (!args[i].StartsWith('-')) { datDir ??= args[i]; @@ -81,10 +87,10 @@ public sealed record StudioOptions( "ui-studio: dat directory required — pass as first arg or set ACDREAM_DAT_DIR."); // Default layout: vitals (0x2100006C), unless a dump slug or markup is requested. - if (layoutId is null && markupPath is null && dumpSlug is null) + if (!mockup && layoutId is null && markupPath is null && dumpSlug is null) layoutId = 0x2100006Cu; - return new StudioOptions(datDir, layoutId, markupPath, dumpSlug, dumpFile, screenshotPath); + return new StudioOptions(datDir, layoutId, markupPath, dumpSlug, dumpFile, screenshotPath, mockup); } /// diff --git a/src/AcDream.App/Studio/StudioWindow.cs b/src/AcDream.App/Studio/StudioWindow.cs index 63191fe1..febfae46 100644 --- a/src/AcDream.App/Studio/StudioWindow.cs +++ b/src/AcDream.App/Studio/StudioWindow.cs @@ -18,7 +18,7 @@ namespace AcDream.App.Studio; /// () and previews a single UI panel /// identified by a . /// -/// Usage: dotnet run -- ui-studio [dat-dir] [--layout 0xNNNN] [--markup path] +/// Usage: dotnet run -- ui-studio [dat-dir] [--layout 0xNNNN] [--markup path] [--mockup] /// /// Task 3 adds an ImGui IDE on top of the panel FBO: /// @@ -124,6 +124,9 @@ public sealed class StudioWindow : IDisposable _stack = RenderBootstrap.Create(gl, _dats, new RenderBootstrapOptions(quality)); + if (_opts.Mockup) + MockupDesktop.Load(_dats, _stack); + // Load the panel described by options and add it to the UI tree. // Task 4b: --dump uses DumpLayout (static retail mockup, no controllers). // All other modes use LayoutSource + FixtureProvider (production path). @@ -141,8 +144,8 @@ public sealed class StudioWindow : IDisposable _dumpSlugs = UiDumpModel.ListSlugs(_dumpFile) .OrderBy(s => s, StringComparer.OrdinalIgnoreCase).ToList(); - UiElement? root; - if (_opts.DumpSlug is not null) + UiElement? root = null; + if (!_opts.Mockup && _opts.DumpSlug is not null) { if (_dumpFile is null) { @@ -160,11 +163,13 @@ public sealed class StudioWindow : IDisposable _currentSlug = _opts.DumpSlug; } } - else + else if (!_opts.Mockup) { root = _source.Load(_opts); if (root is null) Console.Error.WriteLine($"[studio] panel load failed: {_source.LastError}"); + else + NormalizeSinglePanelRoot(root); } _panelRoot = root; @@ -189,12 +194,18 @@ public sealed class StudioWindow : IDisposable if (_opts.ScreenshotPath is null) { var input = _window!.CreateInput(); - // Wire KEYBOARD input into UiHost (keyboard coords are not spatial, so no remapping needed). - // Do NOT wire mouse here — raw Silk window coords would be offset by the canvas sub-window's - // position (tree pane width + ImGui chrome) and land in the wrong panel-local location. - // Mouse is forwarded manually from DrawCanvas with correct panel-local mapping below. + // Mockup mode draws UiHost directly to the window, so raw Silk mouse coordinates + // already match UI pixels. Inspector mode draws UiHost inside an ImGui canvas, so + // mouse is forwarded manually from DrawCanvas with canvas-local remapping. + if (_opts.Mockup) + foreach (var mouse in input.Mice) + _stack.UiHost.WireMouse(mouse); foreach (var kb in input.Keyboards) _stack.UiHost.WireKeyboard(kb); + + if (_opts.Mockup) + return; + _imgui = new AcDream.UI.ImGui.ImGuiBootstrapper(gl, _window!, input); _inspector = new StudioInspector(); } @@ -215,7 +226,7 @@ public sealed class StudioWindow : IDisposable // Pick render size from the loaded root's bounds (clamped to sane limits). // Fall back to 1280×720 when the root has no explicit size. int w = 1280, h = 720; - if (_panelRoot is not null) + if (!_opts.Mockup && _panelRoot is not null) { float rw = _panelRoot.Width; float rh = _panelRoot.Height; @@ -264,6 +275,20 @@ public sealed class StudioWindow : IDisposable } // ── INTERACTIVE PATH ────────────────────────────────────────────────────── + if (_opts.Mockup) + { + var mockupGl = _stack.Gl; + int mockupW = _window!.Size.X; + int mockupH = _window!.Size.Y; + _stack.UiHost.Tick(dt); + mockupGl.BindFramebuffer(FramebufferTarget.Framebuffer, 0); + mockupGl.Viewport(0, 0, (uint)mockupW, (uint)mockupH); + mockupGl.ClearColor(0.08f, 0.08f, 0.08f, 1f); + mockupGl.Clear(ColorBufferBit | DepthBufferBit); + _stack.UiHost.Draw(new Vector2(mockupW, mockupH)); + return; + } + if (_imgui is null || _inspector is null) return; var gl = _stack.Gl; @@ -428,6 +453,18 @@ public sealed class StudioWindow : IDisposable _inspector.Selected = null; } + /// + /// Studio dat-layout previews show one panel in isolation, not at its live-game + /// screen position. Some top-level layouts (inventory: x=500,y=138) otherwise + /// draw entirely outside the root-sized screenshot FBO. + /// + internal static void NormalizeSinglePanelRoot(UiElement root) + { + root.Left = 0f; + root.Top = 0f; + root.Anchors = AnchorEdges.None; + } + private void OnClosing() { _imgui?.Dispose(); diff --git a/tests/AcDream.App.Tests/Studio/StudioWindowTests.cs b/tests/AcDream.App.Tests/Studio/StudioWindowTests.cs new file mode 100644 index 00000000..ba39748c --- /dev/null +++ b/tests/AcDream.App.Tests/Studio/StudioWindowTests.cs @@ -0,0 +1,39 @@ +using AcDream.App.Studio; +using AcDream.App.UI; + +namespace AcDream.App.Tests.Studio; + +public class StudioWindowTests +{ + [Fact] + public void ParseMockup_doesNotDefaultToSinglePanelLayout() + { + var opts = StudioOptions.Parse(new[] { @"C:\fake-dats", "--mockup" }); + + Assert.True(opts.Mockup); + Assert.Null(opts.LayoutId); + Assert.Null(opts.DumpSlug); + Assert.Null(opts.MarkupPath); + } + + [Fact] + public void NormalizeSinglePanelRoot_movesDatPanelToCanvasOrigin() + { + var root = new UiPanel + { + Left = 500f, + Top = 138f, + Width = 300f, + Height = 362f, + Anchors = AnchorEdges.Left | AnchorEdges.Top | AnchorEdges.Right, + }; + + StudioWindow.NormalizeSinglePanelRoot(root); + + Assert.Equal(0f, root.Left); + Assert.Equal(0f, root.Top); + Assert.Equal(300f, root.Width); + Assert.Equal(362f, root.Height); + Assert.Equal(AnchorEdges.None, root.Anchors); + } +}