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:
parent
a944e49b9c
commit
30d294506c
13 changed files with 421 additions and 10 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
135
docs/research/2026-07-13-retail-give-item-pseudocode.md
Normal file
135
docs/research/2026-07-13-retail-give-item-pseudocode.md
Normal 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.
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 <= 2</c>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue