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>
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.txtdocs/research/named-retail/acclient.hdocs/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
- 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 indocs/research/retail-ui/05-panels.mdwas explicitly an inference and must not be implemented as retail fact. Live retail visual confirmation is required before adding a tab skin. NoLongerViewingContentsis an external ground-object lifetime event, not an owned-bag navigation event. Retail centralizes the send inClientUISystem::SetGroundObject(old, new, notifyServer). Main-inventory and owned side-bag navigation use the inventory item-list notices instead.- 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 @ 0x004CCE50gmMainChatUI::ResizeTo @ 0x004CD0A0ChatInterface::ResizeTo @ 0x004F39E0UIElement::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 @ 0x004F4550ChatInterface::OnSetAttribute @ 0x004F3F60ChatInterface::SetDefaultOpacity @ 0x004F3BC0ChatInterface::SetActiveOpacity @ 0x004F3C40ChatInterface::ListenToGlobalMessage @ 0x004F3840ChatInterface::ActivateChatEntry @ 0x004F2F90ChatInterface::DeactivateChatEntry @ 0x004F2FC0ChatInterface::ToggleChatEntry @ 0x004F3B30ChatInterface::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 @ 0x004CDC50gmMainChatUI::GetTalkFocusMenuItem @ 0x004CD370gmMainChatUI::ResetAllTalkFocusMenuButtons @ 0x004CD4A0gmMainChatUI::HandleSelection @ 0x004CD540gmMainChatUI::EnableSelection @ 0x004CE0A0gmMainChatUI::ToggleSquelchOnCurrentSpeakableTarget @ 0x004CD230gmMainChatUI::ListenToElementMessage @ 0x004CDA80gmMainChatUI::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 @ 0x004E4D50UIElement_ItemList::HandleTargetedUseLeftClick @ 0x004E24D0UIElement_ItemList::ItemList_SetParentContainer @ 0x004E49D0UIElement_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 @ 0x004A6750gmInventoryUI::RecvNotice_OpenContainedContainer @ 0x004A6A80
External view anchors:
gmExternalContainerUI::ListenToElementMessage @ 0x004CBAD0gmExternalContainerUI::SetGroundObject @ 0x004CBBD0gmExternalContainerUI::CloseCurrentContainer @ 0x004CBCB0gmExternalContainerUI::OnVisibilityChanged @ 0x004CBF50gmExternalContainerUI::RecvNotice_SetGroundObject @ 0x004CBFD0ClientUISystem::SetGroundObject @ 0x00564510ClientUISystem::OnViewContents @ 0x00565870ClientUISystem::CleanUpGameUI @ 0x005648A0CM_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 @ 0x004A44B0gmPaperDollUI::GetUpperInvObj @ 0x004A4750gmPaperDollUI::CreateClickMap @ 0x004A4850gmPaperDollUI::GetPaperDollItemUnderMouse @ 0x004A4920gmPaperDollUI::ListenToElementMessage @ 0x004A5C30gmPaperDollUI::BeginPartSelectionLighting @ 0x004A4C50gmPaperDollUI::ApplyPartSelectionLighting @ 0x004A3CA0gmPaperDollUI::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 @ 0x004A5360gmPaperDollUI::UpdateAetheria @ 0x004A3E50gmPaperDollUI::RecvNotice_PlayerDescReceived @ 0x004A43E0gmPaperDollUI::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 @ 0x004A3A70gmPaperDollUI::AcceptDragObject @ 0x004A3B10gmPaperDollUI::OnItemListDragOver @ 0x004A4270gmPaperDollUI::AcceptPaperDollDragObject @ 0x004A4A70gmPaperDollUI::HandleDropRelease @ 0x004A4D80CPlayerSystem::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 @ 0x004A5360gmPaperDollUI::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 @ 0x0049A610gmCharacterTitleUI::UpdateButtons @ 0x0049A500gmCharacterTitleUI::ListenToElementMessage @ 0x0049A6D0gmCharacterTitleUI::FindSortedInsertPosition @ 0x0049A760gmCharacterTitleUI::AddTitleToList @ 0x0049A840gmCharacterTitleUI::Refresh @ 0x0049ABC0gmCharacterTitleUI::RecvNotice_UpdateCharacterTitleTable @ 0x0049AD30gmCharacterTitleUI::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 @ 0x004EFD90gmStatManagementUI::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 @ 0x004F03F0gmAttributeUI::RaiseSelection @ 0x0049D020gmAttributeUI::Raise10 @ 0x0049D150gmSkillUI::RaiseSelection @ 0x0049C8C0gmSkillUI::Raise10 @ 0x0049B760InfoRegion::OnQualityChanged @ 0x004F0EB0gmStatManagementUI::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:
- 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
gmMainChatUIpath alone has no tab logic. - Chat strings: capture the exact localized talk-focus labels, target label, and squelch wording from the string table/live menu.
- 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.
- Owned bag close packet: trace packets while hiding inventory on an
owned side bag. Static evidence says this is not an external
NoLongerViewingContentsevent. - External close handshake: capture the order between
gmExternalContainerUI::CloseCurrentContainer,ItemHolder::UseObject, the server response, andClientUISystem::SetGroundObject(0, true). - Paperdoll legality: recover the corrupt
AutoWieldIsLegalmask branch from the older Ghidra chunk and verify missile/ammo, shield, dual-wield, and in-combat denials with golden cases. - Paperdoll visuals: visually verify race camera framing, Olthoi animation enum results, click-map alignment, and the 0.2-second part pulse.
- Luminance text: resolve exact localized label/value formatting from the string table or rendered client.
- 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.