acdream/docs/research/2026-07-10-retail-panel-behavior-pseudocode.md
Erik 0b74d19475 fix(ui): port exact paperdoll body selection
Resolve and sample retail's authored nine-color paperdoll click map, preserve local click coordinates, and select the stable highest-priority worn item with the player fallback. Keep targeted-use body clicks routed to self and pin both synthetic and live-DAT conformance.

Co-Authored-By: Codex <codex@openai.com>
2026-07-11 11:56:26 +02:00

30 KiB

Retail panel behavior oracle — vitals, chat, inventory, paperdoll, character

Date: 2026-07-10 Scope: Wave 0 static research for the retail UI fidelity completion plan. Status: implementation oracle; no runtime code is changed by this note.

Source order and confidence

The Sept 2013 named retail client is the behavioral authority:

  • docs/research/named-retail/acclient_2013_pseudo_c.txt
  • docs/research/named-retail/acclient.h
  • docs/research/named-retail/symbols.json

The layout/DAT discoveries already recorded under docs/research/ are used for element IDs, layout IDs, and imported media. Existing ACE and holtburger cross-references in the inventory deep dive are interpretation aids for the wire contract; the retail client still wins when they differ.

Binary Ninja occasionally assigns a neighboring multiple-inheritance field name to a value. This note therefore names fields by the behavior proved by their setters/callers and records such cases as semantic pseudocode rather than copying a suspect generated field name.

Corrections to assumptions in earlier UI notes

  1. The named client does not prove a tab strip inside the main chat window. It proves one main chat interface plus separately configured floaty chat windows, each with a window ID and a 64-bit text-type filter. The General / Combat / Chat / 1... strip in docs/research/retail-ui/05-panels.md was explicitly an inference and must not be implemented as retail fact. Live retail visual confirmation is required before adding a tab skin.
  2. NoLongerViewingContents is an external ground-object lifetime event, not an owned-bag navigation event. Retail centralizes the send in ClientUISystem::SetGroundObject(old, new, notifyServer). Main-inventory and owned side-bag navigation use the inventory item-list notices instead.
  3. Stat raising is server-authoritative. Retail serializes one request, ghosts the raise control, and waits for a quality-change message. It does not predict the local rank, XP, credits, or cost and then roll them back.

1. Vitals detail mode

Retail anchors

Behavior Named symbol
Bind imported elements gmVitalsUI::PostInit @ 0x004BFCE0
Toggle detail state gmVitalsUI::ListenToElementMessage @ 0x004BFC00
Refresh meters and text gmVitalsUI::Update @ 0x004BFE30

Layout: 0x2100006C.

Meaning Element/state ID
Health meter 0x100000E6
Health value label 0x100000EB
Stamina meter 0x100000EC
Stamina value label 0x100000ED
Mana meter 0x100000EE
Mana value label 0x100000EF
Root HideDetail state 0x10000006
Root ShowDetail state 0x10000007
Meter fill/value property 0x69

The imported ShowDetail layout state selects these overlay media:

Vital Back Front
Health 0x06007490 0x06007491
Stamina 0x06007492 0x06007493
Mana 0x06007494 0x06007495

Pseudocode

PostInit():
    healthMeter   = child(0x100000E6)
    healthLabel   = child(0x100000EB)
    staminaMeter  = child(0x100000EC)
    staminaLabel  = child(0x100000ED)
    manaMeter     = child(0x100000EE)
    manaLabel     = child(0x100000EF)

OnElementMessage(message):
    if message.id == 0x1C and message.pointerAction in { LEFT_CLICK=7,
                                                         DOUBLE_CLICK=10 }:
        nextState = ShowDetail if root.state == HideDetail else HideDetail
        root.setState(nextState)
    return base.OnElementMessage(message)

Update():
    updateVital(healthMeter,  healthLabel,  currentSecondAttribute=2, max=1)
    updateVital(staminaMeter, staminaLabel, currentSecondAttribute=4, max=3)
    updateVital(manaMeter,    manaLabel,    currentSecondAttribute=6, max=5)

