diff --git a/AGENTS.md b/AGENTS.md
index 1947ec2e..e12d6678 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -115,8 +115,8 @@ and the final two-client portal-out/materialization observer flow are
user-gated. Deterministic world-lifecycle automation protects fresh login,
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
-Release builds; the appraisal and retained-item input corrections pass 3,548
-App tests / 3 skips and 7,979 complete-solution tests / 5 skips.
+Release builds; the appraisal and retained catalog-input corrections pass
+3,553 App tests / 3 skips and 7,984 complete-solution tests / 5 skips.
**Current work order:** the six-slice pre-M4 world-interaction completion
program in `docs/plans/2026-07-23-world-interaction-completion.md`: favorite
@@ -126,7 +126,8 @@ transactions. Slices 1 and 2 plus the adjacent shared-cooldown presentation are
user-accepted. Slice 3's first connected gate exposed and corrected the
monster-response flag mismatch, non-retail shared-panel mounting, and missing
inscription transaction. The creature and common-item report corrections are
-user-accepted; press-time retained-item selection is the current visual gate.
+user-accepted; favorite-spell press-time selection and right-click local
+examination are the current visual gate.
**Structural prerequisite before new M4 subsystem work:** all eight
behavior-preserving `GameWindow` decomposition slices and the automated
diff --git a/CLAUDE.md b/CLAUDE.md
index 9da6c181..79e8a0cd 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -113,8 +113,8 @@ and the final two-client portal-out/materialization observer flow are
user-gated. Deterministic world-lifecycle automation protects fresh login,
outdoor/world-edge/dungeon travel, same-location revisit, exact graceful
disconnect, and uncapped fresh-process reconnect with JSON/PNG artifacts.
-Release builds; the appraisal and retained-item input corrections pass 3,548
-App tests / 3 skips and 7,979 complete-solution tests / 5 skips.
+Release builds; the appraisal and retained catalog-input corrections pass
+3,553 App tests / 3 skips and 7,984 complete-solution tests / 5 skips.
**Current work order:** the six-slice pre-M4 world-interaction completion
program in `docs/plans/2026-07-23-world-interaction-completion.md`: favorite
@@ -124,7 +124,8 @@ transactions. Slices 1 and 2 plus the adjacent shared-cooldown presentation are
user-accepted. Slice 3's first connected gate exposed and corrected the
monster-response flag mismatch, non-retail shared-panel mounting, and missing
inscription transaction. The creature and common-item report corrections are
-user-accepted; press-time retained-item selection is the current visual gate.
+user-accepted; favorite-spell press-time selection and right-click local
+examination are the current visual gate.
**Structural prerequisite before new M4 subsystem work:** all eight
behavior-preserving `GameWindow` decomposition slices and the automated
diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 6ce81815..c52d5625 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -158,6 +158,14 @@ three-pixel drag threshold. Target mode is intercepted at that same press.
Bag opening, item use/equip, and shortcut activation remain completed
click/double-click actions, so a drag or target-consumed press cannot also
activate the item.
+The corresponding favorite-spell branch is now ported without conflating a
+spell ID with a physical object GUID. Favorite selection occurs on left press;
+right-click opens the authored local Spell examination subview with
+name, school, mana, duration, range, description, and the current appropriate
+component formula. It sends no Appraise request and takes no busy reference,
+so the status-bar magnifier remains object-only. Switching the shared floaty
+from a pending object appraisal to a spell releases exactly that appraisal
+transaction before showing the SpellPanel.
**Files:** `src/AcDream.Core.Net/Messages/AppraiseInfoParser.cs`;
`src/AcDream.App/UI/RetailUiRuntime.cs`;
@@ -185,6 +193,9 @@ Right-clicking an occupied retained item cell selects and examines it without
using, equipping, looting, or dragging it.
Left-pressing one selects it before the button is released; holding and
dragging preserves that selection without activating the item.
+Left-pressing a favorite spell likewise changes its selection immediately.
+Right-clicking it opens the spell examination subview; selecting it and using
+the status-bar magnifier does not examine the spell.
The Black Phyntos Hive specifically reads `Value: ???`, `Burden: Unknown`,
then its description after one retail paragraph break, without the bogus
255-item/255-container line.
diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 728a96dd..cfb43e4a 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -205,7 +205,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| ~~AP-107~~ | **RETIRED 2026-07-11 (Wave 3.3 / #197)** — typed `OfferPrimaryClick` returns `NotActive`, `ConsumedSuccess`, or `ConsumedRejected`; every retained item surface plus radar/world offers active target mode before local selection/open/use fallback. Rejections are consumed and cannot drift selection. | `src/AcDream.App/UI/ItemInteractionController.cs`; inventory/paperdoll/toolbar/radar/world call sites | — | — | `UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0` |
| AP-108 | Paperdoll/AutoWield still omit the remaining missile/held restrictions and corrupt-mask branch of full `AutoWieldIsLegal`, dual-wield/off-hand rules, double-click examine/drag from the doll, body-part selection lighting, and retail's synchronous `" - cannot unwield the %s"` failure suffix (the current send seam reports rejection asynchronously). **AutoWear legality retired from this row 2026-07-23:** inventory activation and paperdoll drops now apply the retail clothing-priority/location blocker lookup and exact `"You must remove your %s to wear that"` system notice. **Primary replacement retired 2026-07-14; Aetheria retired 2026-07-13.** | `src/AcDream.App/UI/Layout/PaperdollController.cs`; `src/AcDream.App/UI/AutoWieldController.cs` | Basic equip slots, Aetheria, live doll, AutoWear conflict reporting, and primary weapon/incompatible shield/mismatched ammo blocker sequencing work in peace and war | Remaining illegal/off-hand cases, asynchronous dequip rejection wording, doll examine/drag, and selection lighting still differ functionally | `CPlayerSystem::AutoWieldIsLegal @ 0x0055ED60`; `CPlayerSystem::AutoWearIsLegal @ 0x0055EF40`; `CPlayerSystem::AutoWield @ 0x00560A60`; `gmPaperDollUI @ 0x004A3590..0x004A5F90` |
| AP-109 | Character Titles page is inert and live displayed-title/luminance state is absent | `src/AcDream.App/UI/Layout/CharacterStatController.cs`; `CharacterSheetProvider.cs` | Attributes/skills core output is user-accepted | Titles cannot be selected/displayed and level-200 luminance fields are missing | `gmCharacterTitleUI @ 0x0049A610`; `gmStatManagementUI::UpdateExperience @ 0x004F0A70` |
-| AP-110 | Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, residual social/floating chat, quests/map/options/smartbox, vendor/trade/salvage/tinkering, mini-game gameplay, Link Status NAK/retransmission packet-loss averaging, and D.6 nameplates/floaters. Examination has its independent authored floaty layout, inscription transaction, retail creature stat/rating/animated-preview presentation, default selection-follow, and the full EoR item-report dispatch: appraisal-only unknowns; exact equipment-set/rating/tinkering/weapon/armor/caster/requirement/XP/healer/rare prose and intentional blank section rows; ordinary/enchantment DAT spell descriptions; live material-decorated appropriate titles plus DAT material and creature names; expiry, decorated material/gem descriptions; and portal/PK restrictions with authored item colors. It still lacks item-object preview, player-dependent effective shield projection, live cooldown-remaining projection, localized augmentation-cost `StringInfo`, exhaustive character detail regions, and exact creature appraisal FontInfo-list selection. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/Layout/AppraisalUiController.cs`; `src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs`; `src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs`; `src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs`; `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs`; `src/AcDream.Core.Net/LinkStatusSnapshot.cs`; D.5/D.6 roadmap | Basic combat, M3 magic/Link/Vitae surfaces, and the core examination request/presentation/inscription/creature-preview/item-report loop cover the active loops; the residual examination mechanisms require live player/enchantment/localization state or object-preview ownership rather than fabricated content | Item assessments omit only the listed live/localized/preview projections; enchanted/incomplete creature appraisal rows use the normal authored font until the exact FontInfo list is bound; other absent panels remain unavailable; real packet loss is displayed as 0.00% instead of retail's moving average | `BasicCreatureExamineUI::Init @ 0x004AB9C0`; `CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0`; `gmExaminationUI::RecvNotice_SelectionChanged @ 0x004AB3D0`; `AttributeInfoRegion::Update @ 0x004F1D90`; `gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0`; `ACCWeenieObject::GetObjectName @ 0x0058E6E0`; `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`; `ItemExamineUI::AddItemInfo @ 0x004AC050`; `ItemExamineUI::Appraisal_ShowCapacity @ 0x004B2680`; `ItemExamineUI::Appraisal_ShowSpecialProperties @ 0x004B0140`; `ItemExamineUI::Appraisal_ShowWeaponAndArmorData @ 0x004B10E0`; `ItemExamineUI::Appraisal_ShowMagicInfo @ 0x004B2E10`; `ItemExamineUI::Appraisal_ShowDescription @ 0x004B6990`; `MaterialTypeEnumMapper::MaterialTypeToString @ 0x005CD500`; `ItemExamineUI::SetInscription @ 0x004AE2F0`; `CM_Writing::Event_SetInscription @ 0x006A98B0`; `CLinkStatusAverages::GetAveragePacketLoss @ 0x00546610`; LayoutDesc catalog |
+| AP-110 | Remaining retained gameplay panels and world HUD are absent: advanced-combat powerbar, residual social/floating chat, quests/map/options/smartbox, vendor/trade/salvage/tinkering, mini-game gameplay, Link Status NAK/retransmission packet-loss averaging, and D.6 nameplates/floaters. Examination has its independent authored floaty layout, inscription transaction, retail creature stat/rating/animated-preview presentation, default selection-follow, authored local spell subview with appropriate-formula component state, and the full EoR item-report dispatch: appraisal-only unknowns; exact equipment-set/rating/tinkering/weapon/armor/caster/requirement/XP/healer/rare prose and intentional blank section rows; ordinary/enchantment DAT spell descriptions; live material-decorated appropriate titles plus DAT material and creature names; expiry, decorated material/gem descriptions; and portal/PK restrictions with authored item colors. It still lacks item-object preview, player-dependent effective shield projection, live cooldown-remaining projection, localized augmentation-cost `StringInfo`, exhaustive character detail regions, and exact creature appraisal FontInfo-list selection. | `src/AcDream.App/UI/RetailUiRuntime.cs`; `src/AcDream.App/UI/Layout/AppraisalUiController.cs`; `src/AcDream.App/UI/Layout/ItemAppraisalTextFormatter.cs`; `src/AcDream.App/UI/Layout/RetailAppraisalNameResolver.cs`; `src/AcDream.App/UI/Layout/CreatureAppraisalRows.cs`; `src/AcDream.App/Rendering/CreatureAppraisalPresentation.cs`; `src/AcDream.Core.Net/LinkStatusSnapshot.cs`; D.5/D.6 roadmap | Basic combat, M3 magic/Link/Vitae surfaces, and the core examination request/presentation/inscription/creature-preview/item-report loop cover the active loops; the residual examination mechanisms require live player/enchantment/localization state or object-preview ownership rather than fabricated content | Item assessments omit only the listed live/localized/preview projections; enchanted/incomplete creature appraisal rows use the normal authored font until the exact FontInfo list is bound; other absent panels remain unavailable; real packet loss is displayed as 0.00% instead of retail's moving average | `BasicCreatureExamineUI::Init @ 0x004AB9C0`; `CreatureExamineUI::SetAppraiseInfo @ 0x004B3FF0`; `gmExaminationUI::RecvNotice_SelectionChanged @ 0x004AB3D0`; `gmExaminationUI::ExamineSpell @ 0x004B6900`; `SpellExamineUI::ExamineSpell @ 0x004B6210`; `AttributeInfoRegion::Update @ 0x004F1D90`; `gmExaminationUI::SetAppraiseInfo @ 0x004ADAE0`; `ACCWeenieObject::GetObjectName @ 0x0058E6E0`; `ItemExamineUI::SetAppraiseInfo @ 0x004B72B0`; `ItemExamineUI::AddItemInfo @ 0x004AC050`; `ItemExamineUI::Appraisal_ShowCapacity @ 0x004B2680`; `ItemExamineUI::Appraisal_ShowSpecialProperties @ 0x004B0140`; `ItemExamineUI::Appraisal_ShowWeaponAndArmorData @ 0x004B10E0`; `ItemExamineUI::Appraisal_ShowMagicInfo @ 0x004B2E10`; `ItemExamineUI::Appraisal_ShowDescription @ 0x004B6990`; `MaterialTypeEnumMapper::MaterialTypeToString @ 0x005CD500`; `ItemExamineUI::SetInscription @ 0x004AE2F0`; `CM_Writing::Event_SetInscription @ 0x006A98B0`; `CLinkStatusAverages::GetAveragePacketLoss @ 0x00546610`; LayoutDesc catalog |
| ~~AP-111~~ | **RETIRED 2026-07-11 (M2 held-object parenting)** — equipped hand items are no longer omitted from the render world. CreateObject now preserves Placement/Parent/position timestamp bootstrap; live `0xF749` ParentEvent is parsed with retail sequence freshness; a focused render controller resolves `Setup.HoldingLocations`, applies the child's placement frame, and recomposes the separate child entity after every parent animation tick. Pickup retains the weenie's visual metadata for a later wield. | `src/AcDream.Core.Net/Messages/{CreateObject,ParentEvent}.cs`; `src/AcDream.Core/Meshing/EquippedChildAttachment.cs`; `src/AcDream.App/Rendering/EquippedChildRenderController.cs` | — | — | `ClientCombatSystem::GetDefaultCombatMode @ 0x0056B310`; `SmartBox::HandleParentEvent @ 0x004535D0`; `CPhysicsObj::set_parent @ 0x00515A90`; `CPhysicsObj::UpdateChild @ 0x00512D50` |
| AP-112 | The basic combat bar ports visibility, height selection, desired-power slider, exact 1.0/0.8-second charge, ready-stance gating, request/release, `MaybeStopCompletely`, server-response queueing, and auto-repeat, but still omits `StartAttackRequest`'s `FinishJump` call and exact trained-Recklessness visibility semantics (IA-20 keeps the dark range as the accepted baseline) | `src/AcDream.App/Combat/CombatAttackController.cs`; `src/AcDream.App/UI/Layout/CombatUiController.cs` | The shared player movement owner now performs retail's server-control-gated full stop and movement report before an attack build; the remaining seams require the jump owner and a distinct Recklessness treatment | Starting an attack while charging a jump may not finish that jump exactly when retail does; trained/untrained Recklessness presentation is identical | `ClientCombatSystem::StartAttackRequest @ 0x0056C040`; `CommandInterpreter::MaybeStopCompletely @ 0x006B3B90`; `gmCombatUI::ListenToElementMessage @ 0x004CC430` |
| AP-113 | Invalid lifestone-command arguments display the local text `Usage: /lifestone`; retail definitely emits a local usage/error line but Binary Ninja misidentifies the referenced wide-string address, so its exact wording is not yet recovered | `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`; `RetailClientCommandCatalog.cs` | The behavior boundary is exact (handled locally, no chat and no game action); only a low-impact diagnostic sentence differs | `/ls now` can show different wording/color from retail while still refusing the invalid request correctly | `ClientCommunicationSystem::DoLifestone @ 0x0056FC70` |
diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md
index 3da76840..5b793787 100644
--- a/docs/plans/2026-04-11-roadmap.md
+++ b/docs/plans/2026-04-11-roadmap.md
@@ -1,6 +1,6 @@
# acdream — strategic roadmap
-**Status:** Living document. Updated 2026-07-23. **M3 landed; M4 is active.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. All eight slices of the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md), their automated closeout, and the user's connected visual matrix are complete. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate.
+**Status:** Living document. Updated 2026-07-24. **M3 landed; M4 is active.** M3's retail casting/UI, R6 locomotion/collision/projectile/teleport/radar rebaseline, deterministic fresh-login/portal world lifecycle, and final two-client portal observer flow are user-gated. All eight slices of the behavior-preserving ownership campaign in [`docs/architecture/code-structure.md`](../architecture/code-structure.md), their automated closeout, and the user's connected visual matrix are complete. Issue #225's lifestone/particle alpha comparison remains a separate rendering visual gate.
**Purpose:** One source of truth for where the project is and where it's going. Every observed defect or missing feature has a named phase that owns it; when something looks wrong in-game, look here to find the phase that'll address it. Implementation details live in per-phase specs under `docs/superpowers/specs/`, not in this file.
---
@@ -38,7 +38,14 @@ selects and appraises occupied inventory, external-container, paperdoll, and
physical-shortcut cells through the same request owner. Its shared physical
left-click contract now also matches retail's press-time selection: the
selection frame updates on MouseDown before drag promotion, while completed
-click/double-click remains the activation boundary. Slice 3's first connected gate exposed
+click/double-click remains the activation boundary. The same timing is now
+ported for favorite spells through a distinct catalog contract: left press
+selects without waiting for release; right-click opens the examination
+floaty's authored SpellPanel locally; and spell IDs never enter global object
+selection or the status-bar magnifier path. The view projects retail's
+name/school/mana/duration/range/description and authored appropriate-formula
+component cells, including live missing-component state. Its connected visual
+gate is pending. Slice 3's first connected gate exposed
three deeper defects, now corrected: the response enum mislabeled ACE's
creature bit `0x0100` and dropped monster packets while retaining the busy
cursor; the imported 310 x 400 examination layout was incorrectly hosted as
diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md
index 47395428..ded8b6d4 100644
--- a/docs/plans/2026-05-12-milestones.md
+++ b/docs/plans/2026-05-12-milestones.md
@@ -108,6 +108,12 @@ The same shared ItemList contract now ports retail's left-button-down
selection timing across inventory, loot, paperdoll, and physical shortcuts:
selection is visible before release or drag promotion, while use/open/equip
remains release-completed.
+Favorite spells now use the parallel retail catalog contract: MouseDown
+selects immediately, right-click opens the examination floaty's authored
+SpellPanel locally, and no spell ID enters global object selection or the
+status-bar magnifier command. Exact spell fields and the current appropriate
+formula's authored component cells are automated; the connected visual gate
+is pending.
Slice 3's first connected gate found and corrected the real remaining defects:
ACE creature flag `0x0100` was mislabeled as a weapon profile and left the
appraisal busy reference held, retail's examination UI had been mounted as a
diff --git a/docs/plans/2026-07-23-world-interaction-completion.md b/docs/plans/2026-07-23-world-interaction-completion.md
index 16719c6c..9490919b 100644
--- a/docs/plans/2026-07-23-world-interaction-completion.md
+++ b/docs/plans/2026-07-23-world-interaction-completion.md
@@ -8,7 +8,8 @@ shared-panel mount, and its missing inscription transaction. All three are
corrected and live-confirmed. The creature-page follow-up now has retail's
ordered stat rows and animated private preview. The follow-up item report and
authored 310 x 400 layout correction are implemented; their connected visual
-gate is pending.
+gate is pending. Favorite-spell press-time selection and right-click local
+SpellPanel examination are implemented and await their connected visual gate.
**Milestone:** M4 prerequisite/preamble.
**Architecture:** retained gameplay UI over shared selection, object, and
interaction state. `GameWindow` remains a composition/callback shell.
@@ -249,6 +250,16 @@ and the retained green frame update during left-button down, before the
three-pixel drag threshold. Target mode is offered first. Opening, using,
equipping, looting, and shortcut activation remain completed-click or
double-click actions and are suppressed when target mode consumed the press.
+Favorite spells now use the parallel non-weenie path:
+`gmSpellcastingUI::ListenToElementMessage @ 0x004C7AB0` selects the favorite
+on left press, while the spell branch of
+`UIElement_ItemList::ListenToElementMessage @ 0x004E4D50` opens the authored
+SpellPanel locally on right-click. Spell IDs never enter `SelectionState` or
+the status-bar magnifier path, and no Appraise/busy transaction is invented.
+The view projects exact spell fields and the current appropriate formula
+through authored component template `0x1000032E`; its connected visual gate is
+pending. The 61-test focused App gate, Release solution build, 3,553 App tests
+/ 3 skips, and 7,984 complete-solution tests / 5 skips pass.
## Slice 1 — spell-bar overflow arrows
@@ -371,6 +382,11 @@ Named retail references and executable pseudocode are recorded in
refresh; closing the examination window must stop refreshes.
- Keep Examination open while opening/moving Inventory or Skills and confirm
both windows coexist with independent rectangles.
+- On the combat spell bar, press and hold a different favorite: its selection
+ and name should update before release. Right-click a favorite: the same
+ examination floaty should show the SpellPanel with name, school, mana,
+ duration, range, description, and component formula. The toolbar magnifier
+ must not treat the selected spell as an object.
- Assess an owned inscribable weapon. Edit and clear its inscription by
clicking elsewhere, then reassess it. An item authored by another player is
read-only and reports the exact retail permission line when clicked.
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 7f5f0ff6..a591e40f 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
@@ -219,6 +219,85 @@ Outbound character events (`CM_Character`):
0x0224 SetDesiredComponentLevel(componentDid:u32, amount:i32)
```
+### Favorite press selection and local spell examination
+
+Retail:
+
+- `gmSpellcastingUI::ListenToElementMessage @ 0x004C7AB0`
+- `SpellCastSubMenu::SetSelected @ 0x004C5B00`
+- `UIElement_ItemList::ListenToElementMessage @ 0x004E4D50`
+- `ClientUISystem::ExamineSpell @ 0x00564A70`
+- `gmExaminationUI::ExamineSpell @ 0x004B6900`
+- `SpellExamineUI::ExamineSpell @ 0x004B6210`
+- `SpellExamineUI::DetermineSpellRange @ 0x004AD450`
+
+A favorite spell is selected on the UIItem's left-button-down message, not on
+button release. Double-click remains a completed gesture and casts the spell
+that the press already selected.
+
+```text
+favorite UIItem message:
+ if message parameter == 7: // left press
+ submenu.SetSelected(spellId)
+ if message parameter == 10: // completed double click
+ Cast()
+
+UIElement_ItemList right click:
+ if itemId != 0:
+ select itemId as the global weenie selection
+ ExamineObject(itemId)
+ else if spellId != 0:
+ ClientUISystem.ExamineSpell(spellId)
+```
+
+The spell branch is deliberately local. It never stores the spell ID in
+`ACCWeenieObject::selectedID`, never sends an Appraise request, and never takes
+a new UI-busy reference. The toolbar magnifying glass therefore remains an
+object-only command; selecting a favorite cannot make it assess that spell.
+
+The examination floaty is shared between object and spell subviews. Switching
+to a spell cancels only an in-flight/current object appraisal:
+
+```text
+gmExaminationUI::ExamineSpell(spellId):
+ if awaiting object appraisal:
+ clear pending object
+ decrement that appraisal's UI-busy reference
+ clear current object
+ send Appraise(0)
+ else if a current object appraisal exists:
+ clear current object
+ send Appraise(0)
+
+ SpellExamineUI.ExamineSpell(spellId)
+ activate SpellPanel
+ show examination window
+```
+
+The authored spell subview is part of LayoutDesc `0x2100006B`:
+
+```text
+SpellPanel 0x10000153
+school text 0x1000015E
+spell icon 0x1000015F
+mana text 0x10000160
+duration text 0x10000161
+range text 0x10000162
+description/components text 0x10000163
+formula list 0x1000032D
+formula-cell template 0x1000032E
+missing-component overlay 0x10000330
+```
+
+Presentation uses the current appropriate formula, preserving its order and
+duplicates. Each component icon keeps the authored missing-component overlay
+until the player owns that component. Duration is shown as integer seconds
+below one minute and integer minutes from one minute upward. Range is
+`min(baseConstant + baseModifier * schoolSkill, 75) / 0.9144`, displayed with
+one decimal place in yards. School skill IDs are Creature `0x1F`, Item `0x20`,
+Life `0x21`, War `0x22`, and Void `0x2B`; an unclassified spell uses the
+highest of those five skills.
+
## Spellbook and component book
Retail: `gmSpellbookUI::PostInit` `0x0048B2B0` and
diff --git a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs
index c69eeade..d4637635 100644
--- a/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs
+++ b/src/AcDream.App/Composition/InteractionRetainedUiComposition.cs
@@ -405,6 +405,39 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
canSend: () => late.Session.IsInWorld);
checkpoint(InteractionRetainedUiCompositionPoint.MagicRuntimeCreated);
+ uint MagicSkillLevel(MagicSchool school)
+ {
+ static uint SkillId(MagicSchool value) => value switch
+ {
+ MagicSchool.CreatureEnchantment => 0x1Fu,
+ MagicSchool.ItemEnchantment => 0x20u,
+ MagicSchool.LifeMagic => 0x21u,
+ MagicSchool.WarMagic => 0x22u,
+ MagicSchool.VoidMagic => 0x2Bu,
+ _ => 0u,
+ };
+
+ uint skillId = SkillId(school);
+ if (skillId != 0u)
+ return d.LocalPlayer.GetSkill(skillId)?.CurrentLevel ?? 0u;
+
+ uint highest = 0u;
+ foreach (MagicSchool candidate in new[]
+ {
+ MagicSchool.CreatureEnchantment,
+ MagicSchool.ItemEnchantment,
+ MagicSchool.LifeMagic,
+ MagicSchool.WarMagic,
+ MagicSchool.VoidMagic,
+ })
+ {
+ highest = Math.Max(
+ highest,
+ d.LocalPlayer.GetSkill(SkillId(candidate))?.CurrentLevel ?? 0u);
+ }
+ return highest;
+ }
+
foreach (IMouse mouse in d.Input.Mice)
host.WireMouse(mouse);
checkpoint(InteractionRetainedUiCompositionPoint.MouseInputWired);
@@ -500,6 +533,8 @@ internal sealed class RetailInteractionRetainedUiCompositionFactory
iconComposer.GetSpellComponentIcon,
d.Selection,
d.MagicCatalog.GetSpellLevel,
+ magic.GetExamineComponents,
+ MagicSkillLevel,
guid => d.Selection.Select(guid, SelectionChangeSource.Inventory),
guid => late.Session.TryUseItem(guid, d.Log),
(tab, position, spellId) =>
diff --git a/src/AcDream.App/Spells/MagicRuntime.cs b/src/AcDream.App/Spells/MagicRuntime.cs
index e1aa83b8..8c6e0b43 100644
--- a/src/AcDream.App/Spells/MagicRuntime.cs
+++ b/src/AcDream.App/Spells/MagicRuntime.cs
@@ -17,6 +17,12 @@ public sealed record SpellComponentDescriptor(
uint Category,
uint IconId);
+/// One resolved formula cell in retail's spell examination subview.
+public readonly record struct SpellExamineComponent(
+ uint SpellComponentId,
+ SpellComponentDescriptor Descriptor,
+ bool Owned);
+
///
/// App-layer projection of retail's spell, component, and school-focus DAT tables.
/// Keeping this catalog outside GameWindow makes the SCID/WCID boundary and
@@ -52,6 +58,20 @@ public sealed class MagicCatalog
public CoreSpellTable SpellTable { get; }
public IReadOnlyDictionary Components { get; }
+ public bool TryGetComponentBySpellComponentId(
+ uint spellComponentId,
+ out SpellComponentDescriptor descriptor)
+ {
+ if (_wcidByScid.TryGetValue(spellComponentId, out uint weenieClassId)
+ && Components.TryGetValue(weenieClassId, out SpellComponentDescriptor? found))
+ {
+ descriptor = found;
+ return true;
+ }
+ descriptor = null!;
+ return false;
+ }
+
public bool IsComponentPack(uint weenieClassId)
=> Components.ContainsKey(weenieClassId);
@@ -143,15 +163,43 @@ public sealed class MagicCatalog
///
public sealed class MagicRuntime
{
- private MagicRuntime(MagicCatalog catalog, SpellCastingController casting)
+ private readonly SpellComponentRequirementService _requirements;
+
+ private MagicRuntime(
+ MagicCatalog catalog,
+ SpellCastingController casting,
+ SpellComponentRequirementService requirements)
{
Catalog = catalog;
Casting = casting;
+ _requirements = requirements;
}
public MagicCatalog Catalog { get; }
public SpellCastingController Casting { get; }
+ public IReadOnlyList GetExamineComponents(uint spellId)
+ {
+ IReadOnlyList formula = _requirements.GetAppropriateFormula(spellId);
+ if (formula.Count == 0)
+ return [];
+
+ var result = new List(formula.Count);
+ foreach (uint spellComponentId in formula)
+ {
+ if (spellComponentId == 0u
+ || !Catalog.TryGetComponentBySpellComponentId(
+ spellComponentId,
+ out SpellComponentDescriptor descriptor))
+ continue;
+ result.Add(new SpellExamineComponent(
+ spellComponentId,
+ descriptor,
+ _requirements.IsComponentOwned(spellComponentId)));
+ }
+ return result;
+ }
+
public static MagicRuntime Create(
MagicCatalog catalog,
Spellbook spellbook,
@@ -198,7 +246,7 @@ public sealed class MagicRuntime
incrementBusy: incrementBusy,
canSend: canSend,
targetCompatibleSilent: (target, spell) => TargetCompatible(target, spell, false));
- return new MagicRuntime(catalog, casting);
+ return new MagicRuntime(catalog, casting, requirements);
}
public void Reset() => Casting.Reset();
diff --git a/src/AcDream.App/Spells/SpellComponentRequirementService.cs b/src/AcDream.App/Spells/SpellComponentRequirementService.cs
index fa1b10f7..e66e7f3b 100644
--- a/src/AcDream.App/Spells/SpellComponentRequirementService.cs
+++ b/src/AcDream.App/Spells/SpellComponentRequirementService.cs
@@ -75,6 +75,31 @@ public sealed class SpellComponentRequirementService
/// (0x00567D50): an infused school or a directly carried school focus uses
/// the scarab-only formula; otherwise the account-customized formula wins.
///
+ public IReadOnlyList GetAppropriateFormula(uint spellId)
+ {
+ if (!_formulas.TryGetValue(spellId, out SpellFormulaDefinition? formula))
+ return [];
+ uint playerGuid = _playerGuid();
+ return GetAppropriateFormula(
+ formula,
+ _objects.Get(playerGuid),
+ playerGuid);
+ }
+
+ ///
+ /// Whether the component tracker would leave a formula icon unghosted.
+ /// The input is a retail spell-component id (SCID), not a weenie class id.
+ ///
+ public bool IsComponentOwned(uint spellComponentId)
+ {
+ if (!_wcidByScid.TryGetValue(spellComponentId, out uint weenieClassId))
+ return false;
+ uint playerGuid = _playerGuid();
+ return _objects.Objects.Any(item =>
+ item.WeenieClassId == weenieClassId
+ && IsOwnedByPlayer(item, playerGuid));
+ }
+
private IReadOnlyList GetAppropriateFormula(
SpellFormulaDefinition formula,
ClientObject? player,
diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs
index 87bca58e..8b22a10e 100644
--- a/src/AcDream.App/UI/ItemInteractionController.cs
+++ b/src/AcDream.App/UI/ItemInteractionController.cs
@@ -553,6 +553,31 @@ public sealed class ItemInteractionController : IDisposable
return true;
}
+ ///
+ /// Releases only the object-appraisal transaction before the local spell
+ /// examination subview takes ownership of the shared floaty window.
+ ///
+ /// Retail gmExaminationUI::ExamineSpell @ 0x004B6900 decrements the
+ /// appraisal busy reference when a response is pending, clears both pending
+ /// and current object ids, and sends CM_Item::Event_Appraise(0). It
+ /// does not clear unrelated use/inventory busy references.
+ ///
+ public void CancelObjectAppraisalForSpell()
+ {
+ if (_awaitingAppraisalId == 0u && _currentAppraisalId == 0u)
+ return;
+
+ if (_awaitingAppraisalId != 0u)
+ {
+ _awaitingAppraisalId = 0u;
+ if (_busyCount > 0)
+ _busyCount--;
+ }
+ _currentAppraisalId = 0u;
+ _sendExamine?.Invoke(0u);
+ StateChanged?.Invoke();
+ }
+
///
/// Projects retail gmToolbarUI::HandleSelectionChanged @ 0x004BF380
/// from the live selected object's public description. This is deliberately
diff --git a/src/AcDream.App/UI/Layout/AppraisalUiController.cs b/src/AcDream.App/UI/Layout/AppraisalUiController.cs
index 178b094b..976cfb74 100644
--- a/src/AcDream.App/UI/Layout/AppraisalUiController.cs
+++ b/src/AcDream.App/UI/Layout/AppraisalUiController.cs
@@ -1,4 +1,6 @@
using System.Globalization;
+using System.Text;
+using AcDream.App.Spells;
using AcDream.Core.Combat;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
@@ -32,6 +34,13 @@ public sealed class AppraisalUiController : IRetainedPanelController
public const uint CreatureDisplayNameId = 0x1000014Eu;
public const uint CreatureLevelValueId = 0x1000014Cu;
public const uint SpellPanelId = 0x10000153u;
+ public const uint SpellSchoolTextId = 0x1000015Eu;
+ public const uint SpellIconId = 0x1000015Fu;
+ public const uint SpellManaTextId = 0x10000160u;
+ public const uint SpellDurationTextId = 0x10000161u;
+ public const uint SpellRangeTextId = 0x10000162u;
+ public const uint SpellDisplayTextId = 0x10000163u;
+ public const uint SpellFormulaListId = 0x1000032Du;
private const uint TemplateStringProperty = 5u;
private const uint CharacterMarkerIntProperty = 0x105u;
@@ -51,7 +60,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
private readonly Action _closeWindow;
private readonly UiElement _itemPanel;
private readonly UiElement _creaturePanel;
- private readonly UiElement? _spellPanel;
+ private readonly UiElement _spellPanel;
private readonly UiText _title;
private readonly UiText _itemText;
private readonly UiText? _inscriptionText;
@@ -64,6 +73,22 @@ public sealed class AppraisalUiController : IRetainedPanelController
private readonly CreatureAppraisalRowTemplateFactory? _creatureRowTemplates;
private readonly CreatureDisplayNameResolver _creatureNames;
private readonly RetailAppraisalNameResolver _itemNames;
+ private readonly Func _resolveSpellIcon;
+ private readonly Func _resolveComponentIcon;
+ private readonly Func> _spellComponents;
+ private readonly Func _magicSkill;
+ private readonly SpellExamineComponentTemplateFactory? _spellComponentTemplates;
+ private readonly UiText _spellSchool;
+ private readonly UiText _spellMana;
+ private readonly UiText _spellDuration;
+ private readonly UiText _spellRange;
+ private readonly UiText _spellDisplay;
+ private readonly UiElement _spellIconHost;
+ private readonly UiElement _spellFormulaHost;
+ private readonly UiTextureElement _spellIcon;
+ private readonly List _spellFormulaCells = [];
+ private readonly float _spellFormulaOriginX;
+ private readonly float _spellFormulaCellWidth;
private AppraisalView _activeView;
private uint _itemObjectId;
private uint _creatureObjectId;
@@ -75,6 +100,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
private string _scribeName = string.Empty;
private string _oldInscription = string.Empty;
private bool _presentationInscribable;
+ private uint _spellId;
private bool _windowVisible;
private double _refreshElapsed;
private bool _disposed;
@@ -93,11 +119,17 @@ public sealed class AppraisalUiController : IRetainedPanelController
Action close,
UiElement itemPanel,
UiElement creaturePanel,
+ UiElement spellPanel,
UiText title,
UiText itemText,
CreatureAppraisalRowTemplateFactory? creatureRowTemplates,
CreatureDisplayNameResolver? creatureNames,
- RetailAppraisalNameResolver? itemNames)
+ RetailAppraisalNameResolver? itemNames,
+ Func? resolveSpellIcon,
+ Func? resolveComponentIcon,
+ Func>? spellComponents,
+ Func? magicSkill,
+ SpellExamineComponentTemplateFactory? spellComponentTemplates)
{
_layout = layout;
_objects = objects;
@@ -112,7 +144,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
_closeWindow = close;
_itemPanel = itemPanel;
_creaturePanel = creaturePanel;
- _spellPanel = layout.FindElement(SpellPanelId);
+ _spellPanel = spellPanel;
_title = title;
_itemText = itemText;
_creatureRowTemplates = creatureRowTemplates;
@@ -120,6 +152,20 @@ public sealed class AppraisalUiController : IRetainedPanelController
?? new CreatureDisplayNameResolver(
new Dictionary());
_itemNames = itemNames ?? RetailAppraisalNameResolver.Empty;
+ _resolveSpellIcon = resolveSpellIcon ?? (_ => 0u);
+ _resolveComponentIcon = resolveComponentIcon ?? (_ => 0u);
+ _spellComponents = spellComponents ?? (_ => []);
+ _magicSkill = magicSkill ?? (_ => 0u);
+ _spellComponentTemplates = spellComponentTemplates;
+ _spellSchool = (UiText)layout.FindElement(SpellSchoolTextId)!;
+ _spellMana = (UiText)layout.FindElement(SpellManaTextId)!;
+ _spellDuration = (UiText)layout.FindElement(SpellDurationTextId)!;
+ _spellRange = (UiText)layout.FindElement(SpellRangeTextId)!;
+ _spellDisplay = (UiText)layout.FindElement(SpellDisplayTextId)!;
+ _spellIconHost = layout.FindElement(SpellIconId)!;
+ _spellFormulaHost = layout.FindElement(SpellFormulaListId)!;
+ _spellFormulaOriginX = _spellFormulaHost.Left;
+ _spellFormulaCellWidth = _spellFormulaHost.Width;
_inscriptionText = layout.FindElement(InscriptionTextId) as UiText;
_inscriptionField = layout.FindElement(InscriptionTextId) as UiField;
_signature = layout.FindElement(SignatureTextId) as UiText;
@@ -164,6 +210,22 @@ public sealed class AppraisalUiController : IRetainedPanelController
_inscriptionBackground.ClickThrough = false;
}
+ if (_spellIconHost is UiDatElement spellIconDat)
+ spellIconDat.MediaVisible = false;
+ _spellIcon = new UiTextureElement
+ {
+ Width = _spellIconHost.Width,
+ Height = _spellIconHost.Height,
+ Anchors = AnchorEdges.Left | AnchorEdges.Top
+ | AnchorEdges.Right | AnchorEdges.Bottom,
+ };
+ _spellIconHost.AddChild(_spellIcon);
+ ConfigureSpellText(_spellSchool);
+ ConfigureSpellText(_spellMana);
+ ConfigureSpellText(_spellDuration);
+ ConfigureSpellText(_spellRange);
+ ConfigureSpellText(_spellDisplay, wrap: true);
+
if (creatureRowTemplates is not null
&& layout.FindElement(CreatureStatsListId) is { } statsHost
&& layout.FindElement(CreatureExtraListId) is { } extraHost
@@ -184,6 +246,11 @@ public sealed class AppraisalUiController : IRetainedPanelController
}
_selection.Changed += HandleSelectionChanged;
+ _objects.ObjectAdded += HandleSpellComponentObjectChanged;
+ _objects.ObjectMoved += HandleSpellComponentObjectMoved;
+ _objects.ObjectRemoved += HandleSpellComponentObjectChanged;
+ _objects.StackSizeUpdated += HandleSpellComponentObjectChanged;
+ _objects.Cleared += HandleSpellComponentObjectsCleared;
SetActiveView(AppraisalView.Item);
}
@@ -204,7 +271,12 @@ public sealed class AppraisalUiController : IRetainedPanelController
Action close,
CreatureAppraisalRowTemplateFactory? creatureRowTemplates = null,
CreatureDisplayNameResolver? creatureNames = null,
- RetailAppraisalNameResolver? itemNames = null)
+ RetailAppraisalNameResolver? itemNames = null,
+ Func? resolveSpellIcon = null,
+ Func? resolveComponentIcon = null,
+ Func>? spellComponents = null,
+ Func? magicSkill = null,
+ SpellExamineComponentTemplateFactory? spellComponentTemplates = null)
{
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(objects);
@@ -220,8 +292,16 @@ public sealed class AppraisalUiController : IRetainedPanelController
if (layout.FindElement(ItemPanelId) is not { } itemPanel
|| layout.FindElement(CreaturePanelId) is not { } creaturePanel
+ || layout.FindElement(SpellPanelId) is not { } spellPanel
|| layout.FindElement(TitleId) is not UiText title
- || layout.FindElement(ItemTextId) is not UiText itemText)
+ || layout.FindElement(ItemTextId) is not UiText itemText
+ || layout.FindElement(SpellSchoolTextId) is not UiText
+ || layout.FindElement(SpellManaTextId) is not UiText
+ || layout.FindElement(SpellDurationTextId) is not UiText
+ || layout.FindElement(SpellRangeTextId) is not UiText
+ || layout.FindElement(SpellDisplayTextId) is not UiText
+ || layout.FindElement(SpellIconId) is null
+ || layout.FindElement(SpellFormulaListId) is null)
return null;
return new AppraisalUiController(
@@ -238,11 +318,79 @@ public sealed class AppraisalUiController : IRetainedPanelController
close,
itemPanel,
creaturePanel,
+ spellPanel,
title,
itemText,
creatureRowTemplates,
creatureNames,
- itemNames);
+ itemNames,
+ resolveSpellIcon,
+ resolveComponentIcon,
+ spellComponents,
+ magicSkill,
+ spellComponentTemplates);
+ }
+
+ ///
+ /// Opens retail's local spell-examination subview. Unlike object appraisal,
+ /// this performs no network request and never changes the globally selected
+ /// object. Source: gmExaminationUI::ExamineSpell @ 0x004B6900 and
+ /// SpellExamineUI::ExamineSpell @ 0x004B6210.
+ ///
+ public bool ExamineSpell(uint spellId)
+ {
+ if (spellId == 0u
+ || !_spellbook.TryGetMetadata(spellId, out SpellMetadata metadata))
+ return false;
+
+ _interaction.CancelObjectAppraisalForSpell();
+ _spellId = spellId;
+ _titleValue = metadata.Name;
+ SetSpellText(_spellSchool, $"School: {metadata.School}");
+
+ string mana = metadata.ManaCost > 0
+ ? metadata.ManaCost.ToString(CultureInfo.InvariantCulture)
+ : "???";
+ if (metadata.ManaModifier > 0u)
+ mana += $" + {metadata.ManaModifier.ToString(CultureInfo.InvariantCulture)} per target";
+ SetSpellText(_spellMana, $"Mana: {mana}");
+
+ string duration = string.Empty;
+ if (metadata.Duration > 0f && metadata.Duration != -1f)
+ {
+ uint displayed = metadata.Duration >= 60f
+ ? (uint)(metadata.Duration / 60f)
+ : (uint)metadata.Duration;
+ duration = metadata.Duration >= 60f
+ ? $"Duration: {displayed.ToString(CultureInfo.InvariantCulture)} min."
+ : $"Duration: {displayed.ToString(CultureInfo.InvariantCulture)} sec.";
+ }
+ SetSpellText(_spellDuration, duration);
+
+ float range = MathF.Min(
+ metadata.BaseRangeConstant
+ + metadata.BaseRangeModifier * _magicSkill(metadata.SchoolId),
+ 75f);
+ SetSpellText(
+ _spellRange,
+ range > 0f
+ ? string.Format(
+ CultureInfo.InvariantCulture,
+ "Range: {0:F1} yds.",
+ range / 0.9144f)
+ : string.Empty);
+
+ IReadOnlyList components =
+ _spellComponents(spellId);
+ SetSpellText(_spellDisplay, BuildSpellDisplay(metadata, components));
+ _spellDisplay.Scroll.SetScrollY(0);
+ _spellIcon.Texture = _resolveSpellIcon(spellId);
+ RebuildSpellFormula(components);
+
+ SetActiveView(AppraisalView.Spell);
+ _refreshElapsed = 0;
+ _show();
+ return true;
}
///
@@ -335,7 +483,15 @@ public sealed class AppraisalUiController : IRetainedPanelController
_itemObjectId = 0;
_creatureObjectId = 0;
_characterObjectId = 0;
+ _spellId = 0u;
_refreshElapsed = 0;
+ _spellIcon.Texture = 0u;
+ SetSpellText(_spellSchool, string.Empty);
+ SetSpellText(_spellMana, string.Empty);
+ SetSpellText(_spellDuration, string.Empty);
+ SetSpellText(_spellRange, string.Empty);
+ SetSpellText(_spellDisplay, string.Empty);
+ ClearSpellFormula();
SetActiveView(AppraisalView.Item);
ClearCreatureText();
}
@@ -579,6 +735,75 @@ public sealed class AppraisalUiController : IRetainedPanelController
scrollbar.Model = text.Scroll;
}
+ private static void ConfigureSpellText(UiText text, bool wrap = false)
+ {
+ text.PreserveEndOnLayout = false;
+ text.ClickThrough = true;
+ if (wrap)
+ {
+ text.OneLine = false;
+ text.VerticalJustify = VJustify.Top;
+ }
+ }
+
+ private static void SetSpellText(UiText text, string value)
+ => text.LinesProvider = () => IndicatorDetailText.Shape(text, value);
+
+ private static string BuildSpellDisplay(
+ SpellMetadata metadata,
+ IReadOnlyList components)
+ {
+ var display = new StringBuilder(metadata.Description);
+ if (components.Count == 0)
+ return display.ToString();
+
+ display.Append("\nCOMPONENTS:");
+ foreach (SpellExamineComponent component in components)
+ display.Append("\n ").Append(component.Descriptor.Name);
+ return display.ToString();
+ }
+
+ private void RebuildSpellFormula(
+ IReadOnlyList components)
+ {
+ ClearSpellFormula();
+ if (_spellComponentTemplates is null || components.Count == 0)
+ {
+ _spellFormulaHost.Width = _spellFormulaCellWidth;
+ _spellFormulaHost.Left = _spellFormulaOriginX;
+ return;
+ }
+
+ float cellWidth = _spellFormulaCellWidth;
+ _spellFormulaHost.Left =
+ _spellFormulaOriginX - cellWidth * 0.5f * (components.Count - 1);
+ _spellFormulaHost.Width = cellWidth * components.Count;
+ _spellFormulaHost.ResetAnchorCapture();
+ for (int i = 0; i < components.Count; i++)
+ {
+ SpellExamineComponent component = components[i];
+ UiElement cell = _spellComponentTemplates.Create(
+ _resolveComponentIcon(component.Descriptor.WeenieClassId),
+ component.Owned);
+ cell.LayoutPolicy = null;
+ cell.Anchors = AnchorEdges.Left | AnchorEdges.Top;
+ cell.Left = i * cellWidth;
+ cell.Top = 0f;
+ _spellFormulaHost.AddChild(cell);
+ _spellFormulaCells.Add(cell);
+ }
+ }
+
+ private void ClearSpellFormula()
+ {
+ foreach (UiElement cell in _spellFormulaCells)
+ _spellFormulaHost.RemoveChild(cell);
+ _spellFormulaCells.Clear();
+ _spellFormulaHost.Left = _spellFormulaOriginX;
+ _spellFormulaHost.Width = _spellFormulaCellWidth;
+ _spellFormulaHost.ResetAnchorCapture();
+ }
+
private void SetText(uint elementId, string value, bool scrollable = false)
{
if (_layout.FindElement(elementId) is not UiText text)
@@ -623,8 +848,7 @@ public sealed class AppraisalUiController : IRetainedPanelController
_activeView = view;
_itemPanel.Visible = view == AppraisalView.Item;
_creaturePanel.Visible = view is AppraisalView.Creature or AppraisalView.Character;
- if (_spellPanel is not null)
- _spellPanel.Visible = false;
+ _spellPanel.Visible = view == AppraisalView.Spell;
UiElement? creatureInfo = _layout.FindElement(0x1000014Du);
UiElement? characterInfo = _layout.FindElement(0x1000014Fu);
@@ -704,12 +928,39 @@ public sealed class AppraisalUiController : IRetainedPanelController
_closeWindow();
}
+ private void HandleSpellComponentObjectChanged(ClientObject _)
+ => RefreshSpellComponents();
+
+ private void HandleSpellComponentObjectMoved(ClientObjectMove _)
+ => RefreshSpellComponents();
+
+ private void HandleSpellComponentObjectsCleared()
+ => RefreshSpellComponents();
+
+ private void RefreshSpellComponents()
+ {
+ if (_activeView != AppraisalView.Spell
+ || _spellId == 0u
+ || !_spellbook.TryGetMetadata(_spellId, out SpellMetadata metadata))
+ return;
+
+ IReadOnlyList components =
+ _spellComponents(_spellId);
+ SetSpellText(_spellDisplay, BuildSpellDisplay(metadata, components));
+ RebuildSpellFormula(components);
+ }
+
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_selection.Changed -= HandleSelectionChanged;
+ _objects.ObjectAdded -= HandleSpellComponentObjectChanged;
+ _objects.ObjectMoved -= HandleSpellComponentObjectMoved;
+ _objects.ObjectRemoved -= HandleSpellComponentObjectChanged;
+ _objects.StackSizeUpdated -= HandleSpellComponentObjectChanged;
+ _objects.Cleared -= HandleSpellComponentObjectsCleared;
if (_close is not null)
_close.OnClick = null;
if (_inscriptionField is not null)
@@ -720,6 +971,8 @@ public sealed class AppraisalUiController : IRetainedPanelController
}
if (_inscriptionBackground is not null)
_inscriptionBackground.OnClick = null;
+ ClearSpellFormula();
+ _spellIconHost.RemoveChild(_spellIcon);
foreach (UiScrollbar scrollbar in Descendants(_layout.Root).OfType())
if (ReferenceEquals(scrollbar.Model, _itemText.Scroll)
|| (_inscriptionText is not null
@@ -735,4 +988,5 @@ public enum AppraisalView
Item,
Creature,
Character,
+ Spell,
}
diff --git a/src/AcDream.App/UI/Layout/SpellExamineComponentTemplateFactory.cs b/src/AcDream.App/UI/Layout/SpellExamineComponentTemplateFactory.cs
new file mode 100644
index 00000000..a92c8ad5
--- /dev/null
+++ b/src/AcDream.App/UI/Layout/SpellExamineComponentTemplateFactory.cs
@@ -0,0 +1,95 @@
+using AcDream.Content;
+using DatReaderWriter;
+
+namespace AcDream.App.UI.Layout;
+
+///
+/// Instantiates the authored formula-icon template used by retail
+/// SpellExamineUI::ExamineSpell @ 0x004B6210. The controller replaces
+/// the template root's placeholder image with the resolved component icon while
+/// retaining child 0x10000330 as the missing-component overlay.
+///
+public sealed class SpellExamineComponentTemplateFactory
+{
+ public const uint TemplateId = 0x1000032Eu;
+ public const uint MissingOverlayId = 0x10000330u;
+
+ private readonly ElementInfo _template;
+ private readonly Func _resolveSprite;
+ private readonly UiDatFont? _defaultFont;
+ private readonly IReadOnlyDictionary _fonts;
+
+ public SpellExamineComponentTemplateFactory(
+ ElementInfo template,
+ Func resolveSprite,
+ UiDatFont? defaultFont,
+ IReadOnlyDictionary? fonts = null)
+ {
+ _template = template ?? throw new ArgumentNullException(nameof(template));
+ _resolveSprite = resolveSprite ?? throw new ArgumentNullException(nameof(resolveSprite));
+ _defaultFont = defaultFont;
+ _fonts = fonts ?? new Dictionary();
+ }
+
+ public static SpellExamineComponentTemplateFactory? TryLoad(
+ IDatReaderWriter dats,
+ Func resolveSprite,
+ UiDatFont? defaultFont,
+ Func? resolveFont)
+ {
+ ElementInfo? template = LayoutImporter.ImportInfos(
+ dats,
+ AppraisalUiController.LayoutId,
+ TemplateId);
+ if (template is null)
+ return null;
+
+ var fonts = new Dictionary();
+ CaptureFonts(template, resolveFont, fonts);
+ return new SpellExamineComponentTemplateFactory(
+ template,
+ resolveSprite,
+ defaultFont,
+ fonts);
+ }
+
+ public UiElement Create(uint iconTexture, bool owned)
+ {
+ ImportedLayout content = LayoutImporter.Build(
+ _template,
+ _resolveSprite,
+ _defaultFont,
+ did => _fonts.TryGetValue(did, out UiDatFont? font)
+ ? font
+ : _defaultFont);
+ UiElement root = content.Root;
+ if (root is UiDatElement datRoot)
+ datRoot.MediaVisible = false;
+
+ var icon = new UiTextureElement
+ {
+ Width = root.Width,
+ Height = root.Height,
+ Anchors = AnchorEdges.Left | AnchorEdges.Top
+ | AnchorEdges.Right | AnchorEdges.Bottom,
+ Texture = iconTexture,
+ ZOrder = int.MinValue / 2,
+ };
+ root.AddChild(icon);
+
+ if (content.FindElement(MissingOverlayId) is { } missing)
+ missing.Visible = !owned;
+ return root;
+ }
+
+ private static void CaptureFonts(
+ ElementInfo info,
+ Func? resolveFont,
+ Dictionary fonts)
+ {
+ if (info.FontDid != 0u && !fonts.ContainsKey(info.FontDid))
+ fonts[info.FontDid] = resolveFont?.Invoke(info.FontDid);
+ foreach (ElementInfo child in info.Children)
+ CaptureFonts(child, resolveFont, fonts);
+ }
+}
diff --git a/src/AcDream.App/UI/Layout/SpellcastingUiController.cs b/src/AcDream.App/UI/Layout/SpellcastingUiController.cs
index c7b1206c..5e1ed6df 100644
--- a/src/AcDream.App/UI/Layout/SpellcastingUiController.cs
+++ b/src/AcDream.App/UI/Layout/SpellcastingUiController.cs
@@ -44,6 +44,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
private readonly Func _resolveSpellIcon;
private readonly Func _resolveItemDragIcon;
private readonly Action _useItem;
+ private readonly Action? _examineSpell;
private readonly Action? _addFavorite;
private readonly Action? _removeFavorite;
private readonly UiShortcutDigitGraphics? _shortcutDigits;
@@ -74,6 +75,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
Func resolveSpellIcon,
Func resolveItemDragIcon,
Action useItem,
+ Action? examineSpell,
SelectionState selection,
Action? addFavorite,
Action? removeFavorite,
@@ -94,6 +96,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
_resolveSpellIcon = resolveSpellIcon;
_resolveItemDragIcon = resolveItemDragIcon;
_useItem = useItem;
+ _examineSpell = examineSpell;
_addFavorite = addFavorite;
_removeFavorite = removeFavorite;
_shortcutDigits = shortcutDigits;
@@ -120,7 +123,11 @@ public sealed class SpellcastingUiController : IRetainedPanelController
for (int i = 0; i < _lists.Length; i++)
{
- if (_lists[i] is not { } list || _scrollbars[i] is not { } scrollbar)
+ if (_lists[i] is not { } list)
+ continue;
+ list.PrimaryCatalogEntryPressed = SelectSpell;
+ list.ExamineCatalogEntryRequested = _examineSpell;
+ if (_scrollbars[i] is not { } scrollbar)
continue;
list.HorizontalScroll = true;
scrollbar.Horizontal = true;
@@ -161,7 +168,8 @@ public sealed class SpellcastingUiController : IRetainedPanelController
Action? addFavorite,
Action? removeFavorite,
UiShortcutDigitGraphics? shortcutDigits = null,
- uint emptySlotSprite = 0u)
+ uint emptySlotSprite = 0u,
+ Action? examineSpell = null)
{
if (layout.FindElement(CastButtonId) is not UiButton cast
|| layout.FindElement(EndowmentId) is not { } endowmentHost)
@@ -185,7 +193,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
return new SpellcastingUiController(
layout, spellbook, casting, objects, playerGuid, resolveSpellIcon,
- resolveItemDragIcon, useItem, selection,
+ resolveItemDragIcon, useItem, examineSpell, selection,
addFavorite, removeFavorite,
tabs, groups, lists, scrollbars, cast, endowmentHost,
shortcutDigits, emptySlotSprite);
@@ -357,8 +365,11 @@ public sealed class SpellcastingUiController : IRetainedPanelController
DropSpellbookShortcut(shortcut, targetTab, position);
},
};
- slot.Clicked = () => SelectSpell(id);
- slot.DoubleClicked = () => { SelectSpell(id); CastSelected(); };
+ // gmSpellcastingUI::ListenToElementMessage @ 0x004C7AB0
+ // selects on left press (message parameter 7). A completed
+ // double click casts, but release is not required merely to
+ // change the current spell.
+ slot.DoubleClicked = CastSelected;
list.AddItem(slot);
}
}
@@ -557,6 +568,12 @@ public sealed class SpellcastingUiController : IRetainedPanelController
_objects.ObjectRemoved -= OnObjectChanged;
_objects.Cleared -= OnObjectsCleared;
foreach (UiElement tab in _tabs) SetClick(tab, null);
+ foreach (UiItemList? list in _lists)
+ {
+ if (list is null) continue;
+ list.PrimaryCatalogEntryPressed = null;
+ list.ExamineCatalogEntryRequested = null;
+ }
_cast.OnClick = null;
}
diff --git a/src/AcDream.App/UI/RetailUiRuntime.cs b/src/AcDream.App/UI/RetailUiRuntime.cs
index c6f7cd99..7331e345 100644
--- a/src/AcDream.App/UI/RetailUiRuntime.cs
+++ b/src/AcDream.App/UI/RetailUiRuntime.cs
@@ -60,6 +60,8 @@ public sealed record MagicRuntimeBindings(
Func ResolveComponentIcon,
SelectionState Selection,
Func SpellLevel,
+ Func> SpellComponents,
+ Func MagicSkill,
Action SelectObject,
Action UseItem,
Action AddFavorite,
@@ -831,7 +833,8 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Magic.AddFavorite,
_bindings.Magic.RemoveFavorite,
LoadShortcutDigitGraphics(),
- favoriteEmptySprite);
+ favoriteEmptySprite,
+ examineSpell: spellId => AppraisalController?.ExamineSpell(spellId));
if (spellcasting is null)
Console.WriteLine("[M3] spellcasting: required controls missing in LayoutDesc 0x21000073.");
@@ -965,6 +968,7 @@ public sealed class RetailUiRuntime : IDisposable
{
ImportedLayout? layout;
CreatureAppraisalRowTemplateFactory? creatureRows;
+ SpellExamineComponentTemplateFactory? spellComponentTemplates;
CreatureDisplayNameResolver? creatureNames;
RetailAppraisalNameResolver? itemNames;
lock (_bindings.Assets.DatLock)
@@ -981,6 +985,12 @@ public sealed class RetailUiRuntime : IDisposable
_bindings.Assets.ResolveSprite,
_bindings.Assets.DefaultFont,
_bindings.Assets.ResolveFont);
+ spellComponentTemplates =
+ SpellExamineComponentTemplateFactory.TryLoad(
+ _bindings.Assets.Dats,
+ _bindings.Assets.ResolveSprite,
+ _bindings.Assets.DefaultFont,
+ _bindings.Assets.ResolveFont);
creatureNames = CreatureDisplayNameResolver.Load(
_bindings.Assets.Dats);
itemNames = RetailAppraisalNameResolver.Load(
@@ -1008,7 +1018,12 @@ public sealed class RetailUiRuntime : IDisposable
close: () => CloseWindow(WindowNames.Examination),
creatureRowTemplates: creatureRows,
creatureNames: creatureNames,
- itemNames: itemNames);
+ itemNames: itemNames,
+ resolveSpellIcon: _bindings.Magic.ResolveSpellIcon,
+ resolveComponentIcon: _bindings.Magic.ResolveComponentIcon,
+ spellComponents: _bindings.Magic.SpellComponents,
+ magicSkill: _bindings.Magic.MagicSkill,
+ spellComponentTemplates: spellComponentTemplates);
if (controller is null)
{
Console.WriteLine(
diff --git a/src/AcDream.App/UI/UiCatalogSlot.cs b/src/AcDream.App/UI/UiCatalogSlot.cs
index fc59a72e..a6d36372 100644
--- a/src/AcDream.App/UI/UiCatalogSlot.cs
+++ b/src/AcDream.App/UI/UiCatalogSlot.cs
@@ -58,6 +58,9 @@ public sealed class UiCatalogSlot : UiItemSlot
switch (e.Type)
{
case UiEventType.MouseDown:
+ if (EntryId != 0u
+ && FindList() is { PrimaryCatalogEntryPressed: { } pressed })
+ pressed(EntryId);
return true;
case UiEventType.Click:
Clicked?.Invoke();
@@ -65,6 +68,11 @@ public sealed class UiCatalogSlot : UiItemSlot
case UiEventType.DoubleClick:
DoubleClicked?.Invoke();
return true;
+ case UiEventType.RightClick:
+ if (EntryId != 0u
+ && FindList() is { ExamineCatalogEntryRequested: { } examine })
+ examine(EntryId);
+ return true;
case UiEventType.DragBegin:
if (e.Payload is not null) DragBegan?.Invoke(e.Payload);
return true;
diff --git a/src/AcDream.App/UI/UiItemList.cs b/src/AcDream.App/UI/UiItemList.cs
index bf48e6d2..d96b3654 100644
--- a/src/AcDream.App/UI/UiItemList.cs
+++ b/src/AcDream.App/UI/UiItemList.cs
@@ -96,6 +96,25 @@ public sealed class UiItemList : UiElement
///
public Action? ExamineItemRequested { get; set; }
+ ///
+ /// Non-weenie catalog-entry left-button-down command supplied by the owning
+ /// panel. Spell favorites use this distinct path so a spell id is never
+ /// published as the globally selected object GUID.
+ ///
+ /// Retail gmSpellcastingUI::ListenToElementMessage @ 0x004C7AB0
+ /// handles UIItem message parameter 7 (left press) by calling
+ /// SpellCastSubMenu::SetSelected.
+ ///
+ public Action? PrimaryCatalogEntryPressed { get; set; }
+
+ ///
+ /// Local examination command for non-weenie catalog entries. Retail
+ /// UIElement_ItemList::ListenToElementMessage @ 0x004E4D50
+ /// routes right-clicked spellID values to
+ /// ClientUISystem::ExamineSpell without changing the selected object.
+ ///
+ public Action? ExamineCatalogEntryRequested { get; set; }
+
private uint _cellEmptySprite;
/// Empty-slot sprite for THIS list's cells, resolved from the dat cell template
/// (retail attribute 0x1000000e -> catalog 0x21000037 prototype's ItemSlot_Empty; see
diff --git a/src/AcDream.App/UI/UiItemSlot.cs b/src/AcDream.App/UI/UiItemSlot.cs
index b77acb86..898c14af 100644
--- a/src/AcDream.App/UI/UiItemSlot.cs
+++ b/src/AcDream.App/UI/UiItemSlot.cs
@@ -180,7 +180,7 @@ public class UiItemSlot : UiElement
public override bool IsDragSource => ItemId != 0;
/// Walk up to the containing (the drop handler owner).
- private UiItemList? FindList()
+ protected UiItemList? FindList()
{
UiElement? e = Parent;
while (e is not null) { if (e is UiItemList l) return l; e = e.Parent; }
diff --git a/tests/AcDream.App.Tests/Spells/SpellComponentRequirementServiceTests.cs b/tests/AcDream.App.Tests/Spells/SpellComponentRequirementServiceTests.cs
index 0f75d6e5..cf449c0b 100644
--- a/tests/AcDream.App.Tests/Spells/SpellComponentRequirementServiceTests.cs
+++ b/tests/AcDream.App.Tests/Spells/SpellComponentRequirementServiceTests.cs
@@ -165,6 +165,39 @@ public sealed class SpellComponentRequirementServiceTests
Assert.False(service.HasRequiredComponents(50u));
}
+ [Fact]
+ public void ExaminationFormulaPreservesOrderAndReportsNestedOwnership()
+ {
+ var objects = new ClientObjectTable();
+ objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
+ objects.AddOrUpdate(new ClientObject
+ {
+ ObjectId = 2u,
+ ContainerId = 1u,
+ Type = ItemType.Container,
+ });
+ objects.AddOrUpdate(new ClientObject
+ {
+ ObjectId = 3u,
+ WeenieClassId = 101u,
+ ContainerId = 2u,
+ });
+ var service = Service(
+ objects,
+ [10u, 11u, 10u],
+ new Dictionary
+ {
+ [10u] = 101u,
+ [11u] = 102u,
+ });
+
+ Assert.Equal(
+ new uint[] { 10u, 11u, 10u },
+ service.GetAppropriateFormula(50u));
+ Assert.True(service.IsComponentOwned(10u));
+ Assert.False(service.IsComponentOwned(11u));
+ }
+
private static SpellComponentRequirementService Service(
ClientObjectTable objects,
IReadOnlyList components,
diff --git a/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs b/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs
index 4dafc157..d5452b3d 100644
--- a/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs
+++ b/tests/AcDream.App.Tests/UI/DragDropSpineTests.cs
@@ -245,6 +245,30 @@ public class DragDropSpineTests
return (root, list, list.Cell);
}
+ private static (UiRoot root, UiItemList list, UiCatalogSlot cell)
+ RootWithCatalogSlot(uint entryId)
+ {
+ var root = new UiRoot { Width = 800, Height = 600 };
+ var list = new UiItemList(_ => (1u, 1, 1))
+ {
+ Left = 0,
+ Top = 0,
+ Width = 32,
+ Height = 32,
+ };
+ list.Flush();
+ var cell = new UiCatalogSlot
+ {
+ EntryId = entryId,
+ Width = 32,
+ Height = 32,
+ SpriteResolve = _ => (1u, 1, 1),
+ };
+ list.AddItem(cell);
+ root.AddChild(list);
+ return (root, list, cell);
+ }
+
[Fact]
public void BeginDrag_arms_whenPayloadNonNull()
{
@@ -359,6 +383,32 @@ public class DragDropSpineTests
Assert.Null(root.DragSource);
}
+ [Fact]
+ public void CatalogEntryPress_selectsBeforeRelease_withoutForgingItemIdentity()
+ {
+ var (root, list, cell) = RootWithCatalogSlot(42u);
+ var selected = new List();
+ list.PrimaryCatalogEntryPressed = selected.Add;
+
+ root.OnMouseDown(UiMouseButton.Left, 10, 10);
+
+ Assert.Equal(new uint[] { 42u }, selected);
+ Assert.Equal(0u, cell.ItemId);
+ }
+
+ [Fact]
+ public void CatalogEntryRightClick_requestsLocalCatalogExamination()
+ {
+ var (root, list, _) = RootWithCatalogSlot(42u);
+ var examined = new List();
+ list.ExamineCatalogEntryRequested = examined.Add;
+
+ root.OnMouseDown(UiMouseButton.Right, 10, 10);
+ root.OnMouseUp(UiMouseButton.Right, 10, 10);
+
+ Assert.Equal(new uint[] { 42u }, examined);
+ }
+
[Fact]
public void CompletedDrag_doesNotFireUse()
{
diff --git a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs
index 93a1bc73..bb05f10d 100644
--- a/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/AppraisalUiControllerTests.cs
@@ -1,4 +1,5 @@
using System.Numerics;
+using AcDream.App.Spells;
using AcDream.App.UI;
using AcDream.App.UI.Layout;
using AcDream.Core.Combat;
@@ -688,6 +689,103 @@ public sealed class AppraisalUiControllerTests
mounted => mounted.WindowName == WindowNames.Examination);
}
+ [Fact]
+ public void SpellExamination_UsesAuthoredLocalSubviewWithoutSelectingOrAppraisingSpellId()
+ {
+ ImportedLayout layout = FixtureLoader.LoadExamination();
+ var objects = new ClientObjectTable();
+ objects.AddOrUpdate(new ClientObject
+ {
+ ObjectId = ObjectId,
+ Name = "Selected Drudge",
+ Type = ItemType.Creature,
+ });
+ var sent = new List();
+ using var interaction = NewInteraction(objects, sent);
+ var selection = new SelectionState();
+ selection.Select(ObjectId, SelectionChangeSource.World);
+ SpellMetadata metadata = ExaminedSpell();
+ var spellbook = new Spellbook(SpellTable.Create([metadata]));
+ var components = new[]
+ {
+ new SpellExamineComponent(
+ 10u,
+ new SpellComponentDescriptor(100u, "Lead Scarab", 0u, 0x06000010u),
+ Owned: true),
+ new SpellExamineComponent(
+ 11u,
+ new SpellComponentDescriptor(101u, "Malar Herb", 1u, 0x06000011u),
+ Owned: false),
+ };
+ int shown = 0;
+ using AppraisalUiController controller = Bind(
+ layout,
+ objects,
+ interaction,
+ new CombatState(),
+ [],
+ [],
+ () => shown++,
+ () => { },
+ selection: selection,
+ spellbook: spellbook,
+ resolveSpellIcon: id => id + 1_000u,
+ spellComponents: _ => components,
+ magicSkill: _ => 200u)!;
+
+ Assert.True(interaction.ExamineSelectedOrEnterMode(ObjectId));
+ Assert.Equal(1, interaction.BusyCount);
+
+ Assert.True(controller.ExamineSpell(metadata.SpellId));
+
+ Assert.Equal(AppraisalView.Spell, controller.ActiveView);
+ Assert.Equal(1, shown);
+ Assert.Equal(ObjectId, selection.SelectedObjectId);
+ Assert.Equal(new uint[] { ObjectId, 0u }, sent);
+ Assert.Equal(0, interaction.BusyCount);
+ Assert.Equal(0u, controller.CurrentObjectId);
+ Assert.True(layout.FindElement(AppraisalUiController.SpellPanelId)!.Visible);
+ Assert.False(layout.FindElement(AppraisalUiController.ItemPanelId)!.Visible);
+ Assert.False(layout.FindElement(AppraisalUiController.CreaturePanelId)!.Visible);
+
+ AssertSpellText(
+ layout,
+ AppraisalUiController.TitleId,
+ "Incantation of Test");
+ AssertSpellText(
+ layout,
+ AppraisalUiController.SpellSchoolTextId,
+ "School: War Magic");
+ AssertSpellText(
+ layout,
+ AppraisalUiController.SpellManaTextId,
+ "Mana: 50 + 14 per target");
+ AssertSpellText(
+ layout,
+ AppraisalUiController.SpellDurationTextId,
+ "Duration: 1 min.");
+ AssertSpellText(
+ layout,
+ AppraisalUiController.SpellRangeTextId,
+ "Range: 82.0 yds.");
+ string display = string.Join(
+ '\n',
+ ((UiText)layout.FindElement(
+ AppraisalUiController.SpellDisplayTextId)!)
+ .LinesProvider()
+ .Select(line => line.Text));
+ Assert.Contains("A projected retail spell.", display);
+ Assert.Contains("COMPONENTS:", display);
+ Assert.Contains("Lead Scarab", display);
+ Assert.Contains("Malar Herb", display);
+
+ UiElement iconHost = layout.FindElement(
+ AppraisalUiController.SpellIconId)!;
+ UiTextureElement icon = Assert.Single(
+ iconHost.Children.OfType());
+ Assert.Equal(metadata.SpellId + 1_000u, icon.Texture);
+ }
+
private static AppraisalUiController? Bind(
ImportedLayout layout,
ClientObjectTable objects,
@@ -700,14 +798,20 @@ public sealed class AppraisalUiControllerTests
CreatureAppraisalRowTemplateFactory? creatureRows = null,
CreatureDisplayNameResolver? creatureNames = null,
SelectionState? selection = null,
- RetailAppraisalNameResolver? itemNames = null)
+ RetailAppraisalNameResolver? itemNames = null,
+ Spellbook? spellbook = null,
+ Func? resolveSpellIcon = null,
+ Func? resolveComponentIcon = null,
+ Func>? spellComponents = null,
+ Func? magicSkill = null,
+ SpellExamineComponentTemplateFactory? spellComponentTemplates = null)
=> AppraisalUiController.Bind(
layout,
objects,
interaction,
selection ?? new SelectionState(),
combat,
- new Spellbook(),
+ spellbook ?? new Spellbook(),
() => "Tester",
(objectId, text) => inscriptions.Add((objectId, text)),
messages.Add,
@@ -715,7 +819,12 @@ public sealed class AppraisalUiControllerTests
close,
creatureRows,
creatureNames,
- itemNames);
+ itemNames,
+ resolveSpellIcon,
+ resolveComponentIcon,
+ spellComponents,
+ magicSkill,
+ spellComponentTemplates);
private static ItemInteractionController NewInteraction(
ClientObjectTable objects,
@@ -749,4 +858,48 @@ public sealed class AppraisalUiControllerTests
ArmorEnchantments: null,
WeaponEnchantments: null,
ResistEnchantments: null);
+
+ private static void AssertSpellText(
+ ImportedLayout layout,
+ uint elementId,
+ string expected)
+ {
+ UiText text = Assert.IsType(layout.FindElement(elementId));
+ Assert.Equal(
+ expected,
+ string.Join('\n', text.LinesProvider().Select(line => line.Text)));
+ }
+
+ private static SpellMetadata ExaminedSpell()
+ => new(
+ SpellId: 42u,
+ Name: "Incantation of Test",
+ School: "War Magic",
+ Family: 1u,
+ IconId: 0x06001234u,
+ SpellWords: "Malar Aether",
+ Duration: 90f,
+ ManaCost: 50,
+ IsDebuff: false,
+ IsFellowship: false,
+ Description: "A projected retail spell.",
+ SortKey: 0,
+ Difficulty: 0,
+ Flags: 0u,
+ Generation: 6,
+ IsFastWindup: false,
+ IsOffensive: true,
+ IsUntargeted: false,
+ Speed: 0f,
+ CasterEffect: 0u,
+ TargetEffect: 0u,
+ TargetMask: 0u,
+ SpellType: 0)
+ {
+ SchoolId = MagicSchool.WarMagic,
+ BaseRangeConstant = 40f,
+ BaseRangeModifier = 0.25f,
+ ManaModifier = 14u,
+ FormulaComponents = [10u, 11u],
+ };
}
diff --git a/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs b/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs
index 04c454c2..c7713d01 100644
--- a/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs
+++ b/tests/AcDream.App.Tests/UI/Layout/SpellcastingUiControllerTests.cs
@@ -280,6 +280,45 @@ public sealed class SpellcastingUiControllerTests
Assert.Empty(used);
}
+ [Fact]
+ public void FavoritePressSelectsImmediately_AndRightClickExaminesLocally()
+ {
+ ImportedLayout layout = LayoutImporter.Build(
+ FixtureLoader.LoadCombatInfos(), NoTex, datFont: null);
+ var spellbook = new Spellbook();
+ spellbook.OnSpellLearned(42u, 1f);
+ spellbook.OnSpellLearned(43u, 1f);
+ spellbook.SetFavorite(0, 0, 42u);
+ spellbook.SetFavorite(0, 1, 43u);
+ var objects = new ClientObjectTable();
+ objects.AddOrUpdate(new ClientObject { ObjectId = 1u, Name = "Player" });
+ var selection = new SelectionState();
+ var examined = new List();
+ using SpellcastingUiController controller = Bind(
+ layout,
+ spellbook,
+ objects,
+ _ => { },
+ selection: selection,
+ examineSpell: examined.Add)!;
+ UiElement group = layout.FindElement(0x100000AAu)!;
+ UiItemList list = Descendants(group).OfType().First();
+ UiCatalogSlot first = Assert.IsType(list.GetItem(0));
+ UiCatalogSlot second = Assert.IsType(list.GetItem(1));
+ Assert.True(first.Selected);
+
+ second.OnEvent(new UiEvent(0u, second, UiEventType.MouseDown));
+
+ Assert.False(first.Selected);
+ Assert.True(second.Selected);
+ Assert.Null(selection.SelectedObjectId);
+
+ second.OnEvent(new UiEvent(0u, second, UiEventType.RightClick));
+
+ Assert.Equal(new uint[] { 43u }, examined);
+ Assert.Null(selection.SelectedObjectId);
+ }
+
private static SpellcastingUiController? Bind(
ImportedLayout layout,
Spellbook spellbook,
@@ -287,7 +326,9 @@ public sealed class SpellcastingUiControllerTests
Action useItem,
Action? addFavorite = null,
UiShortcutDigitGraphics? shortcutDigits = null,
- uint emptySlotSprite = 0u)
+ uint emptySlotSprite = 0u,
+ SelectionState? selection = null,
+ Action? examineSpell = null)
{
var casting = new SpellCastingController(
spellbook, () => null, () => 1u, () => { }, _ => { }, (_, _) => { }, _ => { });
@@ -296,11 +337,12 @@ public sealed class SpellcastingUiControllerTests
spellId => spellId,
item => item.ObjectId,
useItem,
- new SelectionState(),
+ selection ?? new SelectionState(),
addFavorite ?? ((_, _, _) => { }),
(_, _) => { },
shortcutDigits,
- emptySlotSprite);
+ emptySlotSprite,
+ examineSpell);
}
private static void ApplyAnchors(UiElement parent)