acdream/docs/research/2026-07-15-retail-magic-ui-and-casting-pseudocode.md
Erik 82789eea88 fix(ui): restore effect detail scrolling
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>
2026-07-17 13:28:18 +02:00

27 KiB
Raw Blame History

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.

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.

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

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:

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 19 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):

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):

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:

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:

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:

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.

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:

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.

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.

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.