feat(plugin-ui): Slice B — DAT icons in plugin markup (icon element, button/list icons, plugin icon ids)
Owner request: plugin panels (Decal/VirindiViewService-class, per the
MosswartMassacre reference usage) need to embed real DAT icons the way
FlagTrackerView.SafeSetListImage does — spell/skill art, raw portal
indices, and a window icon. This is Slice B of
docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md (Slice A, the
movable/collapsible shelf, landed in 01b98ca30/4fada238e/718005b21).
What shipped:
- AcDream.Plugin.Abstractions.PluginIcons.Normalize: the one Decal-style
bare-index -> 0x06xxxxxx RenderSurface DID grammar, applied at every
icon SINK (descriptor IconSurfaceId in PluginShelfButton, and markup
<icon did>/<button icon>/<list icons> did-kind ids) rather than on the
plugin-facing records, which already carry real DIDs read straight
from the client's tables.
- PluginSpellInfo.IconId / PluginSkillInfo.IconId /
PluginInventoryItem.IconId / PluginWorldObject.IconId: additive init
properties (default 0), filled in AppAutomationSurface from
SpellMetadata.IconId (already projected from SpellBase.Icon by
RetailSpellMetadataProjector — no gap there), a new BindSkillIcons
parallel to BindSkillNames (GameWindow reads
DatReaderWriter.Types.SkillBase.IconId — confirmed via reflection over
the installed Chorizite.DatReaderWriter package, since its XML docs
don't cover Pack/Unpack-generated public fields: Description, Name,
IconId (uint), TrainedCost, SpecializedCost, Category, ChargenUse,
MinLevel, Formula, UpperBound, LowerBound, LearnMod), and
ClientObject.IconId in CaptureOwnedItems/ProjectWorldObject.
- IMarkupIconResolver (AcDream.App.UI): ResolveDid/ResolveSpell/
ResolveItem. MarkupDocument.Build gains an optional parameter (null by
default -> every icon sink resolves to nothing rather than throwing,
so pre-Slice-B callers/tests are unaffected). RetailUiRuntime.
MountPlugins builds ONE RetailMarkupIconResolver per pass from
RetailUiAssets.ResolveSprite + RetailUiAssets.Icons (the shared
IconComposer) + Toolbar.Objects (the SAME ClientObjectTable
Magic/Toolbar bindings already borrow for their own icon resolution —
no second object lookup introduced).
- New UiMarkupIcon widget (<icon x y w h did|spell|item tooltip>):
exactly one source required (FormatException at Build otherwise,
matching every other malformed-attribute rule), aspect-preserved,
centered, click-through unless a tooltip makes it a real hit-test
target.
- UiSimpleButton.IconSource and UiMarkupList.IconIdsSource/IconResolve:
additive, default null/no-op, so every existing button/list caller
(including the plugin shelf's own toggle/minimize buttons) is
unaffected. Button icon draws flush left and shifts the caption's
centering region right; list icons reserve a leading RowHeight-2
column (Decal's IconColumn) and skip rows whose id is 0 or
unresolvable.
- MarkupDocument centralizes the did/spell/item dispatch (including
PluginIcons.Normalize for did) in two small helpers (BuildIconSource
for <icon>/<button>, BuildRowIconResolve for <list>) so all three
markup surfaces share one resolver call path.
- AcDream.Plugins.Smoke ships a RegisterPanelContent (in-memory KSML,
no plugin-side .xml file) proof panel exercising every new surface:
a bare-index <icon>, a literal-hex <icon>, a composited <icon
spell=...>, a <button icon=...>, and a <list icons=... iconkind=
spell> of the first five known self-buffs with their IconId printed
alongside. Descriptor IconSurfaceId reuses the same bare index to
prove the shelf button and the panel's own icon normalize identically.
- docs/plugin-ui-markup.md is the new SSOT for the full markup
vocabulary + icon grammar + the Slice A shelf; linked from
docs/README.md and docs/plans/2026-04-24-ui-framework.md.
Design decisions where the plan left room:
- Normalize runs inside the resolver dispatch (BuildIconSource/
BuildRowIconResolve), not scattered at each markup call site, so
every did-kind sink shares one choke point.
- did/spell/item all accept either a literal (decimal or 0x-hex) or a
{Binding}, via one BindUintLiteralOrBinding helper, for symmetry —
the plan only showed spell/item as bindings but didn't forbid a
literal.
- <icon> requires exactly one source INCLUDING zero (not just two);
an icon with no source is not a coherent element.
- The button/list icon draw math (icon column extent, padding) lives
in the widgets themselves (UiSimpleButton/UiMarkupList), not in
MarkupDocument, keeping the parser only responsible for wiring
Func<(tex,w,h)> sources.
Tests: PluginIconsTests (Normalize table), MarkupIconTests (icon/button/
list resolver dispatch via a fake IMarkupIconResolver, plus draw-level
pins via the RecordingGpuDevice/TextRenderer apparatus already used by
UiAncestorClipTests/UiRenderContextDrawStringDatOutlineTests — "draws
nothing when unresolvable" and "button/list icon shifts the text"),
and AppAutomationSurfaceIconInstalledDatTests (Lane=InstalledDat: a
known spell's IconId matches the real installed SpellTable's own Icon
field exactly). Verified every new test fails to COMPILE without this
change (git-stashed the src/ changes, rebuilt the test project: CS0246
on IMarkupIconResolver) before restoring. Full App suite: 7331 passed /
97 skipped / 36 failed (identical pre-existing failure set/count to the
7306/97/36 baseline; the +25 passes are exactly the new tests).
AcDream.Plugins.MossTank.Tests (the main consumer of the touched
Plugin.Abstractions records) passes 337/337 unchanged, confirming
API-v1 binary/source compatibility. Full solution builds green.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
718005b210
commit
8217a349e0
21 changed files with 1440 additions and 16 deletions
|
|
@ -92,6 +92,11 @@ document in the same change; do not leave both claims standing.
|
||||||
before trusting any measurement. Enforced by
|
before trusting any measurement. Enforced by
|
||||||
`LaunchOptionsDocumentationTests`: a flag without a row fails the build, and
|
`LaunchOptionsDocumentationTests`: a flag without a row fails the build, and
|
||||||
so does a row whose read site was deleted.
|
so does a row whose read site was deleted.
|
||||||
|
- [`plugin-ui-markup.md`](plugin-ui-markup.md) is the SSOT for the plugin
|
||||||
|
markup vocabulary (elements, attributes, `{Binding}` rules) plus the
|
||||||
|
Decal/VirindiViewService-compatible DAT icon grammar (Slice B,
|
||||||
|
`plans/2026-09-06-plugin-shelf-and-dat-icons.md`) and the movable/
|
||||||
|
collapsible plugin shelf (Slice A of the same plan).
|
||||||
- [`audit/`](audit/) contains completion and conformance audits.
|
- [`audit/`](audit/) contains completion and conformance audits.
|
||||||
- [`reference/ace-commands.md`](reference/ace-commands.md) preserves the local
|
- [`reference/ace-commands.md`](reference/ace-commands.md) preserves the local
|
||||||
ACE server's complete in-game command catalog and points to the authoritative
|
ACE server's complete in-game command catalog and points to the authoritative
|
||||||
|
|
|
||||||
|
|
@ -203,9 +203,18 @@ plugin. No-window hosts retain the plugin session but expose the no-op UI
|
||||||
capability.
|
capability.
|
||||||
|
|
||||||
The retained markup vocabulary includes panels, nested groups, labels,
|
The retained markup vocabulary includes panels, nested groups, labels,
|
||||||
buttons, meters, tabs, lamp-style toggles, and scalar sliders. Controls bind to
|
buttons, meters, tabs, lamp-style toggles, scalar sliders, editable fields,
|
||||||
BCL-visible properties/actions on the plugin binding object; visible controls
|
dropdown menus, scrollable lists, and (Slice B,
|
||||||
must correspond to real behavior, never placeholders that report success.
|
`docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md`) DAT icons — a standalone
|
||||||
|
`<icon>` element plus icon-bearing extensions of `<button>` and `<list>` —
|
||||||
|
resolved from a Decal/VirindiViewService-compatible icon-id grammar
|
||||||
|
(`AcDream.Plugin.Abstractions.PluginIcons.Normalize`) against raw RenderSurface
|
||||||
|
DIDs, retail's composited spell icon, or a live object's composited item icon.
|
||||||
|
Controls bind to BCL-visible properties/actions on the plugin binding object;
|
||||||
|
visible controls must correspond to real behavior, never placeholders that
|
||||||
|
report success. Full grammar and binding rules: `docs/plugin-ui-markup.md`.
|
||||||
|
The Slice A movable/collapsible plugin shelf (`plugin-shelf`, drag grip,
|
||||||
|
Shift+Ctrl+F1 hide/show) is documented there too.
|
||||||
|
|
||||||
The following was the original pre-D.2b proposal and remains historical
|
The following was the original pre-D.2b proposal and remains historical
|
||||||
context, not the shipped plugin contract:
|
context, not the shipped plugin contract:
|
||||||
|
|
|
||||||
230
docs/plugin-ui-markup.md
Normal file
230
docs/plugin-ui-markup.md
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
# Plugin UI markup
|
||||||
|
|
||||||
|
SSOT for `AcDream.Plugin.Abstractions.IUiRegistry`'s markup vocabulary — every
|
||||||
|
element and attribute a plugin can put in the KSML-style XML it hands the host
|
||||||
|
via `AddPanel`/`RegisterPanel`/`RegisterPanelContent`, the `{Binding}` rules
|
||||||
|
those attributes follow, the DAT-icon grammar (Slice B), and the movable
|
||||||
|
plugin shelf (Slice A). Both slices are recorded in
|
||||||
|
[`plans/2026-09-06-plugin-shelf-and-dat-icons.md`](plans/2026-09-06-plugin-shelf-and-dat-icons.md);
|
||||||
|
this page is the day-to-day reference for writing a panel, that plan is the
|
||||||
|
design record.
|
||||||
|
|
||||||
|
Plugins stay BCL-only: nothing in `AcDream.Plugin.Abstractions` references
|
||||||
|
App/UI or Core.Items types. A plugin hands the host raw ids (spell ids,
|
||||||
|
object guids, DAT indices); the host owns every texture, every composited
|
||||||
|
icon, and the parser that turns markup into a live `UiElement` tree
|
||||||
|
(`AcDream.App.UI.MarkupDocument`).
|
||||||
|
|
||||||
|
## Registering a panel
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
host.Ui.AddPanel(
|
||||||
|
new PluginPanelDescriptor("main", "MossTank")
|
||||||
|
{
|
||||||
|
IconText = "MT", // fallback initials if IconSurfaceId is 0
|
||||||
|
IconSurfaceId = 7735, // Decal-style bare index OR a full DID — both normalize
|
||||||
|
StartVisible = true,
|
||||||
|
ShowInSidePanel = true,
|
||||||
|
},
|
||||||
|
Path.Combine(pluginDirectory, "mosstank.xml"),
|
||||||
|
binding);
|
||||||
|
```
|
||||||
|
|
||||||
|
`RegisterPanel` (same signature, returns `IDisposable`) removes the window
|
||||||
|
independently of the plugin's own lifetime. `RegisterPanelContent` takes an
|
||||||
|
in-memory KSML string instead of a file path — the route `AcDream.Plugins.Smoke`
|
||||||
|
uses for its icon-surface proof panel (`SmokeIconPanel.cs`), when a panel is
|
||||||
|
small enough not to need its own shipped `.xml` asset.
|
||||||
|
|
||||||
|
Every registered window gets a stable persisted key
|
||||||
|
(`plugin:{pluginId}:{windowId}`), drag, resize (where the markup opts in),
|
||||||
|
the global UI lock, and a button in the shared plugin shelf
|
||||||
|
(`ShowInSidePanel = true`, the default). Hiding or minimizing a window never
|
||||||
|
disables the plugin or pauses its `Tick`.
|
||||||
|
|
||||||
|
## The `{Binding}` rule
|
||||||
|
|
||||||
|
Every attribute that isn't a plain literal is either:
|
||||||
|
|
||||||
|
- a **literal** — a number, color, or string typed directly in the markup, or
|
||||||
|
- a **binding** — `{PropertyName}`, resolved once at `Build` time against the
|
||||||
|
binding object's public properties/`Action`/`Action<T>` members via
|
||||||
|
reflection, then **re-read every frame** through a `Func<T>` (or invoked
|
||||||
|
live for actions). A plugin updates its panel by assigning a property; it
|
||||||
|
never touches `UiElement` objects directly, and never from a thread other
|
||||||
|
than the one that calls `Tick`.
|
||||||
|
|
||||||
|
A binding that resolves to the wrong CLR type, or names a property that
|
||||||
|
doesn't exist, throws `FormatException` **at `Build`** — the same moment any
|
||||||
|
other malformed attribute throws — never silently at draw time. A resolved
|
||||||
|
binding that returns an out-of-range or default value (0, empty, null) at
|
||||||
|
*runtime* draws nothing/looks empty; it never throws after the panel has
|
||||||
|
loaded.
|
||||||
|
|
||||||
|
## Elements
|
||||||
|
|
||||||
|
| Element | Purpose | Key attributes |
|
||||||
|
|---|---|---|
|
||||||
|
| `panel` (root) | The window itself | `x y w h title resize visible` |
|
||||||
|
| `group` | Transparent layout container | `x y w h background border visible` |
|
||||||
|
| `label` | Static or bound text | `x y text color` |
|
||||||
|
| `button` | Clickable rect + caption (+ Slice B icon) | `x y w h text color background border onclick icon iconkind` |
|
||||||
|
| `icon` | Slice B: a standalone DAT icon | `x y w h did spell item tooltip` |
|
||||||
|
| `meter` | Retail-style nine-slice bar | `x y w h fill cur max color anchor backleft/backtile/backright frontleft/fronttile/frontright` |
|
||||||
|
| `tab` | Selectable tab button | `x y w h text selected onclick` |
|
||||||
|
| `toggle` | Lamp-style checkbox | `x y w h text checked onclick color` |
|
||||||
|
| `slider` | Horizontal scalar | `x y w h value onchange` |
|
||||||
|
| `field` | Single-line editable text | `x y w h text maxlength clearonsubmit onchange onsubmit color background` |
|
||||||
|
| `menu` | Dropdown selector | `x y w h items selected onchange rows rowheight openupward` |
|
||||||
|
| `list` | Scrollable row list (+ Slice B icon column) | `x y w h items colors selected onchange rowheight icons iconkind` |
|
||||||
|
|
||||||
|
Common to every element via `ApplyCommon`: `name`/`id` (a stable control
|
||||||
|
name), `visible` (literal `true`/`false` or a bound `bool` property),
|
||||||
|
`enabled` (same rule), and `tooltip` (a literal string or `{Binding}` shown
|
||||||
|
through retail's own runtime tooltip popup).
|
||||||
|
|
||||||
|
## The icon-id grammar (Slice B)
|
||||||
|
|
||||||
|
Decal/VirindiViewService plugins (the reference usage this ported:
|
||||||
|
MosswartMassacre's `HudPictureBox.Image` assignments, fed from Decal's
|
||||||
|
`FileService.SpellTable`/`SkillTable` icon columns) hand out **bare portal.dat
|
||||||
|
indices** — small integers, not full `0x06xxxxxx` RenderSurface DIDs. acdream's
|
||||||
|
host normalizes every icon id through one function so both styles work
|
||||||
|
everywhere an icon id is accepted:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// AcDream.Plugin.Abstractions.PluginIcons
|
||||||
|
static uint Normalize(uint idOrIndex);
|
||||||
|
// 0 -> 0 (no icon)
|
||||||
|
// 7735 -> 0x06001E37 (bare index -> RenderSurface DID)
|
||||||
|
// 0x06002D14 -> 0x06002D14 (already a DID, unchanged)
|
||||||
|
```
|
||||||
|
|
||||||
|
The host applies `Normalize` at **every** `did`-shaped sink: the descriptor's
|
||||||
|
`IconSurfaceId` (drawn on the plugin shelf button), and every `<icon did>` /
|
||||||
|
`<button icon>` (`iconkind="did"`) / `<list icons>` (`iconkind="did"`) value —
|
||||||
|
literal or bound, re-normalized every frame for a bound value. A plugin never
|
||||||
|
needs to call `Normalize` itself; handing the host either a Decal-style index
|
||||||
|
or a full DID produces the same drawn icon.
|
||||||
|
|
||||||
|
Plugin-facing records that already carry full retail RenderSurface DIDs
|
||||||
|
(`PluginSpellInfo.IconId`, `PluginSkillInfo.IconId`,
|
||||||
|
`PluginInventoryItem.IconId`, `PluginWorldObject.IconId`) are **not**
|
||||||
|
re-normalized — they are already in DID space, read straight from the
|
||||||
|
client's SpellTable/SkillTable/object state. `Normalize` only matters at a
|
||||||
|
markup `did` sink, where a plugin author might type a bare index by hand.
|
||||||
|
|
||||||
|
## The three icon sources
|
||||||
|
|
||||||
|
Every icon-bearing attribute (`<icon>`'s `did`/`spell`/`item`, `<button icon>`,
|
||||||
|
`<list icons>`) resolves through one of three sources, selected by which
|
||||||
|
attribute is set (`<icon>`) or by `iconkind` (`<button>`/`<list>`, default
|
||||||
|
`"did"`):
|
||||||
|
|
||||||
|
| Source | What it draws | Backing API |
|
||||||
|
|---|---|---|
|
||||||
|
| `did` | The raw RenderSurface art at that DID, nothing composited on top | `IMarkupIconResolver.ResolveDid` (a plain sprite resolve, after `PluginIcons.Normalize`) |
|
||||||
|
| `spell` | Retail's **composited** spell icon: power-level backing + spell art + reversed/normal tint + self/fellow-targeted overlay | `IconComposer.GetSpellIcon` (retail `ClientMagicSystem::CompositeSpellIcon`) |
|
||||||
|
| `item` | Retail's **composited** item icon for a *live* object id: type-default underlay + custom underlay + base icon + custom overlay + effect recolor | `IconComposer.GetIcon`, reading the id's fields from the same `ClientObjectTable` the inventory UI already uses |
|
||||||
|
|
||||||
|
`did` accepts a literal (`did="7735"` decimal, or `did="0x06002D14"` hex) or a
|
||||||
|
binding (`did="{IconDid}"`, a `uint` property re-read every frame). `spell`
|
||||||
|
and `item` are almost always bindings (`spell="{SpellId}"`,
|
||||||
|
`item="{ObjectId}"`) but accept the same literal grammar. Any of the three
|
||||||
|
resolving to 0, or the resolver returning no texture, draws nothing — never a
|
||||||
|
placeholder, never a throw.
|
||||||
|
|
||||||
|
### `<icon>`
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<icon x="8" y="8" w="32" h="32" did="7735" tooltip="Decal-style index"/>
|
||||||
|
<icon x="48" y="8" w="32" h="32" did="0x06002D14"/>
|
||||||
|
<icon x="88" y="8" w="32" h="32" spell="{SpellId}" tooltip="{SpellName}"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Exactly one of `did`/`spell`/`item` must be present — two sources on one
|
||||||
|
`<icon>` throws `FormatException` at `Build`. `w`/`h` default to 32 (retail's
|
||||||
|
standard icon size) when omitted. The sprite is drawn nearest-filtered,
|
||||||
|
aspect-preserved, and centered inside the `w`×`h` box — a non-square source
|
||||||
|
never stretches. A `tooltip` attribute makes the icon a real hit-test target
|
||||||
|
(it is click-through otherwise, so it never steals clicks meant for something
|
||||||
|
underneath it).
|
||||||
|
|
||||||
|
### `<button icon="..." iconkind="did|spell|item">`
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<button x="12" y="68" w="120" h="24" text="Report"
|
||||||
|
icon="0x06002D14" onclick="{Report}"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
The icon draws flush left inside the button; the caption's centering region
|
||||||
|
shifts right to make room. `text` may be empty for an icon-only button.
|
||||||
|
`iconkind` defaults to `"did"`.
|
||||||
|
|
||||||
|
### `<list icons="{IconIds}" iconkind="did|spell|item">`
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<list x="12" y="100" w="256" h="108"
|
||||||
|
items="{SpellRows}" icons="{SpellIconIds}" iconkind="spell"
|
||||||
|
selected="{SelectedIndex}"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
`icons` is an `IEnumerable<uint>` binding parallel to `items` — Decal's
|
||||||
|
`IconColumn` convention: a leading square column, `RowHeight - 2` pixels wide,
|
||||||
|
one icon per row. A row past the end of the icons list, or an id that
|
||||||
|
resolves to nothing, draws no icon for that row (the text still draws, just
|
||||||
|
without an icon). Omitting `icons` entirely keeps the list exactly as it was
|
||||||
|
before Slice B (full-width text, no column).
|
||||||
|
|
||||||
|
MosswartMassacre-style example — a list column fed straight from
|
||||||
|
`PluginSpellInfo.IconId`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public IEnumerable<uint> SpellIconIds =>
|
||||||
|
host.Automation.Spells.KnownSelfBuffs.Select(s => s.IconId);
|
||||||
|
public IEnumerable<string> SpellRows =>
|
||||||
|
host.Automation.Spells.KnownSelfBuffs.Select(s => s.Name);
|
||||||
|
```
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<list items="{SpellRows}" icons="{SpellIconIds}" iconkind="did" .../>
|
||||||
|
```
|
||||||
|
|
||||||
|
(`did`, not `spell`, here — `PluginSpellInfo.IconId` is the spell's own raw art
|
||||||
|
tile; use `iconkind="spell"` only when the binding hands the host a **spell
|
||||||
|
id** and wants retail's composited badge instead of the plain art.)
|
||||||
|
|
||||||
|
## The plugin shelf (Slice A)
|
||||||
|
|
||||||
|
The shelf (`AcDream.App.UI.PluginSidePanel`) is the right-edge strip of
|
||||||
|
per-plugin-window buttons. It is a real retained window
|
||||||
|
(`RetailWindowManager` key `plugin-shelf`), so it gets drag, the global UI
|
||||||
|
lock, and persisted position/visibility/collapsed state for free, exactly
|
||||||
|
like every other window.
|
||||||
|
|
||||||
|
- **Drag**: a grip strip across its top (three short dashes) is the move
|
||||||
|
handle. Dragging elsewhere on the shelf (the buttons themselves, the
|
||||||
|
padding between them) does not move the window.
|
||||||
|
- **Collapse**: a small `»`/`«` toggle at the grip's right end shrinks the
|
||||||
|
shelf to just the grip; button entries stay laid out underneath so
|
||||||
|
expanding is instant. Persists through the same window-state channel as
|
||||||
|
position/visibility.
|
||||||
|
- **Hide/show**: `Shift+Ctrl+F1` (retail's plugin-manager chord,
|
||||||
|
`InputAction.TogglePluginManager` — acdream has no separate plugin manager,
|
||||||
|
so this is its honest home). Hiding the shelf never disables a plugin or
|
||||||
|
touches any individual plugin window's own visibility; a new plugin window
|
||||||
|
registering while the shelf is hidden does not un-hide it. If no plugin has
|
||||||
|
registered a shelf entry yet, the chord reports "No plugin windows are
|
||||||
|
registered." instead.
|
||||||
|
- **Default dock**: with no saved layout, the shelf sits at the right screen
|
||||||
|
edge, top 116px — until the user drags it (or a saved layout restores a
|
||||||
|
different position), after which it stays put and growth preserves whatever
|
||||||
|
corner it's anchored from.
|
||||||
|
|
||||||
|
## Testing conventions
|
||||||
|
|
||||||
|
`MarkupDocumentTests`/`MarkupIconTests` build panels with a fake
|
||||||
|
`resolve`/`IMarkupIconResolver` (`_ => (1u, 32, 32)` for sprites; a small
|
||||||
|
in-test class recording which id/kind it was asked to resolve) rather than a
|
||||||
|
live DAT — see `tests/AcDream.App.Tests/UI/`. `PluginSidePanelTests` exercises
|
||||||
|
the shelf's drag/collapse/hide/persistence behavior against a bare `UiRoot`.
|
||||||
|
|
@ -64,6 +64,8 @@ internal sealed class AppAutomationSurface
|
||||||
private MagicCatalog _magicCatalog = MagicCatalog.Empty;
|
private MagicCatalog _magicCatalog = MagicCatalog.Empty;
|
||||||
private IReadOnlyDictionary<uint, string> _skillNames =
|
private IReadOnlyDictionary<uint, string> _skillNames =
|
||||||
new Dictionary<uint, string>();
|
new Dictionary<uint, string>();
|
||||||
|
private IReadOnlyDictionary<uint, uint> _skillIcons =
|
||||||
|
new Dictionary<uint, uint>();
|
||||||
private Func<int, string> _speciesName = static _ => string.Empty;
|
private Func<int, string> _speciesName = static _ => string.Empty;
|
||||||
private IChargenPaletteColorSource? _paletteColors;
|
private IChargenPaletteColorSource? _paletteColors;
|
||||||
private Func<uint, uint, bool>? _equip;
|
private Func<uint, uint, bool>? _equip;
|
||||||
|
|
@ -381,6 +383,18 @@ internal sealed class AppAutomationSurface
|
||||||
_skillNames = skillNames;
|
_skillNames = skillNames;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Supply retail skill icon RenderSurface DIDs (SkillTable
|
||||||
|
/// <c>SkillBase.IconId</c>), read once alongside <see cref="BindSkillNames"/>
|
||||||
|
/// from the same portal.dat SkillTable pass.
|
||||||
|
/// </summary>
|
||||||
|
public void BindSkillIcons(IReadOnlyDictionary<uint, uint> skillIcons)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(skillIcons);
|
||||||
|
lock (_gate)
|
||||||
|
_skillIcons = skillIcons;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Supply the immutable retail spell/component DAT catalog.</summary>
|
/// <summary>Supply the immutable retail spell/component DAT catalog.</summary>
|
||||||
public void BindMagicCatalog(MagicCatalog catalog)
|
public void BindMagicCatalog(MagicCatalog catalog)
|
||||||
{
|
{
|
||||||
|
|
@ -766,6 +780,7 @@ internal sealed class AppAutomationSurface
|
||||||
BaseRangeConstant = meta.BaseRangeConstant,
|
BaseRangeConstant = meta.BaseRangeConstant,
|
||||||
BaseRangeModifier = meta.BaseRangeModifier,
|
BaseRangeModifier = meta.BaseRangeModifier,
|
||||||
FormulaComponentIds = meta.FormulaComponents,
|
FormulaComponentIds = meta.FormulaComponents,
|
||||||
|
IconId = meta.IconId,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -929,10 +944,12 @@ internal sealed class AppAutomationSurface
|
||||||
{
|
{
|
||||||
RuntimeCharacterState? character;
|
RuntimeCharacterState? character;
|
||||||
IReadOnlyDictionary<uint, string> names;
|
IReadOnlyDictionary<uint, string> names;
|
||||||
|
IReadOnlyDictionary<uint, uint> icons;
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
character = _character;
|
character = _character;
|
||||||
names = _skillNames;
|
names = _skillNames;
|
||||||
|
icons = _skillIcons;
|
||||||
}
|
}
|
||||||
if (character is null || names.Count == 0)
|
if (character is null || names.Count == 0)
|
||||||
return Array.Empty<PluginSkillInfo>();
|
return Array.Empty<PluginSkillInfo>();
|
||||||
|
|
@ -940,7 +957,8 @@ internal sealed class AppAutomationSurface
|
||||||
var built = new List<PluginSkillInfo>(names.Count);
|
var built = new List<PluginSkillInfo>(names.Count);
|
||||||
foreach (KeyValuePair<uint, string> pair in names)
|
foreach (KeyValuePair<uint, string> pair in names)
|
||||||
{
|
{
|
||||||
if (TryProjectSkill(character, pair.Key, pair.Value, out PluginSkillInfo skill))
|
uint iconId = icons.TryGetValue(pair.Key, out uint icon) ? icon : 0u;
|
||||||
|
if (TryProjectSkill(character, pair.Key, pair.Value, iconId, out PluginSkillInfo skill))
|
||||||
built.Add(skill);
|
built.Add(skill);
|
||||||
}
|
}
|
||||||
built.Sort(static (a, b) => string.CompareOrdinal(a.Name, b.Name));
|
built.Sort(static (a, b) => string.CompareOrdinal(a.Name, b.Name));
|
||||||
|
|
@ -952,22 +970,25 @@ internal sealed class AppAutomationSurface
|
||||||
{
|
{
|
||||||
RuntimeCharacterState? character;
|
RuntimeCharacterState? character;
|
||||||
IReadOnlyDictionary<uint, string> names;
|
IReadOnlyDictionary<uint, string> names;
|
||||||
|
IReadOnlyDictionary<uint, uint> icons;
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
character = _character;
|
character = _character;
|
||||||
names = _skillNames;
|
names = _skillNames;
|
||||||
|
icons = _skillIcons;
|
||||||
}
|
}
|
||||||
if (character is not null)
|
if (character is not null)
|
||||||
{
|
{
|
||||||
string name = names.TryGetValue(skillId, out string? n) ? n : string.Empty;
|
string name = names.TryGetValue(skillId, out string? n) ? n : string.Empty;
|
||||||
return TryProjectSkill(character, skillId, name, out skill);
|
uint iconId = icons.TryGetValue(skillId, out uint icon) ? icon : 0u;
|
||||||
|
return TryProjectSkill(character, skillId, name, iconId, out skill);
|
||||||
}
|
}
|
||||||
skill = default;
|
skill = default;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool TryProjectSkill(
|
private static bool TryProjectSkill(
|
||||||
RuntimeCharacterState character, uint skillId, string name,
|
RuntimeCharacterState character, uint skillId, string name, uint iconId,
|
||||||
out PluginSkillInfo skill)
|
out PluginSkillInfo skill)
|
||||||
{
|
{
|
||||||
if (!character.View.TryGetSkill(skillId, out var snapshot))
|
if (!character.View.TryGetSkill(skillId, out var snapshot))
|
||||||
|
|
@ -984,6 +1005,7 @@ internal sealed class AppAutomationSurface
|
||||||
skillId, name, Training(snapshot.Status), currentLevel)
|
skillId, name, Training(snapshot.Status), currentLevel)
|
||||||
{
|
{
|
||||||
Base = baseLevel,
|
Base = baseLevel,
|
||||||
|
IconId = iconId,
|
||||||
};
|
};
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -1867,6 +1889,7 @@ internal sealed class AppAutomationSurface
|
||||||
? item.AppraisedSpellIds.ToArray()
|
? item.AppraisedSpellIds.ToArray()
|
||||||
: Array.Empty<uint>(),
|
: Array.Empty<uint>(),
|
||||||
ActiveSpellIds = activeSpells,
|
ActiveSpellIds = activeSpells,
|
||||||
|
IconId = item?.IconId ?? 0u,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2376,6 +2399,7 @@ internal sealed class AppAutomationSurface
|
||||||
MaterialType = item.MaterialType ?? 0u,
|
MaterialType = item.MaterialType ?? 0u,
|
||||||
ObjectClass = ClassifyObject(item),
|
ObjectClass = ClassifyObject(item),
|
||||||
Palettes = ProjectPalettes(runtime, item.ObjectId),
|
Palettes = ProjectPalettes(runtime, item.ObjectId),
|
||||||
|
IconId = item.IconId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
built.Sort(static (left, right) =>
|
built.Sort(static (left, right) =>
|
||||||
|
|
|
||||||
|
|
@ -1019,9 +1019,14 @@ public sealed class GameWindow :
|
||||||
}
|
}
|
||||||
|
|
||||||
var names = new Dictionary<uint, string>(skillTable.Skills.Count);
|
var names = new Dictionary<uint, string>(skillTable.Skills.Count);
|
||||||
|
var icons = new Dictionary<uint, uint>(skillTable.Skills.Count);
|
||||||
foreach (var entry in skillTable.Skills)
|
foreach (var entry in skillTable.Skills)
|
||||||
|
{
|
||||||
names[(uint)entry.Key] = entry.Value.Name;
|
names[(uint)entry.Key] = entry.Value.Name;
|
||||||
|
icons[(uint)entry.Key] = entry.Value.IconId;
|
||||||
|
}
|
||||||
_automation.BindSkillNames(names);
|
_automation.BindSkillNames(names);
|
||||||
|
_automation.BindSkillIcons(icons);
|
||||||
}
|
}
|
||||||
|
|
||||||
void IGameWindowContentEffectsAudioPublication.PublishPreparedAssetSource(
|
void IGameWindowContentEffectsAudioPublication.PublishPreparedAssetSource(
|
||||||
|
|
|
||||||
87
src/AcDream.App/UI/IMarkupIconResolver.cs
Normal file
87
src/AcDream.App/UI/IMarkupIconResolver.cs
Normal file
|
|
@ -0,0 +1,87 @@
|
||||||
|
using AcDream.Core.Items;
|
||||||
|
|
||||||
|
namespace AcDream.App.UI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Host seam that lets plugin markup draw real DAT icons (Slice B,
|
||||||
|
/// <c>docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md</c>) without
|
||||||
|
/// importing App/UI or Core.Items types across the plugin boundary.
|
||||||
|
/// <see cref="MarkupDocument.Build"/> accepts an optional instance; when
|
||||||
|
/// supplied, the <c><icon></c> element and the button/list icon
|
||||||
|
/// extensions route every id through it. See <c>docs/plugin-ui-markup.md</c>
|
||||||
|
/// for the full grammar.
|
||||||
|
/// </summary>
|
||||||
|
public interface IMarkupIconResolver
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Resolve a raw RenderSurface DID to (GL texture, width, height). The
|
||||||
|
/// caller (<see cref="MarkupDocument"/>) has already run the id through
|
||||||
|
/// <see cref="AcDream.Plugin.Abstractions.PluginIcons.Normalize"/>, so
|
||||||
|
/// implementations need not re-normalize. 0/unresolvable → <c>(0, 0, 0)</c>,
|
||||||
|
/// which draws nothing.
|
||||||
|
/// </summary>
|
||||||
|
(uint tex, int w, int h) ResolveDid(uint did);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolve retail's composited spell icon (power-level backing + art +
|
||||||
|
/// reversed/normal tint + self/fellow overlay — <c>IconComposer.GetSpellIcon</c>,
|
||||||
|
/// retail <c>ClientMagicSystem::CompositeSpellIcon</c>).
|
||||||
|
/// </summary>
|
||||||
|
(uint tex, int w, int h) ResolveSpell(uint spellId);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolve retail's composited item icon (type-default underlay + custom
|
||||||
|
/// underlay + base + custom overlay + effect recolor —
|
||||||
|
/// <c>IconComposer.GetIcon</c>) for a live object id, reading its icon
|
||||||
|
/// fields from the SAME <see cref="AcDream.Core.Items.ClientObjectTable"/>
|
||||||
|
/// the inventory UI already uses.
|
||||||
|
/// </summary>
|
||||||
|
(uint tex, int w, int h) ResolveItem(uint objectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Production <see cref="IMarkupIconResolver"/>. Built once in
|
||||||
|
/// <see cref="RetailUiRuntime.MountPlugins"/> from
|
||||||
|
/// <see cref="RetailUiAssets.ResolveSprite"/>, <see cref="RetailUiAssets.Icons"/>
|
||||||
|
/// (the one <see cref="IconComposer"/> every authored panel shares), and the
|
||||||
|
/// live <see cref="ClientObjectTable"/> the inventory UI's own icon resolution
|
||||||
|
/// already borrows (<c>ToolbarRuntimeBindings.Objects</c>/
|
||||||
|
/// <c>MagicRuntimeBindings.Objects</c> — both <c>d.Inventory.Objects</c>, the
|
||||||
|
/// same instance) — no second texture cache or object lookup is introduced.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class RetailMarkupIconResolver : IMarkupIconResolver
|
||||||
|
{
|
||||||
|
private readonly Func<uint, (uint tex, int w, int h)> _resolveSprite;
|
||||||
|
private readonly IconComposer _icons;
|
||||||
|
private readonly ClientObjectTable _objects;
|
||||||
|
|
||||||
|
public RetailMarkupIconResolver(
|
||||||
|
Func<uint, (uint tex, int w, int h)> resolveSprite,
|
||||||
|
IconComposer icons,
|
||||||
|
ClientObjectTable objects)
|
||||||
|
{
|
||||||
|
_resolveSprite = resolveSprite ?? throw new ArgumentNullException(nameof(resolveSprite));
|
||||||
|
_icons = icons ?? throw new ArgumentNullException(nameof(icons));
|
||||||
|
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
|
||||||
|
}
|
||||||
|
|
||||||
|
public (uint tex, int w, int h) ResolveDid(uint did) =>
|
||||||
|
did == 0u ? (0u, 0, 0) : _resolveSprite(did);
|
||||||
|
|
||||||
|
public (uint tex, int w, int h) ResolveSpell(uint spellId)
|
||||||
|
{
|
||||||
|
if (spellId == 0u) return (0u, 0, 0);
|
||||||
|
uint tex = _icons.GetSpellIcon(spellId);
|
||||||
|
return tex == 0u ? (0u, 0, 0) : (tex, 32, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
public (uint tex, int w, int h) ResolveItem(uint objectId)
|
||||||
|
{
|
||||||
|
if (objectId == 0u) return (0u, 0, 0);
|
||||||
|
ClientObject? item = _objects.Get(objectId);
|
||||||
|
if (item is null || item.IconId == 0u) return (0u, 0, 0);
|
||||||
|
uint tex = _icons.GetIcon(
|
||||||
|
item.Type, item.IconId, item.IconUnderlayId, item.IconOverlayId, item.Effects);
|
||||||
|
return tex == 0u ? (0u, 0, 0) : (tex, 32, 32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ using System.Globalization;
|
||||||
using System.Numerics;
|
using System.Numerics;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Xml.Linq;
|
using System.Xml.Linq;
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
namespace AcDream.App.UI;
|
namespace AcDream.App.UI;
|
||||||
|
|
||||||
|
|
@ -29,9 +30,19 @@ public static class MarkupDocument
|
||||||
/// their text through the same glyph path as authored panels; without it
|
/// their text through the same glyph path as authored panels; without it
|
||||||
/// they fall back to the development bitmap font and look foreign.
|
/// they fall back to the development bitmap font and look foreign.
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <param name="icons">
|
||||||
|
/// Slice B (<c>docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md</c>):
|
||||||
|
/// resolves <c><icon></c>, <c><button icon></c>, and
|
||||||
|
/// <c><list icons></c> ids to drawable DAT icons. Null (the
|
||||||
|
/// default, and what every pre-Slice-B caller still passes) makes those
|
||||||
|
/// three surfaces resolve to nothing rather than throwing — a panel
|
||||||
|
/// authored against Slice B markup still loads under a host/test that
|
||||||
|
/// has not wired icon resolution.
|
||||||
|
/// </param>
|
||||||
public static UiNineSlicePanel Build(
|
public static UiNineSlicePanel Build(
|
||||||
string xml, object binding, Func<uint, (uint, int, int)> resolve,
|
string xml, object binding, Func<uint, (uint, int, int)> resolve,
|
||||||
ControlsIni? style = null, UiDatFont? datFont = null)
|
ControlsIni? style = null, UiDatFont? datFont = null,
|
||||||
|
IMarkupIconResolver? icons = null)
|
||||||
{
|
{
|
||||||
var root = XDocument.Parse(xml).Root ?? throw new FormatException("empty markup");
|
var root = XDocument.Parse(xml).Root ?? throw new FormatException("empty markup");
|
||||||
if (root.Name.LocalName != "panel")
|
if (root.Name.LocalName != "panel")
|
||||||
|
|
@ -80,7 +91,7 @@ public static class MarkupDocument
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var el in root.Elements())
|
foreach (var el in root.Elements())
|
||||||
AddElement(panel, el, binding, resolve, datFont);
|
AddElement(panel, el, binding, resolve, datFont, icons);
|
||||||
return panel;
|
return panel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,7 +100,8 @@ public static class MarkupDocument
|
||||||
XElement el,
|
XElement el,
|
||||||
object binding,
|
object binding,
|
||||||
Func<uint, (uint, int, int)> resolve,
|
Func<uint, (uint, int, int)> resolve,
|
||||||
UiDatFont? datFont)
|
UiDatFont? datFont,
|
||||||
|
IMarkupIconResolver? icons)
|
||||||
{
|
{
|
||||||
switch (el.Name.LocalName)
|
switch (el.Name.LocalName)
|
||||||
{
|
{
|
||||||
|
|
@ -114,7 +126,7 @@ public static class MarkupDocument
|
||||||
ApplyCommon(group, el, binding);
|
ApplyCommon(group, el, binding);
|
||||||
parent.AddChild(group);
|
parent.AddChild(group);
|
||||||
foreach (XElement child in el.Elements())
|
foreach (XElement child in el.Elements())
|
||||||
AddElement(group, child, binding, resolve, datFont);
|
AddElement(group, child, binding, resolve, datFont, icons);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "meter":
|
case "meter":
|
||||||
|
|
@ -200,12 +212,60 @@ public static class MarkupDocument
|
||||||
if (el.Attribute("border") is not null)
|
if (el.Attribute("border") is not null)
|
||||||
button.BorderColor = Color(
|
button.BorderColor = Color(
|
||||||
(string?)el.Attribute("border"));
|
(string?)el.Attribute("border"));
|
||||||
|
// Slice B: <button icon="..." iconkind="did|spell|item">.
|
||||||
|
string? buttonIcon = (string?)el.Attribute("icon");
|
||||||
|
if (buttonIcon is not null)
|
||||||
|
{
|
||||||
|
Func<uint> buttonIconReader =
|
||||||
|
BindUintLiteralOrBinding(buttonIcon, binding, "button icon");
|
||||||
|
button.IconSource = BuildIconSource(
|
||||||
|
(string?)el.Attribute("iconkind"),
|
||||||
|
buttonIconReader,
|
||||||
|
icons);
|
||||||
|
}
|
||||||
ApplyCommon(button, el, binding);
|
ApplyCommon(button, el, binding);
|
||||||
if (onClick is not null)
|
if (onClick is not null)
|
||||||
button.Click += onClick;
|
button.Click += onClick;
|
||||||
parent.AddChild(button);
|
parent.AddChild(button);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "icon":
|
||||||
|
{
|
||||||
|
string? didAttr = (string?)el.Attribute("did");
|
||||||
|
string? spellAttr = (string?)el.Attribute("spell");
|
||||||
|
string? itemAttr = (string?)el.Attribute("item");
|
||||||
|
int sourceCount = (didAttr is not null ? 1 : 0)
|
||||||
|
+ (spellAttr is not null ? 1 : 0)
|
||||||
|
+ (itemAttr is not null ? 1 : 0);
|
||||||
|
if (sourceCount != 1)
|
||||||
|
{
|
||||||
|
throw new FormatException(
|
||||||
|
"<icon> requires exactly one of did/spell/item");
|
||||||
|
}
|
||||||
|
|
||||||
|
string iconKind = didAttr is not null ? "did"
|
||||||
|
: spellAttr is not null ? "spell"
|
||||||
|
: "item";
|
||||||
|
string iconExpression = didAttr ?? spellAttr ?? itemAttr!;
|
||||||
|
Func<uint> iconReader = BindUintLiteralOrBinding(
|
||||||
|
iconExpression, binding, $"icon {iconKind}");
|
||||||
|
var icon = new UiMarkupIcon
|
||||||
|
{
|
||||||
|
Left = F(el, "x"),
|
||||||
|
Top = F(el, "y"),
|
||||||
|
Width = FOr(el, "w", 32f),
|
||||||
|
Height = FOr(el, "h", 32f),
|
||||||
|
IconSource = BuildIconSource(iconKind, iconReader, icons),
|
||||||
|
};
|
||||||
|
ApplyCommon(icon, el, binding);
|
||||||
|
// A tooltip needs this element to be a real hit-test
|
||||||
|
// target — see UiMarkupIcon's own doc comment.
|
||||||
|
if (el.Attribute("tooltip") is not null)
|
||||||
|
icon.ClickThrough = false;
|
||||||
|
parent.AddChild(icon);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case "tab":
|
case "tab":
|
||||||
string? tabClickName = (string?)el.Attribute("onclick");
|
string? tabClickName = (string?)el.Attribute("onclick");
|
||||||
Action? tabClick = BindAction(tabClickName, binding);
|
Action? tabClick = BindAction(tabClickName, binding);
|
||||||
|
|
@ -427,12 +487,109 @@ public static class MarkupDocument
|
||||||
"list selected"),
|
"list selected"),
|
||||||
SelectionChanged = listChanged,
|
SelectionChanged = listChanged,
|
||||||
};
|
};
|
||||||
|
// Slice B: <list icons="{IconIds}" iconkind="did|spell|item">.
|
||||||
|
string? listIcons = (string?)el.Attribute("icons");
|
||||||
|
if (!string.IsNullOrWhiteSpace(listIcons))
|
||||||
|
{
|
||||||
|
list.IconIdsSource = BindUintList(listIcons, binding, "list icons");
|
||||||
|
list.IconResolve = BuildRowIconResolve(
|
||||||
|
(string?)el.Attribute("iconkind"), icons);
|
||||||
|
}
|
||||||
ApplyCommon(list, el, binding);
|
ApplyCommon(list, el, binding);
|
||||||
parent.AddChild(list);
|
parent.AddChild(list);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the zero-argument icon resolver every markup icon sink (the
|
||||||
|
/// <c><icon></c> element and <c><button icon></c>) shares:
|
||||||
|
/// dispatch by <c>iconkind</c> (default <c>"did"</c>) to the matching
|
||||||
|
/// <see cref="IMarkupIconResolver"/> method, normalizing <c>did</c>
|
||||||
|
/// through <see cref="PluginIcons.Normalize"/>
|
||||||
|
/// (spell/item ids are never DAT RenderSurface DIDs, so they never pass
|
||||||
|
/// through it). Null <paramref name="icons"/> (no resolver wired) always
|
||||||
|
/// resolves to nothing rather than throwing.
|
||||||
|
/// </summary>
|
||||||
|
private static Func<(uint tex, int w, int h)> BuildIconSource(
|
||||||
|
string? iconKind, Func<uint> idReader, IMarkupIconResolver? icons)
|
||||||
|
{
|
||||||
|
if (icons is null)
|
||||||
|
return static () => (0u, 0, 0);
|
||||||
|
return (iconKind ?? "did") switch
|
||||||
|
{
|
||||||
|
"did" => () => icons.ResolveDid(
|
||||||
|
PluginIcons.Normalize(idReader())),
|
||||||
|
"spell" => () => icons.ResolveSpell(idReader()),
|
||||||
|
"item" => () => icons.ResolveItem(idReader()),
|
||||||
|
var other => throw new FormatException(
|
||||||
|
$"unknown iconkind \"{other}\" (expected did, spell, or item)"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Same dispatch as <see cref="BuildIconSource"/>, shaped for
|
||||||
|
/// <c><list icons></c>'s per-row resolve (the row's own icon id is
|
||||||
|
/// the argument rather than a captured reader).
|
||||||
|
/// </summary>
|
||||||
|
private static Func<uint, (uint tex, int w, int h)>? BuildRowIconResolve(
|
||||||
|
string? iconKind, IMarkupIconResolver? icons)
|
||||||
|
{
|
||||||
|
if (icons is null)
|
||||||
|
return null;
|
||||||
|
return (iconKind ?? "did") switch
|
||||||
|
{
|
||||||
|
"did" => id => icons.ResolveDid(
|
||||||
|
PluginIcons.Normalize(id)),
|
||||||
|
"spell" => icons.ResolveSpell,
|
||||||
|
"item" => icons.ResolveItem,
|
||||||
|
var other => throw new FormatException(
|
||||||
|
$"unknown iconkind \"{other}\" (expected did, spell, or item)"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves a <c>did</c>/<c>spell</c>/<c>item</c> attribute to a live
|
||||||
|
/// <see cref="uint"/> reader: a <c>{Prop}</c> binding re-reads a
|
||||||
|
/// <see cref="uint"/> property every frame; a literal accepts hex
|
||||||
|
/// (<c>0x...</c>) or decimal, matching every other markup id attribute's
|
||||||
|
/// "malformed literal throws at Build" rule.
|
||||||
|
/// </summary>
|
||||||
|
private static Func<uint> BindUintLiteralOrBinding(
|
||||||
|
string expression, object binding, string context)
|
||||||
|
{
|
||||||
|
if (!IsBinding(expression))
|
||||||
|
{
|
||||||
|
uint literal = ParseUintLiteral(expression, context);
|
||||||
|
return () => literal;
|
||||||
|
}
|
||||||
|
PropertyInfo? property = binding.GetType().GetProperty(expression[1..^1]);
|
||||||
|
if (property is null || property.PropertyType != typeof(uint))
|
||||||
|
{
|
||||||
|
throw new FormatException(
|
||||||
|
$"{expression} did not resolve to a uint property on "
|
||||||
|
+ binding.GetType().Name + $" ({context})");
|
||||||
|
}
|
||||||
|
return () => (uint)property.GetValue(binding)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static uint ParseUintLiteral(string text, string context)
|
||||||
|
{
|
||||||
|
string trimmed = text.Trim();
|
||||||
|
if (trimmed.StartsWith("0x", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
if (uint.TryParse(trimmed.AsSpan(2), NumberStyles.HexNumber,
|
||||||
|
CultureInfo.InvariantCulture, out uint hex))
|
||||||
|
return hex;
|
||||||
|
}
|
||||||
|
else if (uint.TryParse(trimmed, NumberStyles.Integer,
|
||||||
|
CultureInfo.InvariantCulture, out uint dec))
|
||||||
|
{
|
||||||
|
return dec;
|
||||||
|
}
|
||||||
|
throw new FormatException($"{context}=\"{text}\" is not a valid uint literal");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Resolves <c>{PropName}</c> to a live string reader, or returns the
|
/// Resolves <c>{PropName}</c> to a live string reader, or returns the
|
||||||
/// literal text unchanged. The indirection matters: binding to a
|
/// literal text unchanged. The indirection matters: binding to a
|
||||||
|
|
|
||||||
|
|
@ -756,7 +756,12 @@ public sealed class PluginSidePanel : UiPanel, IDisposable, IRetainedWindowState
|
||||||
{
|
{
|
||||||
_handle = handle;
|
_handle = handle;
|
||||||
_resolve = resolve;
|
_resolve = resolve;
|
||||||
_iconSurfaceId = descriptor.IconSurfaceId;
|
// Slice B (docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md item
|
||||||
|
// 6): normalize through the shared grammar so a Decal-style bare
|
||||||
|
// portal index (MosswartMassacre's convention) resolves the same
|
||||||
|
// way every markup icon sink does, instead of silently drawing
|
||||||
|
// nothing because it was never a RenderSurface DID to begin with.
|
||||||
|
_iconSurfaceId = PluginIcons.Normalize(descriptor.IconSurfaceId);
|
||||||
_tooltip = string.Equals(descriptor.Title, ownerDisplayName,
|
_tooltip = string.Equals(descriptor.Title, ownerDisplayName,
|
||||||
StringComparison.Ordinal)
|
StringComparison.Ordinal)
|
||||||
? descriptor.Title
|
? descriptor.Title
|
||||||
|
|
|
||||||
|
|
@ -4712,6 +4712,18 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
private void MountPlugins()
|
private void MountPlugins()
|
||||||
{
|
{
|
||||||
if (_bindings.Plugins is null) return;
|
if (_bindings.Plugins is null) return;
|
||||||
|
|
||||||
|
// Slice B (docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md item
|
||||||
|
// 7): one resolver, shared by every plugin panel mounted this pass,
|
||||||
|
// built from the SAME sprite resolve, IconComposer, and
|
||||||
|
// ClientObjectTable the rest of the retained UI already uses — see
|
||||||
|
// RetailMarkupIconResolver's own doc comment for exactly which
|
||||||
|
// existing bindings field supplies the object table.
|
||||||
|
IMarkupIconResolver iconResolver = new RetailMarkupIconResolver(
|
||||||
|
_bindings.Assets.ResolveSprite,
|
||||||
|
_bindings.Assets.Icons,
|
||||||
|
_bindings.Toolbar.Objects);
|
||||||
|
|
||||||
foreach (var panel in _bindings.Plugins.Drain())
|
foreach (var panel in _bindings.Plugins.Drain())
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|
@ -4723,7 +4735,8 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
panel.Binding,
|
panel.Binding,
|
||||||
_bindings.Assets.ResolveSprite,
|
_bindings.Assets.ResolveSprite,
|
||||||
_bindings.Assets.Controls,
|
_bindings.Assets.Controls,
|
||||||
_bindings.Assets.DefaultFont);
|
_bindings.Assets.DefaultFont,
|
||||||
|
iconResolver);
|
||||||
|
|
||||||
if (Host.WindowManager.TryGet(panel.WindowName, out _))
|
if (Host.WindowManager.TryGet(panel.WindowName, out _))
|
||||||
{
|
{
|
||||||
|
|
|
||||||
48
src/AcDream.App/UI/UiMarkupIcon.cs
Normal file
48
src/AcDream.App/UI/UiMarkupIcon.cs
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
using System.Numerics;
|
||||||
|
|
||||||
|
namespace AcDream.App.UI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plugin markup's <c><icon></c> element (Slice B,
|
||||||
|
/// <c>docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md</c>). Draw-only: a
|
||||||
|
/// plugin never receives the resolved GL texture, only the resolved-or-not
|
||||||
|
/// outcome baked into <see cref="IconSource"/> by
|
||||||
|
/// <see cref="MarkupDocument.Build"/>. Aspect is preserved and the sprite is
|
||||||
|
/// centered inside the element's box, nearest-filtered (the same convention
|
||||||
|
/// every other 32x32 DAT icon in the client draws with).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UiMarkupIcon : UiElement
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves to (GL texture, native width, native height) each draw.
|
||||||
|
/// <c>tex == 0</c> (or a non-positive extent) draws nothing — never
|
||||||
|
/// throws, matching every other markup binding's "unresolvable at
|
||||||
|
/// runtime is silent" rule (only a malformed literal throws, at Build).
|
||||||
|
/// </summary>
|
||||||
|
public Func<(uint tex, int w, int h)> IconSource { get; set; } =
|
||||||
|
static () => (0u, 0, 0);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see langword="true"/> unless the element has an authored tooltip —
|
||||||
|
/// a tooltip needs this element to be a real hit-test target (see
|
||||||
|
/// <see cref="UiElement.ClickThrough"/>'s doc: a click-through element
|
||||||
|
/// never becomes the hovered element a tooltip attaches to).
|
||||||
|
/// <see cref="MarkupDocument"/> flips this to <see langword="false"/>
|
||||||
|
/// when <c>tooltip=</c> is present.
|
||||||
|
/// </summary>
|
||||||
|
public UiMarkupIcon() => ClickThrough = true;
|
||||||
|
|
||||||
|
protected override void OnDraw(UiRenderContext ctx)
|
||||||
|
{
|
||||||
|
(uint tex, int w, int h) = IconSource();
|
||||||
|
if (tex == 0u || w <= 0 || h <= 0 || Width <= 0f || Height <= 0f)
|
||||||
|
return;
|
||||||
|
|
||||||
|
float scale = MathF.Min(Width / w, Height / h);
|
||||||
|
float drawWidth = w * scale;
|
||||||
|
float drawHeight = h * scale;
|
||||||
|
float x = (Width - drawWidth) * 0.5f;
|
||||||
|
float y = (Height - drawHeight) * 0.5f;
|
||||||
|
ctx.DrawSprite(tex, x, y, drawWidth, drawHeight, 0f, 0f, 1f, 1f, Vector4.One);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,6 +14,22 @@ public sealed class UiMarkupList : UiElement
|
||||||
static () => Array.Empty<string>();
|
static () => Array.Empty<string>();
|
||||||
public Func<IReadOnlyList<uint>> ItemColorsSource { get; set; } =
|
public Func<IReadOnlyList<uint>> ItemColorsSource { get; set; } =
|
||||||
static () => Array.Empty<uint>();
|
static () => Array.Empty<uint>();
|
||||||
|
/// <summary>
|
||||||
|
/// Plugin markup's <c><list icons="..."></c> (Slice B,
|
||||||
|
/// <c>docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md</c>): one icon
|
||||||
|
/// id per row, parallel to <see cref="ItemsSource"/>. Null (the default)
|
||||||
|
/// means no icon column at all — every list built without an <c>icons</c>
|
||||||
|
/// attribute behaves exactly as before. A row past the end of this list,
|
||||||
|
/// or an id that resolves to nothing, draws no icon (never a placeholder).
|
||||||
|
/// </summary>
|
||||||
|
public Func<IReadOnlyList<uint>>? IconIdsSource { get; set; }
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves one <see cref="IconIdsSource"/> entry to a drawable icon.
|
||||||
|
/// <see cref="MarkupDocument"/> builds this from the same
|
||||||
|
/// <see cref="IMarkupIconResolver"/> every other markup icon sink uses,
|
||||||
|
/// selected by the element's <c>iconkind</c> attribute.
|
||||||
|
/// </summary>
|
||||||
|
public Func<uint, (uint tex, int w, int h)>? IconResolve { get; set; }
|
||||||
public Func<int> SelectedIndexSource { get; set; } = static () => -1;
|
public Func<int> SelectedIndexSource { get; set; } = static () => -1;
|
||||||
public Action<int>? SelectionChanged { get; set; }
|
public Action<int>? SelectionChanged { get; set; }
|
||||||
public UiDatFont? DatFont { get; set; }
|
public UiDatFont? DatFont { get; set; }
|
||||||
|
|
@ -32,6 +48,13 @@ public sealed class UiMarkupList : UiElement
|
||||||
{
|
{
|
||||||
IReadOnlyList<string> items = ItemsSource();
|
IReadOnlyList<string> items = ItemsSource();
|
||||||
IReadOnlyList<uint> itemColors = ItemColorsSource();
|
IReadOnlyList<uint> itemColors = ItemColorsSource();
|
||||||
|
IReadOnlyList<uint>? iconIds = IconIdsSource?.Invoke();
|
||||||
|
// Decal's IconColumn: a leading square per row, RowHeight - 2 wide,
|
||||||
|
// reserved only while the list actually carries an icons= binding —
|
||||||
|
// an ordinary text-only list keeps its full-width text column.
|
||||||
|
float iconColumn = iconIds is not null
|
||||||
|
? MathF.Max(0f, RowHeight - 2f)
|
||||||
|
: 0f;
|
||||||
int visibleRows = VisibleRows;
|
int visibleRows = VisibleRows;
|
||||||
int selected = SelectedIndexSource();
|
int selected = SelectedIndexSource();
|
||||||
if (selected >= 0 && selected < items.Count)
|
if (selected >= 0 && selected < items.Count)
|
||||||
|
|
@ -51,16 +74,40 @@ public sealed class UiMarkupList : UiElement
|
||||||
float y = (index - _topRow) * RowHeight;
|
float y = (index - _topRow) * RowHeight;
|
||||||
if (index == selected)
|
if (index == selected)
|
||||||
context.DrawFill(1f, y + 1f, Width - 2f, RowHeight - 1f, SelectedColor);
|
context.DrawFill(1f, y + 1f, Width - 2f, RowHeight - 1f, SelectedColor);
|
||||||
|
|
||||||
|
if (iconIds is not null && index < iconIds.Count && IconResolve is { } resolve)
|
||||||
|
{
|
||||||
|
uint iconId = iconIds[index];
|
||||||
|
if (iconId != 0u)
|
||||||
|
{
|
||||||
|
(uint tex, int w, int h) = resolve(iconId);
|
||||||
|
if (tex != 0u && w > 0 && h > 0)
|
||||||
|
{
|
||||||
|
float extent = MathF.Max(0f, iconColumn - 2f);
|
||||||
|
float scale = MathF.Min(extent / w, extent / h);
|
||||||
|
float drawWidth = w * scale;
|
||||||
|
float drawHeight = h * scale;
|
||||||
|
context.DrawSprite(
|
||||||
|
tex,
|
||||||
|
1f + (extent - drawWidth) * 0.5f,
|
||||||
|
y + (RowHeight - drawHeight) * 0.5f,
|
||||||
|
drawWidth, drawHeight,
|
||||||
|
0f, 0f, 1f, 1f, Vector4.One);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
string text = items[index];
|
string text = items[index];
|
||||||
Vector4 textColor = index < itemColors.Count
|
Vector4 textColor = index < itemColors.Count
|
||||||
? Rgb(itemColors[index])
|
? Rgb(itemColors[index])
|
||||||
: TextColor;
|
: TextColor;
|
||||||
|
float textX = Padding + iconColumn;
|
||||||
float textY = y + MathF.Max(0f,
|
float textY = y + MathF.Max(0f,
|
||||||
(RowHeight - (DatFont?.LineHeight ?? 14f)) * 0.5f);
|
(RowHeight - (DatFont?.LineHeight ?? 14f)) * 0.5f);
|
||||||
if (DatFont is { } font)
|
if (DatFont is { } font)
|
||||||
context.DrawStringDat(font, text, Padding, textY, textColor, true);
|
context.DrawStringDat(font, text, textX, textY, textColor, true);
|
||||||
else
|
else
|
||||||
context.DrawString(text, Padding, textY, textColor);
|
context.DrawString(text, textX, textY, textColor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -176,6 +176,19 @@ public class UiSimpleButton : UiPanel
|
||||||
/// <summary>Two-plane glyph outline, as retail draws interface text.</summary>
|
/// <summary>Two-plane glyph outline, as retail draws interface text.</summary>
|
||||||
public bool Outline { get; set; } = true;
|
public bool Outline { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plugin markup's <c><button icon="..."></c> (Slice B,
|
||||||
|
/// <c>docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md</c>). Null (the
|
||||||
|
/// default) draws no icon and behaves exactly as before — every existing
|
||||||
|
/// caller of this widget (the plugin shelf's own toggle/minimize buttons
|
||||||
|
/// included) leaves this unset. When set, the icon draws flush left and
|
||||||
|
/// the caption's centering region shifts right by the reserved icon
|
||||||
|
/// column, so an icon-and-text button never overlaps them; an empty
|
||||||
|
/// <see cref="Text"/>/<see cref="TextSource"/> with this set is a valid
|
||||||
|
/// icon-only button.
|
||||||
|
/// </summary>
|
||||||
|
public Func<(uint tex, int w, int h)>? IconSource { get; set; }
|
||||||
|
|
||||||
public event System.Action? Click;
|
public event System.Action? Click;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -208,22 +221,50 @@ public class UiSimpleButton : UiPanel
|
||||||
protected override void OnDraw(UiRenderContext ctx)
|
protected override void OnDraw(UiRenderContext ctx)
|
||||||
{
|
{
|
||||||
base.OnDraw(ctx);
|
base.OnDraw(ctx);
|
||||||
|
|
||||||
|
// Slice B icon column: reserved only when an icon actually resolved
|
||||||
|
// this frame, so a bound id that goes to 0 (or the resolver returning
|
||||||
|
// no texture) collapses back to the un-iconed centering — never a
|
||||||
|
// permanent blank gap.
|
||||||
|
float iconColumn = 0f;
|
||||||
|
if (IconSource is { } iconSource)
|
||||||
|
{
|
||||||
|
(uint tex, int w, int h) = iconSource();
|
||||||
|
if (tex != 0u && w > 0 && h > 0)
|
||||||
|
{
|
||||||
|
float extent = MathF.Max(0f, MathF.Min(Width, Height) - 6f);
|
||||||
|
float scale = MathF.Min(extent / w, extent / h);
|
||||||
|
float drawWidth = w * scale;
|
||||||
|
float drawHeight = h * scale;
|
||||||
|
ctx.DrawSprite(
|
||||||
|
tex,
|
||||||
|
3f + (extent - drawWidth) * 0.5f,
|
||||||
|
(Height - drawHeight) * 0.5f,
|
||||||
|
drawWidth, drawHeight,
|
||||||
|
0f, 0f, 1f, 1f, Vector4.One);
|
||||||
|
iconColumn = extent + 6f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
string caption = TextSource?.Invoke() ?? Text;
|
string caption = TextSource?.Invoke() ?? Text;
|
||||||
if (caption.Length == 0) return;
|
if (caption.Length == 0) return;
|
||||||
|
|
||||||
|
float captionAreaX = iconColumn;
|
||||||
|
float captionAreaWidth = MathF.Max(0f, Width - iconColumn);
|
||||||
if (DatFont is { } dat)
|
if (DatFont is { } dat)
|
||||||
{
|
{
|
||||||
float datW = dat.MeasureWidth(caption);
|
float datW = dat.MeasureWidth(caption);
|
||||||
ctx.DrawStringDat(
|
ctx.DrawStringDat(
|
||||||
dat, caption,
|
dat, caption,
|
||||||
(Width - datW) * 0.5f, (Height - dat.LineHeight) * 0.5f,
|
captionAreaX + (captionAreaWidth - datW) * 0.5f,
|
||||||
|
(Height - dat.LineHeight) * 0.5f,
|
||||||
TextColor, Outline);
|
TextColor, Outline);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ctx.DefaultFont is null) return;
|
if (ctx.DefaultFont is null) return;
|
||||||
float textW = ctx.DefaultFont.MeasureWidth(caption);
|
float textW = ctx.DefaultFont.MeasureWidth(caption);
|
||||||
float tx = (Width - textW) * 0.5f;
|
float tx = captionAreaX + (captionAreaWidth - textW) * 0.5f;
|
||||||
float ty = (Height - ctx.DefaultFont.LineHeight) * 0.5f;
|
float ty = (Height - ctx.DefaultFont.LineHeight) * 0.5f;
|
||||||
ctx.DrawString(caption, tx, ty, TextColor);
|
ctx.DrawString(caption, tx, ty, TextColor);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,17 @@ public readonly record struct PluginSpellInfo(
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int? QualityOverride { get; init; }
|
public int? QualityOverride { get; init; }
|
||||||
public int Quality => QualityOverride ?? Difficulty;
|
public int Quality => QualityOverride ?? Difficulty;
|
||||||
|
/// <summary>
|
||||||
|
/// Raw retail SpellTable (portal.dat <c>0x0E00000E</c>) icon RenderSurface
|
||||||
|
/// DID — Decal's <c>SpellTable.GetById(id).Icon</c>. This is the spell's
|
||||||
|
/// OWN art asset id, distinct from the composited icon a plugin markup
|
||||||
|
/// <c><icon spell="..."></c>/<c>iconkind="spell"</c> draws (which
|
||||||
|
/// layers power-level backing + tint + self/fellow overlay on top of it,
|
||||||
|
/// matching retail's <c>ClientMagicSystem::CompositeSpellIcon</c>) — a
|
||||||
|
/// plugin that wants the plain art tile rather than the composited badge
|
||||||
|
/// draws this id directly through <c>iconkind="did"</c>. 0 when unknown.
|
||||||
|
/// </summary>
|
||||||
|
public uint IconId { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>One enchantment currently in force on the local player.</summary>
|
/// <summary>One enchantment currently in force on the local player.</summary>
|
||||||
|
|
@ -115,6 +126,19 @@ public readonly record struct PluginSkillInfo(
|
||||||
{
|
{
|
||||||
/// <summary>Unenchanted retail skill level before vitae and spell mods.</summary>
|
/// <summary>Unenchanted retail skill level before vitae and spell mods.</summary>
|
||||||
public uint Base { get; init; } = Current;
|
public uint Base { get; init; } = Current;
|
||||||
|
/// <summary>
|
||||||
|
/// Retail SkillTable (portal.dat <c>0x0E000004</c>) icon RenderSurface
|
||||||
|
/// DID — the <c>SkillBase.IconId</c> field (Chorizite.DatReaderWriter
|
||||||
|
/// <c>DatReaderWriter.Types.SkillBase</c>; verified via reflection over
|
||||||
|
/// the installed package, since the XML doc comments don't cover it: the
|
||||||
|
/// type carries public fields <c>Description</c>, <c>Name</c>,
|
||||||
|
/// <c>IconId</c> (uint), <c>TrainedCost</c>, <c>SpecializedCost</c>,
|
||||||
|
/// <c>Category</c>, <c>ChargenUse</c>, <c>MinLevel</c>, <c>Formula</c>,
|
||||||
|
/// <c>UpperBound</c>, <c>LowerBound</c>, <c>LearnMod</c> — no separate
|
||||||
|
/// XML-doc member exists because these are Pack/Unpack-generated public
|
||||||
|
/// fields, not properties). 0 when unknown.
|
||||||
|
/// </summary>
|
||||||
|
public uint IconId { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>One primary attribute. <paramref name="Kind"/> is 0..5.</summary>
|
/// <summary>One primary attribute. <paramref name="Kind"/> is 0..5.</summary>
|
||||||
|
|
|
||||||
|
|
@ -91,6 +91,13 @@ public readonly record struct PluginInventoryItem(
|
||||||
public PluginObjectClass ObjectClass { get; init; }
|
public PluginObjectClass ObjectClass { get; init; }
|
||||||
public IReadOnlyList<PluginPaletteInfo> Palettes { get; init; } =
|
public IReadOnlyList<PluginPaletteInfo> Palettes { get; init; } =
|
||||||
Array.Empty<PluginPaletteInfo>();
|
Array.Empty<PluginPaletteInfo>();
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>ClientObject.IconId</c> RenderSurface DID (base icon, before
|
||||||
|
/// the type-default underlay/custom-underlay/overlay/effect compositing
|
||||||
|
/// a plugin markup <c>iconkind="item"</c> draws through
|
||||||
|
/// <c>IconComposer.GetIcon</c>). 0 when unknown.
|
||||||
|
/// </summary>
|
||||||
|
public uint IconId { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
49
src/AcDream.Plugin.Abstractions/PluginIcons.cs
Normal file
49
src/AcDream.Plugin.Abstractions/PluginIcons.cs
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
namespace AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The one icon-id grammar every host icon sink normalizes through
|
||||||
|
/// (descriptor <see cref="PluginPanelDescriptor.IconSurfaceId"/>, plugin
|
||||||
|
/// markup's <c><icon did></c>, <c><button icon></c>, and
|
||||||
|
/// <c><list icons></c>). Decal/VirindiViewService plugins (the
|
||||||
|
/// reference usage: MosswartMassacre's <c>HudPictureBox.Image</c> assignments)
|
||||||
|
/// hand out bare portal.dat indices — small integers below the
|
||||||
|
/// <c>0x06xxxxxx</c> RenderSurface DID range, the same numbers Decal's
|
||||||
|
/// <c>FileService.SpellTable</c>/<c>SkillTable</c> icon columns return. A
|
||||||
|
/// host that draws those literally as RenderSurface DIDs resolves nothing.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// This grammar is deliberately host-side and applied at the SINK, not at
|
||||||
|
/// every plugin-facing record: <see cref="PluginSpellInfo.IconId"/>,
|
||||||
|
/// <see cref="PluginSkillInfo.IconId"/>, <see cref="PluginInventoryItem.IconId"/>
|
||||||
|
/// and <see cref="PluginWorldObject.IconId"/> already carry full retail
|
||||||
|
/// RenderSurface DIDs (read straight from the client's SpellTable/SkillTable/
|
||||||
|
/// object state), so normalizing them again would be a no-op — but a plugin
|
||||||
|
/// author who only has a Decal-style bare index (typed literally in markup,
|
||||||
|
/// or echoed from an external metadata source) still needs the same
|
||||||
|
/// conversion, which is exactly what happens at the markup <c>did</c> sink.
|
||||||
|
/// </remarks>
|
||||||
|
public static class PluginIcons
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The first RenderSurface DID range (portal.dat's <c>0x06000000</c>
|
||||||
|
/// block). Any value below this is treated as a bare index needing the
|
||||||
|
/// block prefix; any value at or above it is assumed to already be a
|
||||||
|
/// resolvable DID (RenderSurface or otherwise) and is returned unchanged.
|
||||||
|
/// </summary>
|
||||||
|
private const uint RenderSurfaceBlock = 0x06000000u;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Normalizes one plugin-supplied icon id: <c>0</c> stays <c>0</c> (no
|
||||||
|
/// icon); a bare index below <see cref="RenderSurfaceBlock"/> becomes
|
||||||
|
/// <c>0x06000000 + value</c> (Decal/VVS convention); anything else is
|
||||||
|
/// returned unchanged (already a full DID, of whatever DBTYPE the caller
|
||||||
|
/// intends — the host resolves it against the space its own sink
|
||||||
|
/// expects).
|
||||||
|
/// </summary>
|
||||||
|
public static uint Normalize(uint idOrIndex) =>
|
||||||
|
idOrIndex == 0u
|
||||||
|
? 0u
|
||||||
|
: idOrIndex < RenderSurfaceBlock
|
||||||
|
? RenderSurfaceBlock + idOrIndex
|
||||||
|
: idOrIndex;
|
||||||
|
}
|
||||||
|
|
@ -83,6 +83,12 @@ public readonly record struct PluginWorldObject(
|
||||||
public int ContainersCapacity { get; init; }
|
public int ContainersCapacity { get; init; }
|
||||||
public IReadOnlyList<uint> SpellIds { get; init; } = Array.Empty<uint>();
|
public IReadOnlyList<uint> SpellIds { get; init; } = Array.Empty<uint>();
|
||||||
public IReadOnlyList<uint> ActiveSpellIds { get; init; } = Array.Empty<uint>();
|
public IReadOnlyList<uint> ActiveSpellIds { get; init; } = Array.Empty<uint>();
|
||||||
|
/// <summary>
|
||||||
|
/// Retail <c>ClientObject.IconId</c> RenderSurface DID (base icon, before
|
||||||
|
/// compositing). Same id a plugin markup <c>iconkind="item"</c> resolves
|
||||||
|
/// through <c>IconComposer.GetIcon</c>. 0 when unknown.
|
||||||
|
/// </summary>
|
||||||
|
public uint IconId { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
|
||||||
116
src/AcDream.Plugins.Smoke/SmokeIconPanel.cs
Normal file
116
src/AcDream.Plugins.Smoke/SmokeIconPanel.cs
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
|
||||||
|
namespace AcDream.Plugins.Smoke;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Slice B proof panel (<c>docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md</c>
|
||||||
|
/// item 9): exercises every new plugin-markup icon surface in one place —
|
||||||
|
/// registered from in-memory KSML via <see cref="IUiRegistry.RegisterPanelContent"/>
|
||||||
|
/// (no plugin-side XML file), with the descriptor's own
|
||||||
|
/// <see cref="PluginPanelDescriptor.IconSurfaceId"/> also set to the bare
|
||||||
|
/// Decal-style index used below, so the shelf button and the panel's own
|
||||||
|
/// <c><icon did="7735"></c> prove
|
||||||
|
/// <see cref="PluginIcons.Normalize"/> the same way at both sinks.
|
||||||
|
/// </summary>
|
||||||
|
internal static class SmokeIconPanel
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// A Decal/VirindiViewService-style bare portal.dat index (MosswartMassacre's
|
||||||
|
/// convention — see the plan's "Why" section) rather than a full
|
||||||
|
/// <c>0x06xxxxxx</c> RenderSurface DID. Deliberately used on BOTH the
|
||||||
|
/// descriptor and the first <c><icon></c> below to prove the host
|
||||||
|
/// normalizes it identically at each sink.
|
||||||
|
/// </summary>
|
||||||
|
public const uint BareIndexIconId = 7735u;
|
||||||
|
|
||||||
|
/// <summary>A literal, already-normalized RenderSurface DID.</summary>
|
||||||
|
private const uint LiteralDidIconId = 0x06002D14u;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Retail's Strength Other I — the plan's named fallback when the local
|
||||||
|
/// character has not learned any self-castable buff yet.
|
||||||
|
/// </summary>
|
||||||
|
private const uint FallbackSpellId = 1u;
|
||||||
|
|
||||||
|
public static readonly PluginPanelDescriptor Descriptor = new("icons", "Icon Smoke")
|
||||||
|
{
|
||||||
|
IconSurfaceId = BareIndexIconId,
|
||||||
|
StartVisible = false,
|
||||||
|
ShowInSidePanel = true,
|
||||||
|
};
|
||||||
|
|
||||||
|
public const string Markup = """
|
||||||
|
<panel x="60" y="60" w="280" h="220" title="Icon Smoke">
|
||||||
|
<icon x="12" y="28" w="32" h="32" did="7735" tooltip="bare Decal index"/>
|
||||||
|
<icon x="52" y="28" w="32" h="32" did="0x06002D14" tooltip="literal RenderSurface DID"/>
|
||||||
|
<icon x="92" y="28" w="32" h="32" spell="{SpellId}" tooltip="composited spell icon"/>
|
||||||
|
<button x="12" y="68" w="120" h="24" text="Report" icon="0x06002D14" onclick="{Report}"/>
|
||||||
|
<list x="12" y="100" w="256" h="108" items="{SpellRows}" icons="{SpellIconIds}"
|
||||||
|
iconkind="spell" selected="{SelectedIndex}"/>
|
||||||
|
</panel>
|
||||||
|
""";
|
||||||
|
|
||||||
|
/// <summary>Binding object for <see cref="Markup"/>. Reads live host state
|
||||||
|
/// on every frame the same way any BCL-only plugin binding would.</summary>
|
||||||
|
internal sealed class Binding
|
||||||
|
{
|
||||||
|
private readonly IPluginHost _host;
|
||||||
|
|
||||||
|
public Binding(IPluginHost host) => _host = host;
|
||||||
|
|
||||||
|
public int SelectedIndex { get; set; } = -1;
|
||||||
|
|
||||||
|
/// <summary>The first spell a plugin markup <c><icon spell=...></c>
|
||||||
|
/// draws — the character's first known self-buff, falling back to
|
||||||
|
/// <see cref="FallbackSpellId"/> (Strength Other I) when nothing is
|
||||||
|
/// learned yet (fresh character, or no live session).</summary>
|
||||||
|
public uint SpellId
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
IReadOnlyList<PluginSpellInfo> known = _host.Automation.Spells.KnownSelfBuffs;
|
||||||
|
return known.Count > 0 ? known[0].SpellId : FallbackSpellId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Parallel icon-id column for <see cref="SpellRows"/>: the
|
||||||
|
/// first five known self-buffs' raw SpellTable icon DIDs.</summary>
|
||||||
|
public IEnumerable<uint> SpellIconIds
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var ids = new List<uint>();
|
||||||
|
foreach (PluginSpellInfo spell in _host.Automation.Spells.KnownSelfBuffs)
|
||||||
|
{
|
||||||
|
ids.Add(spell.IconId);
|
||||||
|
if (ids.Count == 5)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Row text for the first five known self-buffs, with each
|
||||||
|
/// spell's <see cref="PluginSpellInfo.IconId"/> printed alongside its
|
||||||
|
/// name so the icon column and the raw id are both visible in one
|
||||||
|
/// look.</summary>
|
||||||
|
public IEnumerable<string> SpellRows
|
||||||
|
{
|
||||||
|
get
|
||||||
|
{
|
||||||
|
var rows = new List<string>();
|
||||||
|
foreach (PluginSpellInfo spell in _host.Automation.Spells.KnownSelfBuffs)
|
||||||
|
{
|
||||||
|
rows.Add($"{spell.Name} (icon 0x{spell.IconId:X8})");
|
||||||
|
if (rows.Count == 5)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Action Report =>
|
||||||
|
() => _host.Log.Info(
|
||||||
|
$"smoke icon panel: SpellId={SpellId:X8}, {SpellRows.Count()} spellbook rows");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ public sealed class SmokePlugin : IAcDreamPlugin
|
||||||
{
|
{
|
||||||
private IPluginHost? _host;
|
private IPluginHost? _host;
|
||||||
private int _entitiesSeen;
|
private int _entitiesSeen;
|
||||||
|
private IDisposable? _iconPanel;
|
||||||
|
|
||||||
public void Initialize(IPluginHost host)
|
public void Initialize(IPluginHost host)
|
||||||
{
|
{
|
||||||
|
|
@ -20,6 +21,14 @@ public sealed class SmokePlugin : IAcDreamPlugin
|
||||||
{
|
{
|
||||||
_host.Events.EntitySpawned += OnEntitySpawned;
|
_host.Events.EntitySpawned += OnEntitySpawned;
|
||||||
_host.Log.Info($"smoke plugin sees {_entitiesSeen} entities (replay count at subscribe)");
|
_host.Log.Info($"smoke plugin sees {_entitiesSeen} entities (replay count at subscribe)");
|
||||||
|
|
||||||
|
// Slice B proof panel (docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md
|
||||||
|
// item 9): in-memory KSML, no plugin-side XML file, exercising
|
||||||
|
// every new icon markup surface.
|
||||||
|
_iconPanel = _host.Ui.RegisterPanelContent(
|
||||||
|
SmokeIconPanel.Descriptor,
|
||||||
|
SmokeIconPanel.Markup,
|
||||||
|
new SmokeIconPanel.Binding(_host));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -27,6 +36,8 @@ public sealed class SmokePlugin : IAcDreamPlugin
|
||||||
{
|
{
|
||||||
if (_host is not null)
|
if (_host is not null)
|
||||||
_host.Events.EntitySpawned -= OnEntitySpawned;
|
_host.Events.EntitySpawned -= OnEntitySpawned;
|
||||||
|
_iconPanel?.Dispose();
|
||||||
|
_iconPanel = null;
|
||||||
_host?.Log.Info($"smoke plugin disabled (saw {_entitiesSeen} entities total)");
|
_host?.Log.Info($"smoke plugin disabled (saw {_entitiesSeen} entities total)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
using AcDream.App.Plugins;
|
||||||
|
using AcDream.App.Tests.Rendering;
|
||||||
|
using AcDream.Content;
|
||||||
|
using AcDream.Core.Spells;
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
using DatReaderWriter;
|
||||||
|
using DatReaderWriter.Options;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Plugins;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Slice B item 11 (<c>docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md</c>):
|
||||||
|
/// <see cref="PluginSpellInfo.IconId"/> must carry the REAL installed
|
||||||
|
/// portal.dat SpellTable (<c>0x0E00000E</c>) icon field through
|
||||||
|
/// <see cref="AppAutomationSurface"/> unchanged, not just a synthetic
|
||||||
|
/// <see cref="SpellMetadata"/> fixture. Mirrors the
|
||||||
|
/// <see cref="AcDream.App.Tests.Rendering.ChargenPreviewEntityBuilderTests"/>
|
||||||
|
/// installed-DAT gate convention: <see cref="InstalledDatTestPath"/> +
|
||||||
|
/// <c>[Trait("Lane", "InstalledDat")]</c>, <c>Assert.Fail</c> with the
|
||||||
|
/// standard message when no DAT is configured (see docs/release-gate.md).
|
||||||
|
/// </summary>
|
||||||
|
[Trait("Lane", "InstalledDat")]
|
||||||
|
public sealed class AppAutomationSurfaceIconInstalledDatTests
|
||||||
|
{
|
||||||
|
/// <summary>Retail's Strength Other I — the same well-known spell id
|
||||||
|
/// SmokeIconPanel falls back to when the local character has learned
|
||||||
|
/// nothing yet.</summary>
|
||||||
|
private const uint KnownSpellId = 1u;
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void KnownSpell_IconId_MatchesTheInstalledSpellTablesIconFieldExactly()
|
||||||
|
{
|
||||||
|
string? datDir = InstalledDatTestPath.Resolve();
|
||||||
|
if (datDir is null)
|
||||||
|
{
|
||||||
|
Assert.Fail(
|
||||||
|
"Lane=InstalledDat requires an installed retail DAT directory; see docs/release-gate.md.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var dats = new DatCollection(datDir, DatAccessType.Read);
|
||||||
|
using var adapter = new DatCollectionAdapter(dats);
|
||||||
|
MagicCatalog catalog = MagicCatalog.Load(adapter);
|
||||||
|
|
||||||
|
Assert.True(
|
||||||
|
catalog.SpellTable.TryGet(KnownSpellId, out SpellMetadata expected),
|
||||||
|
$"expected spell {KnownSpellId} (Strength Other I) to exist in the installed SpellTable");
|
||||||
|
// A real production install's SpellTable always has non-zero art for
|
||||||
|
// an ordinary named spell; a zero here would mean the projector
|
||||||
|
// silently dropped SpellBase.Icon rather than proving the pipeline.
|
||||||
|
Assert.NotEqual(0u, expected.IconId);
|
||||||
|
|
||||||
|
using var runtime = GameRuntimeTestFactory.Create();
|
||||||
|
runtime.CharacterOwner.InstallSpellMetadata(catalog.SpellTable);
|
||||||
|
runtime.CharacterOwner.Spellbook.OnSpellLearned(KnownSpellId);
|
||||||
|
using var surface = new AppAutomationSurface();
|
||||||
|
surface.Bind(runtime, runtime.CharacterOwner, runtime.ActionOwner.SpellCast);
|
||||||
|
|
||||||
|
Assert.True(surface.Spells.TryGet(KnownSpellId, out PluginSpellInfo info));
|
||||||
|
Assert.Equal(expected.IconId, info.IconId);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
tests/AcDream.App.Tests/Plugins/PluginIconsTests.cs
Normal file
25
tests/AcDream.App.Tests/Plugins/PluginIconsTests.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
using AcDream.Plugin.Abstractions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.Plugins;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="PluginIcons.Normalize"/> — the one grammar every host icon sink
|
||||||
|
/// (descriptor <see cref="PluginPanelDescriptor.IconSurfaceId"/>, markup
|
||||||
|
/// <c><icon did></c>/<c><button icon></c>/<c><list icons></c>)
|
||||||
|
/// applies. See <c>docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md</c>
|
||||||
|
/// Slice B item 1.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PluginIconsTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0u, 0u)]
|
||||||
|
[InlineData(7735u, 0x06001E37u)] // Decal-style bare index -> RenderSurface DID
|
||||||
|
[InlineData(0x00FFFFFFu, 0x06FFFFFFu)] // largest bare index -> still gets the block prefix
|
||||||
|
[InlineData(0x06002D14u, 0x06002D14u)] // already a RenderSurface DID -> unchanged
|
||||||
|
[InlineData(0x0600FFFFu, 0x0600FFFFu)] // already at/above the block -> unchanged
|
||||||
|
public void Normalize_MapsAccordingToTheGrammar(uint input, uint expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, PluginIcons.Normalize(input));
|
||||||
|
}
|
||||||
|
}
|
||||||
452
tests/AcDream.App.Tests/UI/MarkupIconTests.cs
Normal file
452
tests/AcDream.App.Tests/UI/MarkupIconTests.cs
Normal file
|
|
@ -0,0 +1,452 @@
|
||||||
|
using System.Numerics;
|
||||||
|
using AcDream.App.Rendering;
|
||||||
|
using AcDream.App.Rendering.Gpu;
|
||||||
|
using AcDream.App.Tests.Rendering.Gpu;
|
||||||
|
using AcDream.App.UI;
|
||||||
|
using DatReaderWriter.Types;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace AcDream.App.Tests.UI;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Slice B (<c>docs/plans/2026-09-06-plugin-shelf-and-dat-icons.md</c>):
|
||||||
|
/// <c><icon></c>, <c><button icon></c>, and <c><list icons></c>
|
||||||
|
/// markup, backed by a fake <see cref="IMarkupIconResolver"/> that records
|
||||||
|
/// every call so the resolver-dispatch (did/spell/item, normalized vs raw)
|
||||||
|
/// is directly assertable, plus draw-level pins (via the same
|
||||||
|
/// <see cref="RecordingGpuDevice"/>/<see cref="TextRenderer"/> apparatus
|
||||||
|
/// <c>UiAncestorClipTests</c>/<c>UiRenderContextDrawStringDatOutlineTests</c>
|
||||||
|
/// use) for "draws nothing when unresolvable" and "button/list icon shifts
|
||||||
|
/// the caption/text column".
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MarkupIconTests
|
||||||
|
{
|
||||||
|
private sealed class FakeIconResolver : IMarkupIconResolver
|
||||||
|
{
|
||||||
|
public readonly List<(string Method, uint Id)> Calls = new();
|
||||||
|
public uint DidTexture = 5u;
|
||||||
|
public uint SpellTexture = 6u;
|
||||||
|
public uint ItemTexture = 7u;
|
||||||
|
|
||||||
|
public (uint tex, int w, int h) ResolveDid(uint did)
|
||||||
|
{
|
||||||
|
Calls.Add(("did", did));
|
||||||
|
return did == 0u ? (0u, 0, 0) : (DidTexture, 32, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
public (uint tex, int w, int h) ResolveSpell(uint spellId)
|
||||||
|
{
|
||||||
|
Calls.Add(("spell", spellId));
|
||||||
|
return spellId == 0u ? (0u, 0, 0) : (SpellTexture, 32, 32);
|
||||||
|
}
|
||||||
|
|
||||||
|
public (uint tex, int w, int h) ResolveItem(uint objectId)
|
||||||
|
{
|
||||||
|
Calls.Add(("item", objectId));
|
||||||
|
return objectId == 0u ? (0u, 0, 0) : (ItemTexture, 32, 32);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class IconBinding
|
||||||
|
{
|
||||||
|
public uint IconDid { get; set; } = 0x06001234u;
|
||||||
|
public uint SpellId { get; set; } = 42u;
|
||||||
|
public uint ObjectId { get; set; } = 0x50000001u;
|
||||||
|
public Action Go => () => { };
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (uint, int, int) Sprite(uint id) => (1u, 32, 32);
|
||||||
|
|
||||||
|
// ── <icon>: source dispatch + the Build-time contract ────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Icon_LiteralBareIndex_NormalizesBeforeResolving()
|
||||||
|
{
|
||||||
|
const string xml =
|
||||||
|
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
|
||||||
|
"<icon x=\"0\" y=\"0\" did=\"7735\"/>" +
|
||||||
|
"</panel>";
|
||||||
|
var resolver = new FakeIconResolver();
|
||||||
|
|
||||||
|
var panel = MarkupDocument.Build(xml, new object(), Sprite, icons: resolver);
|
||||||
|
var icon = Assert.IsType<UiMarkupIcon>(panel.Children[0]);
|
||||||
|
(uint tex, int w, int h) = icon.IconSource();
|
||||||
|
|
||||||
|
Assert.Equal(resolver.DidTexture, tex);
|
||||||
|
Assert.Equal(32, w);
|
||||||
|
Assert.Equal(32, h);
|
||||||
|
Assert.Equal(("did", 0x06001E37u), resolver.Calls[^1]); // 0x06000000 + 7735
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Icon_LiteralHexDid_AlreadyInRangeIsUnchangedByNormalize()
|
||||||
|
{
|
||||||
|
const string xml =
|
||||||
|
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
|
||||||
|
"<icon x=\"0\" y=\"0\" did=\"0x06002D14\"/>" +
|
||||||
|
"</panel>";
|
||||||
|
var resolver = new FakeIconResolver();
|
||||||
|
|
||||||
|
var panel = MarkupDocument.Build(xml, new object(), Sprite, icons: resolver);
|
||||||
|
var icon = Assert.IsType<UiMarkupIcon>(panel.Children[0]);
|
||||||
|
icon.IconSource();
|
||||||
|
|
||||||
|
Assert.Equal(("did", 0x06002D14u), resolver.Calls[^1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Icon_BoundDid_ReReadsTheBindingEveryFrame()
|
||||||
|
{
|
||||||
|
const string xml =
|
||||||
|
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
|
||||||
|
"<icon x=\"0\" y=\"0\" did=\"{IconDid}\"/>" +
|
||||||
|
"</panel>";
|
||||||
|
var resolver = new FakeIconResolver();
|
||||||
|
var binding = new IconBinding();
|
||||||
|
|
||||||
|
var panel = MarkupDocument.Build(xml, binding, Sprite, icons: resolver);
|
||||||
|
var icon = Assert.IsType<UiMarkupIcon>(panel.Children[0]);
|
||||||
|
|
||||||
|
icon.IconSource();
|
||||||
|
Assert.Equal(("did", binding.IconDid), resolver.Calls[^1]);
|
||||||
|
|
||||||
|
binding.IconDid = 0x06005678u;
|
||||||
|
icon.IconSource();
|
||||||
|
Assert.Equal(("did", 0x06005678u), resolver.Calls[^1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Icon_Spell_RoutesToResolveSpellWithTheBoundIdUnnormalized()
|
||||||
|
{
|
||||||
|
const string xml =
|
||||||
|
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
|
||||||
|
"<icon x=\"0\" y=\"0\" spell=\"{SpellId}\"/>" +
|
||||||
|
"</panel>";
|
||||||
|
var resolver = new FakeIconResolver();
|
||||||
|
var binding = new IconBinding();
|
||||||
|
|
||||||
|
var panel = MarkupDocument.Build(xml, binding, Sprite, icons: resolver);
|
||||||
|
var icon = Assert.IsType<UiMarkupIcon>(panel.Children[0]);
|
||||||
|
(uint tex, _, _) = icon.IconSource();
|
||||||
|
|
||||||
|
Assert.Equal(resolver.SpellTexture, tex);
|
||||||
|
Assert.Equal(("spell", binding.SpellId), resolver.Calls[^1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Icon_Item_RoutesToResolveItemWithTheBoundId()
|
||||||
|
{
|
||||||
|
const string xml =
|
||||||
|
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
|
||||||
|
"<icon x=\"0\" y=\"0\" item=\"{ObjectId}\"/>" +
|
||||||
|
"</panel>";
|
||||||
|
var resolver = new FakeIconResolver();
|
||||||
|
var binding = new IconBinding();
|
||||||
|
|
||||||
|
var panel = MarkupDocument.Build(xml, binding, Sprite, icons: resolver);
|
||||||
|
var icon = Assert.IsType<UiMarkupIcon>(panel.Children[0]);
|
||||||
|
(uint tex, _, _) = icon.IconSource();
|
||||||
|
|
||||||
|
Assert.Equal(resolver.ItemTexture, tex);
|
||||||
|
Assert.Equal(("item", binding.ObjectId), resolver.Calls[^1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Icon_ZeroId_ResolvesToNothing()
|
||||||
|
{
|
||||||
|
const string xml =
|
||||||
|
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
|
||||||
|
"<icon x=\"0\" y=\"0\" did=\"0\"/>" +
|
||||||
|
"</panel>";
|
||||||
|
var resolver = new FakeIconResolver();
|
||||||
|
|
||||||
|
var panel = MarkupDocument.Build(xml, new object(), Sprite, icons: resolver);
|
||||||
|
var icon = Assert.IsType<UiMarkupIcon>(panel.Children[0]);
|
||||||
|
(uint tex, int w, int h) = icon.IconSource();
|
||||||
|
|
||||||
|
Assert.Equal(0u, tex);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Icon_NoResolverWired_ResolvesToNothingRatherThanThrowing()
|
||||||
|
{
|
||||||
|
const string xml =
|
||||||
|
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
|
||||||
|
"<icon x=\"0\" y=\"0\" did=\"7735\"/>" +
|
||||||
|
"</panel>";
|
||||||
|
|
||||||
|
var panel = MarkupDocument.Build(xml, new object(), Sprite);
|
||||||
|
var icon = Assert.IsType<UiMarkupIcon>(panel.Children[0]);
|
||||||
|
(uint tex, int w, int h) = icon.IconSource();
|
||||||
|
|
||||||
|
Assert.Equal(0u, tex);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Icon_TwoSources_ThrowsFormatExceptionAtBuild()
|
||||||
|
{
|
||||||
|
const string xml =
|
||||||
|
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
|
||||||
|
"<icon x=\"0\" y=\"0\" did=\"7735\" spell=\"{SpellId}\"/>" +
|
||||||
|
"</panel>";
|
||||||
|
|
||||||
|
Assert.Throws<FormatException>(
|
||||||
|
() => MarkupDocument.Build(xml, new IconBinding(), Sprite, icons: new FakeIconResolver()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Icon_NoSources_ThrowsFormatExceptionAtBuild()
|
||||||
|
{
|
||||||
|
const string xml =
|
||||||
|
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
|
||||||
|
"<icon x=\"0\" y=\"0\"/>" +
|
||||||
|
"</panel>";
|
||||||
|
|
||||||
|
Assert.Throws<FormatException>(
|
||||||
|
() => MarkupDocument.Build(xml, new object(), Sprite));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Icon_WidthHeightDefaultTo32WhenOmitted()
|
||||||
|
{
|
||||||
|
const string xml =
|
||||||
|
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
|
||||||
|
"<icon x=\"0\" y=\"0\" did=\"1\"/>" +
|
||||||
|
"</panel>";
|
||||||
|
|
||||||
|
var panel = MarkupDocument.Build(xml, new object(), Sprite);
|
||||||
|
var icon = Assert.IsType<UiMarkupIcon>(panel.Children[0]);
|
||||||
|
|
||||||
|
Assert.Equal(32f, icon.Width);
|
||||||
|
Assert.Equal(32f, icon.Height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── <button icon>: resolver dispatch + draw-level "text shifts right" ────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ButtonIcon_DefaultsToDidKind_AndDrawsThroughTheResolver()
|
||||||
|
{
|
||||||
|
const string xml =
|
||||||
|
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
|
||||||
|
"<button x=\"0\" y=\"0\" w=\"60\" h=\"20\" text=\"Go\" icon=\"7735\"/>" +
|
||||||
|
"</panel>";
|
||||||
|
var resolver = new FakeIconResolver();
|
||||||
|
|
||||||
|
var panel = MarkupDocument.Build(xml, new IconBinding(), Sprite, icons: resolver);
|
||||||
|
var button = Assert.IsType<UiSimpleButton>(panel.Children[0]);
|
||||||
|
|
||||||
|
Assert.NotNull(button.IconSource);
|
||||||
|
(uint tex, _, _) = button.IconSource!();
|
||||||
|
Assert.Equal(resolver.DidTexture, tex);
|
||||||
|
Assert.Equal(("did", 0x06001E37u), resolver.Calls[^1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ButtonIcon_KindSpell_RoutesToResolveSpell()
|
||||||
|
{
|
||||||
|
const string xml =
|
||||||
|
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
|
||||||
|
"<button x=\"0\" y=\"0\" w=\"60\" h=\"20\" text=\"Buff\" " +
|
||||||
|
"icon=\"{SpellId}\" iconkind=\"spell\"/>" +
|
||||||
|
"</panel>";
|
||||||
|
var resolver = new FakeIconResolver();
|
||||||
|
var binding = new IconBinding();
|
||||||
|
|
||||||
|
var panel = MarkupDocument.Build(xml, binding, Sprite, icons: resolver);
|
||||||
|
var button = Assert.IsType<UiSimpleButton>(panel.Children[0]);
|
||||||
|
button.IconSource!();
|
||||||
|
|
||||||
|
Assert.Equal(("spell", binding.SpellId), resolver.Calls[^1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ButtonWithoutIconAttribute_HasNoIconSource()
|
||||||
|
{
|
||||||
|
const string xml =
|
||||||
|
"<panel x=\"0\" y=\"0\" w=\"100\" h=\"60\">" +
|
||||||
|
"<button x=\"0\" y=\"0\" w=\"60\" h=\"20\" text=\"Go\"/>" +
|
||||||
|
"</panel>";
|
||||||
|
|
||||||
|
var panel = MarkupDocument.Build(xml, new object(), Sprite);
|
||||||
|
var button = Assert.IsType<UiSimpleButton>(panel.Children[0]);
|
||||||
|
|
||||||
|
Assert.Null(button.IconSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── <list icons>: resolver dispatch + column reservation ──────────────────
|
||||||
|
|
||||||
|
private sealed class ListIconBinding
|
||||||
|
{
|
||||||
|
public IReadOnlyList<string> Items { get; } = new[] { "First", "Second" };
|
||||||
|
// Row 0 resolves; row 1's id is 0 (unresolvable) — "missing entries
|
||||||
|
// draw no icon" per the plan.
|
||||||
|
public IReadOnlyList<uint> IconIds { get; } = new[] { 7735u, 0u };
|
||||||
|
public int Selected { get; set; } = -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ListIcons_ReservesTheColumn_AndResolvesEachRowThroughTheSharedResolver()
|
||||||
|
{
|
||||||
|
const string xml =
|
||||||
|
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
|
||||||
|
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" rowheight=\"18\" " +
|
||||||
|
"items=\"{Items}\" icons=\"{IconIds}\" iconkind=\"did\" selected=\"{Selected}\"/>" +
|
||||||
|
"</panel>";
|
||||||
|
var resolver = new FakeIconResolver();
|
||||||
|
var binding = new ListIconBinding();
|
||||||
|
|
||||||
|
var panel = MarkupDocument.Build(xml, binding, Sprite, icons: resolver);
|
||||||
|
var list = Assert.IsType<UiMarkupList>(panel.Children[0]);
|
||||||
|
|
||||||
|
Assert.NotNull(list.IconIdsSource);
|
||||||
|
Assert.Equal(binding.IconIds, list.IconIdsSource!());
|
||||||
|
Assert.NotNull(list.IconResolve);
|
||||||
|
|
||||||
|
(uint tex, _, _) = list.IconResolve!(7735u);
|
||||||
|
Assert.Equal(resolver.DidTexture, tex);
|
||||||
|
Assert.Equal(("did", 0x06001E37u), resolver.Calls[^1]);
|
||||||
|
|
||||||
|
(uint missTex, _, _) = list.IconResolve!(0u);
|
||||||
|
Assert.Equal(0u, missTex);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ListWithoutIconsAttribute_HasNoIconColumn()
|
||||||
|
{
|
||||||
|
const string xml =
|
||||||
|
"<panel x=\"0\" y=\"0\" w=\"200\" h=\"100\">" +
|
||||||
|
"<list x=\"0\" y=\"0\" w=\"180\" h=\"60\" items=\"{Items}\" selected=\"{Selected}\"/>" +
|
||||||
|
"</panel>";
|
||||||
|
|
||||||
|
var panel = MarkupDocument.Build(xml, new ListIconBinding(), Sprite);
|
||||||
|
var list = Assert.IsType<UiMarkupList>(panel.Children[0]);
|
||||||
|
|
||||||
|
Assert.Null(list.IconIdsSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Draw-level: "draws nothing" / "shifts text" pinned against real quads ─
|
||||||
|
|
||||||
|
private sealed class NullGpuFrameSource : ICurrentGpuFrameSource
|
||||||
|
{
|
||||||
|
public IGpuFrame? CurrentFrame => null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (TextRenderer renderer, UiRenderContext ctx) MakeContext(float w, float h)
|
||||||
|
{
|
||||||
|
var device = new RecordingGpuDevice();
|
||||||
|
var renderer = new TextRenderer(device, new NullGpuFrameSource(), "unused");
|
||||||
|
renderer.Begin(new Vector2(w, h));
|
||||||
|
var ctx = new UiRenderContext(renderer, new Vector2(w, h));
|
||||||
|
return (renderer, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UiMarkupIcon_ResolvedTexture_DrawsASpriteAspectPreservedAndCentered()
|
||||||
|
{
|
||||||
|
// 32x16 source in a 32x32 box: scale = min(32/32, 32/16) = 1, so the
|
||||||
|
// drawn quad stays 32x16 (never stretched to fill the box) and is
|
||||||
|
// vertically centered: y = (32-16)/2 = 8.
|
||||||
|
var icon = new UiMarkupIcon
|
||||||
|
{
|
||||||
|
Width = 32f,
|
||||||
|
Height = 32f,
|
||||||
|
IconSource = () => (9u, 32, 16),
|
||||||
|
};
|
||||||
|
var (renderer, ctx) = MakeContext(200f, 200f);
|
||||||
|
|
||||||
|
icon.DrawSelfAndChildren(ctx);
|
||||||
|
|
||||||
|
var seg = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 9u);
|
||||||
|
// First vertex = top-left (x,y); AppendQuad's V0..V5 layout, 8 floats/vertex.
|
||||||
|
Assert.Equal(0f, seg.Verts[0], 3);
|
||||||
|
Assert.Equal(8f, seg.Verts[1], 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UiMarkupIcon_UnresolvedTexture_DrawsNothing()
|
||||||
|
{
|
||||||
|
var icon = new UiMarkupIcon
|
||||||
|
{
|
||||||
|
Width = 32f,
|
||||||
|
Height = 32f,
|
||||||
|
IconSource = () => (0u, 0, 0),
|
||||||
|
};
|
||||||
|
var (renderer, ctx) = MakeContext(200f, 200f);
|
||||||
|
|
||||||
|
icon.DrawSelfAndChildren(ctx);
|
||||||
|
|
||||||
|
Assert.Empty(renderer.DebugSpriteSegmentVerts);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UiSimpleButton_WithIcon_DrawsTheIconSprite_AndShiftsTheCaptionRight()
|
||||||
|
{
|
||||||
|
var glyphs = new Dictionary<char, FontCharDesc>
|
||||||
|
{
|
||||||
|
['G'] = new FontCharDesc { Unicode = 'G', Width = 8, Height = 8 },
|
||||||
|
};
|
||||||
|
var font = new UiDatFont(
|
||||||
|
fgTex: 1u, fgW: 32, fgH: 32,
|
||||||
|
bgTex: 0, bgW: 0, bgH: 0,
|
||||||
|
lineHeight: 16f, baselineOffset: 12f,
|
||||||
|
glyphs);
|
||||||
|
|
||||||
|
var withoutIcon = new UiSimpleButton
|
||||||
|
{
|
||||||
|
Width = 60f, Height = 20f, Text = "G", DatFont = font, Outline = false,
|
||||||
|
BackgroundColor = default, BorderColor = default,
|
||||||
|
};
|
||||||
|
var withIcon = new UiSimpleButton
|
||||||
|
{
|
||||||
|
Width = 60f, Height = 20f, Text = "G", DatFont = font, Outline = false,
|
||||||
|
IconSource = () => (9u, 32, 32),
|
||||||
|
BackgroundColor = default, BorderColor = default,
|
||||||
|
};
|
||||||
|
|
||||||
|
var (rendererWithout, ctxWithout) = MakeContext(200f, 200f);
|
||||||
|
withoutIcon.DrawSelfAndChildren(ctxWithout);
|
||||||
|
var (rendererWith, ctxWith) = MakeContext(200f, 200f);
|
||||||
|
withIcon.DrawSelfAndChildren(ctxWith);
|
||||||
|
|
||||||
|
// The icon sprite itself drew.
|
||||||
|
Assert.Contains(rendererWith.DebugSpriteSegmentVerts, s => s.Texture == 9u);
|
||||||
|
Assert.DoesNotContain(rendererWithout.DebugSpriteSegmentVerts, s => s.Texture == 9u);
|
||||||
|
|
||||||
|
// Both buttons draw exactly one glyph quad (font texture 1u); the
|
||||||
|
// icon-bearing button's caption starts strictly to the right of the
|
||||||
|
// icon-less button's, because its centering region was shifted by
|
||||||
|
// the reserved icon column.
|
||||||
|
var textWithout = Assert.Single(rendererWithout.DebugSpriteSegmentVerts, s => s.Texture == 1u);
|
||||||
|
var textWith = Assert.Single(rendererWith.DebugSpriteSegmentVerts, s => s.Texture == 1u);
|
||||||
|
Assert.True(
|
||||||
|
textWith.Verts[0] > textWithout.Verts[0],
|
||||||
|
$"expected the icon-bearing button's caption ({textWith.Verts[0]}) to start right of the icon-less caption ({textWithout.Verts[0]})");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void UiMarkupList_IconColumn_DrawsResolvedRowIcon_AndSkipsAMissingOne()
|
||||||
|
{
|
||||||
|
var list = new UiMarkupList
|
||||||
|
{
|
||||||
|
Width = 180f, Height = 60f, RowHeight = 18f,
|
||||||
|
ItemsSource = () => new[] { "First", "Second" },
|
||||||
|
IconIdsSource = () => new uint[] { 9u, 0u },
|
||||||
|
IconResolve = id => id == 0u ? (0u, 0, 0) : (id, 16, 16),
|
||||||
|
BackgroundColor = default, BorderColor = default,
|
||||||
|
};
|
||||||
|
var (renderer, ctx) = MakeContext(200f, 200f);
|
||||||
|
|
||||||
|
list.DrawSelfAndChildren(ctx);
|
||||||
|
|
||||||
|
// Row 0's icon (id 9u) drew; row 1 has no icon id (0u -> unresolvable)
|
||||||
|
// and therefore emits no sprite for that row's column.
|
||||||
|
Assert.Contains(renderer.DebugSpriteSegmentVerts, s => s.Texture == 9u);
|
||||||
|
var iconQuad = Assert.Single(renderer.DebugSpriteSegmentVerts, s => s.Texture == 9u);
|
||||||
|
// The icon column is RowHeight - 2 wide; the drawn quad's right edge
|
||||||
|
// (the second AppendQuad vertex's x, at float offset 8 — see
|
||||||
|
// UiRenderContextDrawStringDatOutlineTests.DecodeQuads for the same
|
||||||
|
// 8-floats/vertex, 6-vertices/quad layout) must sit entirely inside
|
||||||
|
// it, never overlapping where row text starts.
|
||||||
|
Assert.True(iconQuad.Verts[8] <= list.RowHeight - 2f + 0.01f);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue