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

857 lines
30 KiB
Markdown

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