diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md
index 18a42dc9..46cb90d8 100644
--- a/docs/plans/2026-04-11-roadmap.md
+++ b/docs/plans/2026-04-11-roadmap.md
@@ -531,7 +531,7 @@ behavior. Estimated 17–26 days focused work, 3–5 weeks calendar.
- **Missile/portal VFX campaign Steps 0–5 implemented and independently reviewed 2026-07-14.** The retail oracle and packet fixtures are pinned, complete `PhysicsDesc` plus F754/F755 parsing and nine-channel gates are shipped, and App now has one canonical `LiveEntityRuntime` record per server-object incarnation. Logical register/unregister is separate from spatial rebucketing, so landblock churn and attached equipment retain identity without replaying renderer/script creation or reconstructing from stale spawn data. Canonical materialized and visible target/radar views are distinct; pickup/parent leave-world preserves owners while pausing root simulation; spawn publication is once per incarnation. `GpuWorldState` is spatial-only for live objects. Production PhysicsScript and Animation loading shares a narrow `DatCollection`-backed compatibility reader for retail's inherited blocking-particle payload, including mesh-side preloading; the proven concurrent-safe DatReaderWriter 2.1.7 read path avoids blocking update-thread effects behind streaming. Typed-table selection preserves DAT order and retail's first `intensity <= Mod` boundary, and live `PhysicsDesc` effect defaults replace rather than fall back to Setup. The Step 4 scheduler is now one serial FIFO per owner with duplicate stacking, catch-up dispatch, deterministic delayed `CallPES`, complete hook-router fan-out, update-frame clock publication, retail cell/Frozen eligibility, and structural rejection of malformed zero-time recursive DAT chains without rejecting valid timed weather loops. `EntityEffectController` retains pre-create F754/F755 in one mixed per-GUID FIFO, resolves local identity only through `LiveEntityRuntime`, replays once only after the canonical owner is fully ready and in-world, drops later plays while that existing owner is cell-less, routes attached-child updates through the eligible parent, and replaces/clears the live SoundTable on every PhysicsDesc application. Step 5 publishes current rigid root/indexed-part poses after animation and recursively composed held-child transforms, keeping render-only Setup scale out of particle and holding-location anchors; it then drains animation hooks, owner PhysicsScripts, moving emitters/lights, and particle simulation in fixed order. Normal/blocking/anonymous logical emitter identity is exact, including Stop retaining a blocking ID until final-particle retirement; moving and held Setup lights follow their current roots and share retail's PhysicsState Lighting/SetLight transition state; world-released versus parent-local particle behavior is preserved; missing emitter DAT produces an actionable diagnostic and no invented fallback. Drawable meshes no longer collapse stable Setup-part indices; nested attachments update parent-before-child, recursively withdraw on ancestor pose loss, and recover the complete descendant chain only on real publication edges. Loaded↔pending projection edges skip particle update/draw while retaining original absolute creation times and logical identity, and withdraw light presentation; re-entry evaluates elapsed state once without backlog emission or recreating an effect owner. Scripts, particles, lights, translucency, audio, and teardown all share canonical `WorldEntity.Id`; static allocators fail fast before namespace wrap. IA-7, AD-14, TS-10, TS-11, TS-12, TS-13, and AP-67 are retired; AD-32 now covers only the remaining non-effect, non-Parent pre-create packet families, and AD-43 registers the corrupt-DAT zero-time-cycle safety boundary.
- **✓ M2 local attack receive funnel (live-gated 2026-07-15).** Retail `ExecuteAttack` only sends the request; ACE chooses the concrete melee/missile action and returns it in a non-autonomous mt-0 `UpdateMotion`. The local branch now runs that state through the same constructor-defaulted `MoveToInterpretedState` funnel and 15-bit action-stamp gate as remotes, then applies sticky/long-jump tails. The old local-only direct `Commands[]` replay is deleted. Shared conversion lives in `InboundInterpretedMotionFactory`; research: `docs/research/2026-07-11-local-combat-motion-pseudocode.md`.
- **✓ M2 basic retail combat bar (live-gated 2026-07-15).** Production mounts authored `gmCombatUI` LayoutDesc `0x21000073`, shows it only for Melee/Missile, and routes mouse plus keyboard through one `CombatAttackController`. Corrections include the authored wider centered dark-red child range (accepted IA-20), full-width left-to-right bright live attack-charge feedback, exact Speed-left/Power-right justification, silent control-only `AttackDone(ActionCancelled)`, target-frame Keep in View with manual orbit, and persistent corpse motion: the AP-80 velocity-only NPC adaptation can now replace only Ready/Walk/Run, never authoritative Dead/actions. `CombatTargetController` ports the selection-cleared AutoTarget consumer, so a selected creature's authoritative Dead motion clears it and selects the nearest eligible creature when enabled. The 2026-07-12 replacement-corpse correction unifies both multi-frame and static/reactive spawns behind retail's CreateObject lifecycle: apply the wire's Dead state while detached, then `MotionTableManager::HandleEnterWorld` strips Ready→Dead links before the first in-world tick; multiple corpses remaining fallen passed the live user gate that day. Follow-up #205 makes the toolbar read the final canonical selection after a reentrant Auto Target notice and restricts automatic candidates to hostile non-player monsters (intentional PK-edge divergence IA-19); that live gate also passed 2026-07-12. Research: `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`, `docs/research/2026-07-11-combat-target-camera-pseudocode.md`, `docs/research/2026-07-12-death-and-auto-target-pseudocode.md`; AP-24/AP-95 retired, AP-80 narrowed, AP-110 narrowed, AP-112 records the remaining attack-start and exact trained-Recklessness seams.
-- **M3 cast lifecycle + retained magic UI implemented 2026-07-15; visual gates pending.** `SpellCastingController` ports retail's immediate targeted/untargeted request boundary, component preflight, target eligibility, movement stop, and shared UI-busy lifetime while leaving turn/cast/fizzle/impact outcomes authoritative to ACE. Formula preflight reads decrypted portal.dat formulas, the SCID↔WCID map, canonical account randomization, exact school-focus map, infused-magic properties, and scarab-only substitution. Complete live enchantment packets preserve spell/layer identity, all StatMod fields, client-clock normalization, and exact purge buckets. Authored retained surfaces now include the Magic page of `gmCombatUI`, all eight favorite tabs, equipped caster endowment, Magic-scoped retail keys, `gmSpellbookUI`, `gmSpellComponentUI`, both `gmEffectsUI` windows, and the floating Helpful/Harmful effect indicators. `RetailPanelUiController` ports the shared one-active-panel lifecycle so toolbar and indicator launchers converge on one owner. Exact DAT icons and server-persisted filters/desired components are retained. #L.2/#L.3 close and #L.4/AP-110 narrow. Research: `docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md`.
+- **M3 cast lifecycle + retained magic UI implemented 2026-07-15; visual gates pending.** `SpellCastingController` ports retail's immediate targeted/untargeted request boundary, component preflight, target eligibility, movement stop, and shared UI-busy lifetime while leaving turn/cast/fizzle/impact outcomes authoritative to ACE. Formula preflight reads decrypted portal.dat formulas, the SCID↔WCID map, canonical account randomization, exact school-focus map, infused-magic properties, and scarab-only substitution. Complete live enchantment packets preserve spell/layer identity, all StatMod fields, client-clock normalization, and exact purge buckets. Authored retained surfaces now include the Magic page of `gmCombatUI`, all eight favorite tabs, equipped caster endowment, Magic-scoped retail keys, `gmSpellbookUI`, `gmSpellComponentUI`, both `gmEffectsUI` windows, and the floating Helpful/Harmful effect indicators. The spellbook now resolves retail UIItem prototype `0x10000343` for its one-column icon/name/separator rows and selected overlay, binds the authored scrollbar, ports exact filters/display ordering, drags learned spells into the open favorite bar without mutating their source, and reuses `RetailDialogFactory` for server-authoritative spell deletion (`CM_Magic::Event_RemoveSpell`, `0x01A8`). `RetailPanelUiController` ports the shared one-active-panel lifecycle so toolbar and indicator launchers converge on one owner. Exact DAT icons and server-persisted filters/desired components are retained. #L.2/#L.3 close and #L.4/AP-110 narrow. Research: `docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md`.
- **Retail client command families implemented 2026-07-13; corrective live gate pending.** One shared typed catalog now separates retail client actions from ACE administrator commands for both chat backends. Named-decomp ports cover recall/house/PK travel; age/birth; framerate, lock, version, location, corpse, and die confirmation; clear plus named/automatic UI layouts; AFK/consent; emotes; friends; squelch/filter/message types; and fill-components. App owns execution, Core owns authoritative friends/squelch state, Core.Net owns exact UIQueue/ControlQueue packets. Confirmation reuse now ports retail `DialogFactory` contexts, queue groups/priority, fresh DAT roots, property results, callbacks/close notices, and server abort handling; `/die`, gameplay request tuples, and guarded item-use prompts share its type-1 LayoutDesc `0x2100003C` presenter. The first live gate passed the family except for raw suicide-success code `0x004A` and the title-bar-only FPS presentation; the correction maps retail's text and mounts SmartBox element `0x10000047` with live two-decimal `FPS`/`DEG`. #L.6 is closed; TS-31/TS-47 are narrowed. Research: `docs/research/2026-07-13-retail-client-command-families-pseudocode.md`, `docs/research/2026-07-13-retail-dialog-factory-pseudocode.md`.
- **✓ SHIPPED — Character window** (`LayoutDesc 0x2100002E`, `CharacterStatController`, 2026-06-26, same branch). **Visually user-confirmed 2026-06-26 — Attributes tab reads as retail.** Three tabs, header (name/heritage/PK), large-gold level number (dat font, `largeDatFont` 18px), "Total Experience (XP):" + "XP for next level:" captions, 9-row attribute list (icons + right-aligned values + Health/Stamina/Mana vitals), click-to-select (top/bottom selection bars + footer State-B "{Attr}: {value}" / "Experience To Raise: Infinity!" + affordability-gated raise triangles), centered footer. User noted "still needs some polish for later" — deferred to Issue #158.
- **✓ CORRECTIVE PORT — live character experience qualities** (2026-07-13, #217; connected gate passed). Added retail `PrivateUpdatePropertyInt64 (0x02CF)` so Total Experience, the level-progress meter, and Skills' Unassigned Experience refresh from the same authoritative server update; Total XP value alignment now matches the authored right-justified layout.
diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md
index 3ce1cddf..34389ab9 100644
--- a/docs/plans/2026-05-12-milestones.md
+++ b/docs/plans/2026-05-12-milestones.md
@@ -6,8 +6,9 @@
LANDED 2026-07-15.** The complete connected melee/missile, death/loot,
inventory, and item-giving demo is user-gated. The shared projectile/effect
foundation is hardened through Step 9 and its single-client visual gate passes.
-Next is the F.4/L.1d retail cast-lifecycle gap audit, then F.5 spellbook and
-active enchantments. Carried: the two-client portal/observer VFX gate,
+Next is the connected F.5 spellbook/favorite-bar/delete visual gate, followed
+by cast/enchantment presentation and the two-client portal/observer VFX gate.
+Carried:
#145-residual, #116 slide-response, R6/TS-42, and Track MP0.
---
@@ -649,6 +650,12 @@ The first connected gate exposed and corrected two retained-import defects:
spellbook tabs are authored stateful text controls (not buttons), and retail
text starts with zero margins. Real-DAT fixtures now pin spellbook binding,
Closed/Open child-state propagation, and Magic-tab strings I through VIII.
+The follow-up spellbook slice now clones the DAT-authored one-column
+spell-shortcut presentation (icon, name, separator, selected overlay), binds
+its authored scrollbar, ports exact school/level filter masks and stable
+display-order sorting, carries learned spells into the open favorite bar, and
+routes Delete through the shared retail confirmation factory plus
+server-authoritative `CM_Magic::Event_RemoveSpell (0x01A8)` response path.
**Phases to ship:**
- **F.4** — Spell cast state machine (buffs + recalls first, projectile
diff --git a/docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md b/docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md
index 86cb58e3..276aec96 100644
--- a/docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md
+++ b/docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md
@@ -240,6 +240,105 @@ sorts by `CSpellBase::_display_order`; school and level buttons apply the
server-persisted filter bitfield. Component rows use the installed component
table and the server-persisted desired purchase amounts.
+### Spellbook row, selection, filtering, dragging, and deletion
+
+Named-retail anchors:
+
+- `gmSpellbookUI::IsFilteredOut @ 0x0048AEB0`
+- `gmSpellbookUI::GetSortedInsertionPlace @ 0x0048B440`
+- `gmSpellbookUI::SetSelected @ 0x0048B540`
+- `gmSpellbookUI::UpdateFilter @ 0x0048B5E0`
+- `gmSpellbookUI::AddSpell @ 0x0048B7A0`
+- `gmSpellbookUI::DeleteSpellDialogCallback @ 0x0048BC10`
+- `gmSpellbookUI::DeleteSpell @ 0x0048BD50`
+- `gmSpellbookUI::ListenToElementMessage @ 0x0048BFD0`
+- `UIElement_UIItem::Init_UIItem_Spell_Shortcut @ 0x004E1140`
+- `UIElement_ItemList::ItemList_AddSpellShortcut @ 0x004E40A0`
+- `UIElement_ItemList::ItemList_InsertSpellShortcut @ 0x004E41B0`
+- `CM_Magic::Event_RemoveSpell @ 0x006A3220`
+
+DAT structure:
+
+```text
+spell list 0x10000295 (280 x 224)
+spell scrollbar 0x10000296 (16 x 224)
+spell-row prototype property 0x1000000E -> 0x10000343
+shared UIItem catalog 0x21000037
+row prototype 0x10000343 (280 x 32)
+row background 0x06001396
+selected overlay child 0x10000342 -> 0x06001397
+label child 0x10000344 (x=42, width=230)
+icon child 0x1000033B (x=0, 32 x 32)
+delete button 0x100002A5
+```
+
+The row's authored background contains the narrow horizontal separator visible
+between entries. It is not an invented procedural line. The selection surface
+is a separate overlay, so selecting a spell does not replace its icon or label.
+
+```text
+AddSpell(spellID):
+ if spell metadata is missing: return
+ if IsFilteredOut(spell): return
+ insertion = first existing row whose displayOrder > spell.displayOrder
+ list.InsertSpellShortcut(spellID, insertion)
+
+SetSelected(spellID):
+ for each spell shortcut row:
+ row.selectedOverlay = (row.spellID == spellID)
+ if selected: list.ScrollItemIntoView(row)
+ selectedSpellID = spellID
+
+ListenToElementMessage(message):
+ if message came from a spell shortcut row:
+ if message kind is click or double-click: SetSelected(row.spellID)
+ if message kind is AddSpellShortcut/drag notice:
+ CM_Magic.SendNotice_AddSpellShortcut(row.spellID)
+ if message is click on a school/level filter:
+ UpdateFilter(clickedFilter)
+ if message is click on 0x100002A5:
+ DeleteSpell()
+
+UpdateFilter(button):
+ mask = exact school/level bit assigned to button
+ if button is currently Highlight: filters |= mask
+ else: filters &= ~mask
+ if filters changed:
+ CM_Character.Event_SpellbookFilterEvent(filters)
+ rebuild list from authoritative learned spells
+ scroll list to first row
+
+IsFilteredOut(spell):
+ school bits: Creature=0x0001, Item=0x0002, Life=0x0004,
+ War=0x0008, Void=0x2000
+ level bits: I=0x0010 through VIII=0x0800
+ return school bit is disabled OR level bit is disabled
+
+DeleteSpell():
+ if selectedSpellID == 0: return false
+ name = ClientMagicSystem.GetSpellName(selectedSpellID)
+ message = "Are you sure you want to remove {name} from your spellbook? "
+ "You will no longer be able to cast this spell unless you learn it again!"
+ dialog properties include selectedSpellID under 0x1000003F
+ DialogFactory.MakeCallbackDialogInCurrentUI(message, callback)
+
+DeleteSpellDialogCallback(dialogProperties):
+ if confirmation result property 0x92 is true:
+ spellID = property 0x1000003F
+ CM_Magic.Event_RemoveSpell(spellID)
+
+CM_Magic.Event_RemoveSpell(spellID):
+ send GameAction opcode 0x01A8 followed by spellID
+
+RecvNotice_SpellRemoved(spellID):
+ rebuild from the authoritative player description
+```
+
+The client does not optimistically erase the row when Yes is clicked. It waits
+for the server's `MagicRemoveSpell` notice. Dragging a row similarly carries a
+spell-shortcut identity into the open spellcasting favorite list; it never
+removes the spell from the learned-spell set.
+
## Active enchantment panels
Retail: `gmEffectsUI::PostInit` `0x004B7560` and
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 13d6dfa1..eec3235c 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -2272,6 +2272,7 @@ public sealed class GameWindow : IDisposable
(tab, spellId) =>
_liveSession?.SendRemoveSpellFavorite(spellId, tab),
filters => _liveSession?.SendSpellbookFilter(filters),
+ spellId => _liveSession?.SendRemoveSpell(spellId),
(componentId, amount) =>
_liveSession?.SendSetDesiredComponentLevel(componentId, amount),
ClientTimerNow),
diff --git a/src/AcDream.App/UI/Layout/SpellbookRowStyle.cs b/src/AcDream.App/UI/Layout/SpellbookRowStyle.cs
new file mode 100644
index 00000000..7f81c516
--- /dev/null
+++ b/src/AcDream.App/UI/Layout/SpellbookRowStyle.cs
@@ -0,0 +1,72 @@
+using System.Numerics;
+using DatReaderWriter;
+
+namespace AcDream.App.UI.Layout;
+
+///
+/// Resolved presentation of retail's spell-shortcut row prototype. The spellbook
+/// list points at this prototype through ItemList attribute 0x1000000E;
+/// retail clones it for every learned spell.
+///
+public readonly record struct SpellbookRowStyle(
+ float Width,
+ float Height,
+ float IconLeft,
+ float IconTop,
+ float IconWidth,
+ float IconHeight,
+ float LabelLeft,
+ float LabelWidth,
+ uint FontDid,
+ Vector4 LabelColor,
+ uint BackgroundSprite,
+ uint SelectedSprite)
+{
+ public const uint CatalogLayoutId = 0x21000037u;
+ public const uint PrototypeId = 0x10000343u;
+ public const uint SelectedOverlayId = 0x10000342u;
+ public const uint LabelId = 0x10000344u;
+ public const uint IconId = 0x1000033Bu;
+
+ ///
+ /// Resolve the complete row geometry and media from the installed retail DAT.
+ /// Returns null if the authored prototype is incomplete; callers then leave the
+ /// panel unbound rather than inventing replacement art.
+ ///
+ public static SpellbookRowStyle? TryLoad(DatCollection dats)
+ {
+ ArgumentNullException.ThrowIfNull(dats);
+ ElementInfo? prototype = LayoutImporter.ImportInfos(dats, CatalogLayoutId, PrototypeId);
+ if (prototype is null
+ || Find(prototype, SelectedOverlayId) is not { } selected
+ || Find(prototype, LabelId) is not { } label
+ || Find(prototype, IconId) is not { } icon
+ || !prototype.StateMedia.TryGetValue("", out var background)
+ || !selected.StateMedia.TryGetValue("", out var selection)
+ || prototype.Width <= 0f
+ || prototype.Height <= 0f)
+ return null;
+
+ return new SpellbookRowStyle(
+ prototype.Width,
+ prototype.Height,
+ icon.X,
+ icon.Y,
+ icon.Width,
+ icon.Height,
+ label.X,
+ label.Width,
+ label.FontDid,
+ label.FontColor ?? Vector4.One,
+ background.File,
+ selection.File);
+ }
+
+ private static ElementInfo? Find(ElementInfo root, uint id)
+ {
+ if (root.Id == id) return root;
+ foreach (ElementInfo child in root.Children)
+ if (Find(child, id) is { } found) return found;
+ return null;
+ }
+}
diff --git a/src/AcDream.App/UI/Layout/SpellbookWindowController.cs b/src/AcDream.App/UI/Layout/SpellbookWindowController.cs
index a6282ba2..a7ee7a8e 100644
--- a/src/AcDream.App/UI/Layout/SpellbookWindowController.cs
+++ b/src/AcDream.App/UI/Layout/SpellbookWindowController.cs
@@ -28,6 +28,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController
public const uint SpellPageId = 0x100002ACu;
public const uint ComponentPageId = 0x100002ADu;
public const uint SpellListId = 0x10000295u;
+ public const uint SpellScrollbarId = 0x10000296u;
+ public const uint DeleteButtonId = 0x100002A5u;
public const uint ComponentListId = 0x10000464u;
public const uint ComponentScrollbarId = 0x10000465u;
@@ -52,6 +54,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController
private readonly Action _selectObject;
private readonly Action _addFavorite;
private readonly Action _sendFilter;
+ private readonly Action _removeSpell;
+ private readonly Action> _showConfirmation;
private readonly Action _setDesiredComponent;
private readonly Action _close;
private readonly UiElement _spellPage;
@@ -59,9 +63,12 @@ public sealed class SpellbookWindowController : IRetainedPanelController
private readonly UiElement _spellTab;
private readonly UiElement _componentTab;
private readonly UiButton _closeButton;
+ private readonly UiButton _deleteButton;
private readonly UiItemList _spellList;
private readonly UiItemList _componentList;
private readonly List<(UiButton Button, uint Mask)> _filters = new();
+ private readonly SpellbookRowStyle _rowStyle;
+ private readonly UiDatFont? _rowFont;
private uint? _selectedSpell;
private bool _spellsDirty;
private bool _componentsDirty;
@@ -80,6 +87,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController
Action selectObject,
Action addFavorite,
Action sendFilter,
+ Action removeSpell,
+ Action> showConfirmation,
Action setDesiredComponent,
Action close,
UiElement spellPage,
@@ -87,8 +96,11 @@ public sealed class SpellbookWindowController : IRetainedPanelController
UiElement spellTab,
UiElement componentTab,
UiButton closeButton,
+ UiButton deleteButton,
UiItemList spellList,
- UiItemList componentList)
+ UiItemList componentList,
+ SpellbookRowStyle rowStyle,
+ UiDatFont? rowFont)
{
_spellbook = spellbook;
_objects = objects;
@@ -100,6 +112,8 @@ public sealed class SpellbookWindowController : IRetainedPanelController
_selectObject = selectObject;
_addFavorite = addFavorite;
_sendFilter = sendFilter;
+ _removeSpell = removeSpell;
+ _showConfirmation = showConfirmation;
_setDesiredComponent = setDesiredComponent;
_close = close;
_spellPage = spellPage;
@@ -107,12 +121,16 @@ public sealed class SpellbookWindowController : IRetainedPanelController
_spellTab = spellTab;
_componentTab = componentTab;
_closeButton = closeButton;
+ _deleteButton = deleteButton;
_spellList = spellList;
_componentList = componentList;
+ _rowStyle = rowStyle;
+ _rowFont = rowFont;
SetClick(spellTab, () => ShowPage(SpellbookWindowPage.Spells));
SetClick(componentTab, () => ShowPage(SpellbookWindowPage.Components));
closeButton.OnClick = close;
+ deleteButton.OnClick = RequestDeleteSelected;
foreach ((uint id, uint mask) in FilterButtons)
{
if (layout.FindElement(id) is not UiButton button) continue;
@@ -121,7 +139,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController
_filters.Add((button, mask));
}
- ConfigureSpellList();
+ ConfigureSpellList(layout);
ConfigureComponentList(layout);
_spellbook.SpellbookChanged += OnSpellbookChanged;
_spellbook.DesiredComponentsChanged += OnDesiredComponentsChanged;
@@ -148,14 +166,19 @@ public sealed class SpellbookWindowController : IRetainedPanelController
Action selectObject,
Action addFavorite,
Action sendFilter,
+ Action removeSpell,
+ Action> showConfirmation,
Action setDesiredComponent,
- Action close)
+ Action close,
+ SpellbookRowStyle rowStyle,
+ UiDatFont? rowFont)
{
if (layout.FindElement(SpellPageId) is not { } spellPage
|| layout.FindElement(ComponentPageId) is not { } componentPage
|| layout.FindElement(SpellTabId) is not { } spellTab
|| layout.FindElement(ComponentTabId) is not { } componentTab
|| layout.FindElement(CloseId) is not UiButton closeButton
+ || layout.FindElement(DeleteButtonId) is not UiButton deleteButton
|| layout.FindElement(SpellListId) is not UiItemList spellList)
return null;
@@ -179,9 +202,10 @@ public sealed class SpellbookWindowController : IRetainedPanelController
layout, spellbook, objects, playerGuid, components,
resolveSpellIcon, resolveComponentIcon,
spellLevel, selectObject,
- addFavorite, sendFilter, setDesiredComponent, close,
+ addFavorite, sendFilter, removeSpell, showConfirmation,
+ setDesiredComponent, close,
spellPage, componentPage, spellTab, componentTab, closeButton,
- spellList, componentList);
+ deleteButton, spellList, componentList, rowStyle, rowFont);
}
public void ShowPage(SpellbookWindowPage page)
@@ -193,11 +217,13 @@ public sealed class SpellbookWindowController : IRetainedPanelController
SetTabOpen(_componentTab, page == SpellbookWindowPage.Components);
}
- private void ConfigureSpellList()
+ private void ConfigureSpellList(ImportedLayout layout)
{
- _spellList.Columns = 6;
- _spellList.CellWidth = 32f;
- _spellList.CellHeight = 32f;
+ _spellList.Columns = 1;
+ _spellList.CellWidth = Math.Max(1f, _spellList.Width);
+ _spellList.CellHeight = _rowStyle.Height;
+ if (layout.FindElement(SpellScrollbarId) is UiScrollbar scrollbar)
+ scrollbar.Model = _spellList.Scroll;
}
private void ConfigureComponentList(ImportedLayout layout)
@@ -211,9 +237,12 @@ public sealed class SpellbookWindowController : IRetainedPanelController
private void ToggleFilter(uint mask)
{
+ // gmSpellbookUI::UpdateFilter @ 0x0048B5E0 persists only the changed
+ // filter bitfield, rebuilds from learned spells, and returns to row zero.
uint filters = _spellbook.SpellbookFilters ^ mask;
_spellbook.SetSpellbookFilters(filters);
_sendFilter(filters);
+ _spellList.Scroll.SetScrollY(0);
}
private void RebuildAll()
@@ -230,11 +259,12 @@ public sealed class SpellbookWindowController : IRetainedPanelController
foreach ((UiButton button, uint mask) in _filters)
button.Selected = (effective & mask) != 0;
+ // gmSpellbookUI::GetSortedInsertionPlace @ 0x0048B440 inserts before
+ // the first GREATER display order. Stable OrderBy preserves ties.
SpellMetadata[] spells = _spellbook.LearnedSpells
.Select(id => _spellbook.TryGetMetadata(id, out SpellMetadata metadata) ? metadata : null)
.Where(metadata => metadata is not null && IsVisible(metadata, effective))
.OrderBy(metadata => metadata!.SortKey)
- .ThenBy(metadata => metadata!.Name, StringComparer.OrdinalIgnoreCase)
.Cast()
.ToArray();
@@ -249,7 +279,21 @@ public sealed class SpellbookWindowController : IRetainedPanelController
EntryId = spellId,
CatalogIconTexture = _resolveSpellIcon(spellId),
Label = metadata.Name,
+ ShowLabel = true,
+ BackgroundSprite = _rowStyle.BackgroundSprite,
+ SelectedSprite = _rowStyle.SelectedSprite,
+ SelectionBehindContent = true,
+ LabelFont = _rowFont,
+ LabelColor = _rowStyle.LabelColor,
+ IconLeft = _rowStyle.IconLeft,
+ IconTop = _rowStyle.IconTop,
+ IconWidth = _rowStyle.IconWidth,
+ IconHeight = _rowStyle.IconHeight,
+ LabelLeft = _rowStyle.LabelLeft,
+ LabelWidth = _rowStyle.LabelWidth,
SpriteResolve = _spellList.SpriteResolve,
+ CatalogDragPayload = new SpellbookShortcutDragPayload(spellId),
+ DragBegan = _ => SelectSpell(spellId),
};
slot.Clicked = () => SelectSpell(spellId);
// gmSpellbookUI double-click publishes AddSpellShortcut; the open
@@ -399,8 +443,33 @@ public sealed class SpellbookWindowController : IRetainedPanelController
private void SelectSpell(uint spellId)
{
+ // gmSpellbookUI::SetSelected @ 0x0048B540 toggles the separate overlay
+ // for every row and asks the listbox to expose the matching row.
_selectedSpell = spellId;
SyncSpellSelection();
+ for (int i = 0; i < _spellList.GetNumUIItems(); i++)
+ if (_spellList.GetItem(i) is UiCatalogSlot slot && slot.EntryId == spellId)
+ {
+ _spellList.ScrollItemIntoView(i);
+ break;
+ }
+ }
+
+ private void RequestDeleteSelected()
+ {
+ // gmSpellbookUI::DeleteSpell @ 0x0048BD50 stores the spell id in the
+ // shared callback dialog; DeleteSpellDialogCallback @ 0x0048BC10 sends
+ // only after an affirmative result. The inbound remove notice owns UI state.
+ if (_selectedSpell is not uint spellId
+ || !_spellbook.TryGetMetadata(spellId, out SpellMetadata metadata))
+ return;
+
+ string message = $"Are you sure you want to remove {metadata.Name} from your spellbook? "
+ + "You will no longer be able to cast this spell unless you learn it again!";
+ _showConfirmation(message, accepted =>
+ {
+ if (accepted) _removeSpell(spellId);
+ });
}
private void SyncSpellSelection()
@@ -454,6 +523,7 @@ public sealed class SpellbookWindowController : IRetainedPanelController
SetClick(_spellTab, null);
SetClick(_componentTab, null);
_closeButton.OnClick = null;
+ _deleteButton.OnClick = null;
foreach ((UiButton button, _) in _filters) button.OnClick = null;
}
diff --git a/src/AcDream.App/UI/Layout/SpellcastingUiController.cs b/src/AcDream.App/UI/Layout/SpellcastingUiController.cs
index 9b1b649e..c1e98500 100644
--- a/src/AcDream.App/UI/Layout/SpellcastingUiController.cs
+++ b/src/AcDream.App/UI/Layout/SpellcastingUiController.cs
@@ -274,6 +274,12 @@ public sealed class SpellcastingUiController : IRetainedPanelController
UiItemList? list = _lists[tab];
if (list is null) continue;
IReadOnlyList favorites = _spellbook.GetFavorites(tab);
+ int targetTab = tab;
+ list.CatalogDropped = (payload, x, _) =>
+ {
+ if (payload is SpellbookShortcutDragPayload shortcut)
+ DropSpellbookShortcut(shortcut, targetTab, DropPosition(list, x));
+ };
using (list.DeferLayout())
{
list.Flush();
@@ -297,7 +303,9 @@ public sealed class SpellcastingUiController : IRetainedPanelController
Dropped = payload =>
{
if (payload is SpellFavoriteDragPayload favorite)
- DropFavorite(favorite, tab, position);
+ DropFavorite(favorite, targetTab, position);
+ else if (payload is SpellbookShortcutDragPayload shortcut)
+ DropSpellbookShortcut(shortcut, targetTab, position);
},
};
slot.Clicked = () => SelectSpell(id);
@@ -322,6 +330,27 @@ public sealed class SpellcastingUiController : IRetainedPanelController
_selected[targetTab] = payload.SpellId;
}
+ private void DropSpellbookShortcut(
+ SpellbookShortcutDragPayload payload,
+ int targetTab,
+ int targetPosition)
+ {
+ // gmSpellbookUI::ListenToElementMessage @ 0x0048BFD0 publishes
+ // CM_Magic::SendNotice_AddSpellShortcut; the open spellcasting menu
+ // chooses the favorite tab/position and persists it.
+ _addFavorite?.Invoke(targetTab, targetPosition, payload.SpellId);
+ _spellbook.SetFavorite(targetTab, targetPosition, payload.SpellId);
+ _selected[targetTab] = payload.SpellId;
+ }
+
+ private static int DropPosition(UiItemList list, int localX)
+ {
+ int position = list.CellWidth <= 0f
+ ? list.GetNumUIItems()
+ : (int)MathF.Floor(MathF.Max(0f, localX) / list.CellWidth);
+ return Math.Clamp(position, 0, list.GetNumUIItems());
+ }
+
private void OnSpellbookChanged() => _favoritesDirty = true;
private void OnObjectChanged(ClientObject _) => _endowmentDirty = true;
@@ -465,6 +494,12 @@ public sealed class SpellcastingUiController : IRetainedPanelController
public sealed record SpellFavoriteDragPayload(int SourceTab, int SourcePosition, uint SpellId);
+///
+/// A learned-spell shortcut carried from the spellbook. Unlike a favorite drag,
+/// lifting this payload never removes anything from its source collection.
+///
+public sealed record SpellbookShortcutDragPayload(uint SpellId);
+
internal static class FavoriteListExtensions
{
public static int IndexOf(this IReadOnlyList values, uint value)
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index 57cda9d6..96d4f42b 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -62,6 +62,7 @@ public sealed record MagicRuntimeBindings(
Action AddFavorite,
Action RemoveFavorite,
Action SendSpellbookFilter,
+ Action RemoveSpell,
Action SetDesiredComponent,
Func ServerTime);
@@ -707,6 +708,7 @@ public sealed class RetailUiRuntime : IDisposable
private void MountSpellbook()
{
ImportedLayout? layout;
+ SpellbookRowStyle? rowStyle;
lock (_bindings.Assets.DatLock)
{
layout = LayoutImporter.Import(
@@ -716,13 +718,19 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
+ rowStyle = SpellbookRowStyle.TryLoad(_bindings.Assets.Dats);
}
- if (layout is null)
+ if (layout is null || rowStyle is null)
{
- Console.WriteLine("[M3] spellbook: LayoutDesc 0x21000034 not found.");
+ Console.WriteLine("[M3] spellbook: layout or authored UIItem row prototype not found.");
return;
}
+ UiDatFont? rowFont = rowStyle.Value.FontDid == 0u
+ ? _bindings.Assets.DefaultFont
+ : _bindings.Assets.ResolveFont(rowStyle.Value.FontDid)
+ ?? _bindings.Assets.DefaultFont;
+
SpellbookWindowController? controller = Layout.SpellbookWindowController.Bind(
layout,
_bindings.Magic.Spellbook,
@@ -735,8 +743,12 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Magic.SelectObject,
spellId => SpellcastingUiController?.AddFavorite(spellId),
_bindings.Magic.SendSpellbookFilter,
+ _bindings.Magic.RemoveSpell,
+ (message, completed) => ShowConfirmation(message, completed),
_bindings.Magic.SetDesiredComponent,
- () => CloseWindow(WindowNames.Spellbook));
+ () => CloseWindow(WindowNames.Spellbook),
+ rowStyle.Value,
+ rowFont);
if (controller is null)
{
Console.WriteLine("[M3] spellbook: required controls missing in LayoutDesc 0x21000034.");
diff --git a/src/AcDream.App/UI/UiCatalogSlot.cs b/src/AcDream.App/UI/UiCatalogSlot.cs
index 5a704adb..eaa27bc0 100644
--- a/src/AcDream.App/UI/UiCatalogSlot.cs
+++ b/src/AcDream.App/UI/UiCatalogSlot.cs
@@ -1,12 +1,13 @@
using System;
using System.Numerics;
+using AcDream.App.Rendering;
namespace AcDream.App.UI;
///
/// Icon-list cell for non-weenie catalog entries such as spells and components.
/// It reuses the retail UIItemList geometry without pretending a spell id is an
-/// object GUID and without participating in item drag/drop.
+/// object GUID; catalog drags stay distinct from physical item drag/drop.
///
public sealed class UiCatalogSlot : UiItemSlot
{
@@ -17,6 +18,16 @@ public sealed class UiCatalogSlot : UiItemSlot
public string Label { get; set; } = string.Empty;
public string? Detail { get; set; }
public bool ShowLabel { get; init; }
+ public uint BackgroundSprite { get; init; }
+ public UiDatFont? LabelFont { get; init; }
+ public Vector4 LabelColor { get; init; } = new(0.92f, 0.88f, 0.70f, 1f);
+ public float IconLeft { get; init; }
+ public float IconTop { get; init; }
+ public float IconWidth { get; init; }
+ public float IconHeight { get; init; }
+ public float LabelLeft { get; init; }
+ public float LabelWidth { get; init; }
+ public bool SelectionBehindContent { get; init; }
public new Action? Clicked { get; set; }
public new Action? DoubleClicked { get; set; }
@@ -67,31 +78,78 @@ public sealed class UiCatalogSlot : UiItemSlot
protected override void OnDraw(UiRenderContext ctx)
{
- float iconSize = ShowLabel ? MathF.Min(32f, Height) : Width;
+ if (BackgroundSprite != 0u && SpriteResolve is not null)
+ {
+ var (texture, _, _) = SpriteResolve(BackgroundSprite);
+ if (texture != 0u)
+ ctx.DrawSprite(texture, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
+ }
+
+ if (SelectionBehindContent)
+ DrawSelection(ctx);
+
+ float iconWidth = IconWidth > 0f ? IconWidth : ShowLabel ? MathF.Min(32f, Height) : Width;
+ float iconHeight = IconHeight > 0f ? IconHeight : ShowLabel ? MathF.Min(32f, Height) : Height;
if (CatalogIconTexture != 0)
- ctx.DrawSprite(CatalogIconTexture, 0f, 0f, iconSize, iconSize, 0f, 0f, 1f, 1f, Vector4.One);
+ ctx.DrawSprite(CatalogIconTexture, IconLeft, IconTop, iconWidth, iconHeight, 0f, 0f, 1f, 1f, Vector4.One);
else if (SpriteResolve is not null && EmptySprite != 0)
{
var (texture, _, _) = SpriteResolve(EmptySprite);
if (texture != 0)
- ctx.DrawSprite(texture, 0f, 0f, iconSize, iconSize, 0f, 0f, 1f, 1f, Vector4.One);
+ ctx.DrawSprite(texture, IconLeft, IconTop, iconWidth, iconHeight, 0f, 0f, 1f, 1f, Vector4.One);
}
if (CatalogOverlayTexture != 0)
- ctx.DrawSprite(CatalogOverlayTexture, 0f, 0f, iconSize, iconSize, 0f, 0f, 1f, 1f, Vector4.One);
+ ctx.DrawSprite(CatalogOverlayTexture, IconLeft, IconTop, iconWidth, iconHeight, 0f, 0f, 1f, 1f, Vector4.One);
- if (Selected && SpriteResolve is not null && SelectedSprite != 0)
- {
- var (texture, _, _) = SpriteResolve(SelectedSprite);
- if (texture != 0)
- ctx.DrawSprite(texture, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
- }
+ if (!SelectionBehindContent)
+ DrawSelection(ctx);
if (ShowLabel)
{
- ctx.DrawString(Label, iconSize + 4f, 3f, new Vector4(0.92f, 0.88f, 0.70f, 1f));
+ float labelLeft = LabelLeft > 0f ? LabelLeft : IconLeft + iconWidth + 4f;
+ float labelWidth = LabelWidth > 0f ? LabelWidth : MathF.Max(0f, Width - labelLeft);
+ if (LabelFont is not null)
+ {
+ string text = FitText(Label, labelWidth, LabelFont.MeasureWidth);
+ float y = MathF.Max(0f, (Height - LabelFont.LineHeight) * 0.5f);
+ ctx.DrawStringDat(LabelFont, text, labelLeft, y, LabelColor);
+ }
+ else
+ {
+ BitmapFont? font = ctx.DefaultFont;
+ string text = font is null ? Label : FitText(Label, labelWidth, font.MeasureWidth);
+ float y = font is null ? 3f : MathF.Max(0f, (Height - font.LineHeight) * 0.5f);
+ ctx.DrawString(text, labelLeft, y, LabelColor, font);
+ }
if (!string.IsNullOrWhiteSpace(Detail))
- ctx.DrawString(Detail!, MathF.Max(iconSize + 4f, Width - 48f), 3f, Vector4.One);
+ ctx.DrawString(Detail!, MathF.Max(labelLeft, Width - 48f), 3f, Vector4.One);
}
}
+
+ private void DrawSelection(UiRenderContext ctx)
+ {
+ if (!Selected || SpriteResolve is null || SelectedSprite == 0u) return;
+ var (texture, _, _) = SpriteResolve(SelectedSprite);
+ if (texture != 0u)
+ ctx.DrawSprite(texture, 0f, 0f, Width, Height, 0f, 0f, 1f, 1f, Vector4.One);
+ }
+
+ private static string FitText(string text, float width, Func measure)
+ {
+ if (width <= 0f || string.IsNullOrEmpty(text) || measure(text) <= width)
+ return text;
+
+ const string ellipsis = "...";
+ float ellipsisWidth = measure(ellipsis);
+ if (ellipsisWidth >= width) return string.Empty;
+ int low = 0, high = text.Length;
+ while (low < high)
+ {
+ int mid = (low + high + 1) / 2;
+ if (measure(text[..mid]) + ellipsisWidth <= width) low = mid;
+ else high = mid - 1;
+ }
+ return text[..low] + ellipsis;
+ }
}
diff --git a/src/AcDream.App/UI/UiItemList.cs b/src/AcDream.App/UI/UiItemList.cs
index a8d18d06..aaeddfa1 100644
--- a/src/AcDream.App/UI/UiItemList.cs
+++ b/src/AcDream.App/UI/UiItemList.cs
@@ -39,6 +39,13 @@ public sealed class UiItemList : UiElement
public Func? SpriteResolve { get; set; }
+ ///
+ /// Optional non-weenie catalog drop target. Used by retail spell shortcuts;
+ /// physical-item drops continue through .
+ /// Coordinates are local to this list.
+ ///
+ public Action