From 30d294506c66cedc6b60f040c35c10521e23eb89 Mon Sep 17 00:00:00 2001 From: Erik Date: Mon, 13 Jul 2026 20:28:38 +0200 Subject: [PATCH] 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 --- docs/ISSUES.md | 35 +++++ .../retail-divergence-register.md | 3 +- docs/plans/2026-04-11-roadmap.md | 1 + docs/plans/2026-05-12-milestones.md | 8 ++ .../2026-07-13-retail-give-item-pseudocode.md | 135 ++++++++++++++++++ src/AcDream.App/Rendering/GameWindow.cs | 27 +++- .../UI/ItemInteractionController.cs | 32 ++++- src/AcDream.Core.Net/GameEventWiring.cs | 4 +- .../Messages/PlayerDescriptionParser.cs | 14 ++ src/AcDream.Core.Net/WorldSession.cs | 12 ++ .../UI/ItemInteractionControllerTests.cs | 110 +++++++++++++- .../GameEventWiringTests.cs | 35 +++++ .../WorldSessionInventoryActionTests.cs | 15 ++ 13 files changed, 421 insertions(+), 10 deletions(-) create mode 100644 docs/research/2026-07-13-retail-give-item-pseudocode.md diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 88fe789f..fb84eeb9 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -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 diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index c058cb24..9e484c19 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -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` | --- diff --git a/docs/plans/2026-04-11-roadmap.md b/docs/plans/2026-04-11-roadmap.md index 0c352709..09fc3075 100644 --- a/docs/plans/2026-04-11-roadmap.md +++ b/docs/plans/2026-04-11-roadmap.md @@ -498,6 +498,7 @@ behavior. Estimated 17–26 days focused work, 3–5 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. diff --git a/docs/plans/2026-05-12-milestones.md b/docs/plans/2026-05-12-milestones.md index 7134e811..001d531b 100644 --- a/docs/plans/2026-05-12-milestones.md +++ b/docs/plans/2026-05-12-milestones.md @@ -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 diff --git a/docs/research/2026-07-13-retail-give-item-pseudocode.md b/docs/research/2026-07-13-retail-give-item-pseudocode.md new file mode 100644 index 00000000..f8273653 --- /dev/null +++ b/docs/research/2026-07-13-retail-give-item-pseudocode.md @@ -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. diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index fea729d0..0854105b 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -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. /// 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, diff --git a/src/AcDream.App/UI/ItemInteractionController.cs b/src/AcDream.App/UI/ItemInteractionController.cs index 644fff9d..1993fc25 100644 --- a/src/AcDream.App/UI/ItemInteractionController.cs +++ b/src/AcDream.App/UI/ItemInteractionController.cs @@ -30,6 +30,7 @@ public sealed class ItemInteractionController : IDisposable private readonly Action? _sendWield; private readonly Action? _sendDrop; private readonly Action? _sendSplitToWorld; + private readonly Action? _sendGive; private readonly Action? _toast; private readonly Func _readyForInventoryRequest; private readonly Func _activeVendorId; @@ -42,6 +43,7 @@ public sealed class ItemInteractionController : IDisposable private readonly InteractionState _interactionState; private readonly Func _selectedObjectId; private readonly StackSplitQuantityState? _stackSplitQuantity; + private readonly Func _dragOnPlayerOpensSecureTrade; private readonly AutoWieldController _autoWield; private long _lastUseMs = long.MinValue / 2; @@ -72,7 +74,9 @@ public sealed class ItemInteractionController : IDisposable Action? sendSplitToWorld = null, Func? selectedObjectId = null, StackSplitQuantityState? stackSplitQuantity = null, - Action? sendPutItemInContainer = null) + Action? sendPutItemInContainer = null, + Action? sendGive = null, + Func? 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); + + /// + /// 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 ItemHolder::AttemptPlaceIn3D @ 0x00588600. + /// + 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); diff --git a/src/AcDream.Core.Net/GameEventWiring.cs b/src/AcDream.Core.Net/GameEventWiring.cs index a37a7c51..08759825 100644 --- a/src/AcDream.Core.Net/GameEventWiring.cs +++ b/src/AcDream.Core.Net/GameEventWiring.cs @@ -76,7 +76,8 @@ public static class GameEventWiring Action? onConfirmationDone = null, FriendsState? friends = null, SquelchState? squelch = null, - Action>? onDesiredComponents = null) + Action>? onDesiredComponents = null, + Action? 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. diff --git a/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs b/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs index 34433492..efc1ca50 100644 --- a/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs +++ b/src/AcDream.Core.Net/Messages/PlayerDescriptionParser.cs @@ -197,6 +197,20 @@ public static class PlayerDescriptionParser SpellLists8 = 0x00000400, } + /// + /// Character-option bits consumed by the client runtime. Values are the + /// verbatim retail CharacterOption enum from + /// named-retail/acclient.h:3404. + /// + [Flags] + public enum CharacterOptions1 : uint + { + None = 0, + AllowGive = 0x00000040, + DragItemOnPlayerOpensSecureTrade = 0x04000000, + Default = 0x50C4A54A, + } + /// One inventory entry — a guid plus a ContainerType /// discriminator (0=NonContainer, 1=Container, 2=Foci). Holtburger /// events.rs:143-168 validates ContainerType <= 2 diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs index 471a9f6d..a145df4f 100644 --- a/src/AcDream.Core.Net/WorldSession.cs +++ b/src/AcDream.Core.Net/WorldSession.cs @@ -1479,6 +1479,18 @@ public sealed class WorldSession : IDisposable SendGameAction(InventoryActions.BuildDropItem(seq, itemGuid)); } + /// + /// Send retail GiveObjectRequest (0x00CD). Retail + /// CM_Inventory::Event_GiveObjectRequest @ 0x006ABB00 writes + /// target, source item, then selected stack amount. + /// + public void SendGiveObject(uint targetGuid, uint itemGuid, uint amount) + { + uint seq = NextGameActionSequence(); + SendGameAction(InventoryActions.BuildGiveObjectRequest( + seq, targetGuid, itemGuid, amount)); + } + /// Send GetAndWieldItem (0x001A) — equip an item to an equip slot. public void SendGetAndWieldItem(uint itemGuid, uint equipMask) { diff --git a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs index 27be62a7..e3d2cb10 100644 --- a/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/UI/ItemInteractionControllerTests.cs @@ -21,10 +21,12 @@ public sealed class ItemInteractionControllerTests public readonly List<(uint Item, uint Container, int Placement)> Puts = new(); public readonly List 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 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() { diff --git a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs index 95feaf85..205f16a4 100644 --- a/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs +++ b/tests/AcDream.Core.Net.Tests/GameEventWiringTests.cs @@ -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); + } + } diff --git a/tests/AcDream.Core.Net.Tests/WorldSessionInventoryActionTests.cs b/tests/AcDream.Core.Net.Tests/WorldSessionInventoryActionTests.cs index c962d937..32ea656e 100644 --- a/tests/AcDream.Core.Net.Tests/WorldSessionInventoryActionTests.cs +++ b/tests/AcDream.Core.Net.Tests/WorldSessionInventoryActionTests.cs @@ -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); + } }