fix(ui): select and examine favorite spells like retail

This commit is contained in:
Erik 2026-07-24 06:47:54 +02:00
parent 043ab10b3c
commit 3e31b0ac70
23 changed files with 974 additions and 34 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -17,6 +17,12 @@ public sealed record SpellComponentDescriptor(
uint Category,
uint IconId);
/// <summary>One resolved formula cell in retail's spell examination subview.</summary>
public readonly record struct SpellExamineComponent(
uint SpellComponentId,
SpellComponentDescriptor Descriptor,
bool Owned);
/// <summary>
/// App-layer projection of retail's spell, component, and school-focus DAT tables.
/// Keeping this catalog outside <c>GameWindow</c> makes the SCID/WCID boundary and
@ -52,6 +58,20 @@ public sealed class MagicCatalog
public CoreSpellTable SpellTable { get; }
public IReadOnlyDictionary<uint, SpellComponentDescriptor> 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
/// </summary>
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<SpellExamineComponent> GetExamineComponents(uint spellId)
{
IReadOnlyList<uint> formula = _requirements.GetAppropriateFormula(spellId);
if (formula.Count == 0)
return [];
var result = new List<SpellExamineComponent>(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();

View file

@ -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.
/// </summary>
public IReadOnlyList<uint> GetAppropriateFormula(uint spellId)
{
if (!_formulas.TryGetValue(spellId, out SpellFormulaDefinition? formula))
return [];
uint playerGuid = _playerGuid();
return GetAppropriateFormula(
formula,
_objects.Get(playerGuid),
playerGuid);
}
/// <summary>
/// Whether the component tracker would leave a formula icon unghosted.
/// The input is a retail spell-component id (SCID), not a weenie class id.
/// </summary>
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<uint> GetAppropriateFormula(
SpellFormulaDefinition formula,
ClientObject? player,

View file

@ -553,6 +553,31 @@ public sealed class ItemInteractionController : IDisposable
return true;
}
/// <summary>
/// Releases only the object-appraisal transaction before the local spell
/// examination subview takes ownership of the shared floaty window.
///
/// Retail <c>gmExaminationUI::ExamineSpell @ 0x004B6900</c> decrements the
/// appraisal busy reference when a response is pending, clears both pending
/// and current object ids, and sends <c>CM_Item::Event_Appraise(0)</c>. It
/// does not clear unrelated use/inventory busy references.
/// </summary>
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();
}
/// <summary>
/// Projects retail <c>gmToolbarUI::HandleSelectionChanged @ 0x004BF380</c>
/// from the live selected object's public description. This is deliberately

View file

@ -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<uint, uint> _resolveSpellIcon;
private readonly Func<uint, uint> _resolveComponentIcon;
private readonly Func<uint, IReadOnlyList<SpellExamineComponent>> _spellComponents;
private readonly Func<MagicSchool, uint> _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<UiElement> _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<uint, uint>? resolveSpellIcon,
Func<uint, uint>? resolveComponentIcon,
Func<uint, IReadOnlyList<SpellExamineComponent>>? spellComponents,
Func<MagicSchool, uint>? 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<uint, string>());
_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<uint, uint>? resolveSpellIcon = null,
Func<uint, uint>? resolveComponentIcon = null,
Func<uint, IReadOnlyList<SpellExamineComponent>>? spellComponents = null,
Func<MagicSchool, uint>? 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);
}
/// <summary>
/// Opens retail's local spell-examination subview. Unlike object appraisal,
/// this performs no network request and never changes the globally selected
/// object. Source: <c>gmExaminationUI::ExamineSpell @ 0x004B6900</c> and
/// <c>SpellExamineUI::ExamineSpell @ 0x004B6210</c>.
/// </summary>
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<SpellExamineComponent> 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;
}
/// <summary>
@ -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<SpellExamineComponent> 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<SpellExamineComponent> 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<SpellExamineComponent> 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<UiScrollbar>())
if (ReferenceEquals(scrollbar.Model, _itemText.Scroll)
|| (_inscriptionText is not null
@ -735,4 +988,5 @@ public enum AppraisalView
Item,
Creature,
Character,
Spell,
}

View file

@ -0,0 +1,95 @@
using AcDream.Content;
using DatReaderWriter;
namespace AcDream.App.UI.Layout;
/// <summary>
/// Instantiates the authored formula-icon template used by retail
/// <c>SpellExamineUI::ExamineSpell @ 0x004B6210</c>. The controller replaces
/// the template root's placeholder image with the resolved component icon while
/// retaining child <c>0x10000330</c> as the missing-component overlay.
/// </summary>
public sealed class SpellExamineComponentTemplateFactory
{
public const uint TemplateId = 0x1000032Eu;
public const uint MissingOverlayId = 0x10000330u;
private readonly ElementInfo _template;
private readonly Func<uint, (uint tex, int w, int h)> _resolveSprite;
private readonly UiDatFont? _defaultFont;
private readonly IReadOnlyDictionary<uint, UiDatFont?> _fonts;
public SpellExamineComponentTemplateFactory(
ElementInfo template,
Func<uint, (uint tex, int w, int h)> resolveSprite,
UiDatFont? defaultFont,
IReadOnlyDictionary<uint, UiDatFont?>? fonts = null)
{
_template = template ?? throw new ArgumentNullException(nameof(template));
_resolveSprite = resolveSprite ?? throw new ArgumentNullException(nameof(resolveSprite));
_defaultFont = defaultFont;
_fonts = fonts ?? new Dictionary<uint, UiDatFont?>();
}
public static SpellExamineComponentTemplateFactory? TryLoad(
IDatReaderWriter dats,
Func<uint, (uint tex, int w, int h)> resolveSprite,
UiDatFont? defaultFont,
Func<uint, UiDatFont?>? resolveFont)
{
ElementInfo? template = LayoutImporter.ImportInfos(
dats,
AppraisalUiController.LayoutId,
TemplateId);
if (template is null)
return null;
var fonts = new Dictionary<uint, UiDatFont?>();
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<uint, UiDatFont?>? resolveFont,
Dictionary<uint, UiDatFont?> 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);
}
}

