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>
1105 lines
43 KiB
Markdown
1105 lines
43 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.
|
|
|
|
---
|
|
|
|
## 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'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
|
|
|
|
```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, 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
|
|
|
|
```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
|
|
|
|
- `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
|
|
|
|
```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
|
|
|
|
```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`.
|
|
|
|
---
|
|
|
|
## 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. 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.
|