feat(ui): port retail spellbook interactions

Resolve the authored spell shortcut row prototype so the spellbook presents the retail icon, name, separator, selected overlay, and scrollbar geometry. Port exact school and level filters, stable display ordering, selection exposure, and learned-spell drags into the open favorite bar.

Route deletion through the shared retail confirmation dialog and send CM_Magic::Event_RemoveSpell only after an affirmative answer, leaving the inbound server notice authoritative for list state.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-15 16:26:30 +02:00
parent ad30c37a48
commit ac2ca8f965
17 changed files with 727 additions and 47 deletions

View file

@ -531,7 +531,7 @@ behavior. Estimated 1726 days focused work, 35 weeks calendar.
- **Missile/portal VFX campaign Steps 05 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.

View file

@ -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

View file

@ -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

View file

@ -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),

View file

@ -0,0 +1,72 @@
using System.Numerics;
using DatReaderWriter;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Resolved presentation of retail's spell-shortcut row prototype. The spellbook
/// list points at this prototype through ItemList attribute <c>0x1000000E</c>;
/// retail clones it for every learned spell.
/// </summary>
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;
/// <summary>
/// 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.
/// </summary>
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;
}
}

View file

@ -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<uint> _selectObject;
private readonly Action<uint> _addFavorite;
private readonly Action<uint> _sendFilter;
private readonly Action<uint> _removeSpell;
private readonly Action<string, Action<bool>> _showConfirmation;
private readonly Action<uint, uint> _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<uint> selectObject,
Action<uint> addFavorite,
Action<uint> sendFilter,
Action<uint> removeSpell,
Action<string, Action<bool>> showConfirmation,
Action<uint, uint> 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<uint> selectObject,
Action<uint> addFavorite,
Action<uint> sendFilter,
Action<uint> removeSpell,
Action<string, Action<bool>> showConfirmation,
Action<uint, uint> 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<SpellMetadata>()
.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;
}

View file

@ -274,6 +274,12 @@ public sealed class SpellcastingUiController : IRetainedPanelController
UiItemList? list = _lists[tab];
if (list is null) continue;
IReadOnlyList<uint> 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);
/// <summary>
/// A learned-spell shortcut carried from the spellbook. Unlike a favorite drag,
/// lifting this payload never removes anything from its source collection.
/// </summary>
public sealed record SpellbookShortcutDragPayload(uint SpellId);
internal static class FavoriteListExtensions
{
public static int IndexOf(this IReadOnlyList<uint> values, uint value)

View file

@ -62,6 +62,7 @@ public sealed record MagicRuntimeBindings(
Action<int, int, uint> AddFavorite,
Action<int, uint> RemoveFavorite,
Action<uint> SendSpellbookFilter,
Action<uint> RemoveSpell,
Action<uint, uint> SetDesiredComponent,
Func<double> 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.");

View file

@ -1,12 +1,13 @@
using System;
using System.Numerics;
using AcDream.App.Rendering;
namespace AcDream.App.UI;
/// <summary>
/// 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.
/// </summary>
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<string, float> 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;
}
}

View file

