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>
48 KiB
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.txtdocs/research/named-retail/acclient.hdocs/research/named-retail/symbols.json
Client/server cross-checks:
- ACE:
Source/ACE.Server/Network/Structure/Shortcut.csSource/ACE.Server/Network/GameAction/Actions/GameActionAddShortcut.csSource/ACE.Server/Network/GameAction/Actions/GameActionUseItem.csSource/ACE.Server/Network/GameAction/Actions/GameActionUseWithTarget.csSource/ACE.Server/Network/GameEvent/Events/GameEventPlayerDescription.csSource/ACE.Server/WorldObjects/Player_Character.cs
- holtburger:
crates/holtburger-protocol/src/messages/player/shortcuts.rscrates/holtburger-protocol/src/messages/player/events.rscrates/holtburger-protocol/src/messages/object/types.rscrates/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:
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:
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
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
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:
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:
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
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):
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:
- Object exists.
- A particular PWD bitfield condition either is absent or the object is a player.
- Objects whose
InqType()includes0x10are rejected unlessIsPlayer(). - 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.
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
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
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:
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
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.
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
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
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, updateGenItemHolder::splitSize, update slider attribute0x86 = splitSize/maxSplitSize, and broadcast the stack-slider notice. UIElement_Scrollbar::SetScrollbarPosition @ 0x00470EC0clamps the normalized position to[0,1], multiplies by1000.0f, truncates through_ftol2, and broadcasts that integer as message0xAparameter 1.gmToolbarUI::ListenToElementMessage @ 0x004BEFE0..0x004BF041converts the message exactly as follows, stores it, rewrites the entry, and broadcasts the stack-slider notice:
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
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
OpenContainedContainerfor 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
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.
UseObjectowns confirmation routing and busy-count increments.arg2is established by call sites as "use the current selected target now" versus "enter target mode".arg3bypasses classification in several internal paths; its original source-level name remains unresolved.- The
2..7bound excludesDetermineUseResult == 8even thoughCPlayerSystem::UsingItemhas a case for 8. Preserve this asymmetry; do not normalize it without tracing the left-side-wield call path.
7.1 Target acquisition completion
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:
Use (0x0036): u32 objectId
UseWithTarget (0x0035): u32 sourceObjectId, u32 targetObjectId
8. AttemptPlaceIn3D pseudocode
8.1 Shared selected-stack amount
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:
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.
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:
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:
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:
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:
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:
- captured widget cursor;
- last-entered/hovered widget cursor;
ClientUISystemglobal 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.
PlayerDescriptionParser.ShortcutEntryconsumes 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.ShortcutStoreis 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.ToolbarController.HandleDropReleaseuses the shortcut-source restoration branch for every source. Fresh inventory drops need the cyclic-right empty-slot branch.- 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.
ItemInteractionController.ActivateItemis a useful orchestration seam, but it is not a faithfulUseObjectport: it omits ready/vendor/trade/confirmations, the completeDetermineUseResultclassification, container post-actions,AttemptPlaceIn3D, and retail busy semantics.- Resolved 2026-07-11: selection is owned by Core
SelectionStateand shared by world/radar/inventory/paperdoll/toolbar/plugins; the formerGameWindow._selectedGuidand inventory-local selected item are gone. - Current cursor catalog exposes only
0x27..0x29. The reachable global catalog also includes0x01..0x06and0x0A..0x0F. - Current cursor resolution uses a modern static priority chain. Retail instead has a global-default selector plus captured/hovered widget cursor arbitration.
- Current target controller clears its pending source before compatibility and
sends. That matches the one-shot
TargetAcquiredsource 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
- Shortcut bytes: count plus entries containing negative index, object-only, nonzero layered spell word, duplicate index, and out-of-range index.
- Toolbar input: every action range above, stack-entry focus suppression, and CreateShortcut on selected/no-selected object.
- 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.
- Selected strip: unselected, health target, owned mana item, ordinary stack, vendor stack, coinstack, and unusable item.
- Use policy: all
DetermineUseResultvalues; direct, targeted, confirmation, vendor, trade, busy/not-ready, source-location, and cannot-use paths. - 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.
- Target click sequences: toolbar, inventory cell, paperdoll cell, world, invalid target, spell-only cell, and target-mode cancel/leave.
- 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.