updateVital(meter, label, currentProperty, maximumProperty):
    current = player.attribute2nd(currentProperty)
    maximum = player.attribute2nd(maximumProperty)
    meter.property[0x69] = current / maximum
    label.text = formatCurrentAndMaximum(current, maximum)

Implementation consequence: bind and update the imported label elements. Creating new text children over the meters is not retail-equivalent and loses the DAT state-driven geometry/visibility.

2. Chat window

Layout and elements

Main chat layout: 0x21000006.

Meaning ID
Root field 0x1000000E
Resize bar 0x1000000F
Transcript panel 0x10000010
Transcript/log 0x10000011
Scrollbar track 0x10000012
Input bar 0x10000013
Talk-focus menu 0x10000014
Chat target text 0x10000015
Editable input 0x10000016
Send button 0x10000019
Maximize/restore button 0x1000046F
New non-visible text indicator 0x1000048C

Retail option/property IDs named in the client are:

Meaning ID
Talk-focus enum on a menu item 0x1000000B
Chat window ID 0x1000007E
64-bit text-type filter 0x1000007F
Default/inactive opacity 0x10000080
Active/focused opacity 0x10000081
Maximum height 0x3C
Maximum width 0x3D
Minimum height 0x3E
Minimum width 0x3F
Maximize button state 0x10000047
Restore/minimized button state 0x10000048

Maximize, restore, and resize

Retail anchors:

  • gmMainChatUI::HandleMaximizeButton @ 0x004CCE50
  • gmMainChatUI::ResizeTo @ 0x004CD0A0
  • ChatInterface::ResizeTo @ 0x004F39E0
  • UIElement::ResizeTo @ 0x00463C30
HandleMaximizeButton():
    parentHeight = root.parent.height
    minHeight = root.property[0x3E]
    maxHeight = root.property[0x3C]

    if maximized:
        restoredHeight = clamp(savedHeight, minHeight, maxHeight)
        restoredY = clamp(savedY, 0, parentHeight - minHeight)
        maximized = false
        maximizeButton.state = 0x10000048
        root.resize(root.width, restoredHeight)
        root.move(root.x, restoredY)
        return

    savedY = root.y
    savedHeight = root.height

    expansion = parentHeight / 2
    targetHeight = root.height + expansion

    if expanding downward would cross the parent bottom,
       or the window is already in the lower half:
        targetY = max(0, root.y - expansion)
    else:
        targetY = root.y

    targetHeight = min(targetHeight, parentHeight - targetY)
    targetHeight = clamp(targetHeight, minHeight, maxHeight)

    maximized = true
    maximizeButton.state = 0x10000047
    root.resize(root.width, targetHeight)
    root.move(root.x, targetY)

ResizeTo(width, height):
    wasAtTranscriptEnd = transcript.isAtVerticalEnd()
    base.resizeTo(width, height)        // base enforces 0x3C..0x3F limits
    resize the bound transcript/input panel, not a hard-coded synthetic box
    if wasAtTranscriptEnd:
        transcript.scrollToFinalGlyph()

The saved position and size are part of gmMainChatUI (m_OldY, m_OldHeight, m_Maximized in acclient.h). A fixed expanded height is not retail behavior.

Opacity and input activation

Retail anchors:

  • ChatInterface::ChatInterface @ 0x004F4550
  • ChatInterface::OnSetAttribute @ 0x004F3F60
  • ChatInterface::SetDefaultOpacity @ 0x004F3BC0
  • ChatInterface::SetActiveOpacity @ 0x004F3C40
  • ChatInterface::ListenToGlobalMessage @ 0x004F3840
  • ChatInterface::ActivateChatEntry @ 0x004F2F90
  • ChatInterface::DeactivateChatEntry @ 0x004F2FC0
  • ChatInterface::ToggleChatEntry @ 0x004F3B30
  • ChatInterface::IsTextEntryFocused @ 0x004F30A0

