Reflow selected Helpful/Harmful spell descriptions through the retained retail text shaper, bind their authored 0x10000127 information scrollbar independently from the list scrollbar, and allow display-only text to consume wheel scrolling without becoming selectable. Format Vitae recovery experience through retail's grouped integer presentation. Release build succeeds and all 5,832 tests pass with five intentional skips. Co-authored-by: OpenAI Codex <codex@openai.com>
652 lines
27 KiB
Markdown
652 lines
27 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.
|
||
|
||
The visible row is still an ordinary `UIElement_ItemList`; it is not a
|
||
spell-specific strip painted by `gmSpellcastingUI`:
|
||
|
||
```text
|
||
UIElement_ItemList::UpdateEmptySlots @ 0x004E3700
|
||
if list is visible and FixedSlots (0x10000015) == -1:
|
||
read row/column constraints 0x5F and 0x60
|
||
when the horizontal constraint is unbounded:
|
||
occupiedWidth = itemCount * cellWidth
|
||
append floor((listWidth - occupiedWidth) / cellWidth) empty UIItems
|
||
when the list shrinks, remove only trailing UIItems in ItemSlot_Empty state
|
||
never remove an occupied trailing item merely because it exceeds the viewport
|
||
|
||
SpellCastSubMenu::UpdateShortcutOverlays @ 0x004C5BE0
|
||
for each UIItem in list order:
|
||
if index < 9: SetShortcutNum(item, index, ghosted=false)
|
||
else: SetShortcutNum(item, -1, ghosted=false)
|
||
|
||
UIElement_UIItem::SetShortcutNum @ 0x004E1590
|
||
occupied spell shortcut -> regular digit array 0x10000042
|
||
empty padding cell -> empty digit array 0x1000005E
|
||
```
|
||
|
||
Thus the first nine visible cells use the same blue 1–9 UIItem overlays as the
|
||
main status toolbar, including empty cells, while later cells retain only the
|
||
authored `ItemSlot_Empty` background. The count is based on the reflowed list
|
||
width (639 pixels at the 800-pixel design parent, 439 pixels inside the live
|
||
600-pixel combat page, yielding 13 whole 32-pixel cells).
|
||
|
||
The two layers must not be conflated. The favorite ItemList is inherited through
|
||
LayoutDesc `0x21000010`; its attribute `0x1000000E` selects UIItem prototype
|
||
`0x10000341` in catalog `0x21000037`. That prototype's `m_elem_Icon`
|
||
(`0x1000033B`) maps `ItemSlot_Empty` to the brown/gold surface `0x06001A97`.
|
||
The blue toolbar-style `0x060074CF` surface is not the unnumbered favorite-cell
|
||
background. Resolve the list prototype after cross-layout inheritance, then add
|
||
the independent shortcut-number overlay only for indices zero through eight.
|
||
|
||
The Cast button is likewise fully DAT-authored rather than a synthesized red
|
||
rectangle. Element `0x100000B2` is 75 by 32 pixels and its face is three stateful
|
||
children authored against a 79-pixel design parent: left `0..31`, middle
|
||
`32..46`, and right `47..78`. Apply each child's independent retail edge policy
|
||
after the button reflows; at 75 pixels they become `0..31`, `32..42`, and
|
||
`43..74`. Collapsing child-authored button media to the first child renders only
|
||
the left cap and is not retail behavior.
|
||
|
||
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_Text::CalcJustification @ 0x00467260
|
||
available = max(scrollableExtent, surfaceExtent) - leadingMargin - trailingMargin
|
||
if justification is Center:
|
||
offset = available / 2 - contentExtent / 2
|
||
else if justification is Right or Bottom:
|
||
offset = available - contentExtent
|
||
else:
|
||
offset = 0
|
||
return leadingMargin + offset
|
||
|
||
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 same mechanism applies to the Character window `0x2100002E`. Its
|
||
Attributes `0x10000228`, Skills `0x10000229`, and Titles `0x10000538` controls
|
||
are also compound Type-12 tabs with Closed/Open state color and three authored
|
||
chrome children. The controller reduction is therefore shared:
|
||
|
||
```text
|
||
BindRetailTab(tab, activate):
|
||
tab.Click = activate
|
||
|
||
SetActiveTab(active):
|
||
for each tab:
|
||
tab.SetState(tab == active ? Open(12) : Closed(11))
|
||
// UIElement::SetState propagates the same state to the retained children
|
||
```
|
||
|
||
Page visibility remains controller-owned because the three character page
|
||
containers carry no active-page visibility state. Tab appearance does not:
|
||
injecting replacement sprites or hard-coded caption colors bypasses the exact
|
||
same DAT state machine already used by Spellbook/Components.
|
||
|
||
Layout `0x21000034` supplies the 300 by 600 content surface, including its tab
|
||
bar and close control, but not the enclosing movable-window border. Production
|
||
mounts it through the same shared retained nine-slice frame used by the
|
||
Attributes/Skills window (`0x2100002E`). Treating the root as already framed
|
||
removes the gold outer border.
|
||
|
||
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.
|
||
|
||
### Component-book listbox templates and synchronization
|
||
|
||
Named-retail anchors:
|
||
|
||
- `gmSpellComponentUI::PostInit @ 0x0048A190`
|
||
- `gmSpellComponentUI::ListenToElementMessage @ 0x0048A420`
|
||
- `gmSpellComponentUI::SyncComponentRegionWithData @ 0x0048A620`
|
||
- `gmSpellComponentUI::UpdateComponents @ 0x0048A910`
|
||
- `gmSpellComponentUI::RecvNotice_SelectionChanged @ 0x00489C40`
|
||
- `gmSpellComponentUI::UpdateBuyRates @ 0x00489FE0`
|
||
|
||
The component page is not a generic icon catalog. LayoutDesc `0x21000033`
|
||
authors a listbox with two entry templates, referenced in order by list
|
||
property `0x64`:
|
||
|
||
```text
|
||
component list 0x10000464 (284 x 600)
|
||
component scrollbar 0x10000465 (16 x 600)
|
||
|
||
template 0 / category 0x10000466 (284 x 32, UIElement_Text)
|
||
background 0x06001392
|
||
centered category font 0x40000004
|
||
|
||
template 1 / component 0x10000467 (284 x 32, UIElement_Field)
|
||
normal background 0x06005E21
|
||
Highlight background 0x06005E22
|
||
icon region 0x10000468 (x=0, 32 x 32)
|
||
name 0x10000469 (x=42, width=180, height=32)
|
||
owned count 0x1000046A (x=224, y=0, width=60, height=15)
|
||
desired purchase count 0x1000046B (x=224, y=15, width=60, height=15)
|
||
```
|
||
|
||
The seven localized headings are string hashes in local string table
|
||
`0x23000001`: `SCARABS`, `HERBS`, `POWDERED GEMS`, `ALCHEMICAL SUBSTANCES`,
|
||
`TALISMANS`, `TAPERS`, and `PEAS`. They correspond in order to retail
|
||
`SpellComponentCategory` values 0 through 6. Category 6 is Peas; treating it
|
||
as an invented "Prismatic Components" group is not retail behavior.
|
||
|
||
```text
|
||
PostInit():
|
||
componentList = GetChildRecursive(0x10000464) as ListBox
|
||
register component/player/selection notices
|
||
if PlayerDescription already arrived: UpdateBuyRates(all components)
|
||
|
||
UpdateComponents(change):
|
||
tracker = PlayerSystem.ComponentTracker
|
||
insertion = 0
|
||
for category in 0..6:
|
||
if tracker.componentLists[category] is empty:
|
||
remove a stale category entry for this category, if present
|
||
continue
|
||
|
||
if entry[insertion] is not this category header:
|
||
header = list.AddItemFromTemplateList(templateIndex=0, insertion)
|
||
header.attribute[0x1000004D] = category
|
||
header.text = localizedCategoryTitle[category]
|
||
insertion++
|
||
|
||
reconcile component rows against tracker.componentLists[category]
|
||
each new row = list.AddItemFromTemplateList(templateIndex=1, insertion)
|
||
row.attribute[0x1000004C] = componentWCID
|
||
row[0x10000468] = MakeIcon(componentWCID)
|
||
row[0x10000469] = componentName
|
||
row[0x1000046A] = totalOwnedCount
|
||
row[0x1000046B] = desiredPurchaseCount
|
||
row[0x1000046B].inputFilter = NumberInputFilter
|
||
insertion++
|
||
|
||
SyncComponentRegionWithData(row, componentData):
|
||
row[0x1000046A].text = componentData.numItems
|
||
|
||
UIElement_Text::DrawSelf(rowText, visibleSurface):
|
||
position each glyph line using CalcJustification
|
||
draw intersecting glyphs clipped to visibleSurface
|
||
do not discard a line merely because its line box exceeds the surface
|
||
|
||
ListenToElementMessage(message):
|
||
if selection changed:
|
||
if selected entry is a category:
|
||
clear selected world object
|
||
else if selected entry is a component:
|
||
select the first owned object represented by that component region
|
||
|
||
if edit completed on 0x1000046B:
|
||
parse integer
|
||
if 0 <= value <= 5000:
|
||
send SetDesiredComponentLevel(componentWCID, value)
|
||
update local PlayerModule projection
|
||
else:
|
||
restore the authoritative desired value in the field
|
||
|
||
RecvNotice_SelectionChanged():
|
||
if the selected world object belongs to a tracked component region:
|
||
select and expose its matching component row
|
||
else:
|
||
clear component-list selection
|
||
```
|
||
|
||
The retained port instantiates these resolved templates through
|
||
`ComponentBookTemplateFactory` and `UiTemplateListSlot`, the architectural
|
||
equivalent of `AddItemFromTemplateList`. Controllers bind only the live icon,
|
||
texts, selection, and edit callbacks. They do not duplicate the row sprites,
|
||
fonts, or geometry.
|
||
|
||
The owned-count child is 15 pixels high while installed font `0x40000000`
|
||
advances 16 pixels. Retail's `UIElement_Text::DrawSelf @ 0x00467AA0` clips the
|
||
glyph blits against the supplied visible surface, so the count remains visible.
|
||
A retained renderer that accepts only fully contained lines suppresses this
|
||
field and is not retail-conformant.
|
||
|
||
### 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
|
||
effect-list scrollbar 0x10000124
|
||
information text 0x10000126
|
||
information scrollbar 0x10000127
|
||
effect row template 0x10000128 (300 x 32)
|
||
row icon / name / duration 0x10000129 / 0x1000012A / 0x1000012B
|
||
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.
|
||
|
||
`gmEffectsUI::UpdateSelection @ 0x004B7F90` concatenates spell name, a blank
|
||
line, and spell description, then passes that one literal to
|
||
`UIElement_Text::SetText`. The text element reflows the literal to its authored
|
||
280-pixel information width. LayoutDesc `0x2100001B` supplies a separate
|
||
scrollbar `0x10000127` for that 78-pixel-high information text; it does not
|
||
reuse the effect-list scrollbar `0x10000124`.
|
||
|
||
`InfoRegion::InfoRegion @ 0x004F1450` creates each row with
|
||
`UIElement_ListBox::AddItemFromTemplateList(template 0)`. In installed retail
|
||
data the icon is x=0 width 32, name is x=37 width 188, and duration is x=225
|
||
width 50. `EffectInfoRegion::Update @ 0x004F1C00` formats finite time as
|
||
`m:ss` or `h:mm:ss`; a negative/permanent duration leaves the field empty.
|
||
`gmEffectsUI::UpdateSelection @ 0x004B7F90` displays localized
|
||
`ID_Effects_Info_SelectASpell` while nothing is selected, otherwise spell name
|
||
plus two newlines plus description. Normal rows use state 1 and every row with
|
||
the selected spell ID uses Highlight state 6.
|
||
|
||
## 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.
|