acdream/docs/research/2026-07-10-retail-toolbar-interaction-pseudocode.md
Erik 5090aa7217 fix #212: preserve shortcut tint in physical combat
Port the retail m_bShortcutGhosted meaning instead of treating SetShortcutNum's boolean as peace versus war. Keep occupied shortcut numbers on the gold sheet in NonCombat, Melee, and Missile, reserve the gray sheet for Magic, and lock the four-mode matrix with conformance tests and corrected research.

Co-Authored-By: Codex <codex@openai.com>
2026-07-13 11:21:12 +02:00

1214 lines
48 KiB
Markdown

# Retail toolbar, item interaction, targeting, and cursor pseudocode
**Date:** 2026-07-10
**Build oracle:** Sept. 2013 EoR `acclient.exe` v11.4186 with matching PDB
**Purpose:** Wave 0 oracle for the retained retail UI fidelity plan. This note
records behavior only; it does not prescribe App/Core class layout and changes no
runtime code.
## Sources and confidence
Primary retail sources:
- `docs/research/named-retail/acclient_2013_pseudo_c.txt`
- `docs/research/named-retail/acclient.h`
- `docs/research/named-retail/symbols.json`
Client/server cross-checks:
- ACE:
- `Source/ACE.Server/Network/Structure/Shortcut.cs`
- `Source/ACE.Server/Network/GameAction/Actions/GameActionAddShortcut.cs`
- `Source/ACE.Server/Network/GameAction/Actions/GameActionUseItem.cs`
- `Source/ACE.Server/Network/GameAction/Actions/GameActionUseWithTarget.cs`
- `Source/ACE.Server/Network/GameEvent/Events/GameEventPlayerDescription.cs`
- `Source/ACE.Server/WorldObjects/Player_Character.cs`
- holtburger:
- `crates/holtburger-protocol/src/messages/player/shortcuts.rs`
- `crates/holtburger-protocol/src/messages/player/events.rs`
- `crates/holtburger-protocol/src/messages/object/types.rs`
- `crates/holtburger-protocol/src/messages/game_action.rs`
The reference repositories are present in the developer checkout rather than this
worktree's intentionally sparse `references/` directory. Retail named symbols are
the behavioral oracle; ACE and holtburger are wire-shape checks.
Confidence labels used below:
- **Exact:** control flow and fields are legible in the named pseudo-C/header.
- **Exact wire:** retail, ACE, and holtburger agree on byte count and order.
- **Semantic name uncertain:** numeric behavior is exact, but the decompiler lost
a source enum/property name.
- **Unresolved:** the named body is ambiguous enough that implementation must pin
it with Ghidra/cdb or a DAT/capture fixture before porting.
---
## 1. Symbol index
### Toolbar and selected-object strip
| Symbol | Address | Role |
|---|---:|---|
| `gmToolbarUI::RecvNotice_UpdateItemMana` | `0x004BD0C0` | Selected-item mana visibility/value |
| `gmToolbarUI::RecvNotice_UpdateObjectHealth` | `0x004BD110` | Selected-object health visibility/value |
| `gmToolbarUI::OnItemListDragOver` | `0x004BD150` | Shortcut drop accept state |
| `gmToolbarUI::IsShortcutEligible` | `0x004BD1D0` | Item eligibility for a shortcut |
| `gmToolbarUI::RecvNotice_SplitStack` | `0x004BD2A0` | Focus/select stack entry text |
| `gmToolbarUI::RecvNotice_SetPanelVisibility` | `0x004BD300` | Open/closed panel-button state |
| `gmToolbarUI::UseShortcut` | `0x004BD350` | Target/use/select shortcut dispatch |
| `gmToolbarUI::FlushShortcuts` | `0x004BD420` | Clear all 18 visual lists |
| `gmToolbarUI::RemoveShortcut` | `0x004BD450` | Remove by object id |
| `gmToolbarUI::RemoveShortcutInSlotNum` | `0x004BD4F0` | Remove by slot and return object id |
| `gmToolbarUI::GetFirstEmptyShortcutToTheRightOf` | `0x004BD560` | Cyclic empty-slot search |
| `gmToolbarUI::IsShortcutSlotAvailable` | `0x004BD5E0` | Bounds + empty check |
| `gmToolbarUI::RecvNotice_SetCombatMode` | `0x004BD610` | Shortcut-number/combat indicator refresh |
| `gmToolbarUI::RecvNotice_ItemListBeginDrag` | `0x004BD930` | Remove-on-lift and remember source slot |
| `gmToolbarUI::AddShortcut` | `0x004BD9A0` | Visual add plus optional wire/local model update |
| `gmToolbarUI::CreateShortcutToItem` | `0x004BDAC0` | Eligibility/ownership/slot orchestration |
| `gmToolbarUI::ListenToGlobalMessage` | `0x004BE4E0` | Use/select/create shortcut input actions |
| `gmToolbarUI::HandleDropRelease` | `0x004BE7C0` | Inventory/toolbar/inventory-button drop branches |
| `gmToolbarUI::RecvNotice_FullMergingItem` | `0x004BE9B0` | Rekey a shortcut after a full stack merge |
| `gmToolbarUI::PostInit` | `0x004BEA80` | Bind buttons, meters, stack controls, and slots |
| `gmToolbarUI::ListenToElementMessage` | `0x004BEE90` | Stack editing, drop, combat/use/examine buttons |
| `gmToolbarUI::HandleSelectionChanged` | `0x004BF380` | Name/health/mana/stack/use-button state |
| `gmToolbarUI::UpdateFromPlayerDesc` | `0x004BF810` | Restore object shortcuts from player module |
### Item interaction and target mode
| Symbol | Address | Role |
|---|---:|---|
| `UIElement_ItemList::HandleTargetedUseLeftClick` | `0x004E24D0` | Item/spell target-mode dispatch |
| `UIElement_ItemList::ListenToElementMessage` | `0x004E4D50` | Target-first item-list click ordering |
| `CPlayerSystem::OnAction` | `0x00561890` | World selection/target acquisition ordering |
| `CPlayerSystem::UsingItem` | `0x00562F70` | Execute `DetermineUseResult` and container paths |
| `ClientUISystem::SetTargetMode` | `0x00564940` | Input-map and cursor lifecycle |
| `ClientUISystem::ExecuteTargetModeForItem` | `0x00564B30` | Use/examine/use-with-target dispatch |
| `ItemHolder::TargetCompatibleWithObject` | `0x00587520` | Full compatibility and user-message path |
| `ItemHolder::IsTargetCompatibleWithTargetingObject` | `0x00588070` | Cursor-compatible, no-message predicate |
| `ItemHolder::DetermineUseResult` | `0x00588460` | Primary-use classification |
| `ItemHolder::AttemptPlaceIn3D` | `0x00588600` | Drag/drop placement decision tree |
| `ItemHolder::UseObject` | `0x00588A80` | Primary use orchestration |
| `ItemHolder::TargetAcquired` | `0x00588EF0` | Complete targeted use and confirmations |
### Cursor and shortcut persistence
| Symbol | Address | Role |
|---|---:|---|
| `UIElementManager::SetCursor` | `0x0045A800` | Set current and optionally default cursor |
| `UIElementManager::CheckCursor` | `0x0045ABF0` | Captured/hovered/default cursor precedence |
| `UIElement::SetCursor` / `UnSetCursor` | `0x0045FF50` / `0x0045FF80` | Widget cursor changes |
| `MediaMachine::Update_Cursor` | `0x00465A80` | Apply state `MediaDescCursor` to a widget |
| `ClientUISystem::UpdateCursorState` | `0x00564630` | Global cursor enum selection |
| `ShortCutData::ShortCutData(int,uint,uint)` | `0x005D55E0` | Three-field shortcut record |
| `ShortCutManager::Pack` / `UnPack` | `0x005D5710` / `0x005D5820` | Count + packed shortcut entries |
| `ShortCutManager::AddShortCut` | `0x005D5790` | Validate index and copy all three fields |
---
## 2. Exact shortcut representation and spell-bar separation
### 2.1 Retail in-memory and packed shape
`acclient.h` defines:
```text
ShortCutData : PackObj {
int index_;
uint objectID_;
uint spellID_;
}
ShortCutManager : PackObj {
ShortCutData* shortCuts_[18];
}
PlayerModule : PackObj {
ShortCutManager* shortcuts_;
PackableList<uint> favorite_spells_[8];
...
}
```
The C++ object occupies 16 bytes because it includes the `PackObj` vtable pointer.
The serialized entry is **12 bytes**, not 16: signed/unsigned 32-bit index,
32-bit object id, and one 32-bit spell word. `ShortCutManager::pack_size` starts
at four bytes for the count and adds each entry's 12-byte pack size.
ACE and holtburger interpret the final 32-bit word as a `LayeredSpell` encoded as
`u16 spellId, u16 layer`. That interpretation is wire-compatible with retail's
single `uint spellID_` field:
```text
rawSpellWord = spellId | (layer << 16)
```
Therefore the faithful model must retain the raw 32-bit word losslessly. It may
offer `SpellId`/`Layer` accessors, but those are a view over the word, not two
additional wire fields.
**Current documentation correction:** `PlayerDescriptionParser.ShortcutEntry`'s
comment says "16 bytes wire size". Its actual fields and parser consume 12 bytes;
16 is only the retail C++ object size including the vptr.
### 2.2 Manager pseudocode
```text
ShortCutManager.AddShortCut(entry): // 0x005D5790
if entry.index < 0 or entry.index >= 18:
return false
if shortCuts[entry.index] already exists:
copy index, objectID, and raw spellID into it
return true
allocate a ShortCutData
if allocation succeeds:
copy index, objectID, and raw spellID
store pointer (null on allocation failure)
return true // retail behavior
ShortCutManager.Pack(dst, available): // 0x005D5710
required = pack_size()
if available < required:
return required
reserve/write a u32 count
for slots 0..17:
if entry exists:
pack its three 32-bit fields
count++
write count into reserved word
return required
ShortCutManager.UnPack(src): // 0x005D5820
destroy existing entries
count = read u32
repeat count times:
read 12-byte ShortCutData
AddShortCut(entry) // rejects bad indices
return true
```
The pseudo-C labels the 12-byte entry unpack call as
`HookAppraisalProfile::UnPack`; this is an analysis alias collision. The field
count, constructor, manager copy, ACE, and holtburger all establish the shortcut
shape.
### 2.3 The retail toolbar is object-only in this build
```text
gmToolbarUI.UpdateFromPlayerDesc(): // 0x004BF810
FlushShortcuts()
module = query CPlayerModule
if module and module.shortcuts:
for slot in 0..17:
entry = module.shortcuts.shortCuts[slot]
if entry != null and entry.objectID != 0:
AddShortcut(entry.objectID, slot, send=false)
UpdateAmmoID()
```
`gmToolbarUI::AddShortcut` itself constructs
`CShortCutData(slot, objectId, rawSpellWord=0)` when it sends a toolbar mutation.
No `gmToolbarUI` path restores or renders `spellID_` when `objectID_ == 0`.
The eight `PlayerModule::favorite_spells_` lists are serialized separately under
the player-description `SpellLists8` flag. ACE also exposes separate
`AddSpellFavorite (0x01E3)` and `RemoveSpellFavorite (0x01E4)` actions. They feed
spell-bar UI, not `gmToolbarUI`'s 18 object slots.
**Implementation constraint:** preserve the raw shortcut spell word through
parse/store/write even though this toolbar ignores it. Do not add spell icons or
spell casting to `gmToolbarUI`; implement the eight favorite-spell lists as a
separate subsystem.
### 2.4 Physical-shortcut number ghosting
The Boolean parameter of `UIElement_UIItem::SetShortcutNum @ 0x004E1590` is
not a peace/war selector. `ACCWeenieObject::SetShortcutNum @ 0x0058C100`
stores it verbatim as `m_bShortcutGhosted`, and `UIElement_UIItem::Update`
replays that named field into the widget. The digit-array selection is:
```text
UIElement_UIItem.SetShortcutNum(slot, ghosted): // 0x004E1590
if slot < 0:
hide shortcut-number element
return
if item icon is in empty state 0x1000001C:
digits = property 0x1000005E // empty slot
else if ghosted:
digits = property 0x10000043 // gray mesh
else:
digits = property 0x10000042 // gold mesh
set number image to digits[slot]
show shortcut-number element
```
`gmToolbarUI::RecvNotice_SetCombatMode @ 0x004BD610` first stores
`mode != MAGIC_COMBAT_MODE`, then passes the inverse to every occupied
shortcut cell. In direct pseudocode:
```text
shortcutsGhosted = (mode == MAGIC_COMBAT_MODE)
for each occupied shortcut slot i:
cell.SetShortcutNum(i, shortcutsGhosted)
```
Therefore NonCombat, Melee, and Missile all use property `0x10000042`; only
Magic uses the ghosted `0x10000043` sheet. Calling these arrays “peace” and
“war” reverses the meaning and incorrectly grays physical shortcut numbers in
melee and missile combat.
---
## 3. `gmToolbarUI` input, use, and selection pseudocode
### 3.1 Global input messages
`ListenToGlobalMessage(1, action)` ignores shortcut actions while the stack-size
entry owns focus. Otherwise:
| Action ids | Result |
|---|---|
| `0x10000042..0x1000004D` | `UseShortcut(action - 0x10000042, use=true)` for slots 0..11 |
| `0x1000004E..0x10000059` | `UseShortcut(action - 0x1000004E, use=false)` for slots 0..11 |
| `0x10000132..0x10000137` | Use slots 12..17 |
| `0x10000138..0x1000013D` | Select slots 12..17 |
| `0x1000010D` | Create a shortcut to the selected object, auto-slot |
When the stack entry has focus, action `0x27` restores its text from the global
split size and relinquishes focus. The input map identifies `0x27` as the commit/
escape-style action in this context; its human key name is not recovered here.
### 3.2 Use/select/target ordering
```text
gmToolbarUI.UseShortcut(slot, use): // 0x004BD350
if slot outside the visual slot array: return
cell = slot's first UIItem
if no UIItem: return
if ClientUISystem.targetMode != NONE:
if cell.itemID != 0:
ExecuteTargetModeForItem(cell.itemID, targetMode)
SetTargetMode(NONE) // always clear here
return
if use:
ItemHolder.UseObject(cell.itemID, 0, 0)
else:
ACCWeenieObject.SetSelectedObject(cell.itemID, 0)
```
Target mode is thus checked before both normal use and selection. Toolbar target
dispatch is one-shot because this method explicitly clears the mode after
dispatch.
### 3.3 Use and Examine buttons
On button click (`ListenToElementMessage`, message `1`):
```text
Use button 0x1000019D:
if selectedID != 0: ItemHolder.UseObject(selectedID, 0, 0)
else: SetTargetMode(USE)
Examine button 0x100001A5:
if selectedID != 0: ExamineObject(selectedID)
else: SetTargetMode(EXAMINE)
Combat indicators 0x10000192..0x10000195:
ToggleCombatMode()
```
The seven panel buttons are discovered in `PostInit` from element attribute
`0x10000029`; `RecvNotice_SetPanelVisibility(panelId, visible)` sets button state
`6` while visible and state `1` while hidden.
---
## 4. Toolbar drag/drop pseudocode
### 4.1 Eligibility
`IsShortcutEligible(object)` accepts only when all of the following retail tests
pass:
1. Object exists.
2. A particular PWD bitfield condition either is absent or the object is a player.
3. Objects whose `InqType()` includes `0x10` are rejected unless `IsPlayer()`.
4. If the object has a container, that container is not the active vendor.
The decompiler has replaced the first bit's source enum name with a raw `4`; keep
the numeric branch in the pseudocode fixture and recover the PWD property name
before introducing a public enum.
`CreateShortcutToItem` additionally requires ownership. With its pickup flag set,
it first attempts `PlaceInBackpack`; with the flag clear it reports the appropriate
not-owned/ineligible/full/duplicate message. For a specified slot it removes any
existing occurrence of the same object and adds it at that slot. For auto-slot it
passes `-1` to `AddShortcut`; `AddShortcut @ 0x004BD9A0` scans from slot zero and
stops at the first empty slot. A duplicate does not get added again.
### 4.2 Shortcut aliases are not physical items
Retail preserves the dragged element's source identity in `DropItemFlags`. The
shared inventory-list path and paperdoll path both require
`(flags & 0xE) == 0` before they show an accept/reject state or perform a physical
item move. A shortcut alias therefore remains visually neutral over inventory or
paperdoll, and releasing it there performs no item operation. The shortcut was
already removed by `RecvNotice_ItemListBeginDrag`, so the net result is only
removing the alias from the toolbar.
```text
UIElement_ItemList.ItemList_DragOver(source, target): // 0x004E3400
(objectId, _, flags) = InqDropIconInfo(source)
if no custom handler and (flags & 0xE) == 0:
set the target's physical-item accept/reject state
// alias source: leave the target neutral
UIElement_ItemList.HandleDropRelease(message): // 0x004E4790
(objectId, _, flags) = InqDropIconInfo(source)
if objectId != 0 and (flags & 0xE) == 0:
AcceptDragObject(objectId, flags & 1)
// alias source: no physical item operation
gmPaperDollUI.OnItemListDragOver / HandleDropRelease: // 0x004A4270 / 0x004A4D80
apply the same (flags & 0xE) == 0 gate before wield policy
gmToolbarUI.OnItemListDragOver: // 0x004BD150
accept when (flags & 0xE) == 0 or (flags & 4) != 0
```
In acdream's typed payload, `ItemDragSource.ShortcutBar` is the `flags & 4`
alias case. Inventory, equipment, and world placement controllers must never
reinterpret that payload as the underlying physical object.
### 4.3 Remove-on-lift
```text
RecvNotice_ItemListBeginDrag(sourceList): // 0x004BD930
find sourceList's index in the 18 slots
item = sourceList.first UIItem
if item.itemID != 0:
RemoveShortcut(item.itemID, send=true)
m_lastShortcutNumDragged = source index
```
`RemoveShortcut` flushes the visual slot, clears the object's shortcut number,
then, when `send=true`, sends `Event_RemoveShortCut(index)` and removes that slot
from `PlayerModule`. Releasing a lifted shortcut off the toolbar leaves it removed.
### 4.4 Empty-slot search is cyclic to the right
```text
GetFirstEmptyShortcutToTheRightOf(slot): // 0x004BD560
search slot+1 .. 17
if none, search 0 .. slot
return first empty, or -1
```
It is not an inventory grid index, the original source index, or a global
first-empty search unless wraparound reaches slot zero.
### 4.5 Drop release branches
`InqDropIconInfo` supplies the dragged object id and `DropItemFlags`. The exact
source-flag enum names are not present in the named body, but the masks and branch
effects are exact:
```text
HandleDropRelease(message): // 0x004BE7C0
sourceElement = message.drag source
targetElement = message.drop target
if either is null: return
if target element id == inventory button 0x100001B1:
(objectId, flags) = InqDropIconInfo(sourceElement)
if (flags & 0xE) == 0:
object = lookup objectId
if owned: PlaceInBackpack(objectId, 0)
else: AttemptToPlaceInContainer(objectId, playerId, ..., quiet=true)
if operation reports false: clear object's waiting state
return
targetSlot = first shortcut slot that is an ancestor of targetElement
if no target slot: return
(objectId, flags) = InqDropIconInfo(sourceElement)
if objectId == 0: return
if (flags & 0xE) == 0: // fresh/non-alias source
displaced = RemoveShortcutInSlotNum(targetSlot, send=true)
CreateShortcutToItem(objectId, targetSlot, pickup=true, quiet=false)
if displaced != 0 and displaced != objectId:
empty = GetFirstEmptyShortcutToTheRightOf(targetSlot)
if empty != -1: AddShortcut(displaced, empty, send=true)
else if (flags & 4) != 0: // shortcut-alias source
displaced = RemoveShortcutInSlotNum(targetSlot, send=true)
AddShortcut(objectId, targetSlot, send=true)
if displaced != 0 and displaced != objectId
and IsShortcutSlotAvailable(m_lastShortcutNumDragged):
AddShortcut(displaced, m_lastShortcutNumDragged, send=true)
```
This distinction is behaviorally important. A fresh inventory item dropped onto
an occupied slot sends the displaced item to the first empty slot cyclically to
the right of the target. Only a dragged shortcut alias attempts to restore the
displaced item to `m_lastShortcutNumDragged`.
### 4.6 Stack-merge attempt rekey
```text
RecvNotice_FullMergingItem(oldId, newId): // 0x004BE9B0
slot = RemoveShortcut(oldId, send=true)
if slot != -1:
CreateShortcutToItem(newId, slot, pickup=false, quiet=true)
```
This body updates the first matching shortcut returned by `RemoveShortcut`.
Whether duplicate object shortcuts can exist through external player-description
data is unresolved; `CreateShortcutToItem` normally prevents duplicates.
The notice name is misleading: `ItemHolder::AttemptMerge @ 0x005878F0` broadcasts
`SendNotice_FullMergingItem(oldId, newId)` immediately after `UIAttemptMerge`, for
every legal merge request. It is not an ACE confirmation and is not conditional on
the source stack being exhausted. `CreateShortcutToItem` also removes any existing
`newId` shortcut before adding it at the old source shortcut's slot.
```text
IsMergeAttemptLegal(sourceId, targetId, quiet): // 0x00586F30
if player not ready for inventory request: return false
if sourceId == targetId: return false
source = lookup sourceId; target = lookup targetId
if either missing: return false
if source.maxStackSize <= 1 or target.maxStackSize <= 1: return false
if source.tradeState == 1 or target.tradeState == 1: return false
if source.wcid != target.wcid: return false
return target.stackSize < target.maxStackSize
AttemptMerge(sourceId, targetId, quiet): // 0x005878F0
if not IsMergeAttemptLegal(...): return false
requested = sourceId == selectedId ? splitSize : max(1, source.stackSize)
available = target.maxStackSize - max(1, target.stackSize)
amount = min(requested, available)
source.UIAttemptMerge(targetId, amount) // sends 0x0054
SendNotice_FullMergingItem(sourceId, targetId) // immediate local notice
Select(targetId)
return true
```
acdream now has one Core-owned split-quantity state shared by the selected-object
entry/slider and inventory merge. A selected source supplies that quantity to
`AttemptMerge`; an unselected source retains retail's full-source default. Legality,
capacity clamping, and wire ordering are unchanged.
---
## 5. Selected-object health, mana, and stack behavior
### 5.1 Initial binding
`PostInit` binds the selected field and these children:
| Element | Retail member | Initial state |
|---:|---|---|
| `0x1000019F` | selected name | empty |
| `0x100001A1` | health meter | hidden |
| `0x100001A2` | mana meter | hidden |
| `0x100001A3` | stack-size text | hidden, `NumberInputFilter` |
| `0x100001A4` | stack slider | hidden |
### 5.2 Selection refresh
```text
HandleSelectionChanged(): // 0x004BF380
if cached selected id changed:
cache selectedID
clear name
selected field state = 0
if health meter visible:
QueryHealth(0)
hide health meter
if mana meter visible:
QueryItemMana(0)
hide mana meter
hide stack entry and slider
if selected id == 0:
Use button state = 1
return
object = lookup selected id
if object missing: return
set selected name:
normal object: NAME_APPROPRIATE, or `"%d %hs"` (count then appropriate
name) when stacked
owned coinstack: formatted coin total/name path
splitSize = 1
maxSplitSize = 1
hide stack controls
if stackSize > 1:
initialSplit = stackSize
if object is in active vendor and passes vendor type mask:
initialSplit = 1
splitSize = initialSplit
maxSplitSize = max(stackSize, 1)
entry text = splitSize
slider value attribute 0x86 = splitSize / maxSplitSize
selected field state = 0x1000000C // stacked
show entry and slider
else:
selected field state = 0x1000000B // object selected
if object is player or pet or attackable:
QueryHealth(selected id)
else if object is owned:
QueryItemMana(selected id)
construct ItemUses(useability)
if no combat use, not relevant item types, and not useable:
Use button state = 13 // ghosted
else:
Use button state = 1
```
The exact item-type mask in the use-button ghost test is visible as separate
`InqType() & 4`, `& 2`, and `& 8` checks. Preserve those numeric tests until the
retail `ITEM_TYPE` names are pinned.
### 5.3 Meter notices
```text
RecvNotice_UpdateObjectHealth(id, fraction): // 0x004BD110
if id != cached selected id: return
if health meter hidden: show it
set meter float attribute 0x69 = fraction
RecvNotice_UpdateItemMana(id, fraction, valid): // 0x004BD0C0
if id != cached selected id: return
if not valid:
QueryItemMana(0)
return
if mana meter hidden: show it
set meter float attribute 0x69 = fraction
```
### 5.4 Stack editing
`ACCWeenieObject::GetObjectName(NAME_APPROPRIATE) @ 0x0058E6E0` selects
`PublicWeenieDesc._plural_name` whenever `_stackSize > 1`. If the optional plural
string is empty, retail copies the singular and appends `s`, or `es` when the
singular already ends in lowercase `s`. The CreateObject WeenieHeader carries the
explicit plural as flag `0x00000001`, before ItemsCapacity.
The stack entry's authored DAT horizontal justification is Right. Its number is
therefore right-aligned against the slider's left edge rather than drawn from the
entry's left edge.
- `RecvNotice_SplitStack(selectedId)` activates and selects all text only when the
selected object still has a stack size greater than one.
- On entry activation, select all text.
- On entry deactivation, parse unsigned text, clamp to `1..maxSplitSize`, rewrite
the text if clamped, update `GenItemHolder::splitSize`, update slider attribute
`0x86 = splitSize/maxSplitSize`, and broadcast the stack-slider notice.
- `UIElement_Scrollbar::SetScrollbarPosition @ 0x00470EC0` clamps the normalized
position to `[0,1]`, multiplies by `1000.0f`, truncates through `_ftol2`, and
broadcasts that integer as message `0xA` parameter 1.
- `gmToolbarUI::ListenToElementMessage @ 0x004BEFE0..0x004BF041` converts the
message exactly as follows, stores it, rewrites the entry, and broadcasts the
stack-slider notice:
```text
positionMillis = truncate(clamp01(sliderRatio) * 1000)
splitSize = clamp(1 + floor(positionMillis * maxSplitSize / 1000),
1, maxSplitSize)
```
The apparently odd extra one is verbatim retail behavior. Static cdb disassembly
of the matching v11.4186 binary also pins the stack-name format string at
`0x007B4738` to `"%d %hs"` and the numeric-entry format at `0x007A0184` to `"%d"`.
While the pointer is moving, the scrollbar retains its own normalized position and
the toolbar only rewrites the entry text. Selection refresh or entry deactivation
explicitly writes attribute `0x86 = splitSize/maxSplitSize`. Consequently a pointer
drag can remain at exact position `0` (quantity 1) or `1` (the maximum), rather than
being redrawn from the quantized quantity during the drag.
---
## 6. `DetermineUseResult` and `CPlayerSystem::UsingItem`
`DetermineUseResult` returns a numeric primary-use classification. The meanings
are established by the switch in `CPlayerSystem::UsingItem @ 0x00562F70`:
| Result | `UsingItem` behavior |
|---:|---|
| `0` | No classified primary action |
| `1` | Item has a direct/targeted `ItemUses` use path |
| `2` | `PlaceInBackpack` |
| `3` | `AutoWield(..., SLOT_SIDE_RIGHT, ...)` |
| `4` | `AutoSort` |
| `5` | Attempt to open secure trade negotiations |
| `6` | Open salvage panel |
| `7` | Begin game with object |
| `8` | `AutoWield(..., SLOT_SIDE_LEFT, ...)` |
### 6.1 Classification pseudocode
```text
DetermineUseResult(object): // 0x00588460
if object is in a container or has the relevant PWD flag,
or object is in the currently viewed ground container:
if not wielded by somebody else:
if object has item/container capacity and is not component pack:
return 2 // pick up/open carrier path
type = object.InqType()
if owned by player:
if combat-use or type bit 0x8000 or PWD bit 0x20000000:
if not currently wielded by player:
return 8 when PWD bit 0x40000000 is set, else 3
if valid-location masks contain an unworn usable location:
return 4
if type has 0x20000000:
return 6
else if type has its high bit set:
return 7
if ItemUses(useability).IsUseable():
return 1
if object is another player:
return 5
return 0
```
The first capacity bit expression was corrupted into a string address by Binary
Ninja. Matching-executable disassembly at `0x005884B6` resolves it exactly as
`test PWD._bitfield, 0x00800000`, i.e. `BF_REQUIRES_PACKSLOT`. The complete
result-2 predicate is therefore `(!BF_REQUIRES_PACKSLOT && itemsCapacity == 0 &&
containersCapacity == 0) || IsComponentPack()` after the loose/viewed-container
and wielder gates. This was pinned on 2026-07-11 before the Core policy port.
`CPlayerSystem::UsingItem` first executes result `2..8`, then independently:
- sends `OpenContainedContainer` for a player-owned container; and
- for a non-owned, directly usable, non-targeted container, calls
`AttemptSetGroundObject`.
These are not mutually exclusive with the classification switch. A pure policy
port must model the complete ordered action list, not reduce the result to a
single modern enum and accidentally omit the post-switch container steps.
---
## 7. `ItemHolder::UseObject` pseudocode
```text
UseObject(objectId, useCurrentSelection, bypassClassification): // 0x00588A80
ui = GetUISystem()
if ui is null: return
object = lookup objectId
if object is null: return
if now is not later than m_timeLastUsed + 0.2 seconds: return
m_timeLastUsed = now
if player is not ready for an inventory request: return
if active vendor exists and object.containerID == vendorID: return
classified = false
if bypassClassification == 0:
result = DetermineUseResult(object)
if result in 2..7: // exact bound; excludes 8
classified = true
uses = ItemUses(object.useability)
if classified:
PlayerSystem.UsingItem(objectId, true, bypassClassification)
return
if object.tradeState == 1:
display "cannot use while trading" message
return
if object.location == 0 and least-limited source use requires wielded:
display "must wield" message
return
if uses.IsUseable_Targeted():
if useCurrentSelection == 0:
ItemHolder.targetingObject = objectId
SetTargetMode(USE_TARGET)
display "Choose a target..."
return
target = ACCWeenieObject.selectedID
if target == 0:
display "Select your target..."
return
if TargetCompatibleWithObject(target, objectId, showErrors=false,
showUsing=true):
Event_UseWithTargetEvent(objectId, target)
IncrementBusyCount()
PlayerSystem.UsingItem(objectId, true, bypassClassification)
return
if uses.IsUseable():
if PK altar flag: show PK altar confirmation; return
if NPK altar flag: show NPK altar confirmation; return
if volatile-rare flag and preference requires confirmation:
show volatile-rare confirmation; return
Event_UseEvent(objectId)
IncrementBusyCount()
PlayerSystem.UsingItem(objectId, true, bypassClassification)
display "Approaching" for creature/player-like type, else "Using"
return
PlayerSystem.UsingItem(objectId, false, bypassClassification)
if object is not self:
if object has the no-open/close restriction bit:
display restricted message; return
if attackable and combat mode is noncombat:
display switch-to-combat message; return
if not attackable or combat mode is noncombat:
display "cannot be used" message; return
```
Important boundaries:
- The 200 ms throttle timestamp advances before the ready/vendor/legality gates.
- `UseObject` owns confirmation routing and busy-count increments.
- `arg2` is established by call sites as "use the current selected target now"
versus "enter target mode". `arg3` bypasses classification in several internal
paths; its original source-level name remains unresolved.
- The `2..7` bound excludes `DetermineUseResult == 8` even though
`CPlayerSystem::UsingItem` has a case for 8. Preserve this asymmetry; do not
normalize it without tracing the left-side-wield call path.
### 7.1 Target acquisition completion
```text
TargetAcquired(targetId): // 0x00588EF0
sourceId = ItemHolder.targetingObject
ItemHolder.targetingObject = 0
if not TargetCompatibleWithObject(targetId, sourceId,
showErrors=false, showUsing=true):
return
source = lookup sourceId
target = lookup targetId
if source and target:
if source is mana stone requiring confirmation:
reject no-mana target or show mana-stone confirmation
return
if source is salvage-like and craft-success-dialog preference requires it:
show salvage confirmation
return
Event_UseWithTargetEvent(sourceId, targetId)
IncrementBusyCount()
PlayerSystem.UsingItem(sourceId, true, 0)
```
ACE and holtburger confirm the wire actions after this policy:
```text
Use (0x0036): u32 objectId
UseWithTarget (0x0035): u32 sourceObjectId, u32 targetObjectId
```
---
## 8. `AttemptPlaceIn3D` pseudocode
### 8.1 Shared selected-stack amount
```text
GetObjectSplitSize(item): // 0x00586F00
if item.id == selectedID:
return GenItemHolder.splitSize
if item.stackSize != 0:
return item.stackSize
return 1
```
The quantity field is therefore global UI state, but it applies only to the
currently selected object. Dragging any other stack uses that object's full
quantity.
### 8.2 Container move versus container split
After the legality, capacity, and auto-merge branches choose a destination,
retail makes this exact decision:
```text
AttemptToPlaceInContainer(itemId, containerId, ..., placement): // 0x00588140
...legality and destination selection...
if splitSize >= maxSplitSize:
item.UIAttemptPutInContainer(containerId, placement)
return true
item.UIAttemptSplitToContainer(containerId, placement, splitSize)
return true
UIAttemptSplitToContainer(item, containerId, placement, amount): // 0x0058D7D0
Event_StackableSplitToContainer(item.id, containerId, placement, amount)
```
The partial path sends GameAction `0x0055` with
`(stackGuid, containerGuid, placement, amount)`. It does not move the original
client object into the destination: the server reduces that source stack and
creates a distinct destination object with a new guid.
```text
AttemptPlaceIn3D(itemId, targetId, allowGroundFallback): // 0x00588600
if player not ready for inventory request: return false
item = lookup itemId
if item missing: return false
if targetId == playerId:
return PlaceInBackpack(itemId, 0)
if item is not owned by player:
display "You must first pick up..."
return false
if item.tradeState != 0:
display "You are trading..."
return false
if targetId == 0:
if not allowGroundFallback: return false
goto PLACE_ON_GROUND
if AttemptMerge(itemId, targetId, quiet=false):
return true
target = lookup targetId
if target missing:
if allowGroundFallback: goto PLACE_ON_GROUND
return false
if target passes the retail vendor-drop PWD bit test:
if splitSize == maxSplitSize:
AttemptSellToVendor(targetId, itemId)
return false // exact retail return
display "must split the stack before..."
return false
if preference DragItemOnPlayerOpensSecureTrade and target.IsPlayer():
AttemptToTradeItem(targetId, itemId)
return false // exact retail return
if target.InqType() == 0x10:
item.UIAttemptGive(targetId, GetObjectSplitSize(item))
return true
if target.IsContainer():
if target is locked: display locked message; return false
if targetId != ui.groundObject:
display "must open first"; return false
placed = AttemptToPlaceInContainer(itemId, targetId, ..., quiet=true)
if placed: return true
if allowGroundFallback: goto PLACE_ON_GROUND
display formatted "Cannot give item to target"
return false
PLACE_ON_GROUND:
playerPhysics = lookup player physics
if playerPhysics missing or not on_ground:
display "You cannot do that in mid air"
return false
if splitSize < maxSplitSize:
item.UIAttemptSplitTo3D(splitSize)
return true
if item.current_state != IN_3D_VIEW:
item.UIAttemptPutIn3D()
return true
display "Move cancelled"
return false
```
The return value means neither strictly "a server message was sent" nor strictly
"the item moved": vendor and secure-trade attempts deliberately return false.
Callers that use it only as an action-success boolean will diverge.
The vendor-drop test is now pinned. Matching-executable disassembly at
`0x0058874C` loads `PWD._bitfield` and `test ah, 0x02`, i.e. overall mask
`0x00000200 = BF_VENDOR`. It is an object-trait test, distinct from
`ClientUISystem.vendorID`.
`UIAttemptSplitTo3D @ 0x0058D850` dispatches
`CM_Inventory::Event_StackableSplitTo3D @ 0x006ABFC0`, GameAction `0x0056`
with `(stackGuid, amount)`. Like the container split, the source object remains
in place until authoritative server updates arrive.
---
## 9. Target-mode click ordering
### 9.1 Item lists
For left-click message `0x1C`, parameter `7`:
```text
UIElement_ItemList.ListenToElementMessage: // 0x004E4D50
item = item under mouse
if targetMode != NONE:
HandleTargetedUseLeftClick(item)
return base-list handler result // no selection/open fallback
if item.itemID != 0:
apply single-selection visual if configured
SetSelectedObject(item.itemID)
if this is a container list and child list exists:
bind/open child container and scroll it home
```
`HandleTargetedUseLeftClick` dispatches `spellID` first, otherwise `itemID`, using
the current target mode. Thus a valid or invalid target-mode click is consumed
before selection/container opening. The body itself does not clear target mode.
Other item-list mouse actions:
- parameter `8`: select the item, then Examine; a spell-only cell examines spell.
- parameter `0xA`: double-click UseObject only when not vendor/salvage and either
not a container list or the item is the current ground object.
### 9.2 Toolbar slots
Toolbar `UseShortcut` follows the same target-first rule, but then explicitly
calls `SetTargetMode(NONE)`. This is a real caller difference and should remain in
the orchestration policy unless a later input-map trace proves a shared deferred
clear.
### 9.3 World clicks
`CPlayerSystem::OnAction @ 0x00561890`, on the world-selection action:
```text
if targetMode == USE_TARGET:
SetTargetMode(NONE) // clear before completion
ItemHolder.TargetAcquired(foundObjectId)
consume
if targetMode == EXAMINE:
SetSelectedObject(foundObjectId)
SetTargetMode(NONE)
ExamineObject(foundObjectId)
consume
SetSelectedObject(foundObjectId)
consume
```
**Unresolved:** the visible `CPlayerSystem::OnAction` branch does not separately
execute `TARGET_MODE_USE`; it falls through to selection, while toolbar/item-list
paths route mode `USE` through `ExecuteTargetModeForItem`. SmartBox/input-map
coordination likely supplies the remaining world-use path. Pin that call chain in
Ghidra/cdb before centralizing all world clicks behind one policy.
### 9.4 Target-mode lifecycle
`SetTargetMode` registers input map `0x1000000B` at priority `0x7D0` for modes
1..3 and unregisters it for `NONE`, then refreshes the cursor. `ClientUISystem`
also owns `m_bLeaveTargetMode`, processed from `UseTime @ 0x005651F0`; the named
pseudo-C does not make the originating input transition sufficiently clear.
Do not replace this with an arbitrary timeout or focus-loss rule.
---
## 10. Full `ClientUISystem` global cursor catalog
`ClientUISystem::UpdateCursorState @ 0x00564630` resolves an enum through master
`EnumIDMap` entry `6`, then calls `UIElementManager::SetCursor`. "Found" below
means `SmartBox::get_found_object_id() != 0`.
| Precedence branch | Not found enum | Found enum | Hotspot |
|---|---:|---:|---:|
| Busy count `> 0` | `0x0E` | `0x0F` | `(0,0)` |
| Target `USE_TARGET` | `0x27` pending | `0x28` compatible / `0x29` incompatible | `(14,14)` |
| Target `USE` | `0x0C` | `0x0D` | `(14,14)` |
| Target `EXAMINE` | `0x0A` | `0x0B` | `(0,0)` |
| No target mode, Magic combat | `0x05` | `0x06` | `(0,0)` |
| No target mode, Melee or Missile combat | `0x03` | `0x04` | `(0,0)` |
| No target mode, other/NonCombat | `0x01` | `0x02` | `(0,0)` |
Enum values `0x07..0x09` have no reachable branch in this method. They may be
used by another system or retained in the DAT map; do not assign semantics merely
to make the range contiguous.
Exact decision order:
```text
UpdateCursorState(): // 0x00564630
found = SmartBox.foundObjectId != 0
enum = found ? 2 : 1
hotspot = (0,0)
if busyCount > 0:
enum = found ? 0x0F : 0x0E
else:
switch targetMode:
NONE:
if combat is MELEE or MISSILE: enum = found ? 4 : 3
else if combat is MAGIC: enum = found ? 6 : 5
USE:
enum = found ? 0x0D : 0x0C
hotspot = (14,14)
EXAMINE:
enum = found ? 0x0B : 0x0A
USE_TARGET:
hotspot = (14,14)
if not found: enum = 0x27
else if IsTargetCompatibleWithTargetingObject(foundId): enum = 0x28
else: enum = 0x29
did = DBObj.GetDIDByEnum(enum, table=6)
if did changed:
currentCursorDid = did
UIElementManager.SetCursor(did, hotspot, setAsDefault=true)
```
Production DAT golden values pinned by `RetailCursorCatalogTests`:
| Enum | RenderSurface DID |
|---:|---:|
| `0x01` | `0x06004D68` |
| `0x02` | `0x06004D69` |
| `0x03` | `0x06004D6A` |
| `0x04` | `0x06004D6B` |
| `0x05` | `0x06004D6C` |
| `0x06` | `0x06004D6D` |
| `0x0A` | `0x06004D71` |
| `0x0B` | `0x06004D71` |
| `0x0C` | `0x06004D72` |
| `0x0D` | `0x06004D72` |
| `0x0E` | `0x06004D74` |
| `0x0F` | `0x06004D75` |
| `0x27` | `0x06004D73` |
| `0x28` | `0x06005E6B` |
| `0x29` | `0x06005E6A` |
DAT enums `0x07..0x09` resolve to `0x06004D6E..0x06004D70`, but no reachable
`UpdateCursorState` branch selects them. They remain intentionally unnamed and
outside the global cursor catalog until a retail call site supplies semantics.
### 10.1 Widget cursor precedence is separate and temporal
The global cursor selected above is stored as `UIElementManager`'s **default**.
Retail widget states can supply their own `MediaDescCursor`:
```text
MediaMachine.Update_Cursor(cursorMedia): // 0x00465A80
if media.file is valid:
owner.SetCursor(file, hotspot)
else:
owner.UnSetCursor()
UIElement.SetCursor(...): // 0x0045FF50
store widget cursor
UIElementManager.CheckCursor()
UIElementManager.CheckCursor(): // 0x0045ABF0
if captured element exists and HasCursor():
apply captured element cursor, setAsDefault=false
else if last-entered element exists and HasCursor():
apply hovered element cursor, setAsDefault=false
else:
apply global default cursor, setAsDefault=true
```
Therefore retail precedence is:
1. captured widget cursor;
2. last-entered/hovered widget cursor;
3. `ClientUISystem` global default cursor.
However, `ClientUISystem::UpdateCursorState` immediately applies its newly chosen
cursor as well as storing it as default. A later widget cursor transition invokes
`CheckCursor` and may override it; a later busy/target/combat/found-object change
may reassert the global cursor. The exact behavior is event-ordered, not a static
rule that target cursors always beat slot cursors or vice versa.
**Plan correction:** do not hard-code "global target bullseye always wins widget
cursor" as retail behavior. Port the default-versus-local ownership and the
`CheckCursor` order, then pin target-mode-over-slot scenarios with event-sequence
tests.
---
## 11. Current acdream gaps this oracle retires or sharpens
This section describes the inspected code as of 2026-07-10; it is not an
implementation patch.
1. `PlayerDescriptionParser.ShortcutEntry` consumes the correct 12 bytes but calls
it 16 bytes and immediately separates the raw word into two fields. Preserve a
raw-word round trip and expose layered accessors.
2. `ShortcutStore` is GUID-only, so any mutation discards the raw spell word and
signed index representation. The toolbar may project object IDs, but the session
store cannot if it is also the persistence/wire owner.
3. `ToolbarController.HandleDropRelease` uses the shortcut-source restoration
branch for every source. Fresh inventory drops need the cyclic-right empty-slot
branch.
4. **Resolved 2026-07-11:** toolbar input actions, all seven DAT-discovered panel
buttons, Use, Examine, selected-item mana, and stack controls are wired. Mounted
panel state follows typed retained-window visibility events; unavailable panels
are ghosted. The separate ammo-number display remains tracked as AP-101.
5. `ItemInteractionController.ActivateItem` is a useful orchestration seam, but it
is not a faithful `UseObject` port: it omits ready/vendor/trade/confirmations,
the complete `DetermineUseResult` classification, container post-actions,
`AttemptPlaceIn3D`, and retail busy semantics.
6. **Resolved 2026-07-11:** selection is owned by Core `SelectionState` and shared
by world/radar/inventory/paperdoll/toolbar/plugins; the former
`GameWindow._selectedGuid` and inventory-local selected item are gone.
7. Current cursor catalog exposes only `0x27..0x29`. The reachable global catalog
also includes `0x01..0x06` and `0x0A..0x0F`.
8. Current cursor resolution uses a modern static priority chain. Retail instead
has a global-default selector plus captured/hovered widget cursor arbitration.
9. Current target controller clears its pending source before compatibility and
sends. That matches the one-shot `TargetAcquired` source clear, but click
consumption and target-mode clearing differ by retail caller and must be tested
at the router boundary.
---
## 12. Required conformance fixtures before production port
1. **Shortcut bytes:** count plus entries containing negative index, object-only,
nonzero layered spell word, duplicate index, and out-of-range index.
2. **Toolbar input:** every action range above, stack-entry focus suppression, and
CreateShortcut on selected/no-selected object.
3. **Drop planner:** fresh inventory to empty/occupied; shortcut alias to
empty/occupied; cyclic-right wrap; no empty slot; duplicate object; release off
bar; inventory-button drop.
4. **Selected strip:** unselected, health target, owned mana item, ordinary stack,
vendor stack, coinstack, and unusable item.
5. **Use policy:** all `DetermineUseResult` values; direct, targeted, confirmation,
vendor, trade, busy/not-ready, source-location, and cannot-use paths.
6. **Placement:** merge, vendor full/partial stack, secure trade, give, locked/
unopened/open container, grounded drop, mid-air rejection, split-to-ground, and
already-in-3D cancellation.
7. **Target click sequences:** toolbar, inventory cell, paperdoll cell, world,
invalid target, spell-only cell, and target-mode cancel/leave.
8. **Cursor enums:** DAT-resolved DIDs for every reachable enum and event-order
sequences covering captured widget, hovered widget, global mode transition,
busy transition, and found-object transition.
Open retail-research questions are deliberately limited to the corrupted PWD bit
names, stack-slider rounding expression, `TARGET_MODE_USE` world-click handoff,
and deferred target-mode leave event. None justifies an implementation guess or a
workaround.