Defaults are 0.5 inactive, 1.0 active, and 0.5 current.

OnAttributeChanged(id, value):
    if id == 0x10000080: setDefaultOpacity(value)
    if id == 0x10000081: setActiveOpacity(value)

OnChatFocusStateChanged():
    target = activeOpacity if input is focused or window is forced active
                           else defaultOpacity
    registerForGlobalTicks()

OnGlobalTick():
    step = abs(activeOpacity - defaultOpacity) * 0.05
    currentOpacity = moveToward(currentOpacity, target, step)
    enclosingWindow.alpha = currentOpacity
    if currentOpacity == target:
        unregisterFromGlobalTicks()

ActivateChatEntry():
    input.activate()
    input.takeFocus()
    sendNotice(ToggleChatEntry, true)

DeactivateChatEntry():
    input.relinquishFocus()
    input.deactivate()
    sendNotice(ToggleChatEntry, false)

IsTextEntryFocused():
    return activeRoot == thisChatRoot and focusedElement is input-or-descendant

Talk focus and squelch

Retail anchors:

  • gmMainChatUI::InitTalkFocusMenu @ 0x004CDC50
  • gmMainChatUI::GetTalkFocusMenuItem @ 0x004CD370
  • gmMainChatUI::ResetAllTalkFocusMenuButtons @ 0x004CD4A0
  • gmMainChatUI::HandleSelection @ 0x004CD540
  • gmMainChatUI::EnableSelection @ 0x004CE0A0
  • gmMainChatUI::ToggleSquelchOnCurrentSpeakableTarget @ 0x004CD230
  • gmMainChatUI::ListenToElementMessage @ 0x004CDA80
  • gmMainChatUI::PostInit @ 0x004CE130

The menu is flushed and rebuilt at runtime. Its first item is the squelch toggle. The 13 talk-focus entries store these enum values, in visual order:

[5, 2, 4, 1, 6, 3, 7, 8, 9, 10, 11, 12, 13]
InitTalkFocusMenu():
    menu = child(0x10000014)
    menu.flush()
    squelchItem = menu.addRuntimeItem(...)
    for focus in [5,2,4,1,6,3,7,8,9,10,11,12,13]:
        item = menu.addRuntimeItem(stringTableTextFor(focus))
        item.enumProperty[0x1000000B] = focus

ResetTalkFocusButtons():
    for each focus item:
        item.state = ENABLED(1) if communication.isTalkFocusEnabled(focus)
                                else GHOSTED(13)
    // Olthoi mode permits only focus 13 and focus 1.
    select item whose 0x1000000B equals communication.currentTalkFocus

OnTalkFocusMenuSelection(item):
    menu.clearSelectedItem()
    if item == squelchItem:
        ToggleSquelchOnCurrentSpeakableTarget()
    else:
        communication.setTalkFocus(item.enumProperty[0x1000000B])
        ResetTalkFocusButtons()

ToggleSquelchOnCurrentSpeakableTarget():
    target = communication.lastSpeakableTarget
    objectAndName = resolve(target)
    currentlySquelched = communication.isSquelched(target, objectAndName.name, 1)
    squelchItem.state = ENABLED(1)
    send ModifyCharacterSquelch(
        add = not currentlySquelched,
        targetId = target,
        targetName = objectAndName.name,
        scope = 1)

Exact localized menu labels are table-driven and are not legible enough in the pseudo-C to reproduce verbatim. Resolve them from the retail string table or capture the menu in live retail; do not invent labels.

Chat windows and the unproven “tabs”

ChatInterface::PostInit @ 0x004F3DD0 reads window ID 0x1000007E, text filter 0x1000007F, and binds transcript 0x10000011, input 0x10000016, and the non-visible indicator 0x1000048C. Static default filter values include:

Window ID Default 64-bit filter
1 or 8 0x0 for the non-visible indicator mask path
2 0x101C
3 0x40C00
4 0x80000
5 0x78000000

