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>
857 lines
30 KiB
Markdown
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.
|