acdream/docs/research/2026-07-10-retail-toolbar-interaction-pseudocode.md
Erik dc1649c493 feat(ui): complete retail item drop branches
Give retained buttons a reusable item-drop seam, wire the toolbar backpack target, and port retail stack-merge legality, capacity clamping, wire dispatch, destination selection, and immediate shortcut rekey notice. Record the live ACE merge gate and keep split quantity under AP-101.

Co-Authored-By: Codex <codex@openai.com>
2026-07-11 09:46:32 +02:00

43 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.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:

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.


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:

  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.

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's selected-object stack entry/slider is still absent under AP-101, so the current inventory merge path requests the whole source stack and lets the same retail capacity clamp limit the transfer. Once the shared split-quantity owner lands, it supplies requested without changing legality or wire ordering.


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, or formatted name + count 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

  • 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.
  • Slider message 0xA maps its normalized value to an integer in 1..maxSplitSize, stores it, rewrites the entry, and broadcasts the notice.

Unresolved: Binary Ninja lost the exact floating expression immediately before the slider's 1 - _ftol2() conversion at 0x004BF02D. The clamp, endpoints, and text update are exact, but the intermediate rounding direction must be recovered from Ghidra/disassembly before writing conformance vectors.


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

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

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

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.


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:

  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. Toolbar input actions, seven panel buttons, Use, Examine, mana, and stack controls are not fully wired.
  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.