feat: port retail magic lifecycle and retained spell UI

Complete the retail cast-intent, target, component, enchantment, and busy-state paths; mount the DAT-authored spell bar, spellbook, component book, effects panels, and shared panel lifecycle; and add scoped input plus conformance coverage.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-15 10:55:22 +02:00
parent 7b7ffcd278
commit 07be994d97
84 changed files with 17822 additions and 1051 deletions

View file

@ -0,0 +1,277 @@
# Retail magic UI and casting pseudocode
This note is the implementation oracle for the M3 cast lifecycle and retained
spell UI. Retail addresses refer to the September 2013 EoR named client.
Wire layouts were cross-checked against ACE and the existing holtburger-derived
`PlayerDescriptionParser`.
## Cast request ownership
Retail: `ClientMagicSystem::CastSpell` `0x00568040` and
`ClientMagicSystem::FreeHandsAndCastSpell` `0x00566EF0`.
```text
CastSpell(spellId, useSelectedTarget):
spell = SpellTable[spellId]
if components are required:
formula = GetAppropriateSpellFormula(spell)
if any required component is absent:
display "You do not have all of this spell's components."
return
if spell targets self:
target = local player id (or untargeted when no SmartBox exists)
FreeHandsAndCastSpell(spellId, target)
return
if spell has no target type:
MaybeStopCompletely()
send CastUntargetedSpell(spellId)
increment UI busy count
return
target = selected object
if target == 0:
display "You must select a suitable target."
return
if the selected GUID no longer resolves to an object:
return silently
if ObjectCompatibleWithSpell(target, spellId):
FreeHandsAndCastSpell(spellId, target)
reselect target
FreeHandsAndCastSpell(spellId, target):
MaybeStopCompletely()
if target == 0: send CastUntargetedSpell(spellId)
else: send CastTargetedSpell(target, spellId)
increment UI busy count
```
The request is sent immediately. ACE remains authoritative for turning,
casting motion, component consumption, mana use, fizzle, impact, damage, and
enchantment state. The client does not invent a parallel gameplay outcome.
### Formula selection and target compatibility
Retail: `ClientMagicSystem::GetAppropriateSpellFormula` `0x00567D50`,
`CSpellBase::InqScarabOnlyFormula` `0x00597050`,
`ACCWeenieObject::MagicPackIsOwned` `0x0058CB80`, and
`ClientMagicSystem::ObjectCompatibleWithSpellTargetType` `0x00567230`.
```text
GetAppropriateSpellFormula(spell):
schoolFocusWcid = SchoolOfMagic2WCID(spell.school)
infusedProperty = [War 0x129, Life 0x128, Item 0x127,
Creature 0x126, Void 0x148][school]
if infusedProperty > 0 OR a directly carried side-pack has schoolFocusWcid:
retain every power component in formula order
strongest = highest retained component power
append prismatic taper SCID 0xBC:
power 1 => 1; power 2 => 2; power 3/4/7 => 3;
power 5/6/8/9/10 => 4
return result
return account-customized formula
ObjectCompatibleWithSpellTargetType(target, targetMask):
special = targetMask & 0x8107
reject self when special == 0
reject stacks
require (targetMask & target.type) != 0 OR special != 0
require target.IsPlayer OR target.publicBitfield has BF_ATTACKABLE
require target.petOwner == 0
```
The final Player/Attackable and pet gates apply after both the ordinary type
match and the special-mask bypass. Formula entries are SCIDs; inventory and
favorite-component events use WCIDs, resolved through DualEnumIDMap
`0x27000002`. The magic-school focus map is retail enum category `0x28`, key
`0x10000001`.
## Authoritative enchantment events
Retail dispatchers:
- `CM_Magic::DispatchUI_UpdateEnchantment` `0x006A32F0`
- `CM_Magic::DispatchUI_RemoveEnchantment` `0x006A2FB0`
- `CM_Magic::DispatchUI_DispelEnchantment` `0x006A2F20`
```text
0x02C2 UpdateEnchantment:
unpack one Enchantment record
0x02C4 UpdateMultipleEnchantments:
u32 count
repeat count times: unpack Enchantment record
Enchantment record (60 or 64 bytes):
u16 spellId
u16 layer
u16 spellCategory
u16 hasSpellSetId
u32 powerLevel
f64 startTime
f64 duration
u32 casterGuid
f32 degradeModifier
f32 degradeLimit
f64 lastDegraded
u32 statModType
u32 statModKey
f32 statModValue
if hasSpellSetId: u32 spellSetId
0x02C3 RemoveEnchantment:
u16 spellId
u16 layer
0x02C7 DispelEnchantment:
u16 spellId
u16 layer
0x02C5 / 0x02C8 Remove/DispelMultipleEnchantments:
u32 count
repeat count times: u16 spellId, u16 layer
0x02C6 PurgeEnchantments:
no payload; clear the authoritative active set
```
## Magic combat page (spell bar)
Retail: `gmSpellcastingUI::PostInit` `0x004C5D00`,
`SpellCastSubMenu::Init` `0x004C5A90`, and `gmSpellcastingUI::Cast`
`0x004C6050`.
The spell bar is the authored Magic page inside combat layout `0x21000073`.
It is not an advanced-combat placeholder.
```text
combat basic page 0x1000005C
spellcasting page 0x10000061
spellcasting content 0x100000A2
spell name text 0x1000048B
spellcasting background 0x100000A0
cast button 0x100000B2
endowment icon 0x100000B1
favorite tab buttons 0x100000A3..0x100000A9, 0x100005C2
favorite list groups 0x100000AA..0x100000B0, 0x100005C3
item list within each group 0x100000B6
```
There are eight favorite tabs. Each list is authoritative character state.
Selecting a spell updates its name and current spell. Cast invokes
`ClientMagicSystem::CastSpell(selectedSpell, true)`. PageUp/PageDown and the
retail spell input actions change tab/selection; the cast-current action casts
the current selection. Favorite edits are sent to ACE.
Outbound character events (`CM_Character`):
```text
0x01E3 AddSpellFavorite(spellId:u32, position:i32, tab:i32)
0x01E4 RemoveSpellFavorite(spellId:u32, tab:i32)
0x0286 SpellbookFilterEvent(filter:u32)
0x0224 SetDesiredComponentLevel(componentDid:u32, amount:i32)
```
## Spellbook and component book
Retail: `gmSpellbookUI::PostInit` `0x0048B2B0` and
`gmSpellComponentUI::PostInit` `0x0048A190`.
Both pages are authored in layout `0x21000034` (300 by 600 pixels):
```text
window root 0x100002A8
spellbook tab 0x100002A9
component tab 0x100002AA
close button 0x100002AB
spellbook page 0x100002AC (base 0x10000294 / 0x21000032)
component page 0x100002AD (base 0x100002A7 / 0x21000033)
spell list 0x10000295
school buttons 0x10000298..0x1000029B, 0x100005C0
level buttons 1..7 0x1000029C..0x100002A2
level button 8 0x1000054E
component item list 0x10000464
component scrollbar 0x10000465
```
Spellbook rows come only from the authoritative learned-spell set. Retail
sorts by `CSpellBase::_display_order`; school and level buttons apply the
server-persisted filter bitfield. Component rows use the installed component
table and the server-persisted desired purchase amounts.
## Active enchantment panels
Retail: `gmEffectsUI::PostInit` `0x004B7560` and
`gmEffectsUI::RebuildList` `0x004B8350`.
```text
layout 0x2100001B
positive root 0x1000011F (inherits 0x10000122)
negative root 0x10000121 (inherits 0x10000122)
shared effects template 0x10000122
effect list 0x10000123
information text 0x10000126
effect UI type property 0x1000000C
```
The `0x10000184`/`0x10000185` values used by `gmPanelUI` are panel-slot
identities, not roots in effects LayoutDesc `0x2100001B`. The two authored
effects roots carry their title/close overrides and inherit the list,
scrollbar, information area, and remaining chrome from `0x10000122`.
Retail `gmPanelUI::SetupChildren` (`0x004BC9E0`) registers those children as
panel IDs 4 and 5. `RecvNotice_SetPanelVisibility` (`0x004BC6F0`) owns one
active child: showing either effect panel hides the previous main panel, while
closing the active child hides the shared host. DAT bool property `0x10000049`
selects the exceptional save-and-restore-previous behavior. The resolved
Helpful/positive root sets it, so closing Helpful restores the preceding
ordinary panel. Harmful/negative does not set it and leaves the host empty.
The reopen path is not the main toolbar. Retail
`gmUIElement_EffectsIndicator::PostInit` (`0x004E6930`) reads effect type 1 or
2 from property `0x1000000C`, and `Update` (`0x004E6A50`) ghosts the button
when no matching enchantment exists. The floating indicators layout is
`0x21000071`; Helpful button `0x100000F5` opens panel 4 and Harmful button
`0x100000F6` opens panel 5. The docked equivalent is layout `0x21000015`.
Retail `LayoutDesc::InqFullDesc` (`0x0069A520`) resolves the base first and
then calls `ElementDesc::Incorporate` (`0x0069B5A0`). Child tables are merged
by element identity: base-only children remain, same-identity children are
incorporated recursively, and derived-only children are appended with their
read order shifted by the retained base-only count.
When visible, each panel rebuilds from the authoritative active-enchantment
snapshot and refreshes each token's duration at most once per second.
`CEnchantmentRegistry::GetEnchantmentsInEffect` (`0x00594540`) merges only the
multiplicative and additive registries, so Vitae and cooldown records neither
appear in these panels nor enable an indicator. Each incoming entry is dueled
against the current winner for its spell category (`Duel @ 0x005942B0`): higher
power wins, with later start time breaking an equal-power tie. Positive and
negative panels then test the winning spell's Beneficial flag and sort
alphabetically.
Live `AddEnchantmentToList @ 0x00593DF0` inserts a new identity at the bucket
head, while an existing identity refreshes in place; bulk UnPack retains its
received head-to-tail order. Since an exact power/start tie keeps the first
duel incumbent, the newest incrementally added identity wins an exact tie.
The Helpful/Harmful indicators intentionally use a different projection.
`CountSpellsInList @ 0x00593CD0` feeds the registry's two indicator counters by
visiting every raw mult/add node before any category duel. Therefore opposite-
sign spells in the same category can enable both indicators while only the duel
winner appears in the effects list.
Remaining duration is `(startTime + duration) - current game time`. Selecting
an active token stores its spell ID, so every layer of that spell highlights
together and shows the shared name/description; clicking either layer again
clears the selection.
## Portal presentation boundary
Recall and portal presentation remains DAT-driven. `/ls` starts the retail
recall motion (`0x10000153`); its animation hooks/PES chain supplies the cloud.
Remote disappearance and materialization use authoritative Hidden/UnHide state
and typed physics scripts. No generic teleport effect, guessed color, or local
teleport outcome is added by the spell UI work.