440 lines
18 KiB
Markdown
440 lines
18 KiB
Markdown
# 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
|
|
```
|
|
|
|
The two top-level tabs are not `UIElement_Button` instances. They resolve from
|
|
base `0x1000043A` as `UIElement_Text` (type 12), with state 11 `Closed` and
|
|
state 12 `Open`. Both states set `PassToChildren`; the three child chrome
|
|
elements carry the left/center/right Closed/Open RenderSurface media.
|
|
Their direct string property `0x17` resolves through the installed string
|
|
tables to `Spellbook` (string id `49867858`) and `Components` (string id
|
|
`79360818`).
|
|
|
|
Retail state and text construction used by these tabs:
|
|
|
|
```text
|
|
UIElement_Text::UIElement_Text @ 0x00468570
|
|
construct UIElement_Scrollable
|
|
bitField = 0x300 // OneLine bit 0x2 is clear
|
|
horizontalJustification = 2
|
|
verticalJustification = 4
|
|
marginUp/Down/Left/Right = 0
|
|
|
|
UIElement_Text::OnSetAttribute @ 0x0046A640
|
|
property 0x20 only:
|
|
SetOneLine(value)
|
|
// absent 0x20 therefore remains multi-line; do not force it true
|
|
|
|
UIElement::SetState @ 0x00464E70
|
|
next = desc.AccessStateDesc(stateId)
|
|
if next != current:
|
|
current = next
|
|
if next.PassToChildren:
|
|
for each child:
|
|
child.SetState(stateId)
|
|
diff old/new effective properties
|
|
call OnSetAttribute for changed properties
|
|
|
|
Retained rendering reduction:
|
|
draw the UIElement_Text background
|
|
draw its three stateful chrome children
|
|
draw the authored glyph content as the parent's foreground
|
|
```
|
|
|
|
Retail `UIElement_Text::DrawSelf @ 0x00467AA0` owns the glyphs while the child
|
|
regions own the state chrome. acdream's sprites and DAT glyphs share one
|
|
submission-ordered batch, so submitting the glyphs in the parent's foreground
|
|
pass preserves that same visible composition; submitting the entire parent
|
|
before its retained children lets the center chrome overpaint the caption.
|
|
This rule is derived structurally from `PassToChildren`, not from the two
|
|
spellbook element ids.
|
|
|
|
The Magic-combat favorite tab captions are authored text (`I` through `VIII`)
|
|
and intentionally omit property `0x20`. Their small rectangles still render
|
|
because retail begins with zero text margins. A generic four-pixel inset on
|
|
all sides leaves less than one DAT-font line and clips the captions completely;
|
|
that inset is not retail behavior.
|
|
|
|
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.
|
|
|
|
### Spellbook row, selection, filtering, dragging, and deletion
|
|
|
|
Named-retail anchors:
|
|
|
|
- `gmSpellbookUI::IsFilteredOut @ 0x0048AEB0`
|
|
- `gmSpellbookUI::GetSortedInsertionPlace @ 0x0048B440`
|
|
- `gmSpellbookUI::SetSelected @ 0x0048B540`
|
|
- `gmSpellbookUI::UpdateFilter @ 0x0048B5E0`
|
|
- `gmSpellbookUI::AddSpell @ 0x0048B7A0`
|
|
- `gmSpellbookUI::DeleteSpellDialogCallback @ 0x0048BC10`
|
|
- `gmSpellbookUI::DeleteSpell @ 0x0048BD50`
|
|
- `gmSpellbookUI::ListenToElementMessage @ 0x0048BFD0`
|
|
- `UIElement_UIItem::Init_UIItem_Spell_Shortcut @ 0x004E1140`
|
|
- `UIElement_ItemList::ItemList_AddSpellShortcut @ 0x004E40A0`
|
|
- `UIElement_ItemList::ItemList_InsertSpellShortcut @ 0x004E41B0`
|
|
- `CM_Magic::Event_RemoveSpell @ 0x006A3220`
|
|
|
|
DAT structure:
|
|
|
|
```text
|
|
spell list 0x10000295 (280 x 224)
|
|
spell scrollbar 0x10000296 (16 x 224)
|
|
spell-row prototype property 0x1000000E -> 0x10000343
|
|
shared UIItem catalog 0x21000037
|
|
row prototype 0x10000343 (280 x 32)
|
|
row background 0x06001396
|
|
selected overlay child 0x10000342 -> 0x06001397
|
|
label child 0x10000344 (x=42, width=230)
|
|
icon child 0x1000033B (x=0, 32 x 32)
|
|
school/level filters Type-1, 90 x 14; label property on parent
|
|
filter indicator child 0x10000328 (x=0, y=1, 13 x 13)
|
|
filter Normal/Highlight art 0x06004D15 / 0x06004D17
|
|
delete button 0x100002A5
|
|
delete label child 0x100002A6 (100 x 32, centered, font 0x40000001)
|
|
```
|
|
|
|
The row's authored background contains the narrow horizontal separator visible
|
|
between entries. It is not an invented procedural line. The selection surface
|
|
is a separate overlay, so selecting a spell does not replace its icon or label.
|
|
The filter controls are ordinary `UIElement_Button` objects whose states have
|
|
`PassToChildren`; the green selected face is therefore the `Highlight` media on
|
|
their 13-pixel child, while the parent owns the label and the 90-pixel hit box.
|
|
The label begins four pixels after that child. Conversely, the Delete button
|
|
owns its frame media but its caption/font live in a full-size text child. A
|
|
retained leaf must lift those authored child roles; discarding all button
|
|
children loses both the filter indicator and the Delete caption.
|
|
|
|
```text
|
|
AddSpell(spellID):
|
|
if spell metadata is missing: return
|
|
if IsFilteredOut(spell): return
|
|
insertion = first existing row whose displayOrder > spell.displayOrder
|
|
list.InsertSpellShortcut(spellID, insertion)
|
|
|
|
SetSelected(spellID):
|
|
for each spell shortcut row:
|
|
row.selectedOverlay = (row.spellID == spellID)
|
|
if selected: list.ScrollItemIntoView(row)
|
|
selectedSpellID = spellID
|
|
|
|
ListenToElementMessage(message):
|
|
if message came from a spell shortcut row:
|
|
if message kind is click or double-click: SetSelected(row.spellID)
|
|
if message kind is AddSpellShortcut/drag notice:
|
|
CM_Magic.SendNotice_AddSpellShortcut(row.spellID)
|
|
if message is click on a school/level filter:
|
|
UpdateFilter(clickedFilter)
|
|
if message is click on 0x100002A5:
|
|
DeleteSpell()
|
|
|
|
UpdateFilter(button):
|
|
mask = exact school/level bit assigned to button
|
|
if button is currently Highlight: filters |= mask
|
|
else: filters &= ~mask
|
|
if filters changed:
|
|
CM_Character.Event_SpellbookFilterEvent(filters)
|
|
rebuild list from authoritative learned spells
|
|
scroll list to first row
|
|
|
|
IsFilteredOut(spell):
|
|
school bits: Creature=0x0001, Item=0x0002, Life=0x0004,
|
|
War=0x0008, Void=0x2000
|
|
level bits: I=0x0010 through VIII=0x0800
|
|
return school bit is disabled OR level bit is disabled
|
|
|
|
DeleteSpell():
|
|
if selectedSpellID == 0: return false
|
|
name = ClientMagicSystem.GetSpellName(selectedSpellID)
|
|
message = "Are you sure you want to remove {name} from your spellbook? "
|
|
"You will no longer be able to cast this spell unless you learn it again!"
|
|
dialog properties include selectedSpellID under 0x1000003F
|
|
DialogFactory.MakeCallbackDialogInCurrentUI(message, callback)
|
|
|
|
DeleteSpellDialogCallback(dialogProperties):
|
|
if confirmation result property 0x92 is true:
|
|
spellID = property 0x1000003F
|
|
CM_Magic.Event_RemoveSpell(spellID)
|
|
|
|
CM_Magic.Event_RemoveSpell(spellID):
|
|
send GameAction opcode 0x01A8 followed by spellID
|
|
|
|
RecvNotice_SpellRemoved(spellID):
|
|
rebuild from the authoritative player description
|
|
```
|
|
|
|
The client does not optimistically erase the row when Yes is clicked. It waits
|
|
for the server's `MagicRemoveSpell` notice. Dragging a row similarly carries a
|
|
spell-shortcut identity into the open spellcasting favorite list; it never
|
|
removes the spell from the learned-spell set.
|
|
|
|
## Active enchantment panels
|
|
|
|
Retail: `gmEffectsUI::PostInit` `0x004B7560` and
|
|
`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.
|