docs(plugin-ui): plan the movable plugin shelf and DAT-icon markup slices

Owner pivot 2026-09-06: before MossTank feature work resumes, the plugin
shelf must be movable and easy to hide, and plugin markup must embed DAT
icons the way Decal/VirindiViewService plugins (MosswartMassacre) do.
This plan is the contract for Slice A (shelf) and Slice B (icons): verified
current-state facts, behavior, files, tests, review lenses, and scope.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-09-06 13:13:32 +02:00
parent 01b98ca30c
commit b41ccc87a0

View file

@ -0,0 +1,254 @@
# Plugin shelf mobility + DAT icons in plugin markup
Date: 2026-09-06
Status: ACTIVE — prerequisite for the MossTank plugin work that follows
Owner direction: plan (Fable) → implement (Sonnet) → review (Opus, two lenses)
## Why
Two owner requests before MossTank feature work resumes:
1. The plugin shelf (`PluginSidePanel`, the right-edge strip of plugin-window
buttons) must be **movable** and **easy to hide**. Today it is pinned to the
right screen edge every tick, is not draggable, has no hide affordance, and
its position is never persisted.
2. Plugin panels must be able to **embed icons from the game's DAT files**, the
way Decal/VirindiViewService plugins do. The owner's own Decal plugin,
MosswartMassacre (`C:\Users\erikn\source\repos\MosswartMassacre`), is the
reference usage: `DecalControls.IconColumn` list columns fed by
`HudPictureBox.Image = iconId`, icon ids resolved from Decal's
`FileService.SpellTable` (spell art) and `SkillTable` (skill art), raw
portal.dat indices normalized by adding `0x06000000`, and a `<view icon=...>`
window icon. See `MosswartMassacre/Views/FlagTrackerView.cs`
(`SafeSetListImage`) and `MosswartMassacre/FlagTrackerData.cs`
(`GetSpellIcon`, `GetSkillIconId`).
## Non-negotiables
- Plugins stay **BCL-only**. Nothing in `AcDream.Plugin.Abstractions` may
reference App/UI types. Plugins hand the host DAT ids; the host owns every
texture.
- API-v1 source/binary compatibility: every abstraction change is **additive**
(new `init` properties with defaults, new default-interface members).
- No new default-on behavior, no env-var probes. Persisted state goes through
the existing `SettingsStore` / `RetailWindowLayoutPersistence` path only.
- The shelf must never disable a plugin or dispose a window. Hiding is
presentation only (existing invariant, keep the existing tests green).
- Retail's UI lock (`UiRoot.UiLocked`, retail `@lockui`) must also lock the
shelf's drag, exactly like every other retained window.
## Current state (verified 2026-09-06, HEAD `0275b4ca5`)
| Thing | Where | Fact |
|---|---|---|
| Shelf | `src/AcDream.App/UI/PluginSidePanel.cs` | `UiPanel` subclass; `Draggable=false`; `OnTick` forces `Left = parent.Width - Width - 4`; `Top = 116`; `Visible = EntryCount > 0`; not registered with `RetailWindowManager`; disposed at `RetailUiRuntime.cs:5583`. |
| Shelf construction | `RetailUiRuntime.MountPlugins` (`RetailUiRuntime.cs:4637-4710`) | Built lazily on the first `ShowInSidePanel` plugin window; `Host.Root.AddChild(_pluginSidePanel)`. |
| Plugin windows | same method | `MarkupDocument.Build(xml, binding, Assets.ResolveSprite, Assets.Controls, Assets.DefaultFont)`; registered as `plugin:{pluginId}:{windowId}` with `Host.WindowManager.Register`; geometry/visibility persist via `RetailWindowLayoutPersistence` (attaches on `WindowRegistered`). |
| Window layout schema | `src/AcDream.UI.Abstractions/Panels/Settings/UiWindowLayout.cs` | `X, Y, Width, Height, Visible, Collapsed, Maximized, AuthoredGeometryRevision``Collapsed` already exists and is captured/restored through `IRetainedWindowStateController` (`RetailWindowLayoutPersistence.cs:249,296`). |
| Retail plugin-manager chord | `InputAction.TogglePluginManager` (Shift+Ctrl+F1, retail id `0x7C`) | Handler at `RetailUiRuntime.cs:1044` only prints "The retail plugin manager is not available in acdream." |
| Markup vocabulary | `src/AcDream.App/UI/MarkupDocument.cs` | `panel` root; `group, meter, label, button, tab, toggle, slider, field, menu, list`. Bindings are `{PropertyName}` reflection reads per frame. `resolve: Func<uint,(tex,w,h)>` is `TextureCache.GetOrUploadRenderSurface` (any `0x06xxxxxx` RenderSurface DID). |
| List widget | `src/AcDream.App/UI/UiMarkupList.cs` | text rows + optional per-row colors; no icon column. |
| Button widget | `UiSimpleButton` in `src/AcDream.App/UI/UiPanel.cs:161` | rect + text; no icon. |
| Descriptor icon | `PluginPanelDescriptor.IconSurfaceId` | already drawn on the shelf button (`PluginShelfButton.OnDraw`); zero → initials. No normalization: a Decal-style bare index (`7735`) resolves to nothing. |
| Composited icons | `src/AcDream.App/UI/IconComposer.cs` | `GetSpellIcon(spellId)` = retail `CompositeSpellIcon` (power backing + art + tint + self/fellow overlay); `GetIcon(itemType, iconId, underlay, overlay, effects)` = retail item icon. Available to the UI runtime as `RetailUiAssets.Icons`. |
| Plugin-visible icon data | `src/AcDream.Plugin.Abstractions/Automation.cs` | `PluginSpellComponentInfo.IconId` exists. `PluginSpellInfo`, `PluginSkillInfo`, `PluginInventoryItem`, `PluginWorldObject` carry **no** icon id. `ClientObject` has `IconId/IconUnderlayId/IconOverlayId` (`src/AcDream.Core/Items/ClientObject.cs:201`). `AppAutomationSurface` implements `ISpellCatalog` and reads the DAT SpellTable. |
| Tests | `tests/AcDream.App.Tests/UI/PluginSidePanelTests.cs`, `MarkupDocumentTests.cs`, `MarkupPanelClickTests.cs` | harness pattern: `new UiRoot{Width,Height}`, `root.WindowManager.Register`, fake `resolve` (`_ => (1u,32,32)`), `root.Tick(...)`, `OnEvent(new UiEvent{...})`. |
| Smoke plugin | `src/AcDream.Plugins.Smoke/SmokePlugin.cs` | no UI; copied into `plugins/AcDream.Plugins.Smoke/` by `AcDream.App.csproj`. User plugins load from `{ConfigDirectory}/plugins` (`Program.cs:186`). |
## Slice A — movable, hideable plugin shelf
### Behavior
1. **The shelf is a retained window.** Register it with
`Host.WindowManager.Register("plugin-shelf", shelf, shelf, controller)` the
moment it is created in `MountPlugins`. That gives it drag, z-order,
`RetailWindowLayoutPersistence` (position + visibility + collapsed, per
character and resolution) and the UI lock for free. Name it exactly
`plugin-shelf` (stable persisted key; document it).
2. **Default dock is unchanged.** With no saved layout, the shelf sits where it
sits today (right edge, top 116). The edge dock is computed once when the
parent has a real size (first tick with `parent.Width > 0`) and **only while
the user has never moved it** — after a drag, or after a restored layout,
the shelf stays where it was put. Reflow on entry add/remove keeps the
shelf's **top-right corner** fixed when it is docked and its **top-left**
fixed otherwise, so growth never pushes it off screen. Keep the existing
`KeepWindowReachable` clamp for the shelf itself on display resize.
3. **Drag grip.** A grip strip across the top of the shelf (≈10 px tall, full
width, drawn with a subtle three-line glyph or dotted texture in the
existing border color) is the drag handle. Set `Draggable = true` on the
shelf; verify in `UiRoot` that the shelf buttons (which handle clicks)
do not promote to a window drag and that the grip does. Respect
`ConstrainDragToParent = true`.
4. **Collapse toggle.** The grip carries a small toggle at its right end
(`»` when expanded, `«` when collapsed; use the DAT font). Collapsed =
only the grip (with the toggle) remains, buttons hidden, width shrinks to
the grip's minimum. Collapsed state persists through `UiWindowLayout.Collapsed`
by implementing `IRetainedWindowStateController` on the shelf (capture/
restore). A collapsed shelf still reflows its hidden button set so
expanding is instant.
5. **Hide/show.** Re-purpose `InputAction.TogglePluginManager`
(Shift+Ctrl+F1, retail's plugin-manager chord — acdream has no other
plugin manager, so this is the honest home). Handler in `RetailUiRuntime`:
- shelf exists and visible → `Hide()`;
- shelf exists and hidden → `Show()` (and expand if collapsed);
- no shelf (no plugin windows registered) → keep today's system message
but reword to "No plugin windows are registered." Update the
Configure Keyboard caption for the action if a caption table names it
("Plugin Manager" → "Plugin Shelf"); check `RetailActionIdentityTable`
and the keymap caption source before renaming anything retail-derived
and cite what you found in the commit message.
- Hidden state persists (window layout `Visible`). New plugin windows
registering while the shelf is hidden do **not** show it.
- Also add a `/plugins` chat verb? **No.** Out of scope.
6. **Minimize buttons on plugin windows** stay as they are.
### Files
- `src/AcDream.App/UI/PluginSidePanel.cs` — grip, toggle, docking rule,
`IRetainedWindowStateController`, remove the per-tick right-edge pin.
- `src/AcDream.App/UI/RetailUiRuntime.cs` — register the shelf window in
`MountPlugins`; `TogglePluginManager` handler; shutdown path unchanged
(unregister happens through the window manager's own disposal — verify).
- Tests in `tests/AcDream.App.Tests/UI/PluginSidePanelTests.cs` (extend; keep
every existing test green):
- default dock when no layout, top-right anchored growth;
- drag via grip moves the shelf and the position survives a reflow;
- drag refused while `UiLocked`;
- collapse hides buttons, shrinks width, `CaptureWindowState().Collapsed`
round-trips through `RestoreWindowState`;
- toggle action: visible→hidden→visible; hidden shelf stays hidden when a
new plugin window registers;
- persistence: `RetailWindowLayoutPersistence` round-trips X/Y/Visible/
Collapsed for `plugin-shelf` (use the existing persistence test harness
if one exists; otherwise a focused test with a temp `SettingsStore`).
### Acceptance
`dotnet build` green; `dotnet test tests/AcDream.App.Tests --filter
"FullyQualifiedName~PluginSidePanel|FullyQualifiedName~RetailWindowLayout"`
green; then the full App suite green. Connected visual gate by the owner
(after Slice B): drag the shelf, collapse, hide with Shift+Ctrl+F1, relog,
confirm it comes back where it was.
## Slice B — DAT icons in plugin markup
### Plugin-facing contract (BCL-only, additive)
1. **Icon id grammar (`PluginIcons`).** New static class
`AcDream.Plugin.Abstractions.PluginIcons` with
`static uint Normalize(uint idOrIndex)`: `0 → 0`; values below
`0x01000000` are Decal/VVS-style bare portal indices and become
`0x06000000 + value`; anything else is returned unchanged. The host applies
`Normalize` at **every** icon sink (descriptor `IconSurfaceId`, `<icon
did>`, `<button icon>`, list icons). Document the grammar on the class.
2. **Icon ids on existing records** (all `init` properties defaulting to 0,
so API-v1 plugins compile unchanged):
- `PluginSpellInfo.IconId` — raw SpellTable `spell.Icon` DID (Decal's
`SpellTable.GetById(id).Icon`).
- `PluginSkillInfo.IconId` — SkillTable icon DID. Verify the
Chorizite.DatReaderWriter `SkillBase` member name from its XML docs
(`~/.nuget/packages/chorizite.datreaderwriter/1.0.0/lib/net8.0/DatReaderWriter.xml`)
and from how the character panel already draws skill icons; cite it.
- `PluginInventoryItem.IconId`, `PluginWorldObject.IconId` — the object's
`ClientObject.IconId`.
Fill them in `AppAutomationSurface` (spells/skills) and the existing
item/world-object snapshot builders. Where a builder is shared with
headless hosts, keep it BCL-only.
### Markup
3. **`<icon>` element.** Attributes: `x y w h` (w/h default 32), exactly one
source: `did="0x06001234"` | `did="7735"` | `did="{IconDid}"` (uint
property, re-read every frame) | `spell="{SpellId}"` (retail composited
spell icon via `IconComposer.GetSpellIcon`) | `item="{ObjectId}"` (retail
composited item icon via `IconComposer.GetIcon` from the live
`ClientObject`). Plus the common `name/visible/enabled/tooltip`.
Zero/unresolvable → draws nothing (never throws at draw time; a malformed
literal throws at `Build`, like the other elements). Aspect is preserved,
sprite centered in the box, nearest filtering for 32×32 art.
4. **`<button icon="...">`** — same `did` grammar/binding; icon drawn at the
left inside the button with the text shifted right; text may be empty
(icon-only button). Optional `iconkind="did|spell|item"` (default `did`).
5. **`<list icons="{IconDids}" iconkind="did|spell|item">`** — `IEnumerable<uint>`
parallel to `items`, rendered as a leading square column of
`RowHeight - 2` px per row (Decal's `IconColumn`); text shifts right by the
column width when `icons` is present. Missing entries draw no icon.
6. **Descriptor** — apply `PluginIcons.Normalize` to
`PluginPanelDescriptor.IconSurfaceId` in `PluginShelfButton`.
### Host plumbing
7. Introduce `IMarkupIconResolver` in `AcDream.App.UI`:
`(uint tex,int w,int h) ResolveDid(uint did)`, `ResolveSpell(uint spellId)`,
`ResolveItem(uint objectId)`. `MarkupDocument.Build` gains an optional
`IMarkupIconResolver? icons = null` parameter (tests pass a fake). When
null, `spell`/`item` sources resolve to nothing and `did` falls back to the
existing `resolve` func. `MountPlugins` passes a real resolver built from
`RetailUiAssets.Icons` (+ `ResolveSprite`) and the live object table the
inventory UI already uses — find the existing item-icon lookup path in
`src/AcDream.App/UI` (search for `IconComposer`/`UiItemSlot`) and reuse it;
do not add a second object lookup.
8. New widget `UiMarkupIcon : UiElement` (draw-only, `ClickThrough = true`
unless a tooltip is set). Button icon and list icon column draw through the
same resolver; cache nothing beyond what `TextureCache`/`IconComposer`
already cache.
### Proof and docs
9. **Smoke plugin panel.** Give `AcDream.Plugins.Smoke` a small panel
(`RegisterPanelContent`, in-memory KSML) that exercises every new surface:
`<icon did="7735">` (bare index), `<icon did="0x06002D14">`, `<icon
spell="{SpellId}">` bound to a spell the character knows (fall back to
spell 1 — Strength Other I is fine for art), `<button icon=...>`, and a
`<list icons=... iconkind="spell">` of the first five spellbook entries
with `PluginSpellInfo.IconId` also shown as text. Descriptor
`IconSurfaceId = 7735` to prove normalization. This is the owner's visual
gate.
10. **Author docs.** New `docs/plugin-ui-markup.md`: every element, every
attribute, binding rules (`{Prop}`, types, per-frame reads), the icon id
grammar, the three icon sources, the shelf (`plugin-shelf`, drag,
collapse, Shift+Ctrl+F1), and a MosswartMassacre-style example (list with
icon column fed from `PluginSpellInfo.IconId`). Link it from
`docs/README.md` and from the "Plugin UI API" section of
`docs/plans/2026-04-24-ui-framework.md` (update that section's vocabulary
sentence).
11. Tests (`MarkupDocumentTests` + new `MarkupIconTests`):
- `PluginIcons.Normalize` table (0, 7735, 0x00FFFFFF, 0x06002D14,
0x0600FFFF);
- `<icon>` builds with each source; literal `did` variants; bound `did`
re-reads; `spell`/`item` route to the fake resolver with the bound id;
unresolvable draws nothing; two sources on one element → `FormatException`;
- `<button icon>` shifts text and draws through the resolver;
- `<list icons>` reserves the column and draws per row; missing entries
skip; `iconkind` selects the resolver method;
- `AppAutomationSurface` fills `PluginSpellInfo.IconId` from the installed
DAT (use the existing InstalledDat test category/fixture conventions;
skip cleanly when the DAT is absent, as sibling tests do);
- API-v1 compatibility: an existing test binding record without the new
properties still constructs (compile-level).
### Acceptance
`dotnet build` green; App suite green; `AcDream.Plugins.Smoke` builds and is
copied by the App csproj. Owner visual gate: Smoke panel shows real DAT art in
all four places, list icon column aligned with rows.
## Execution
- **Order:** Slice A, Opus review, fix round, commit → Slice B, Opus review,
fix round, commit → connected visual gate (owner) → memory + doc updates.
Both slices edit `RetailUiRuntime.MountPlugins`, so they are sequential
(see `feedback_dont_parallelize_coupled_plan_slices`).
- **Implementer (Sonnet):** one agent per slice, this document is the contract.
Commit style: `feat(plugin-ui): <slice> — <what>` with the why; do not
touch files outside the slice's list without saying so in the report.
- **Review (Opus):** two independent lenses per slice — (1) architecture:
BCL boundary, ownership, no duplicate lookups, no workaround shapes, tests
prove behavior not shape; (2) plugin-author/retail lens: does the surface
match Decal/VVS usage in MosswartMassacre, is the icon grammar unambiguous,
does the shelf behave like a retail window under `@lockui`, persistence
keys stable. Each code finding gets a bounded fix and a narrow re-review;
documentation findings the lead fixes directly.
- **Out of scope (file, do not build):** a `/plugins` chat verb, a plugin
manager panel, icon support in `menu`/`tab`, per-plugin shelves, item icon
drag from plugin panels.