View file

@ -44,6 +44,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
private readonly Func<uint, uint> _resolveSpellIcon;
private readonly Func<ClientObject, uint> _resolveItemDragIcon;
private readonly Action<uint> _useItem;
private readonly Action<uint>? _examineSpell;
private readonly Action<int, int, uint>? _addFavorite;
private readonly Action<int, uint>? _removeFavorite;
private readonly UiShortcutDigitGraphics? _shortcutDigits;
@ -74,6 +75,7 @@ public sealed class SpellcastingUiController : IRetainedPanelController
Func<uint, uint> resolveSpellIcon,
Func<ClientObject, uint> resolveItemDragIcon,
Action<uint> useItem,
Action<uint>? examineSpell,
SelectionState selection,
Action<int, int, uint>? addFavorite,
Action<int, uint>? 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<int, int, uint>? addFavorite,
Action<int, uint>? removeFavorite,
UiShortcutDigitGraphics? shortcutDigits = null,
uint emptySlotSprite = 0u)
uint emptySlotSprite = 0u,
Action<uint>? 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;
}

View file

@ -60,6 +60,8 @@ public sealed record MagicRuntimeBindings(
Func<uint, uint> ResolveComponentIcon,
SelectionState Selection,
Func<uint, int> SpellLevel,
Func<uint, IReadOnlyList<SpellExamineComponent>> SpellComponents,
Func<MagicSchool, uint> MagicSkill,
Action<uint> SelectObject,
Action<uint> UseItem,
Action<int, int, uint> 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(

View file

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

View file

@ -96,6 +96,25 @@ public sealed class UiItemList : UiElement
/// </summary>
public Action<uint>? ExamineItemRequested { get; set; }
/// <summary>
/// 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 <c>gmSpellcastingUI::ListenToElementMessage @ 0x004C7AB0</c>
/// handles UIItem message parameter 7 (left press) by calling
/// <c>SpellCastSubMenu::SetSelected</c>.
/// </summary>
public Action<uint>? PrimaryCatalogEntryPressed { get; set; }
/// <summary>
/// Local examination command for non-weenie catalog entries. Retail
/// <c>UIElement_ItemList::ListenToElementMessage @ 0x004E4D50</c>
/// routes right-clicked <c>spellID</c> values to
/// <c>ClientUISystem::ExamineSpell</c> without changing the selected object.
/// </summary>
public Action<uint>? ExamineCatalogEntryRequested { 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

View file

@ -180,7 +180,7 @@ public class UiItemSlot : UiElement
public override bool IsDragSource => ItemId != 0;
/// <summary>Walk up to the containing <see cref="UiItemList"/> (the drop handler owner).</summary>
private UiItemList? FindList()
protected UiItemList? FindList()
{
UiElement? e = Parent;
while (e is not null) { if (e is UiItemList l) return l; e = e.Parent; }

View file

@ -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<uint, uint>
{
[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<uint> components,

View file

@ -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<uint>();
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<uint>();
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()
{

View file

@ -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<uint>();
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<UiTextureElement>());
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<uint, uint>? resolveSpellIcon = null,
Func<uint, uint>? resolveComponentIcon = null,
Func<uint, IReadOnlyList<SpellExamineComponent>>? spellComponents = null,
Func<MagicSchool, uint>? 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<UiText>(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],
};
}

View file

@ -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<uint>();
using SpellcastingUiController controller = Bind(
layout,
spellbook,
objects,
_ => { },
selection: selection,
examineSpell: examined.Add)!;
UiElement group = layout.FindElement(0x100000AAu)!;
UiItemList list = Descendants(group).OfType<UiItemList>().First();
UiCatalogSlot first = Assert.IsType<UiCatalogSlot>(list.GetItem(0));
UiCatalogSlot second = Assert.IsType<UiCatalogSlot>(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<uint> useItem,
Action<int, int, uint>? addFavorite = null,
UiShortcutDigitGraphics? shortcutDigits = null,
uint emptySlotSprite = 0u)
uint emptySlotSprite = 0u,
SelectionState? selection = null,
Action<uint>? 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)