@ -39,6 +39,13 @@ public sealed class UiItemList : UiElement
public Func<uint, (uint tex, int w, int h)>? SpriteResolve { get; set; }
/// <summary>
/// Optional non-weenie catalog drop target. Used by retail spell shortcuts;
/// physical-item drops continue through <see cref="DragHandler"/>.
/// Coordinates are local to this list.
/// </summary>
public Action<object, int, int>? CatalogDropped { get; set; }
private uint _cellEmptySprite;
/// <summary>Empty-slot sprite for THIS list's cells, resolved from the dat cell template
/// (retail attribute 0x1000000e -> catalog 0x21000037 prototype's ItemSlot_Empty; see
@ -204,6 +211,16 @@ public sealed class UiItemList : UiElement
public override bool OnEvent(in UiEvent e)
{
if (CatalogDropped is not null && e.Payload is not null)
{
if (e.Type is UiEventType.DragEnter or UiEventType.DragOver)
return true;
if (e.Type == UiEventType.DropReleased)
{
CatalogDropped(e.Payload, e.Data1, e.Data2);
return true;
}
}
if (e.Type == UiEventType.Scroll && CellWidth > 0f)
{
// Mirror UiText: Silk +Y wheel = up/older = decrease ScrollY; negate Data0.
@ -213,6 +230,24 @@ public sealed class UiItemList : UiElement
return base.OnEvent(e);
}
/// <summary>
/// Port of the listbox expose-item behavior called by
/// <c>gmSpellbookUI::SetSelected @ 0x0048B540</c>.
/// </summary>
public void ScrollItemIntoView(int index)
{
if (CellWidth <= 0f || index < 0 || index >= _cells.Count) return;
int columns = Math.Max(1, Columns);
int row = Flow == UiItemListFlow.ColumnMajor
? index % Math.Max(1, RowCount(_cells.Count, columns))
: index / columns;
float top = row * CellHeight;
float bottom = top + CellHeight;
if (top < Scroll.ScrollY) Scroll.SetScrollY((int)MathF.Floor(top));
else if (bottom > Scroll.ScrollY + Height)
Scroll.SetScrollY((int)MathF.Ceiling(bottom - Height));
}
protected override void OnDraw(UiRenderContext ctx)
{
// The factory sets Width/Height AFTER construction, so re-layout each frame:

View file

@ -36,6 +36,7 @@ public static class ClientCommandRequests
public const uint AddSpellFavoriteOpcode = 0x01E3u;
public const uint RemoveSpellFavoriteOpcode = 0x01E4u;
public const uint SpellbookFilterOpcode = 0x0286u;
public const uint RemoveSpellOpcode = 0x01A8u;
public const uint LegacyFriendsOpcode = 0xF7CDu;
// Named-retail anchors:
@ -205,6 +206,10 @@ public static class ClientCommandRequests
public static byte[] BuildSpellbookFilter(uint sequence, uint filters) =>
BuildUInt32(sequence, SpellbookFilterOpcode, filters);
// CM_Magic::Event_RemoveSpell @ 0x006A3220.
public static byte[] BuildRemoveSpell(uint sequence, uint spellId) =>
BuildUInt32(sequence, RemoveSpellOpcode, spellId);
private static byte[] BuildParameterless(uint sequence, uint opcode)
{
byte[] body = new byte[12];

View file

@ -1480,6 +1480,12 @@ public sealed class WorldSession : IDisposable
SendGameAction(ClientCommandRequests.BuildSpellbookFilter(seq, filters));
}
public void SendRemoveSpell(uint spellId)
{
uint seq = NextGameActionSequence();
SendGameAction(ClientCommandRequests.BuildRemoveSpell(seq, spellId));
}
public void SendCastUntargetedSpell(uint spellId)
{
uint seq = NextGameActionSequence();

View file

@ -0,0 +1,32 @@
using AcDream.App.UI.Layout;
using DatReaderWriter;
using DatReaderWriter.Options;
namespace AcDream.App.Tests.UI.Layout;
public sealed class SpellbookRowStyleTests
{
[Fact]
public void InstalledDat_ResolvesPinnedRetailSpellShortcutPrototype()
{
string datDir = System.Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR")
?? Path.Combine(System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile),
"Documents", "Asheron's Call");
if (!Directory.Exists(datDir)) return;
using var dats = new DatCollection(datDir, DatAccessType.Read);
SpellbookRowStyle style = Assert.IsType<SpellbookRowStyle>(
SpellbookRowStyle.TryLoad(dats));
Assert.Equal(280f, style.Width);
Assert.Equal(32f, style.Height);
Assert.Equal(0f, style.IconLeft);
Assert.Equal(32f, style.IconWidth);
Assert.Equal(42f, style.LabelLeft);
Assert.Equal(230f, style.LabelWidth);
Assert.Equal(0x40000001u, style.FontDid);
Assert.Equal(System.Numerics.Vector4.One, style.LabelColor);
Assert.Equal(0x06001396u, style.BackgroundSprite);
Assert.Equal(0x06001397u, style.SelectedSprite);
}
}

View file

@ -3,30 +3,33 @@ using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Items;
using AcDream.Core.Spells;
using System.Numerics;
namespace AcDream.App.Tests.UI.Layout;
public sealed class SpellbookWindowControllerTests
{
private static readonly SpellbookRowStyle RowStyle = new(
Width: 280f,
Height: 32f,
IconLeft: 0f,
IconTop: 0f,
IconWidth: 32f,
IconHeight: 32f,
LabelLeft: 42f,
LabelWidth: 230f,
FontDid: 0x40000001u,
LabelColor: Vector4.One,
BackgroundSprite: 0x06001396u,
SelectedSprite: 0x06001397u);
[Fact]
public void RetailFixture_BindsTextTabs_AndPropagatesOpenClosedState()
{
ImportedLayout layout = FixtureLoader.LoadSpellbook();
var closed = new List<uint>();
using SpellbookWindowController? controller = SpellbookWindowController.Bind(
layout,
new Spellbook(),
new ClientObjectTable(),
() => 1u,
new Dictionary<uint, SpellComponentDescriptor>(),
spellId => spellId,
iconId => iconId,
_ => 1,
_ => { },
_ => { },
_ => { },
(_, _) => { },
() => closed.Add(1u));
using SpellbookWindowController? controller = Bind(
layout, new Spellbook(), close: () => closed.Add(1u));
Assert.NotNull(controller);
UiText spellTab = Assert.IsType<UiText>(layout.FindElement(SpellbookWindowController.SpellTabId));
@ -53,4 +56,176 @@ public sealed class SpellbookWindowControllerTests
Assert.Equal(RetailUiStateIds.Open, Assert.IsAssignableFrom<IUiDatStateful>(child).ActiveRetailStateId));
Assert.Empty(closed);
}
[Fact]
public void LearnedSpells_AreAuthoredRows_InDisplayOrder_WithSelectionAndDragPayload()
{
ImportedLayout layout = FixtureLoader.LoadSpellbook();
Spellbook book = CreateSpellbook();
book.OnSpellLearned(101u);
book.OnSpellLearned(102u);
book.OnSpellLearned(103u);
var added = new List<uint>();
using SpellbookWindowController controller = Bind(
layout, book, addFavorite: added.Add)!;
UiItemList list = Assert.IsType<UiItemList>(
layout.FindElement(SpellbookWindowController.SpellListId));
UiScrollbar scrollbar = Assert.IsType<UiScrollbar>(
layout.FindElement(SpellbookWindowController.SpellScrollbarId));
Assert.Equal(1, list.Columns);
Assert.Equal(280f, list.CellWidth);
Assert.Equal(32f, list.CellHeight);
Assert.Same(list.Scroll, scrollbar.Model);
Assert.Equal(3, list.GetNumUIItems());
UiCatalogSlot first = Assert.IsType<UiCatalogSlot>(list.GetItem(0));
UiCatalogSlot second = Assert.IsType<UiCatalogSlot>(list.GetItem(1));
UiCatalogSlot third = Assert.IsType<UiCatalogSlot>(list.GetItem(2));
Assert.Equal([102u, 101u, 103u], [first.EntryId, second.EntryId, third.EntryId]);
Assert.Equal("Creature One", first.Label);
Assert.True(first.ShowLabel);
Assert.Equal(RowStyle.BackgroundSprite, first.BackgroundSprite);
Assert.Equal(RowStyle.SelectedSprite, first.SelectedSprite);
Assert.True(first.SelectionBehindContent);
Assert.Equal(RowStyle.LabelLeft, first.LabelLeft);
second.OnEvent(new UiEvent(0, second, UiEventType.Click));
Assert.True(second.Selected);
Assert.False(first.Selected);
first.OnEvent(new UiEvent(
0, first, UiEventType.DragBegin,
Payload: new SpellbookShortcutDragPayload(first.EntryId)));
Assert.True(first.Selected);
Assert.IsType<SpellbookShortcutDragPayload>(first.GetDragPayload());
first.OnEvent(new UiEvent(0, first, UiEventType.DoubleClick));
Assert.Equal([102u], added);
}
[Fact]
public void SchoolAndLevelFilters_RebuildFromAuthoritativeBitfield()
{
ImportedLayout layout = FixtureLoader.LoadSpellbook();
Spellbook book = CreateSpellbook();
book.OnSpellLearned(101u); // War I
book.OnSpellLearned(102u); // Creature I
book.OnSpellLearned(103u); // War VIII
var sent = new List<uint>();
using SpellbookWindowController controller = Bind(
layout, book, sendFilter: sent.Add)!;
UiItemList list = Assert.IsType<UiItemList>(
layout.FindElement(SpellbookWindowController.SpellListId));
UiButton war = Assert.IsType<UiButton>(layout.FindElement(0x1000029Bu));
UiButton levelOne = Assert.IsType<UiButton>(layout.FindElement(0x1000029Cu));
Assert.True(war.Selected);
Assert.Equal(3, list.GetNumUIItems());
war.OnEvent(new UiEvent(0, war, UiEventType.Click));
controller.Tick();
Assert.Equal([0x3FF7u], sent);
Assert.Single(VisibleSpellIds(list));
Assert.Equal(102u, VisibleSpellIds(list)[0]);
war.OnEvent(new UiEvent(0, war, UiEventType.Click));
levelOne.OnEvent(new UiEvent(0, levelOne, UiEventType.Click));
controller.Tick();
Assert.Equal([103u], VisibleSpellIds(list));
Assert.False(levelOne.Selected);
}
[Fact]
public void Delete_UsesSharedConfirmation_AndWaitsForServerRemovalNotice()
{
ImportedLayout layout = FixtureLoader.LoadSpellbook();
Spellbook book = CreateSpellbook();
book.OnSpellLearned(101u);
var removed = new List<uint>();
string? prompt = null;
Action<bool>? complete = null;
using SpellbookWindowController controller = Bind(
layout,
book,
removeSpell: removed.Add,
showConfirmation: (message, callback) =>
{
prompt = message;
complete = callback;
})!;
UiItemList list = Assert.IsType<UiItemList>(
layout.FindElement(SpellbookWindowController.SpellListId));
UiButton delete = Assert.IsType<UiButton>(
layout.FindElement(SpellbookWindowController.DeleteButtonId));
delete.OnEvent(new UiEvent(0, delete, UiEventType.Click));
Assert.Null(prompt);
UiCatalogSlot row = Assert.IsType<UiCatalogSlot>(list.GetItem(0));
row.OnEvent(new UiEvent(0, row, UiEventType.Click));
delete.OnEvent(new UiEvent(0, delete, UiEventType.Click));
Assert.Equal(
"Are you sure you want to remove War One from your spellbook? "
+ "You will no longer be able to cast this spell unless you learn it again!",
prompt);
Assert.Empty(removed);
complete!(false);
Assert.Empty(removed);
Assert.True(book.Knows(101u));
delete.OnEvent(new UiEvent(0, delete, UiEventType.Click));
complete!(true);
Assert.Equal([101u], removed);
Assert.True(book.Knows(101u));
Assert.Single(VisibleSpellIds(list));
book.OnSpellForgotten(101u);
controller.Tick();
Assert.False(book.Knows(101u));
Assert.Empty(VisibleSpellIds(list));
}
private static SpellbookWindowController? Bind(
ImportedLayout layout,
Spellbook spellbook,
Action<uint>? addFavorite = null,
Action<uint>? sendFilter = null,
Action<uint>? removeSpell = null,
Action<string, Action<bool>>? showConfirmation = null,
Action? close = null)
=> SpellbookWindowController.Bind(
layout,
spellbook,
new ClientObjectTable(),
() => 1u,
new Dictionary<uint, SpellComponentDescriptor>(),
spellId => spellId + 1000u,
iconId => iconId,
spellId => spellId == 103u ? 8 : 1,
_ => { },
addFavorite ?? (_ => { }),
sendFilter ?? (_ => { }),
removeSpell ?? (_ => { }),
showConfirmation ?? ((_, _) => { }),
(_, _) => { },
close ?? (() => { }),
RowStyle,
rowFont: null);
private static Spellbook CreateSpellbook()
{
const string csv = "Spell ID,Name,School,IconId [Hex],SortKey\n"
+ "101,War One,War Magic,0x06000001,2\n"
+ "102,Creature One,Creature Enchantment,0x06000002,1\n"
+ "103,War Eight,War Magic,0x06000003,3\n";
return new Spellbook(SpellTable.LoadFromReader(new StringReader(csv)));
}
private static uint[] VisibleSpellIds(UiItemList list)
=> Enumerable.Range(0, list.GetNumUIItems())
.Select(index => Assert.IsType<UiCatalogSlot>(list.GetItem(index)).EntryId)
.ToArray();
}

View file

@ -78,6 +78,35 @@ public sealed class SpellcastingUiControllerTests
Assert.Equal([42u], spellbook.GetFavorites(0));
}
[Fact]
public void SpellbookShortcutDrop_AddsToOpenTabWithoutRemovingLearnedSpell()
{
ImportedLayout layout = LayoutImporter.Build(
FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
var spellbook = new Spellbook();
spellbook.OnSpellLearned(42u, 1f);
var objects = new ClientObjectTable();
objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
var added = new List<(int Tab, int Position, uint Spell)>();
using SpellcastingUiController controller = Bind(
layout, spellbook, objects, _ => { },
(tab, position, spell) => added.Add((tab, position, spell)))!;
UiElement group = Assert.IsAssignableFrom<UiElement>(layout.FindElement(0x100000AAu));
UiItemList list = Descendants(group).OfType<UiItemList>().First();
bool handled = list.OnEvent(new UiEvent(
0,
list,
UiEventType.DropReleased,
Data1: 5,
Payload: new SpellbookShortcutDragPayload(42u)));
Assert.True(handled);
Assert.Equal([(0, 0, 42u)], added);
Assert.Equal([42u], spellbook.GetFavorites(0));
Assert.True(spellbook.Knows(42u));
}
[Fact]
public void UnrelatedObjectUpdate_DoesNotRecreateFavoriteSlots()
{
@ -137,7 +166,8 @@ public sealed class SpellcastingUiControllerTests
ImportedLayout layout,
Spellbook spellbook,
ClientObjectTable objects,
Action<uint> useItem)
Action<uint> useItem,
Action<int, int, uint>? addFavorite = null)
{
var casting = new SpellCastingController(
spellbook, () => null, () => 1u, () => { }, _ => { }, (_, _) => { }, _ => { });
@ -147,7 +177,7 @@ public sealed class SpellcastingUiControllerTests
item => item.ObjectId,
useItem,
new SelectionState(),
(_, _, _) => { },
addFavorite ?? ((_, _, _) => { }),
(_, _) => { });
}

View file

@ -43,4 +43,43 @@ public class UiItemListTests
list.CellEmptySprite = 0u;
Assert.Equal(0x060074CFu, list.GetItem(0)!.EmptySprite); // unchanged default
}
[Fact]
public void CatalogDrop_routes_payload_and_local_coordinates()
{
var list = new UiItemList();
object payload = new();
(object Payload, int X, int Y)? received = null;
list.CatalogDropped = (value, x, y) => received = (value, x, y);
bool handled = list.OnEvent(new UiEvent(
0, list, UiEventType.DropReleased, Data1: 17, Data2: 9, Payload: payload));
Assert.True(handled);
Assert.NotNull(received);
Assert.Same(payload, received.Value.Payload);
Assert.Equal((17, 9), (received.Value.X, received.Value.Y));
}
[Fact]
public void ScrollItemIntoView_moves_only_when_row_is_outside_viewport()
{
var list = new UiItemList
{
Width = 100f,
Height = 64f,
Columns = 1,
CellWidth = 100f,
CellHeight = 32f,
};
list.Flush();
for (int i = 0; i < 5; i++) list.AddItem(new UiItemSlot());
list.ScrollItemIntoView(3);
Assert.Equal(64, list.Scroll.ScrollY);
list.ScrollItemIntoView(2);
Assert.Equal(64, list.Scroll.ScrollY);
list.ScrollItemIntoView(0);
Assert.Equal(0, list.Scroll.ScrollY);
}
}

View file

@ -160,16 +160,20 @@ public sealed class ClientCommandRequestsTests
}
[Fact]
public void RemoveFavoriteAndFilter_MatchRetailPayloads()
public void RemoveFavoriteFilterAndLearnedSpell_MatchRetailPayloads()
{
byte[] remove = ClientCommandRequests.BuildRemoveSpellFavorite(2u, 42u, 6);
byte[] filter = ClientCommandRequests.BuildSpellbookFilter(3u, 0xA5u);
byte[] removeLearned = ClientCommandRequests.BuildRemoveSpell(4u, 77u);
Assert.Equal(ClientCommandRequests.RemoveSpellFavoriteOpcode, Read(remove, 8));
Assert.Equal(42u, Read(remove, 12));
Assert.Equal(6u, Read(remove, 16));
Assert.Equal(ClientCommandRequests.SpellbookFilterOpcode, Read(filter, 8));
Assert.Equal(0xA5u, Read(filter, 12));
Assert.Equal(16, removeLearned.Length);
Assert.Equal(ClientCommandRequests.RemoveSpellOpcode, Read(removeLearned, 8));
Assert.Equal(77u, Read(removeLearned, 12));
}
[Fact]