feat(inventory): port retail item giving (#216)

Preserve SmartBox drag-release coordinates, route the picked 3-D target through the retail AttemptPlaceIn3D policy, and send authoritative GiveObject requests with the selected stack quantity. Honor PlayerDescription's player secure-trade option and document the remaining trade-system boundary.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-13 20:28:38 +02:00
parent a944e49b9c
commit 30d294506c
13 changed files with 421 additions and 10 deletions

View file

@ -44,6 +44,41 @@ Copy this block when adding a new issue:
---
## #216 — Inventory drags into the 3-D world could only drop items
**Status:** IN-PROGRESS
**Severity:** HIGH
**Filed:** 2026-07-13
**Component:** retained UI / inventory / interaction / net
**Description:** Releasing a physical inventory item over an NPC always sent
the ground-drop path. A starter-dungeon character therefore could not hand the
exit token to the exit NPC and could not complete the dungeon.
**Root cause / status:** Implementation complete; connected live gate pending.
The exact `AttemptPlaceIn3D` policy and the `0x00CD`
packet builder already existed, but `GameWindow.OnUiDragReleasedOutside`
discarded the release coordinates and always called `DropToWorld`, hard-coding
`TargetId=0`. The release path now world-picks at the actual release point,
the controller executes its existing `GiveToTarget` action without optimistic
mutation, `WorldSession` sends the exact request, and PlayerDescription drives
the retail player secure-trade preference.
**Files:** `src/AcDream.App/UI/ItemInteractionController.cs`;
`src/AcDream.App/Rendering/GameWindow.cs`;
`src/AcDream.Core.Net/WorldSession.cs`.
**Research:** `docs/research/2026-07-13-retail-give-item-pseudocode.md`;
`ItemHolder::AttemptPlaceIn3D @ 0x00588600`;
`ACCWeenieObject::UIAttemptGive @ 0x0058D620`;
`CM_Inventory::Event_GiveObjectRequest @ 0x006ABB00`.
**Acceptance:** Drag the starter-dungeon exit token from the backpack onto the
exit NPC. ACE accepts the token, the inventory removes/decrements it only from
the authoritative server response, and the quest teleport exits the dungeon.
---
## #213 — Retail client commands were sent to ACE as chat text
**Status:** IN-PROGRESS — command-family gate passed except two corrective fixes, pending re-gate

View file

@ -217,7 +217,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-112 | The basic combat bar ports visibility, height selection, desired-power slider, exact 1.0/0.8-second charge, ready-stance gating, request/release, server-response queueing, and auto-repeat, but omits `StartAttackRequest`'s `FinishJump`/`MaybeStopCompletely` command-interpreter calls and exact trained-Recklessness visibility semantics (IA-20 keeps the dark range as the accepted baseline) | `src/AcDream.App/Combat/CombatAttackController.cs`; `src/AcDream.App/UI/Layout/CombatUiController.cs` | The M2 attack contract and authored basic panel are live; the remaining seams require the jump/movement command owner and a distinct Recklessness treatment rather than UI-local guesses | Starting an attack while charging a jump or deliberately moving may not stop/cancel exactly when retail does; trained/untrained Recklessness presentation is identical | `ClientCombatSystem::StartAttackRequest @ 0x0056C040`; `gmCombatUI::ListenToElementMessage @ 0x004CC430` |
| AP-113 | Invalid lifestone-command arguments display the local text `Usage: /lifestone`; retail definitely emits a local usage/error line but Binary Ninja misidentifies the referenced wide-string address, so its exact wording is not yet recovered | `src/AcDream.UI.Abstractions/Panels/Chat/ChatCommandRouter.cs`; `RetailClientCommandCatalog.cs` | The behavior boundary is exact (handled locally, no chat and no game action); only a low-impact diagnostic sentence differs | `/ls now` can show different wording/color from retail while still refusing the invalid request correctly | `ClientCommunicationSystem::DoLifestone @ 0x0056FC70` |
## 4. Temporary stopgap (TS) — 39 rows (TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-44 narrowed same day — NPC UP unified onto the interp queue, gate retained for orientation)
## 4. Temporary stopgap (TS) — 40 rows (TS-37 is a retired-row historical note, not an active count; TS-39 retired R5-V3 — sticky seams bound to the ported PositionManager/StickyManager, radii threaded; TS-45 retired 2026-07-07 — hand-rolled `SphereCollision` replaced by the faithful CSphere family port, fixing the player-vs-monster crowd wedge; TS-3 retired 2026-07-07 — `frames_stationary_fall` accounting ported in the #182 verbatim UpdateObjectInternal rebuild, fixing the airborne falling-animation wedge; TS-41 retired 2026-07-07 — SERVERVEL synth-velocity remote body-drive replaced by the retail interp catch-up + unconditional MovementManager::UseTime, the remote-creature de-overlap #184; TS-44 narrowed same day — NPC UP unified onto the interp queue, gate retained for orientation)
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
@ -263,6 +263,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| ~~TS-45~~ | **RETIRED 2026-07-07** — the hand-rolled `SphereCollision` (forced `combinedR+1 cm` radial de-penetration + leaked `SetSlidingNormal` + always-Slid, head-sphere ignored) is REPLACED by the faithful `CSphere::intersects_sphere` family port (branch dispatcher 0x00537A80 + `step_sphere_up`/`slide_sphere`/`land_on_sphere`/`collide_with_point`/`step_sphere_down`), routing the grounded slide through the shared crease `SlideSphere` (0x00537440). Humanoid creatures collide via body Spheres, so this was the player-vs-monster crowd path; the radial de-penetration was the "can't wiggle free in a packed crowd" wedge. `SphereCollisionFamilyTests` (slide-around, block, ethereal) + `docs/research/2026-07-07-csphere-collision-family-pseudocode.md`. Residual `AP-84` (PerfectClip TOI dead in M1.5). | — | — | — | `CSphere::intersects_sphere` 0x00537A80 (pc:321678) |
| TS-43 | Remote teleport has no `teleport_hook` equivalent: retail tears down the position managers on every teleport (`CPhysicsObj::teleport_hook` 0x00514ed0 — `CancelMoveTo(0x3c)` @0x00514edf, `PositionManager::UnStick` @0x00514eee, `StopInterpolating`/`UnConstrain`); acdream's remote teleport is a bare UP hard-snap, so a stuck/chasing remote that the server teleports keeps its stick/moveto for up to the 1 s sticky lease / next UM. The LOCAL player side IS wired (R5-V3: `PlayerMovementController.SetPosition``PositionManager.UnStick`; the moveto cancel was already there via `StopCompletely`; the teleport-arrival site also fires the hook's tail — `EntityPhysicsHost.NotifyTeleported` = `TargetManager::ClearTarget` + `NotifyVoyeurOfEvent(Teleported)` @0x00514f1b-0x00514f28, which is what makes mobs stuck to the player drop their sticks on a recall) | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` — no teleport-flag manager teardown for remotes) | Remote teleports are rare (recalls/summons); the sticky 1 s lease + UP hard-snaps self-correct within a second; wiring it properly wants the UP teleport-stamp plumbing (TS-26's stamp work) | A teleported-away attacker briefly steers toward its pre-teleport target from the new location (≤1 s) before the lease/next-UM corrects it | `CPhysicsObj::teleport_hook` 0x00514ed0; retire with the TS-26 UP-stamp port |
| TS-47 | **NARROWED 2026-07-13** — typed routing now ports the named-retail recall/house/PK travel, age/birth, local display/location, UI persistence, AFK/consent, emote, friends, squelch/filter, and fill-components families. Retail-owned verbs outside the researched family set still fall through to ACE until individually verified. | `src/AcDream.UI.Abstractions/Panels/Chat/RetailClientCommandCatalog.cs`; `src/AcDream.App/UI/ClientCommandController.cs`; `src/AcDream.Core.Net/Messages/ClientCommandRequests.cs` | The high-use researched families have decomp pseudocode, typed actions, and conformance tests; unresearched registry entries must follow the same evidence-first path | An unported retail-owned verb can still produce ACE unknown-command output or server-specific behavior instead of its client action | `ClientCommunicationSystem` command-table construction around `0x00581A40..0x005850A0`; `docs/research/2026-07-13-retail-client-command-routing-pseudocode.md`; `docs/research/2026-07-13-retail-client-command-families-pseudocode.md` |
| TS-48 | Dragging an item onto another player honors the authoritative `DragItemOnPlayerOpensSecureTrade` option, but the option's default-true branch stops at the existing unavailable toast because the secure-trade transaction and UI are not ported. Direct player giving through `GiveObjectRequest 0x00CD` works when the option is disabled; NPC giving is complete. | `src/AcDream.App/UI/ItemInteractionController.cs` (`PlaceIn3D`, `PolicyActionMessage`); `src/AcDream.Core/Items/ItemInteractionPolicy.cs` | The player/NPC distinction and character preference are now faithful; inventing a direct gift while the option requests secure trade would be a worse behavioral divergence. Secure trade is a separate multi-party state machine beyond the starter-dungeon NPC-give slice. | With retail's default character options, an item dragged onto another player cannot be exchanged until the secure-trade subsystem lands. | `ItemHolder::AttemptPlaceIn3D @ 0x00588600`; `PlayerModule::DragItemOnPlayerOpensSecureTrade @ 0x005D31B0`; `ClientTradeSystem`; `docs/research/2026-07-13-retail-give-item-pseudocode.md` |
---

View file

@ -498,6 +498,7 @@ behavior. Estimated 1726 days focused work, 35 weeks calendar.
- **Wave 4.4e implemented — exact missile ammo number (live gate pending).** Pure Core reproduces retail's thrown-weapon-vs-separate-ammo resolution over the ordered player equipment list and its zero-to-one count normalization. Relevant equipment/stack events update authored missile indicator `0x10000194` with the DAT font. Warning-free Release build and 4,754-pass / 5-skip suite are green; AP-101 is retired.
- **Wave 4.5 implemented — retail primary-weapon switching (corrective live gate pending).** Inventory double-click now routes through a focused `AutoWieldController`: the occupied `0x03500000` weapon-ready group is returned to the player container, followed when necessary by an incompatible shield or mismatched ammo; each blocker waits for authoritative `InventoryPutObjInContainer` before the next step, then `GetAndWieldItem` equips the requested bow/sword/caster/two-hander. The transaction remains inventory-busy through the exact authoritative `WieldObject` acknowledgement, independent of the object table's rollback counter, matching retail's waiting state and preventing rapid switches from overlapping or remaining stuck. `CreateObject` retains exact `AmmoType` for compatibility. The same transaction runs in peace and war; ACE owns the latter's old-stance → peace → new-stance motion chain. Player PropertyInt 40 updates `CombatState`, keeping toolbar/combat UI synchronized with ACE's selected Melee/Missile/Magic stance. Research: `docs/research/2026-07-12-retail-weapon-switch-pseudocode.md`.
- **Wave 4 inventory drag visuals implemented — corrective live visual gate pending.** `IconComposer` now preserves retail's separate underlay-free `m_pDragIcon` for cursor tracking across inventory, paperdoll, and toolbar bindings. Physical source cells keep their full icon in place and reveal authored ghost mesh `0x0600109A` from drag begin through every release/cancel path, with the persistent selection square above the mesh; shortcut aliases remain unghosted. `ItemList_DragOver` destination states are split exactly: contents-grid placement uses the green accept circle `0x060011F9`, while side-bag/main-pack container drop-in uses the green arrow `0x060011F7`. AP-47 retired. Research: `docs/research/2026-07-13-retail-item-drag-visuals-pseudocode.md`.
- **Wave 4.6 item giving implemented 2026-07-13; starter-dungeon live gate pending.** SmartBox drag release now preserves the exact release coordinates, world-picks the target, and runs the existing retail `ItemHolder::AttemptPlaceIn3D` policy. NPC/creature targets send exact `GiveObjectRequest 0x00CD(target,item,amount)` bytes, selected partial stacks use the shared split quantity, and the object table waits for ACE's authoritative remove/stack-size response. `PlayerDescription.Options1` supplies the real `DragItemOnPlayerOpensSecureTrade` option. Issue #216; research: `docs/research/2026-07-13-retail-give-item-pseudocode.md`.
- **M2 held-object parenting shipped and live-gated 2026-07-11.** The combat toggle now ports `GetDefaultCombatMode` over ordered equipped contents, so a bow requests Missile instead of the old hardcoded Melee. CreateObject preserves parent/placement/timestamp fields, `0xF749` ParentEvent is handled, and `EquippedChildRenderController` renders the weapon as a separate child composed from the animated hand part + holding frame + child placement frame. Live gate passed: bow selected missile stance, rendered in-hand, followed animation, unequipped cleanly, and melee remained correct. App Release builds with zero warnings; the full 4,765-pass / 5-skip suite is green. AP-111 is retired; research: `docs/research/2026-07-11-combat-default-and-parent-event-pseudocode.md`.
- **M2 local attack receive funnel implemented 2026-07-11; live gate pending.** Retail `ExecuteAttack` only sends the request; ACE chooses the concrete melee/missile action and returns it in a non-autonomous mt-0 `UpdateMotion`. The local branch now runs that state through the same constructor-defaulted `MoveToInterpretedState` funnel and 15-bit action-stamp gate as remotes, then applies sticky/long-jump tails. The old local-only direct `Commands[]` replay is deleted. Shared conversion lives in `InboundInterpretedMotionFactory`; research: `docs/research/2026-07-11-local-combat-motion-pseudocode.md`.
- **M2 basic retail combat bar implemented 2026-07-11; corrective live visual gate pending.** Production mounts authored `gmCombatUI` LayoutDesc `0x21000073`, shows it only for Melee/Missile, and routes mouse plus keyboard through one `CombatAttackController`. Corrections include the authored wider centered dark-red child range (accepted IA-20), full-width left-to-right bright live attack-charge feedback, exact Speed-left/Power-right justification, silent control-only `AttackDone(ActionCancelled)`, target-frame Keep in View with manual orbit, and persistent corpse motion: the AP-80 velocity-only NPC adaptation can now replace only Ready/Walk/Run, never authoritative Dead/actions. `CombatTargetController` ports the selection-cleared AutoTarget consumer, so a selected creature's authoritative Dead motion clears it and selects the nearest eligible creature when enabled. The 2026-07-12 replacement-corpse correction unifies both multi-frame and static/reactive spawns behind retail's CreateObject lifecycle: apply the wire's Dead state while detached, then `MotionTableManager::HandleEnterWorld` strips Ready→Dead links before the first in-world tick; multiple corpses remaining fallen passed the live user gate that day. Follow-up #205 makes the toolbar read the final canonical selection after a reentrant Auto Target notice and restricts automatic candidates to hostile non-player monsters (intentional PK-edge divergence IA-19); that live gate also passed 2026-07-12. Research: `docs/research/2026-07-11-retail-combat-bar-pseudocode.md`, `docs/research/2026-07-11-combat-target-camera-pseudocode.md`, `docs/research/2026-07-12-death-and-auto-target-pseudocode.md`; AP-24/AP-95 retired, AP-80 narrowed, AP-110 narrowed, AP-112 records the remaining attack-start and exact trained-Recklessness seams.

View file

@ -463,6 +463,14 @@ include dungeons.
acknowledgement independently of rollback bookkeeping, and prevents rapid
switches from overlapping or remaining stuck; ACE owns stance-specific motions and its PropertyInt 40 update
now drives the client `CombatState`.
- **M2 item giving (implemented 2026-07-13; starter-dungeon live gate pending)**
a physical inventory drag released over a 3-D creature now ports
`ItemHolder::AttemptPlaceIn3D` through the exact `GiveObjectRequest 0x00CD`
packet, including selected partial-stack quantities and authoritative-only
inventory mutation. Release coordinates feed the existing world picker, and
`PlayerDescription.Options1` preserves retail's player secure-trade
preference. See
`docs/research/2026-07-13-retail-give-item-pseudocode.md` and issue #216.
- **L.1c local attack receive path (implemented 2026-07-11; live gate pending)**
local non-autonomous mt-0 UpdateMotion now uses retail's wholesale interpreted
funnel and action-stamp gate, so ACE's server-selected melee/missile action

View file

@ -0,0 +1,135 @@
# Retail item giving — pseudocode and implementation plan (2026-07-13)
## Scope
Port the retail inventory-drag path needed to hand a starter-dungeon exit
token to its NPC. The same path also covers direct gifts to players when the
retail `DragItemOnPlayerOpensSecureTrade` character option is disabled.
This is **not** targeted item use. Retail has no `TARGET_MODE_GIVE`: the player
drags the physical item icon out of an item list and releases it over the 3-D
target.
## Retail sources
- `ItemHolder::AttemptPlaceIn3D @ 0x00588600`
- `ACCWeenieObject::UIAttemptGive @ 0x0058D620`
- `CM_Inventory::Event_GiveObjectRequest @ 0x006ABB00`
- `ItemHolder::GetObjectSplitSize @ 0x00586F00`
- `PlayerModule::DragItemOnPlayerOpensSecureTrade @ 0x005D31B0`
- SmartBox drag release call site at `0x004E5C32`
- `InventoryRequest::IR_GIVE = 0x9` in `named-retail/acclient.h`
The earlier complete placement translation remains in
`docs/research/2026-07-10-retail-toolbar-interaction-pseudocode.md` §8. This
note pins the live integration seam that was still missing.
## Cross-reference
ACE's `GameActionGiveObjectRequest.Handle` reads `(targetGuid, objectGuid,
int32 amount)` and calls `Player.HandleActionGiveObjectRequest`. The server
validates ownership/amount/trade state, walks the player into range, then
routes to `GiveObjectToNPC` or `GiveObjectToPlayer`. Accepted full-stack NPC
gifts send `InventoryRemoveObject`; accepted partial gifts send an
authoritative stack-size update. Rejected gifts send
`InventoryServerSaveFailed`. Therefore the client must not remove or resize
the source optimistically.
The inventory wire catalog independently cross-checked ACE and holtburger in
`docs/research/2026-06-16-inventory-deep-dive.md` §4: action `0x00CD`, payload
`u32 target, u32 item, i32 amount`. The existing acdream builder already
matches that layout byte-for-byte.
## Retail pseudocode
```text
SmartBox left-button release while dragging an inventory item:
target = object under the release point, or 0 when none
success = AttemptPlaceIn3D(draggedItem, target, allowGroundFallback=true)
if not success:
clear the item's waiting state
AttemptPlaceIn3D(itemId, targetId, allowGroundFallback):
if player cannot make an inventory request: return false
item = lookup(itemId)
if item missing: return false
if targetId == playerId:
return PlaceInBackpack(itemId)
if item is not owned by player:
show "You must first pick up ..."
return false
if item.tradeState != 0:
show traded-item error
return false
if targetId == 0:
goto DROP_ON_GROUND when fallback is allowed
return false
if AttemptMerge(itemId, targetId): return true
target = lookup(targetId)
if target missing:
goto DROP_ON_GROUND when fallback is allowed
return false
if target has BF_VENDOR:
sell only a full stack; otherwise reject
return false
if DragItemOnPlayerOpensSecureTrade and target.IsPlayer:
AttemptToTradeItem(targetId, itemId)
return false
if target.InqType() == ItemType.Creature:
item.UIAttemptGive(targetId, GetObjectSplitSize(item))
return true
if target.IsContainer:
require unlocked + currently open, then place in it
if fallback is allowed: goto DROP_ON_GROUND
show "Cannot give ..."
return false
DROP_ON_GROUND:
require player on ground
if selected amount < full stack:
send StackableSplitTo3D(itemId, amount)
else:
send DropItem(itemId)
return true
UIAttemptGive(targetId, amount):
if player cannot make an inventory request: return
Event_GiveObjectRequest(targetId, this.id, amount)
prevRequestObjectID = this.id
prevRequest = IR_GIVE
prevRequestTime = now
Event_GiveObjectRequest(targetId, itemId, amount):
send GameAction 0x00CD(targetId, itemId, amount)
```
`GetObjectSplitSize` uses the global selected quantity only when the dragged
object is the selected object; any other stack uses its full current size.
The retail default character-options mask `0x50C4A54A` includes
`DragItemOnPlayerOpensSecureTrade` (`0x04000000`). Consequently a normal
drag onto another player opens secure trade by default; a direct player gift
uses `0x00CD` only when that option is off. `AllowGive` (`0x40`) is the
recipient-side option and ACE validates it server-side.
## Implementation plan
1. Keep `ItemInteractionPolicy.DecidePlacement` as the single pure policy.
2. Pass the exact drag-release coordinates through the existing world picker
and call the controller with its target GUID (or zero).
3. Execute `GiveToTarget` through a new `WorldSession.SendGiveObject` wrapper;
leave the object table unchanged until ACE's response arrives.
4. Feed `PlayerDescription.Options1` into the controller so the player branch
uses the real retail secure-trade preference rather than a constant.
5. Add policy/controller/session/wiring tests for NPCs, stack quantities,
non-optimistic state, the player option, and exact packet bytes.
6. Build, run the full test suite, commit, then visually gate the starter
token turn-in and teleport.

View file

@ -848,6 +848,11 @@ public sealed class GameWindow : IDisposable
private AcDream.App.Rendering.RetailChaseCamera? _retailChaseCamera;
private bool _playerMode;
private uint _playerServerGuid;
// Retail Default_CharacterOption (acclient.h:3434). PlayerDescription
// replaces this before any in-world drag can normally occur.
private AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1
_characterOptions1 =
AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1.Default;
private uint? _playerMotionTableId; // server-sent MotionTable override for the player's character
private MovementTruthOutbound? _lastMovementTruthOutbound;
private (System.Numerics.Vector3 Position,
@ -2002,6 +2007,12 @@ public sealed class GameWindow : IDisposable
sendUseWithTarget: (source, target) => _liveSession?.SendUseWithTarget(source, target),
sendWield: (item, mask) => _liveSession?.SendGetAndWieldItem(item, mask),
sendDrop: item => _liveSession?.SendDropItem(item),
sendGive: (target, item, amount) =>
_liveSession?.SendGiveObject(target, item, amount),
dragOnPlayerOpensSecureTrade: () =>
(_characterOptions1
& AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1
.DragItemOnPlayerOpensSecureTrade) != 0,
toast: text => _debugVm?.AddToast(text),
readyForInventoryRequest: () => _liveSession is not null
&& _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld,
@ -2643,7 +2654,11 @@ public sealed class GameWindow : IDisposable
_retailUiRuntime?.HandleConfirmationDone(done),
friends: Friends,
squelch: Squelch,
onDesiredComponents: components => DesiredComponents = components);
onDesiredComponents: components => DesiredComponents = components,
onCharacterOptions: (options1, _) =>
_characterOptions1 =
(AcDream.Core.Net.Messages.PlayerDescriptionParser.CharacterOptions1)
options1);
// Phase I.7: subscribe to CombatState events and emit
// retail-faithful "You hit X for Y damage" chat lines into
@ -11332,7 +11347,10 @@ public sealed class GameWindow : IDisposable
private void OnUiDragReleasedOutside(object payload, int x, int y)
{
if (payload is AcDream.App.UI.ItemDragPayload itemPayload)
_itemInteractionController?.DropToWorld(itemPayload);
{
uint target = PickWorldGuidAt(x, y, includeSelf: true) ?? 0u;
_itemInteractionController?.PlaceIn3D(itemPayload, target);
}
}
// L.0 follow-up: persisted-settings cache populated by
@ -11997,6 +12015,9 @@ public sealed class GameWindow : IDisposable
/// yourself by clicking your own toon); plain selection never does.
/// </summary>
private uint? PickWorldGuidAtCursor(bool includeSelf)
=> PickWorldGuidAt(_lastMouseX, _lastMouseY, includeSelf);
private uint? PickWorldGuidAt(float mouseX, float mouseY, bool includeSelf)
{
if (_cameraController is null || _window is null) return null;
@ -12015,7 +12036,7 @@ public sealed class GameWindow : IDisposable
}
return AcDream.Core.Selection.WorldPicker.Pick(
mouseX: _lastMouseX, mouseY: _lastMouseY,
mouseX: mouseX, mouseY: mouseY,
view: camera.View, projection: camera.Projection,
viewport: viewport,
candidates: _entitiesByServerGuid.Values,

View file

@ -30,6 +30,7 @@ public sealed class ItemInteractionController : IDisposable
private readonly Action<uint, uint>? _sendWield;
private readonly Action<uint>? _sendDrop;
private readonly Action<uint, uint>? _sendSplitToWorld;
private readonly Action<uint, uint, uint>? _sendGive;
private readonly Action<string>? _toast;
private readonly Func<bool> _readyForInventoryRequest;
private readonly Func<uint> _activeVendorId;
@ -42,6 +43,7 @@ public sealed class ItemInteractionController : IDisposable
private readonly InteractionState _interactionState;
private readonly Func<uint> _selectedObjectId;
private readonly StackSplitQuantityState? _stackSplitQuantity;
private readonly Func<bool> _dragOnPlayerOpensSecureTrade;
private readonly AutoWieldController _autoWield;
private long _lastUseMs = long.MinValue / 2;
@ -72,7 +74,9 @@ public sealed class ItemInteractionController : IDisposable
Action<uint, uint>? sendSplitToWorld = null,
Func<uint>? selectedObjectId = null,
StackSplitQuantityState? stackSplitQuantity = null,
Action<uint, uint, int>? sendPutItemInContainer = null)
Action<uint, uint, int>? sendPutItemInContainer = null,
Action<uint, uint, uint>? sendGive = null,
Func<bool>? dragOnPlayerOpensSecureTrade = null)
{
_objects = objects ?? throw new ArgumentNullException(nameof(objects));
_playerGuid = playerGuid ?? throw new ArgumentNullException(nameof(playerGuid));
@ -82,6 +86,7 @@ public sealed class ItemInteractionController : IDisposable
_sendWield = sendWield;
_sendDrop = sendDrop;
_sendSplitToWorld = sendSplitToWorld;
_sendGive = sendGive;
_nowMs = nowMs ?? (() => Environment.TickCount64);
_toast = toast;
_readyForInventoryRequest = readyForInventoryRequest ?? (() => true);
@ -94,6 +99,7 @@ public sealed class ItemInteractionController : IDisposable
_auxiliaryAction = auxiliaryAction;
_selectedObjectId = selectedObjectId ?? (() => 0u);
_stackSplitQuantity = stackSplitQuantity;
_dragOnPlayerOpensSecureTrade = dragOnPlayerOpensSecureTrade ?? (() => true);
_interactionState = interactionState ?? new InteractionState();
_interactionState.Changed += OnInteractionModeChanged;
_autoWield = new AutoWieldController(
@ -310,6 +316,14 @@ public sealed class ItemInteractionController : IDisposable
}
public bool DropToWorld(ItemDragPayload payload)
=> PlaceIn3D(payload, targetGuid: 0u);
/// <summary>
/// Retail inventory drag released into SmartBox. The release target is the
/// world object under the cursor, or zero for empty ground. This is the live
/// adapter for <c>ItemHolder::AttemptPlaceIn3D @ 0x00588600</c>.
/// </summary>
public bool PlaceIn3D(ItemDragPayload payload, uint targetGuid)
{
ArgumentNullException.ThrowIfNull(payload);
@ -321,16 +335,17 @@ public sealed class ItemInteractionController : IDisposable
uint fullStack = (uint)Math.Max(1, item.StackSize);
int splitSize = (int)(_stackSplitQuantity?.GetObjectSplitSize(
item.ObjectId, _selectedObjectId(), fullStack) ?? fullStack);
ClientObject? target = targetGuid == 0u ? null : _objects.Get(targetGuid);
var decision = ItemInteractionPolicy.DecidePlacement(new ItemPlacementPolicyInput(
Snapshot(item),
_playerGuid(),
_groundObjectId(),
CanMakeInventoryRequest,
TargetId: 0,
Target: null,
TargetId: targetGuid,
Target: target is null ? null : Snapshot(target),
AllowGroundFallback: true,
MergeAccepted: false,
DragOnPlayerOpensSecureTrade: true,
DragOnPlayerOpensSecureTrade: _dragOnPlayerOpensSecureTrade(),
PlayerOnGround: _playerOnGround(),
SplitSize: splitSize));
ExecutePlacementActions(decision.Actions);
@ -410,6 +425,15 @@ public sealed class ItemInteractionController : IDisposable
// the server assigns the split-off ground stack a new guid.
_sendSplitToWorld?.Invoke(action.ObjectId, (uint)action.Amount);
break;
case ItemPolicyActionKind.GiveToTarget:
// UIAttemptGive @ 0x0058D620 sends the request and leaves
// the source untouched until the authoritative server
// InventoryRemoveObject / SetStackSize response arrives.
_sendGive?.Invoke(
action.TargetId,
action.ObjectId,
(uint)Math.Max(1, action.Amount));
break;
case ItemPolicyActionKind.Reject:
if (!string.IsNullOrWhiteSpace(action.Message))
_toast?.Invoke(action.Message);

View file

@ -76,7 +76,8 @@ public static class GameEventWiring
Action<GameEvents.CharacterConfirmationDone>? onConfirmationDone = null,
FriendsState? friends = null,
SquelchState? squelch = null,
Action<IReadOnlyList<(uint Id, uint Amount)>>? onDesiredComponents = null)
Action<IReadOnlyList<(uint Id, uint Amount)>>? onDesiredComponents = null,
Action<uint /*options1*/, uint /*options2*/>? onCharacterOptions = null)
{
ArgumentNullException.ThrowIfNull(dispatcher);
ArgumentNullException.ThrowIfNull(items);
@ -439,6 +440,7 @@ public static class GameEventWiring
Console.WriteLine($"vitals: PlayerDescription body.len={e.Payload.Length} parsed={(p is null ? "NULL" : $"vec={p.Value.VectorFlags} attrs={p.Value.Attributes.Count} spells={p.Value.Spells.Count}")}");
if (p is null) return;
onCharacterOptions?.Invoke(p.Value.Options1, p.Value.Options2);
onDesiredComponents?.Invoke(p.Value.DesiredComps);
// B-Wire: deliver the player's OWN properties to the player ClientObject.

View file

@ -197,6 +197,20 @@ public static class PlayerDescriptionParser
SpellLists8 = 0x00000400,
}
/// <summary>
/// Character-option bits consumed by the client runtime. Values are the
/// verbatim retail <c>CharacterOption</c> enum from
/// <c>named-retail/acclient.h:3404</c>.
/// </summary>
[Flags]
public enum CharacterOptions1 : uint
{
None = 0,
AllowGive = 0x00000040,
DragItemOnPlayerOpensSecureTrade = 0x04000000,
Default = 0x50C4A54A,
}
/// <summary>One inventory entry — a guid plus a ContainerType
/// discriminator (0=NonContainer, 1=Container, 2=Foci). Holtburger
/// <c>events.rs:143-168</c> validates <c>ContainerType &lt;= 2</c>

View file

@ -1479,6 +1479,18 @@ public sealed class WorldSession : IDisposable
SendGameAction(InventoryActions.BuildDropItem(seq, itemGuid));
}
/// <summary>
/// Send retail GiveObjectRequest (0x00CD). Retail
/// <c>CM_Inventory::Event_GiveObjectRequest @ 0x006ABB00</c> writes
/// target, source item, then selected stack amount.
/// </summary>
public void SendGiveObject(uint targetGuid, uint itemGuid, uint amount)
{
uint seq = NextGameActionSequence();
SendGameAction(InventoryActions.BuildGiveObjectRequest(
seq, targetGuid, itemGuid, amount));
}
/// <summary>Send GetAndWieldItem (0x001A) — equip an item to an equip slot.</summary>
public void SendGetAndWieldItem(uint itemGuid, uint equipMask)
{

View file

@ -21,10 +21,12 @@ public sealed class ItemInteractionControllerTests
public readonly List<(uint Item, uint Container, int Placement)> Puts = new();
public readonly List<uint> Drops = new();
public readonly List<(uint Item, uint Amount)> SplitDrops = new();
public readonly List<(uint Target, uint Item, uint Amount)> Gives = new();
public readonly List<string> Toasts = new();
public readonly StackSplitQuantityState SplitQuantity = new();
public uint SelectedObject;
public bool NonCombatMode;
public bool DragOnPlayerOpensSecureTrade = true;
public long Now = 1_000;
public Harness()
@ -59,7 +61,9 @@ public sealed class ItemInteractionControllerTests
stackSplitQuantity: SplitQuantity,
inNonCombatMode: () => NonCombatMode,
sendPutItemInContainer: (item, container, placement) =>
Puts.Add((item, container, placement)));
Puts.Add((item, container, placement)),
sendGive: (target, item, amount) => Gives.Add((target, item, amount)),
dragOnPlayerOpensSecureTrade: () => DragOnPlayerOpensSecureTrade);
}
public ItemInteractionController Controller { get; }
@ -697,6 +701,110 @@ public sealed class ItemInteractionControllerTests
Assert.Equal(0u, h.Objects.Get(0x50000A07u)!.ContainerId);
}
[Fact]
public void InventoryDragOnNpc_sendsGiveWithoutOptimisticInventoryMutation()
{
var h = new Harness();
const uint item = 0x50000A71u;
const uint npc = 0x50001001u;
h.AddContained(item);
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = npc,
Name = "Starter Exit Warden",
Type = ItemType.Creature,
});
var payload = new ItemDragPayload(
item, ItemDragSource.Inventory, SourceSlot: 0, SourceCell: new UiItemSlot());
Assert.True(h.Controller.PlaceIn3D(payload, npc));
Assert.Equal(new[] { (npc, item, 1u) }, h.Gives);
Assert.Empty(h.Drops);
Assert.Equal(Pack, h.Objects.Get(item)!.ContainerId);
Assert.Equal(1, h.Objects.Get(item)!.StackSize);
}
[Fact]
public void SelectedPartialStackDragOnNpc_sendsSelectedGiveAmount()
{
var h = new Harness();
const uint item = 0x50000A72u;
const uint npc = 0x50001002u;
h.AddContained(item, value => value.StackSize = 10);
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = npc,
Type = ItemType.Creature,
});
h.SelectedObject = item;
h.SplitQuantity.Reset(10u);
h.SplitQuantity.SetValue(3u);
var payload = new ItemDragPayload(
item, ItemDragSource.Inventory, SourceSlot: 0, SourceCell: new UiItemSlot());
Assert.True(h.Controller.PlaceIn3D(payload, npc));
Assert.Equal(new[] { (npc, item, 3u) }, h.Gives);
Assert.Equal(10, h.Objects.Get(item)!.StackSize);
Assert.Equal(Pack, h.Objects.Get(item)!.ContainerId);
}
[Fact]
public void InventoryDragOnNonCreature_usesRetailGroundFallback()
{
var h = new Harness();
const uint item = 0x50000A73u;
const uint scenery = 0x50002001u;
h.AddContained(item);
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = scenery,
Type = ItemType.Misc,
});
var payload = new ItemDragPayload(
item, ItemDragSource.Inventory, SourceSlot: 0, SourceCell: new UiItemSlot());
Assert.True(h.Controller.PlaceIn3D(payload, scenery));
Assert.Empty(h.Gives);
Assert.Equal(new[] { item }, h.Drops);
Assert.Equal(0u, h.Objects.Get(item)!.ContainerId);
}
[Theory]
[InlineData(true, false)]
[InlineData(false, true)]
public void InventoryDragOnPlayer_honorsRetailSecureTradeOption(
bool opensSecureTrade,
bool sendsGive)
{
var h = new Harness { DragOnPlayerOpensSecureTrade = opensSecureTrade };
const uint item = 0x50000A74u;
const uint targetPlayer = 0x50003001u;
h.AddContained(item);
h.Objects.AddOrUpdate(new ClientObject
{
ObjectId = targetPlayer,
Type = ItemType.Creature,
PublicWeenieBitfield = (uint)PublicWeenieFlags.Player,
});
var payload = new ItemDragPayload(
item, ItemDragSource.Inventory, SourceSlot: 0, SourceCell: new UiItemSlot());
bool result = h.Controller.PlaceIn3D(payload, targetPlayer);
Assert.Equal(sendsGive, result);
if (sendsGive)
Assert.Equal(new[] { (targetPlayer, item, 1u) }, h.Gives);
else
{
Assert.Empty(h.Gives);
Assert.Contains(h.Toasts, text => text.Contains("Secure trade", StringComparison.Ordinal));
}
Assert.Equal(Pack, h.Objects.Get(item)!.ContainerId);
}
[Fact]
public void SelectedPartialStackDragOutsideUi_splitsWithoutMovingOriginal()
{

View file

@ -990,4 +990,39 @@ public sealed class GameEventWiringTests
Assert.False(items.RollbackMove(0x50000A03u));
}
[Fact]
public void WireAll_PlayerDescription_PublishesCharacterOptions()
{
var dispatcher = new GameEventDispatcher();
(uint Options1, uint Options2)? observed = null;
GameEventWiring.WireAll(
dispatcher,
new ClientObjectTable(),
new CombatState(),
new Spellbook(),
new ChatLog(),
onCharacterOptions: (options1, options2) =>
observed = (options1, options2));
var stream = new MemoryStream();
using var writer = new BinaryWriter(stream);
writer.Write(0u); // property flags
writer.Write(0x52u); // player weenie type
writer.Write(0u); // vector flags
writer.Write(0u); // has health
writer.Write(0x40u); // option flags: CharacterOptions2
writer.Write(0x50C4A54Au); // options1
writer.Write(0u); // legacy hotbar count
writer.Write(0u); // spellbook filters
writer.Write(0x948700u); // options2
writer.Write(0u); // inventory count
writer.Write(0u); // equipped count
var envelope = GameEventEnvelope.TryParse(
WrapEnvelope(GameEventType.PlayerDescription, stream.ToArray()));
dispatcher.Dispatch(envelope!.Value);
Assert.Equal((0x50C4A54Au, 0x948700u), observed);
}
}

View file

@ -45,4 +45,19 @@ public sealed class WorldSessionInventoryActionTests
Assert.Equal(InventoryActions.BuildStackableSplitTo3D(1, 0xAAu, 2u), captured);
}
[Fact]
public void SendGiveObject_UsesNextSequenceAndExactBuilderBytes()
{
using var session = new WorldSession(
new IPEndPoint(IPAddress.Loopback, 65000));
byte[]? captured = null;
session.GameActionCapture = body => captured = body;
session.SendGiveObject(targetGuid: 0xAAu, itemGuid: 0xBBu, amount: 3u);
Assert.Equal(
InventoryActions.BuildGiveObjectRequest(1, 0xAAu, 0xBBu, 3u),
captured);
}
}