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
|
|
@ -64,6 +64,8 @@ internal sealed class AppAutomationSurface
|
|||
private MagicCatalog _magicCatalog = MagicCatalog.Empty;
|
||||
private IReadOnlyDictionary<uint, string> _skillNames =
|
||||
new Dictionary<uint, string>();
|
||||
private IReadOnlyDictionary<uint, uint> _skillIcons =
|
||||
new Dictionary<uint, uint>();
|
||||
private Func<int, string> _speciesName = static _ => string.Empty;
|
||||
private IChargenPaletteColorSource? _paletteColors;
|
||||
private Func<uint, uint, bool>? _equip;
|
||||
|
|
@ -381,6 +383,18 @@ internal sealed class AppAutomationSurface
|
|||
_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>
|
||||
public void BindMagicCatalog(MagicCatalog catalog)
|
||||
{
|
||||
|
|
@ -766,6 +780,7 @@ internal sealed class AppAutomationSurface
|
|||
BaseRangeConstant = meta.BaseRangeConstant,
|
||||
BaseRangeModifier = meta.BaseRangeModifier,
|
||||
FormulaComponentIds = meta.FormulaComponents,
|
||||
IconId = meta.IconId,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -929,10 +944,12 @@ internal sealed class AppAutomationSurface
|
|||
{
|
||||
RuntimeCharacterState? character;
|
||||
IReadOnlyDictionary<uint, string> names;
|
||||
IReadOnlyDictionary<uint, uint> icons;
|
||||
lock (_gate)
|
||||
{
|
||||
character = _character;
|
||||
names = _skillNames;
|
||||
icons = _skillIcons;
|
||||
}
|
||||
if (character is null || names.Count == 0)
|
||||
return Array.Empty<PluginSkillInfo>();
|
||||
|
|
@ -940,7 +957,8 @@ internal sealed class AppAutomationSurface
|
|||
var built = new List<PluginSkillInfo>(names.Count);
|
||||
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.Sort(static (a, b) => string.CompareOrdinal(a.Name, b.Name));
|
||||
|
|
@ -952,22 +970,25 @@ internal sealed class AppAutomationSurface
|
|||
{
|
||||
RuntimeCharacterState? character;
|
||||
IReadOnlyDictionary<uint, string> names;
|
||||
IReadOnlyDictionary<uint, uint> icons;
|
||||
lock (_gate)
|
||||
{
|
||||
character = _character;
|
||||
names = _skillNames;
|
||||
icons = _skillIcons;
|
||||
}
|
||||
if (character is not null)
|
||||
{
|
||||
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;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryProjectSkill(
|
||||
RuntimeCharacterState character, uint skillId, string name,
|
||||
RuntimeCharacterState character, uint skillId, string name, uint iconId,
|
||||
out PluginSkillInfo skill)
|
||||
{
|
||||
if (!character.View.TryGetSkill(skillId, out var snapshot))
|
||||
|
|
@ -984,6 +1005,7 @@ internal sealed class AppAutomationSurface
|
|||
skillId, name, Training(snapshot.Status), currentLevel)
|
||||
{
|
||||
Base = baseLevel,
|
||||
IconId = iconId,
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
|
@ -1867,6 +1889,7 @@ internal sealed class AppAutomationSurface
|
|||
? item.AppraisedSpellIds.ToArray()
|
||||
: Array.Empty<uint>(),
|
||||
ActiveSpellIds = activeSpells,
|
||||
IconId = item?.IconId ?? 0u,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -2376,6 +2399,7 @@ internal sealed class AppAutomationSurface
|
|||
MaterialType = item.MaterialType ?? 0u,
|
||||
ObjectClass = ClassifyObject(item),
|
||||
Palettes = ProjectPalettes(runtime, item.ObjectId),
|
||||
IconId = item.IconId,
|
||||
});
|
||||
}
|
||||
built.Sort(static (left, right) =>
|
||||
|
|
|
|||
|
|
@ -1019,9 +1019,14 @@ public sealed class GameWindow :
|
|||
}
|
||||
|
||||
var names = new Dictionary<uint, string>(skillTable.Skills.Count);
|
||||
var icons = new Dictionary<uint, uint>(skillTable.Skills.Count);
|
||||
foreach (var entry in skillTable.Skills)
|
||||
{
|
||||
names[(uint)entry.Key] = entry.Value.Name;
|
||||
icons[(uint)entry.Key] = entry.Value.IconId;
|
||||
}
|
||||
_automation.BindSkillNames(names);
|
||||
_automation.BindSkillIcons(icons);
|
||||
}
|
||||
|
||||
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.Reflection;
|
||||
using System.Xml.Linq;
|
||||
using AcDream.Plugin.Abstractions;
|
||||
|
||||
namespace AcDream.App.UI;
|
||||
|
||||
|
|
@ -29,9 +30,19 @@ public static class MarkupDocument
|
|||
/// their text through the same glyph path as authored panels; without it
|
||||
/// they fall back to the development bitmap font and look foreign.
|
||||
/// </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(
|
||||
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");
|
||||
if (root.Name.LocalName != "panel")
|
||||
|
|
@ -80,7 +91,7 @@ public static class MarkupDocument
|
|||
}
|
||||
|
||||
foreach (var el in root.Elements())
|
||||
AddElement(panel, el, binding, resolve, datFont);
|
||||
AddElement(panel, el, binding, resolve, datFont, icons);
|
||||
return panel;
|
||||
}
|
||||
|
||||
|
|
@ -89,7 +100,8 @@ public static class MarkupDocument
|
|||
XElement el,
|
||||
object binding,
|
||||
Func<uint, (uint, int, int)> resolve,
|
||||
UiDatFont? datFont)
|
||||
UiDatFont? datFont,
|
||||
IMarkupIconResolver? icons)
|
||||
{
|
||||
switch (el.Name.LocalName)
|
||||
{
|
||||
|
|
@ -114,7 +126,7 @@ public static class MarkupDocument
|
|||
ApplyCommon(group, el, binding);
|
||||
parent.AddChild(group);
|
||||
foreach (XElement child in el.Elements())
|
||||
AddElement(group, child, binding, resolve, datFont);
|
||||
AddElement(group, child, binding, resolve, datFont, icons);
|
||||
break;
|
||||
|
||||
case "meter":
|
||||
|
|
@ -200,12 +212,60 @@ public static class MarkupDocument
|
|||
if (el.Attribute("border") is not null)
|
||||
button.BorderColor = Color(
|
||||
(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);
|
||||
if (onClick is not null)
|
||||
button.Click += onClick;
|
||||
parent.AddChild(button);
|
||||
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":
|
||||
string? tabClickName = (string?)el.Attribute("onclick");
|
||||
Action? tabClick = BindAction(tabClickName, binding);
|
||||
|
|
@ -427,12 +487,109 @@ public static class MarkupDocument
|
|||
"list selected"),
|
||||
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);
|
||||
parent.AddChild(list);
|
||||
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>
|
||||
/// Resolves <c>{PropName}</c> to a live string reader, or returns the
|
||||
/// literal text unchanged. The indirection matters: binding to a
|
||||
|
|
|
|||
|
|
@ -756,7 +756,12 @@ public sealed class PluginSidePanel : UiPanel, IDisposable, IRetainedWindowState
|
|||
{
|
||||
_handle = handle;
|
||||
_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,
|
||||
StringComparison.Ordinal)
|
||||
? descriptor.Title
|
||||
|
|
|
|||
|
|
@ -4712,6 +4712,18 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
private void MountPlugins()
|
||||
{
|
||||
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())
|
||||
{
|
||||
try
|
||||
|
|
@ -4723,7 +4735,8 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
panel.Binding,
|
||||
_bindings.Assets.ResolveSprite,
|
||||
_bindings.Assets.Controls,
|
||||
_bindings.Assets.DefaultFont);
|
||||
_bindings.Assets.DefaultFont,
|
||||
iconResolver);
|
||||
|
||||
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>();
|
||||
public Func<IReadOnlyList<uint>> ItemColorsSource { get; set; } =
|
||||
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 Action<int>? SelectionChanged { get; set; }
|
||||
public UiDatFont? DatFont { get; set; }
|
||||
|
|
@ -32,6 +48,13 @@ public sealed class UiMarkupList : UiElement
|
|||
{
|
||||
IReadOnlyList<string> items = ItemsSource();
|
||||
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 selected = SelectedIndexSource();
|
||||
if (selected >= 0 && selected < items.Count)
|
||||
|
|
@ -51,16 +74,40 @@ public sealed class UiMarkupList : UiElement
|
|||
float y = (index - _topRow) * RowHeight;
|
||||
if (index == selected)
|
||||
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];
|
||||
Vector4 textColor = index < itemColors.Count
|
||||
? Rgb(itemColors[index])
|
||||
: TextColor;
|
||||
float textX = Padding + iconColumn;
|
||||
float textY = y + MathF.Max(0f,
|
||||
(RowHeight - (DatFont?.LineHeight ?? 14f)) * 0.5f);
|
||||
if (DatFont is { } font)
|
||||
context.DrawStringDat(font, text, Padding, textY, textColor, true);
|
||||
context.DrawStringDat(font, text, textX, textY, textColor, true);
|
||||
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>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -208,22 +221,50 @@ public class UiSimpleButton : UiPanel
|
|||
protected override void OnDraw(UiRenderContext 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;
|
||||
if (caption.Length == 0) return;
|
||||
|
||||
float captionAreaX = iconColumn;
|
||||
float captionAreaWidth = MathF.Max(0f, Width - iconColumn);
|
||||
if (DatFont is { } dat)
|
||||
{
|
||||
float datW = dat.MeasureWidth(caption);
|
||||
ctx.DrawStringDat(
|
||||
dat, caption,
|
||||
(Width - datW) * 0.5f, (Height - dat.LineHeight) * 0.5f,
|
||||
captionAreaX + (captionAreaWidth - datW) * 0.5f,
|
||||
(Height - dat.LineHeight) * 0.5f,
|
||||
TextColor, Outline);
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.DefaultFont is null) return;
|
||||
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;
|
||||
ctx.DrawString(caption, tx, ty, TextColor);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,17 @@ public readonly record struct PluginSpellInfo(
|
|||
/// </summary>
|
||||
public int? QualityOverride { get; init; }
|
||||
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>
|
||||
|
|
@ -115,6 +126,19 @@ public readonly record struct PluginSkillInfo(
|
|||
{
|
||||
/// <summary>Unenchanted retail skill level before vitae and spell mods.</summary>
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -91,6 +91,13 @@ public readonly record struct PluginInventoryItem(
|
|||
public PluginObjectClass ObjectClass { get; init; }
|
||||
public IReadOnlyList<PluginPaletteInfo> Palettes { get; init; } =
|
||||
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>
|
||||
|
|
|
|||
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 IReadOnlyList<uint> SpellIds { 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>
|
||||
|
|
|
|||
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 int _entitiesSeen;
|
||||
private IDisposable? _iconPanel;
|
||||
|
||||
public void Initialize(IPluginHost host)
|
||||
{
|
||||
|
|
@ -20,6 +21,14 @@ public sealed class SmokePlugin : IAcDreamPlugin
|
|||
{
|
||||
_host.Events.EntitySpawned += OnEntitySpawned;
|
||||
_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)
|
||||
_host.Events.EntitySpawned -= OnEntitySpawned;
|
||||
_iconPanel?.Dispose();
|
||||
_iconPanel = null;
|
||||
_host?.Log.Info($"smoke plugin disabled (saw {_entitiesSeen} entities total)");
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue