# 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 ```text 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` ```text 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. ```text 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: ```text [5, 2, 4, 1, 6, 3, 7, 8, 9, 10, 11, 12, 13] ``` ```text 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` ```text 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: ```text opcode: 0x0195 NoLongerViewingContents payload: u32 containerGuid ``` ```text 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` ```text 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. ```text 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` ```text 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` | ```text 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` | ```text 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` ```text 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.