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>
1214 lines
48 KiB
Markdown
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.
|