PlayerModule persists a filter per window and the client has gmFloatyChatUI/gmFloatyMainChatUI types. That is evidence for multiple configurable windows, not evidence for tabs inside gmMainChatUI.

3. Inventory click targeting and view teardown

Target-mode precedence

Retail anchors:

  • UIElement_ItemList::ListenToElementMessage @ 0x004E4D50
  • UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0
  • UIElement_ItemList::ItemList_SetParentContainer @ 0x004E49D0
  • UIElement_ItemList::ItemList_OpenContainer @ 0x004E4AE0
OnItemListPointerMessage(message):
    item = uiItemUnderPointer(message)

    if message.action == LEFT_CLICK(7):
        if ClientUISystem.targetMode != NONE:
            HandleTargetedUseLeftClick(item)
            return CONSUMED                 // before select/open navigation

        setUiSelected(item)
        SetSelectedObject(item.itemId, false)

        if this list is container navigation:
            open/select the item's parent-or-child list according to list role
        return

    if message.action == DOUBLE_CLICK(10):
        if vendor/salvage rules did not consume it:
            if this is a container-list, require item == current external object
            ItemHolder.UseObject(item.itemId)
        return

    if message.action == EXAMINE_CLICK(8):
        select item
        ExamineSpell(item.spellId) if this is a spell shortcut
        else ExamineObject(item.itemId)

HandleTargetedUseLeftClick(item):
    targetMode = ClientUISystem.targetMode
    if item represents a spell:
        ClientUISystem.ExecuteTargetModeForSpell(item.spellId, targetMode)
    else:
        ClientUISystem.ExecuteTargetModeForItem(item.itemId, targetMode)

The critical rule is ordering: a target-mode click is consumed before ordinary selection, container opening, or drag behavior, including an invalid target attempt.

Owned inventory versus external ground views

Owned inventory anchors:

  • gmInventoryUI::RecvNotice_SetDisplayInventory @ 0x004A6750
  • gmInventoryUI::RecvNotice_OpenContainedContainer @ 0x004A6A80

External view anchors:

  • gmExternalContainerUI::ListenToElementMessage @ 0x004CBAD0
  • gmExternalContainerUI::SetGroundObject @ 0x004CBBD0
  • gmExternalContainerUI::CloseCurrentContainer @ 0x004CBCB0
  • gmExternalContainerUI::OnVisibilityChanged @ 0x004CBF50
  • gmExternalContainerUI::RecvNotice_SetGroundObject @ 0x004CBFD0
  • ClientUISystem::SetGroundObject @ 0x00564510
  • ClientUISystem::OnViewContents @ 0x00565870
  • ClientUISystem::CleanUpGameUI @ 0x005648A0
  • CM_Inventory::Event_NoLongerViewingContents @ 0x006ABC50

Wire contract, cross-confirmed by the existing holtburger reference in the inventory deep dive:

opcode: 0x0195 NoLongerViewingContents
payload: u32 containerGuid
ShowMainOwnedInventory():
    topList.flush()
    topList.add(playerId)
    childList.setParentContainer(playerId)
    topList.openContainer(playerId)
    paperdoll.remake()

OpenOwnedContainedContainer(containerId):
    require object is owned by player and has an allowed container type
    make inventory visible
    if containerId == playerId:
        use top/main list
    else:
        use owned side/container list
    // This path does not make containerId ClientUISystem.groundObject.

OnExternalViewContentsResponse(containerId, contentProfile):
    apply authoritative content profile to the external object
    if containerId == requestedGroundObject:
        SetGroundObject(containerId)
        publish SetGroundObject notice to gmExternalContainerUI

ExternalPanel.SetGroundObject(containerId):
    unregister range handler for old external object
    flush top list
    add containerId
    bind child lists and open container
    if containerId == 0:
        hide panel
    else:
        register range handler and show panel

ExternalPanel.OnCloseButton():
    setVisible(false)

ExternalPanel.OnVisibilityChanged(false):
    CloseCurrentContainer()

ExternalPanel.CloseCurrentContainer():
    if externalObjectId != 0:
        ItemHolder.UseObject(externalObjectId) // retail close/use handshake
        unregister range handler
        externalObjectId = 0
        flush and detach lists

ClientUISystem.SetGroundObject(newId, notifyServer):
    if oldId == newId: return
    close vendor if needed
    if oldId != 0:
        queue old contents for destruction
        publish SetGroundObject(0)
        if notifyServer:
            send NoLongerViewingContents(oldId) exactly once
        clear selected child of old object when applicable
    groundObject = newId
    requestedGroundObject = newId
    mark corpse-open state if applicable

SetGroundObject(0, true) is also used for session cleanup, vendor opening, the viewed object moving, range/error conditions, and other external-view termination. This centralized old-ID comparison provides the one-shot guard; adding independent sends in several UI close callbacks would duplicate it.

Static code does not establish that hiding the main owned-inventory window after navigating an owned side bag sends 0x0195; the object is not the ClientUISystem.groundObject on that path. Treat such a send as a divergence unless a packet trace proves otherwise.

4. Paperdoll

Paperdoll layout: 0x21000024.

Slot mapping

Retail anchor: gmPaperDollUI::GetLocationInfoFromElementID @ 0x004A37F0.

Element Equip/location mask Side/meaning
0x100001DA 0x00008000 Neck
0x100001DB 0x00010000 Left wrist
0x100001DC 0x00040000 Left ring
0x100001DD 0x00020000 Right wrist
0x100001DE 0x00080000 Right ring
0x100001DF 0x03500000 Weapon composite
0x100001E0 0x00800000 Ammunition
0x100001E1 0x00200000 Shield/offhand
0x100001E2 0x00000002 Shirt
0x100001E3 0x00000040 Pants
0x1000058E 0x04000000 Trinket
0x100005E9 0x08000000 Cloak
0x10000595 0x10000000 Aetheria/sigil 1
0x10000596 0x20000000 Aetheria/sigil 2
0x10000597 0x40000000 Aetheria/sigil 3
0x100005AB 0x00000001 Head armor
0x100005AC 0x00000200 Chest armor
0x100005AD 0x00000400 Abdomen armor
0x100005AE 0x00000800 Upper arm armor
0x100005AF 0x00001000 Lower arm armor
0x100005B0 0x00000020 Hand armor
0x100005B1 0x00002000 Upper leg armor
0x100005B2 0x00004000 Lower leg armor
0x100005B3 0x00000100 Foot armor

The named pseudo-C renders the ammunition constant poorly in one expression; 0x00800000 is the consistent EquipMask/source value. Preserve a decomp cite and conformance test when implementing that branch.

Upper-item priority and body hit testing

Retail anchors:

  • InventoryPlacement::DetermineHigherPriority @ 0x004A44B0
  • gmPaperDollUI::GetUpperInvObj @ 0x004A4750
  • gmPaperDollUI::CreateClickMap @ 0x004A4850
  • gmPaperDollUI::GetPaperDollItemUnderMouse @ 0x004A4920
  • gmPaperDollUI::ListenToElementMessage @ 0x004A5C30
  • gmPaperDollUI::BeginPartSelectionLighting @ 0x004A4C50
  • gmPaperDollUI::ApplyPartSelectionLighting @ 0x004A3CA0
  • gmPaperDollUI::UpdatePartSelectionLighting @ 0x004A4360
DetermineHigherPriority(a, b, bodyLocationMask):
    aLoc = a.location & bodyLocationMask
    bLoc = b.location & bodyLocationMask

    aPriority = a.placementPriority
    bPriority = b.placementPriority

    if aPriority == 0 and aLoc overlaps armor range 0x200..0x4000:
        aPriority = 0x7F
    if bPriority == 0 and bLoc overlaps armor range 0x200..0x4000:
        bPriority = 0x7F

    return b if bPriority > aPriority else a  // stable: existing wins ties

GetUpperInvObj(bodyLocationMask):
    if invalid mask/player/list: return 0
    winner = none
    for placement in player.inventoryPlacements in list order:
        if placement.location overlaps bodyLocationMask:
            winner = placement if winner is none
                               else DetermineHigherPriority(winner, placement,
                                                            bodyLocationMask)
    if winner exists: return winner.itemId
    return playerId // retail body hit fallback when a placement list exists

CreateClickMap loads enum DID 0x1000000C in category 7. The sampled body color maps to these masks:

Click-map value Body mask
Head 0x00000001
Chest 0x00000202 (shirt + chest)
Abdomen 0x00000404
Upper arm 0x00000808
Lower arm 0x00001010
Upper leg 0x00002040
Lower leg 0x00004080
Hand 0x00000020
Foot 0x00000100

The drag mask is 0x100001D6. Left-click selects the upper equipped object when not targeting. In target mode, the body click deliberately executes the target mode against the player ID, not the visually upper item. Examine and drag use the upper item. Selection lighting pulses matching body parts at 0.2-second intervals; player-ID selection uses mask 0x7FFFFFFF.

Implementation note (2026-07-11): PaperdollClickMap now resolves the exact enum DID, decodes and samples the nine-color map, and PaperdollSelectionPolicy ports the stable upper-item priority plus the uncovered player fallback. Normal selection and target-self dispatch are wired through the authored drag mask. Examine, doll-body drag, and part-selection lighting remain under AP-108.

Aetheria visibility

Retail anchors:

  • gmPaperDollUI::PostInit @ 0x004A5360
  • gmPaperDollUI::UpdateAetheria @ 0x004A3E50
  • gmPaperDollUI::RecvNotice_PlayerDescReceived @ 0x004A43E0
  • gmPaperDollUI::OnQualityChanged @ 0x004A4490

Property 0x142 is the Aetheria bitfield.

PostInit():
    aetheria[0..2] = children(0x10000595, 0x10000596, 0x10000597)
    hide all three
    observe player int quality 0x142

UpdateAetheria():
    bits = player.intQuality(0x142)
    aetheria[0].visible = (bits & 1) != 0
    aetheria[1].visible = (bits & 2) != 0
    aetheria[2].visible = (bits & 4) != 0

Wield/drop legality

Retail anchors:

  • gmPaperDollUI::HandlePaperDollDragOver @ 0x004A3A70
  • gmPaperDollUI::AcceptDragObject @ 0x004A3B10
  • gmPaperDollUI::OnItemListDragOver @ 0x004A4270
  • gmPaperDollUI::AcceptPaperDollDragObject @ 0x004A4A70
  • gmPaperDollUI::HandleDropRelease @ 0x004A4D80
  • CPlayerSystem::AutoWieldIsLegal @ 0x0055ED60
OnSlotDragOver(item, slotMask):
    accept = item.validLocations overlaps slotMask
             and PlayerSystem.AutoWieldIsLegal(item, silent=true)
    set overlay accept/reject state

AcceptDragObject(item, slotMask, side):
    if slotMask == SHIELD(0x00200000)
       and item.validLocations includes MELEE_WEAPON(0x00100000):
        side = LEFT       // retail dual-wield/offhand special case

    if not (item.validLocations overlaps slotMask): reject

    if slotMask overlaps wearable mask 0x080001FF:
        PlayerSystem.AutoWear(item)
    else:
        PlayerSystem.AutoWield(item, side)

AcceptWholeDollDrop(item):
    require item.validLocations overlaps wearable body mask 0x08007FFF
    require AutoWearIsLegal(item)
    AutoWear(item)

AutoWieldIsLegal checks nonzero valid locations, missile/ammunition compatibility, shield versus current weapon, and held-item restrictions while in combat. One mask expression is corrupt in the named pseudo-C. Resolve that exact branch from the older Ghidra chunk or a live breakpoint before porting it; do not infer the missing bit from the error string alone.

Race/body viewport variants

Retail anchors:

  • gmPaperDollUI::PostInit @ 0x004A5360
  • gmPaperDollUI::UpdateForRace @ 0x004A3ED0

Viewport 0x100001D5, drag mask 0x100001D6, drag overlay 0x1000046D, and slot-mode checkbox 0x100005BE are bound in PostInit. The default camera is approximately (0.12, -2.4, 0.88) with heading 191.37 degrees; the default animation is enum DID 0x10000005.

Heritage/body property 0xBC selects these static variants:

Heritage value Name Camera position Animation override
6 Gearknight (0.12, -3.0, 0.88) none
7 Tumerok (0.12, -3.0, 0.88) none
8 Lugian (0.12, -3.4, 1.0) none
9 Empyrean (0.12, -3.4, 0.88) none
10 Penumbraen no explicit branch none
11 Undead no explicit branch none
12 Olthoi (0.12, -3.4, 0.88) enum DID 0x10000011, category 7
13 Olthoi variant (0.12, -3.4, 0.88) enum DID 0x10000013, category 7

The look-at point for explicit variants is (0, 0, 0). Values 10 and 11 have no explicit case in this function; preserve default/previous setup rather than inventing a camera.

5. Character titles, luminance, and stat raises

Character layout: 0x2100002E; Titles sublayout: 0x2100005E; Titles page element: 0x10000539.

Titles

Retail anchors:

  • gmCharacterTitleUI::PostInit @ 0x0049A610
  • gmCharacterTitleUI::UpdateButtons @ 0x0049A500
  • gmCharacterTitleUI::ListenToElementMessage @ 0x0049A6D0
  • gmCharacterTitleUI::FindSortedInsertPosition @ 0x0049A760
  • gmCharacterTitleUI::AddTitleToList @ 0x0049A840
  • gmCharacterTitleUI::Refresh @ 0x0049ABC0
  • gmCharacterTitleUI::RecvNotice_UpdateCharacterTitleTable @ 0x0049AD30
  • gmCharacterTitleUI::RecvNotice_SetDisplayCharacterTitle @ 0x0049AD50
Meaning ID
Displayed title text 0x1000052F
Set/display button 0x10000535
Title listbox 0x10000532
Runtime row title text child 0x10000537
Runtime row title-ID enum property 0x1000008E
Refresh():
    displayedTitle.text = titleTable.resolve(displayedTitleId) or "Unknown"
    titleList.flush()
    titleList.clearSelection()
    for titleId in ownedTitleIds:
        AddTitleToList(titleId)
    UpdateButtons()

AddTitleToList(titleId):
    titleText = titleTable.resolve(titleId)
    insertIndex = first row whose text compares after titleText using wcscmp
    row = createRuntimeTitleRow()
    row.child(0x10000537).text = titleText
    row.enumProperty[0x1000008E] = titleId
    titleList.insert(insertIndex, row)

UpdateButtons():
    selectedId = selectedRow.enumProperty[0x1000008E]
    displayButton.state = ENABLED(1) if selectedId exists
                                      and selectedId != displayedTitleId
                                    else GHOSTED(13)

OnDisplayButtonClick():
    send CM_Social.Event_SetDisplayCharacterTitle(selectedId)

OnTitleTableNotice(table):
    copy table
    Refresh()

OnDisplayedTitleNotice(titleId):
    displayedTitleId = titleId
    ensure titleId is present in the owned list
    Refresh()

gmStatManagementUI::UpdateCharacterInfo @ 0x004F0770 resolves the current title through CharacterTitleTable and appends it to the gender/heritage header. A synthetic inert titles page is therefore incomplete behavior.

Luminance

Retail anchors:

  • gmStatManagementUI::PostInit @ 0x004EFD90
  • gmStatManagementUI::UpdateExperience @ 0x004F0A70
Meaning ID/property
Luminance label element 0x100005C5
Luminance value element 0x100005C6
Total XP text 0x10000235
XP meter 0x10000236
Next XP text 0x10000238
XP list 0x1000023D
Level int quality 0x19
TotalExperience int64 quality 1
AvailableLuminance int64 quality 6
MaximumLuminance int64 quality 7
Meter float property 0x69
UpdateExperience():
    level = player.intQuality(0x19)
    totalXp = player.int64Quality(1)
    availableLuminance = player.int64Quality(6)
    maximumLuminance = player.int64Quality(7)

    if level < 200 or maximumLuminance == 0:
        luminanceLabel.text = ""
        luminanceValue.text = ""
    else:
        luminanceLabel.text = localizedLuminanceLabel
        luminanceValue.text = formatXp(availableLuminance)
                              + localizedSeparator
                              + formatXp(maximumLuminance)

    update total/next XP strings
    xpMeter.property[0x69] = fractionalProgressWithinCurrentLevel

The exact localized label/separator is obscured in the generated pseudo-C. Read it from the retail string table or capture the rendered panel before claiming verbatim text.

Server-authoritative raises

Retail anchors:

  • gmStatManagementUI::gmStatManagementUI @ 0x004F03F0
  • gmAttributeUI::RaiseSelection @ 0x0049D020
  • gmAttributeUI::Raise10 @ 0x0049D150
  • gmSkillUI::RaiseSelection @ 0x0049C8C0
  • gmSkillUI::Raise10 @ 0x0049B760
  • InfoRegion::OnQualityChanged @ 0x004F0EB0
  • gmStatManagementUI::ListenToElementMessage @ 0x004EFBE0
OnRaiseClick(amount):
    if awaitingRaise: return
    require selected row and player description
    exactCost = selectedRow.computeRetailCost(amount)
    if local preconditions fail: return

    awaitingRaise = true
    send the appropriate TrainAttribute/TrainSkill event
    clickedButton.state = GHOSTED(13)
    // Do not mutate rank, advancement class, XP, credits, or costs locally.

InfoRegion.OnAuthoritativeQualityChanged(statType, statId):
    refresh row from player qualities
    broadcast element message 0x10000004(statType, statId)

StatManagement.OnElementMessage(0x10000004, statType, statId):
    awaitingRaise = false
    if changed stat is the selected row:
        refresh footer and controls from authoritative qualities

This is direct static evidence against optimistic prediction. The current implementation should remove its local ApplyLocalRaise mutation rather than building a rollback ledger around behavior retail does not have.

The static class does not make the rejection-only path fully obvious when the server sends no changed quality. Verify capped/invalid requests with a packet trace or breakpoint before choosing how to release awaitingRaise on explicit server failure. Retail still permits only one request in flight.

6. Items requiring live retail, cdb, or an older Ghidra chunk

These uncertainties are intentionally not guessed:

  1. Chat surface: visually confirm whether this client build exposes its multiple chat filters as separate floaty windows, tabs supplied by another layout layer, or both. The named gmMainChatUI path alone has no tab logic.
  2. Chat strings: capture the exact localized talk-focus labels, target label, and squelch wording from the string table/live menu.
  3. Chat geometry: capture a maximize/restore sequence near the top and bottom edges to conformance-test the decompiler's signed half-height arithmetic and DAT min/max constraints.
  4. Owned bag close packet: trace packets while hiding inventory on an owned side bag. Static evidence says this is not an external NoLongerViewingContents event.
  5. External close handshake: capture the order between gmExternalContainerUI::CloseCurrentContainer, ItemHolder::UseObject, the server response, and ClientUISystem::SetGroundObject(0, true).
  6. Paperdoll legality: recover the corrupt AutoWieldIsLegal mask branch from the older Ghidra chunk and verify missile/ammo, shield, dual-wield, and in-combat denials with golden cases.
  7. Paperdoll visuals: visually verify race camera framing, Olthoi animation enum results, click-map alignment, and the 0.2-second part pulse.
  8. Luminance text: resolve exact localized label/value formatting from the string table or rendered client.
  9. Raise rejection: trace a capped/invalid raise that produces no quality change to identify the exact event that clears m_bAwaitingRaise.

Everything else above is statically pinned strongly enough to drive the first implementation and conformance-test wave.