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:
parent
01b98ca30c
commit
b41ccc87a0
1 changed files with 254 additions and 0 deletions
254
docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md
Normal file
254
docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md
Normal 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.
|
||||
Loading…
Add table
Add a link
Reference in a new issue