From 0ccbb4e52c8b60707c7a3900dc8d6477e697ebee Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Jul 2026 19:09:38 +0200 Subject: [PATCH] fix(interaction): port retail's wielded-item pickup rejection (Slice 4 F1) Slice 4 made a remote character's wielded weapon selectable, which made the pickup chain reachable end to end for the first time: SelectionPickUp on another player's weapon captured identity, passed ValidatePickupTarget (which checked only the Stuck flag and the small-item mask, and a MeleeWeapon clears both), installed a real non-autonomous approach through PlayerInteractionMovementSink, and then sent a pickup request the server rejects. Retail does none of that. ItemHolder::AttemptToPlaceInContainer @ 0x00588140 runs AttemptToPlaceInContainer_IsItemLegal @ 0x005870C0 first, at 0x00588173 -- ahead of container legality, auto-merge, the container walk, and the only CM_Inventory::Event_PutItemInContainer emitter (ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680). IsItemLegal's arm at 0x005872B7 rejects `!ACCWeenieObject::IsOwnedByPlayer(item) && item->pwd._location != 0` with one local ECM_UI::SendNotice_DisplayStringInfo(0x1a, ...), and CPlayerSystem::PlaceInBackpack @ 0x0055D8C0 then withdraws the waiting slot it had published (SetWaitingState(obj, 0) + SendNotice_EndPendingInPlayer at 0x0055D918). No request, no movement. acdream had never ported that arm; it was harmless while wielded children were unpickable and stopped being harmless at f6db964f. The notice is data_7e2228, "The %s is being wielded by someone else!" -- WITH the exclamation mark. IsItemLegal's six strings occupy one contiguous literal block, 0x007e21f0 through 0x007e234c, one per arm in reverse code order, and the two neighbours already ported here (0x007e227c "The %s cannot be picked up!" at 0x00587264, 0x007e22b4 "You cannot pick up creatures!" at 0x005871f4) pin it. The punctuation-free 0x007cd350 variant belongs to the wield/wear block and is emitted from a different function at 0x00560aef. pwd._location is the PublicWeenieDesc CurrentWieldedLocation field (acclient.h:37175), which acdream projects as ClientObject.CurrentlyEquippedLocation, and ACCWeenieObject::IsOwnedByPlayer @ 0x0058D160 is IsOwnedByObject(this, player_id) -- already ported as ClientObjectTable.IsOwnedByObject @ 0x0058CEB0 and reached here through the existing ItemInteractionController.IsOwnedByPlayer. The arm reads pwd._location verbatim rather than adding a WielderId belt-and-braces test, because retail's predicate is the thing being ported. The player's OWN wielded item is IsOwnedByPlayer, so retail passes it and takes a different route. ACCWeenieObject::DeterminePositionState @ 0x0058BE70 gives it PositionState.WIELDED (acclient.h:6802) rather than IN_3D_VIEW, and UIAttemptPutInContainer records IR_PICK_UP only for IN_3D_VIEW, treating WIELDED and IN_CONTAINER alike as a plain IR_PUT_IN_CONTAINER transfer. So an own-wielded item is unwielded in place: the request goes out immediately with no approach, joining the existing current-ground-object shortcut. The shortcut carries an ownership conjunct so it can never outrun the 0x005872B7 gate. TryGetApproach now refuses attached children outright, for the same IN_3D_VIEW reason. An Attached projection's bookkeeping WorldEntity.Position carries the PARENT's composed root (EquippedChildRenderController .ApplyParentWorldPose), not the child frame CPhysicsObj::UpdateChild @ 0x00512D50 composes, so an approach built from it walked toward the wielder. Slice 4 de-parented the marker anchor but left this one parent-derived; no approach can anchor on a wielder now. The pick predicates are deliberately untouched. Picking, selecting, examining, lighting-pulse identity, and the vivid-marker anchor on a remote's wielded weapon all behave exactly as Slice 4 shipped them -- retail's sr_Select and sr_Examine branches of RecvNotice_SmartBoxObjectFound @ 0x004E5AD0 never consult IsItemLegal. The gate is the transaction, not the pick. f6db964f's message asserted the slice introduced no deviation and owed no retail-divergence-register row. That was wrong: the unported 0x005872B7 arm was a deviation it made reachable. This commit ports the arm in full, matches retail on the own-wielded path, and removes the parent-derived approach anchor, so the record is corrected here and no register row is owed. Gates: dotnet build green; AcDream.App.Tests 3,960 passed / 3 skipped; complete Release solution 9,792 passed / 5 skipped; tools\run-connected-world-lifecycle-gate.ps1 -SkipBuild RESULT=PASS. Co-Authored-By: Claude Opus 5 --- ...2026-07-23-world-interaction-completion.md | 20 ++ .../SelectionInteractionController.cs | 44 +++- .../Interaction/WorldSelectionQuery.cs | 35 ++++ src/AcDream.Core/Ui/RetailMessages.cs | 21 ++ .../Combat/CombatCameraTargetSourceTests.cs | 1 + .../SelectionInteractionControllerTests.cs | 197 +++++++++++++++++- .../Interaction/WorldSelectionQueryTests.cs | 128 +++++++++++- .../Runtime/CurrentGameRuntimeAdapterTests.cs | 1 + 8 files changed, 442 insertions(+), 5 deletions(-) diff --git a/docs/plans/2026-07-23-world-interaction-completion.md b/docs/plans/2026-07-23-world-interaction-completion.md index 4598c6b9..ba8d289d 100644 --- a/docs/plans/2026-07-23-world-interaction-completion.md +++ b/docs/plans/2026-07-23-world-interaction-completion.md @@ -422,6 +422,26 @@ The own-wielded `sr_Use` gate (`0x004E5BE9`) ships through the new complete Release solution 9,783/5 skips, connected world-lifecycle gate `RESULT=PASS`. +**Correction 2026-07-29 (Opus review finding F1, HIGH).** Making a remote's +wielded weapon selectable made the pickup chain reachable end to end for the +first time, and acdream had never ported +`ItemHolder::AttemptToPlaceInContainer_IsItemLegal @ 0x005870C0`'s arm at +`0x005872B7` — `!ACCWeenieObject::IsOwnedByPlayer(item) && item->pwd._location +!= 0` — so `SelectionPickUp` on another character's weapon installed a real +approach and a wire request the server rejects. That arm now ships, with +retail's own notice (`0x007e2228`) and retail's placement ahead of every other +`AttemptToPlaceInContainer @ 0x00588140` stage: one local message, no movement, +no request. The player's own wielded item is `IsOwnedByPlayer`, so it passes +the arm and takes retail's `PositionState.WIELDED` route — +`ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680` records +`IR_PUT_IN_CONTAINER`, not `IR_PICK_UP` — dispatching its container transfer +immediately with no approach. `TryGetApproach` now refuses attached children +outright for the same reason, so no approach can anchor on a wielder's root. +Picking, selection, examination, and the marker anchor are unchanged. The +slice therefore introduces no divergence, contrary to what `f6db964f`'s message +claimed; no register row is owed. Gates: App tests 3,960/3 skips, complete +Release solution 9,792/5 skips, connected world-lifecycle gate `RESULT=PASS`. + ### The retail mechanism Retail picking is render-coupled, not a scene-graph ray walk. A click arms a diff --git a/src/AcDream.App/Interaction/SelectionInteractionController.cs b/src/AcDream.App/Interaction/SelectionInteractionController.cs index 534fd46f..9c788f91 100644 --- a/src/AcDream.App/Interaction/SelectionInteractionController.cs +++ b/src/AcDream.App/Interaction/SelectionInteractionController.cs @@ -255,7 +255,17 @@ internal sealed class SelectionInteractionController // current-ground-object contents straight to // ItemHolder::AttemptToPlaceInContainer @ 0x00588140. A corpse or // chest child has no independent 3-D projection to approach. - if (_items.IsInCurrentGroundObject(itemGuid)) + // + // ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680 records the + // IR_PICK_UP world request only for PositionState.IN_3D_VIEW and + // treats WIELDED as a plain IR_PUT_IN_CONTAINER transfer, so the + // player's own wielded item is unwielded in place with no approach. + // The ownership conjunct keeps this shortcut behind IsItemLegal's + // 0x005872B7 arm, which ValidatePickupTarget enforces below: a wielded + // item that is not the player's must never reach a wire request. + if (_items.IsInCurrentGroundObject(itemGuid) + || (_query.IsWieldedPositionState(itemGuid) + && _items.IsOwnedByPlayer(itemGuid))) { var contained = new RuntimePendingPickup( Token: 0u, @@ -505,6 +515,20 @@ internal sealed class SelectionInteractionController } } + /// + /// ItemHolder::AttemptToPlaceInContainer @ 0x00588140 runs + /// AttemptToPlaceInContainer_IsItemLegal @ 0x005870C0 first, at + /// 0x00588173 — ahead of container legality, auto-merge, the + /// container walk, and the only CM_Inventory::Event_PutItemInContainer + /// emitter (ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680). + /// A rejection is therefore one local + /// ECM_UI::SendNotice_DisplayStringInfo(0x1a, …) and nothing else: + /// no wire request and no movement. + /// CPlayerSystem::PlaceInBackpack @ 0x0055D8C0 then withdraws the + /// waiting slot it had published (SetWaitingState(obj, 0) plus + /// CM_Item::SendNotice_EndPendingInPlayer at 0x0055D918), + /// which is what a false return drives here. + /// private bool ValidatePickupTarget(uint serverGuid, bool showToast) { if (_query.IsCreature(serverGuid)) @@ -513,6 +537,24 @@ internal sealed class SelectionInteractionController _toast?.Invoke(RetailMessages.CannotPickUpCreatures); return false; } + // IsItemLegal's arm at 0x005872B7 rejects + // `!ACCWeenieObject::IsOwnedByPlayer(item) && item->pwd._location != 0` + // with the notice at 0x005872DB. Retail runs it after the stuck arm at + // 0x00587240; the two are disjoint, because the stuck arm fires only + // when _containerID and _wielderID are both zero while this one needs a + // wielded _location, so acdream's fused stuck/type predicate may follow. + // Equipped-child picking made a remote character's wielded item + // selectable, which is what makes this arm reachable. + if (_query.IsWieldedPositionState(serverGuid) + && !_items.IsOwnedByPlayer(serverGuid)) + { + if (showToast) + { + _toast?.Invoke(RetailMessages.BeingWieldedBySomeoneElse( + _query.Describe(serverGuid))); + } + return false; + } if (_query.IsPickupable(serverGuid)) return true; if (showToast) diff --git a/src/AcDream.App/Interaction/WorldSelectionQuery.cs b/src/AcDream.App/Interaction/WorldSelectionQuery.cs index efa9b877..08375d88 100644 --- a/src/AcDream.App/Interaction/WorldSelectionQuery.cs +++ b/src/AcDream.App/Interaction/WorldSelectionQuery.cs @@ -48,6 +48,7 @@ internal interface IWorldSelectionQuery bool IsUseable(uint serverGuid); bool IsPickupable(uint serverGuid); bool IsWieldedByPlayer(uint serverGuid); + bool IsWieldedPositionState(uint serverGuid); bool TryGetApproach(uint serverGuid, out InteractionApproach approach); Vector3? GetCombatCameraTargetPoint(uint serverGuid); } @@ -388,6 +389,27 @@ internal sealed class WorldSelectionQuery && item.WielderId == playerGuid; } + /// + /// ACCWeenieObject::DeterminePositionState @ 0x0058BE70 resolves + /// PositionState.WIELDED (acclient.h:6802) as a zero + /// pwd._containerID with a nonzero pwd._location; + /// IN_CONTAINER wins when both are set. + /// ClientObject.CurrentlyEquippedLocation is acdream's projection of + /// pwd._location (the CurrentWieldedLocation PublicWeenieDesc + /// field, acclient.h:37175). + /// + /// + /// Two retail gates read this state. The pickup legality arm at + /// 0x005872B7 pairs it with ownership, and + /// ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680 records the + /// IR_PICK_UP world request only for IN_3D_VIEW, treating + /// WIELDED as a plain IR_PUT_IN_CONTAINER transfer. + /// + public bool IsWieldedPositionState(uint serverGuid) + => _objects.Get(serverGuid) is { } item + && item.ContainerId == 0u + && item.CurrentlyEquippedLocation != EquipMask.None; + /// ItemHolder::DetermineUseResult @ 0x00588460 pickup gate. public bool IsPickupable(uint serverGuid) { @@ -399,11 +421,24 @@ internal sealed class WorldSelectionQuery return ((spawn.ItemType ?? 0u) & SmallItemMask) != 0u; } + /// + /// Builds the world-pickup approach. Only retail's + /// PositionState.IN_3D_VIEW objects have one: + /// ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680 records + /// IR_PICK_UP for that state alone, and an attached child is by + /// construction not an independent 3-D object — its bookkeeping + /// WorldEntity.Position carries the PARENT's composed root + /// (EquippedChildRenderController.ApplyParentWorldPose), not the + /// child frame CPhysicsObj::UpdateChild @ 0x00512D50 composes. + /// Refusing the attached case keeps an approach from ever anchoring on a + /// wielder; wielded items reach their container transfer without one. + /// public bool TryGetApproach( uint serverGuid, out InteractionApproach approach) { if (_playerPose() is not { } player + || _liveEntities.TryGetAttachedProjectedRecord(serverGuid, out _) || !TryGetInteractionTarget(serverGuid, out WorldInteractionTarget target)) { approach = default; diff --git a/src/AcDream.Core/Ui/RetailMessages.cs b/src/AcDream.Core/Ui/RetailMessages.cs index 5689705d..dbc7871a 100644 --- a/src/AcDream.Core/Ui/RetailMessages.cs +++ b/src/AcDream.Core/Ui/RetailMessages.cs @@ -55,6 +55,27 @@ public static class RetailMessages /// public const string CannotPickUpCreatures = "You cannot pick up creatures!"; + /// + /// Retail: "The %s is being wielded by someone else!". + /// Data: 0x007e2228 (line 1033029). Runtime sprintf at + /// 0x005872db (line 401606) inside + /// ItemHolder::AttemptToPlaceInContainer_IsItemLegal @ 0x005870c0, + /// on the reject arm at 0x005872b7 that fires when + /// !ACCWeenieObject::IsOwnedByPlayer(item) && + /// item->pwd._location != 0. Shown when the player tries to pick up + /// an item another character is wielding. + /// + /// + /// Not to be confused with the punctuation-free + /// "The %s is being wielded by someone else" at 0x007cd350, + /// which belongs to the wield/wear notice block and is emitted from a + /// different function at 0x00560aef. The pickup gate's own literal + /// block runs 0x007e21f00x007e234c, one string per + /// IsItemLegal arm, in reverse code order. + /// + public static string BeingWieldedBySomeoneElse(string entityName) + => $"The {entityName} is being wielded by someone else!"; + /// /// Retail: "Cannot be used with %s". /// Data: 0x007cc834 (line 1024669). Runtime sprintf at diff --git a/tests/AcDream.App.Tests/Combat/CombatCameraTargetSourceTests.cs b/tests/AcDream.App.Tests/Combat/CombatCameraTargetSourceTests.cs index 24f89987..8c0bc9e1 100644 --- a/tests/AcDream.App.Tests/Combat/CombatCameraTargetSourceTests.cs +++ b/tests/AcDream.App.Tests/Combat/CombatCameraTargetSourceTests.cs @@ -61,6 +61,7 @@ public sealed class CombatCameraTargetSourceTests public bool IsUseable(uint serverGuid) => false; public bool IsPickupable(uint serverGuid) => false; public bool IsWieldedByPlayer(uint serverGuid) => false; + public bool IsWieldedPositionState(uint serverGuid) => false; public bool TryGetApproach(uint serverGuid, out InteractionApproach approach) { approach = default; diff --git a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs index 5f3e838a..1bccc07f 100644 --- a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs @@ -18,6 +18,10 @@ public sealed class SelectionInteractionControllerTests private const uint Player = 0x5000_0001u; private const uint Target = 0x7000_0001u; private const uint GroundContainer = 0x7000_0010u; + private const uint Wielder = 0x7000_0100u; + private const uint RemoteWeapon = 0x7000_0101u; + private const uint OwnWeapon = 0x7000_0102u; + private const uint GroundWeapon = 0x7000_0103u; private sealed class Query : IWorldSelectionQuery { @@ -29,6 +33,13 @@ public sealed class SelectionInteractionControllerTests public bool Useable { get; set; } = true; public bool Pickupable { get; set; } = true; public bool WieldedByPlayer { get; set; } + /// + /// Guids whose ACCWeenieObject::DeterminePositionState @ + /// 0x0058BE70 result is PositionState.WIELDED. Ownership is + /// NOT faked — the controller resolves it through the real + /// . + /// + public HashSet WieldedPositionStates { get; } = new(); public bool CaptureIdentity { get; set; } = true; public uint LocalEntityId { get; set; } = 101u; public ClosestCombatTarget? Closest { get; set; } @@ -59,6 +70,8 @@ public sealed class SelectionInteractionControllerTests public bool IsUseable(uint serverGuid) => Useable; public bool IsPickupable(uint serverGuid) => Pickupable; public bool IsWieldedByPlayer(uint serverGuid) => WieldedByPlayer; + public bool IsWieldedPositionState(uint serverGuid) + => WieldedPositionStates.Contains(serverGuid); public Vector3? GetCombatCameraTargetPoint(uint serverGuid) => null; public bool TryGetApproach(uint serverGuid, out InteractionApproach approach) @@ -194,19 +207,22 @@ public sealed class SelectionInteractionControllerTests Items.PendingBackpackPlacementCancelled += CancelledPlacements.Add; } - public void SetApproach(bool closeRange, uint localEntityId = 101u) + public void SetApproach( + bool closeRange, + uint localEntityId = 101u, + uint serverGuid = Target) { var entity = new WorldEntity { Id = localEntityId, - ServerGuid = Target, + ServerGuid = serverGuid, SourceGfxObjOrSetupId = 0x0200_0001u, Position = new Vector3(5f, 0f, 0f), Rotation = Quaternion.Identity, MeshRefs = [], }; Query.Approach = new InteractionApproach( - new WorldInteractionTarget(Target, localEntityId, entity), + new WorldInteractionTarget(serverGuid, localEntityId, entity), new PlayerInteractionPose(0x0101_0001u, Vector3.Zero), 0.6f, closeRange, @@ -746,6 +762,181 @@ public sealed class SelectionInteractionControllerTests Assert.Empty(h.Transport.Uses); } + /// + /// Registers a wielded weapon exactly as ClientObjectTable.Ingest + /// projects a CreateObject PublicWeenieDesc that carries Wielder plus + /// CurrentWieldedLocation. Ownership is resolved by the real + /// ACCWeenieObject::IsOwnedByObject @ 0x0058CEB0 port, not a fake. + /// + private static void AddWieldedWeapon(Harness h, uint guid, uint wielderId) + { + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = guid, + Name = $"Weapon {guid:X8}", + Type = ItemType.MeleeWeapon, + WielderId = wielderId, + CurrentlyEquippedLocation = EquipMask.MeleeWeapon, + }); + h.Query.WieldedPositionStates.Add(guid); + } + + /// + /// ItemHolder::AttemptToPlaceInContainer_IsItemLegal @ 0x005870C0 + /// rejects !IsOwnedByPlayer(item) && item->pwd._location != 0 + /// at 0x005872B7 with the notice at 0x005872DB, and it runs + /// at 0x00588173 before every other stage of + /// AttemptToPlaceInContainer @ 0x00588140. So: one local message, + /// no approach, no wire request, no waiting slot. + /// + [Fact] + public void PickupOfARemotesWieldedItemIsRejectedLocallyWithNoMovementOrRequest() + { + var h = new Harness(); + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = Wielder, + Name = "Remote", + Type = ItemType.Creature, + }); + AddWieldedWeapon(h, RemoteWeapon, wielderId: Wielder); + h.SetApproach(closeRange: false, serverGuid: RemoteWeapon); + h.Selection.Select(RemoteWeapon, SelectionChangeSource.World); + + Assert.True(h.Controller.HandleInputAction(InputAction.SelectionPickUp)); + h.Controller.DrainOutbound(); + + Assert.Contains( + // Query.Describe is the fake's ACCWeenieObject::GetObjectNameWide. + $"The Target {RemoteWeapon:X8} is being wielded by someone else!", + h.Toasts); + Assert.Empty(h.Transport.Pickups); + Assert.Empty(h.Transport.Uses); + Assert.Empty(h.Movement.Approaches); + Assert.Empty(h.PendingPlacements); + } + + /// + /// The same gate must hold on the direct + /// CPlayerSystem::PlaceInBackpack @ 0x0055D8C0 entry point, whose + /// published waiting slot retail withdraws at 0x0055D918 + /// (SetWaitingState(obj, 0) + SendNotice_EndPendingInPlayer). + /// + [Fact] + public void DirectBackpackPlacementOfARemotesWieldedItemWithdrawsItsWaitingSlot() + { + var h = new Harness(); + AddWieldedWeapon(h, RemoteWeapon, wielderId: Wielder); + h.SetApproach(closeRange: true, serverGuid: RemoteWeapon); + + Assert.True(h.Items.PlaceWorldItemInBackpack(RemoteWeapon)); + + Assert.Contains( + // Query.Describe is the fake's ACCWeenieObject::GetObjectNameWide. + $"The Target {RemoteWeapon:X8} is being wielded by someone else!", + h.Toasts); + Assert.Empty(h.Transport.Pickups); + Assert.Empty(h.Movement.Approaches); + Assert.Single(h.PendingPlacements); + Assert.Equal( + new[] { RemoteWeapon }, + h.CancelledPlacements.Select(p => p.ItemId)); + } + + /// + /// Slice 4 non-regression: the rejected item stays fully selectable and + /// examinable. Retail's sr_Select/sr_Examine branches of + /// RecvNotice_SmartBoxObjectFound @ 0x004E5AD0 never consult + /// IsItemLegal. + /// + [Fact] + public void ARemotesWieldedItemStaysSelectableAndExaminable() + { + var h = new Harness(); + AddWieldedWeapon(h, RemoteWeapon, wielderId: Wielder); + h.Query.Picked = RemoteWeapon; + + Assert.True(h.Controller.HandleInputAction(InputAction.SelectRight)); + + Assert.Equal(RemoteWeapon, h.Selection.SelectedObjectId); + Assert.Equal(new[] { "pick", "pulse", "examine" }, h.Query.Events); + Assert.Equal(new[] { RemoteWeapon }, h.Examines); + } + + /// + /// Positive control: the identical ItemType lying in the world is not in + /// PositionState.WIELDED (DeterminePositionState @ 0x0058BE70 + /// needs a nonzero pwd._location) and picks up normally. + /// + [Fact] + public void AGroundItemOfTheSameTypeStillPicksUpNormally() + { + var h = new Harness(); + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = GroundWeapon, + Name = "Ground weapon", + Type = ItemType.MeleeWeapon, + }); + h.SetApproach(closeRange: false, serverGuid: GroundWeapon); + h.Selection.Select(GroundWeapon, SelectionChangeSource.World); + + Assert.True(h.Controller.HandleInputAction(InputAction.SelectionPickUp)); + h.Controller.DrainOutbound(); + + Assert.Equal(new[] { (GroundWeapon, Player, 0) }, h.Transport.Pickups); + Assert.Single(h.Movement.Approaches); + Assert.Empty(h.CancelledPlacements); + Assert.DoesNotContain(h.Toasts, text => text.Contains("wielded")); + } + + /// + /// The player's OWN wielded item is IsOwnedByPlayer, so + /// IsItemLegal's 0x005872B7 arm passes it. Its + /// PositionState is WIELDED rather than IN_3D_VIEW, so + /// ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680 records + /// IR_PUT_IN_CONTAINER instead of IR_PICK_UP: the transfer + /// goes out immediately, with no world approach. + /// + [Fact] + public void PickupOfThePlayersOwnWieldedItemTransfersImmediatelyWithoutApproach() + { + var h = new Harness(); + AddWieldedWeapon(h, OwnWeapon, wielderId: Player); + h.SetApproach(closeRange: true, serverGuid: OwnWeapon); + h.Selection.Select(OwnWeapon, SelectionChangeSource.World); + + Assert.True(h.Controller.HandleInputAction(InputAction.SelectionPickUp)); + h.Controller.DrainOutbound(); + + Assert.Equal(new[] { (OwnWeapon, Player, 0) }, h.Transport.Pickups); + Assert.Empty(h.Movement.Approaches); + Assert.Empty(h.CancelledPlacements); + Assert.DoesNotContain(h.Toasts, text => text.Contains("wielded")); + } + + /// + /// The WIELDED no-approach shortcut must never outrun the legality gate: + /// ownership alone decides which of the two wielded items may transfer. + /// + [Fact] + public void TheWieldedShortcutIsGatedOnOwnershipNotOnWieldedStateAlone() + { + var h = new Harness(); + AddWieldedWeapon(h, RemoteWeapon, wielderId: Wielder); + AddWieldedWeapon(h, OwnWeapon, wielderId: Player); + + h.SetApproach(closeRange: true, serverGuid: RemoteWeapon); + Assert.True(h.Items.PlaceWorldItemInBackpack(RemoteWeapon)); + Assert.Empty(h.Transport.Pickups); + + h.SetApproach(closeRange: true, serverGuid: OwnWeapon); + Assert.True(h.Items.PlaceWorldItemInBackpack(OwnWeapon)); + + Assert.Equal(new[] { (OwnWeapon, Player, 0) }, h.Transport.Pickups); + Assert.Empty(h.Movement.Approaches); + } + [Fact] public void DragReleasePulsesTheDropTargetWithoutChangingSelection() { diff --git a/tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs b/tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs index 633c2f02..e1c4a0b6 100644 --- a/tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs +++ b/tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs @@ -124,7 +124,8 @@ public sealed class WorldSelectionQueryTests ItemType type, uint wielderId, Matrix4x4? childRoot = null, - float? objScale = null) + float? objScale = null, + EquipMask equippedLocation = EquipMask.MeleeWeapon) { WorldSession.EntitySpawn spawn = Spawn(guid, 1) with { @@ -148,6 +149,10 @@ public sealed class WorldSelectionQueryTests Name = $"Object {guid:X8}", Type = type, WielderId = wielderId, + // CreateObject's PublicWeenieDesc carries CurrentWieldedLocation + // alongside Wielder for every equipped child; retail's + // pwd._location is what DeterminePositionState reads. + CurrentlyEquippedLocation = equippedLocation, }); if (childRoot is { } root) ChildRoots[entity.Id] = root; @@ -743,6 +748,127 @@ public sealed class WorldSelectionQueryTests Assert.Equal(4f, radius); } + /// + /// ACCWeenieObject::DeterminePositionState @ 0x0058BE70: + /// BEING_REMOVED, else IN_CONTAINER when pwd._containerID != 0, else + /// pwd._location != 0 selects WIELDED over IN_3D_VIEW. Both the + /// pickup-legality arm at 0x005872B7 and the IR_PICK_UP-vs- + /// IR_PUT_IN_CONTAINER split in + /// ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680 read it. + /// + [Fact] + public void WieldedPositionStateFollowsRetailsContainerThenLocationOrder() + { + var h = new Harness(); + const uint ground = 0x7000_0030u; + const uint stowed = 0x7000_0031u; + h.Add(ground, Vector3.UnitX, ItemType.MeleeWeapon); + h.Add( + Wielder, + new Vector3(0f, 0f, -10f), + ItemType.Creature, + SelectedObjectHealthPolicy.BfAttackable); + h.AddAttached( + RemoteWeapon, + new Vector3(0f, 0f, -10f), + ItemType.MeleeWeapon, + wielderId: Wielder, + childRoot: Matrix4x4.CreateTranslation(0f, 0f, -5f)); + h.AddAttached( + OwnWeapon, + Vector3.Zero, + ItemType.MeleeWeapon, + wielderId: Player, + childRoot: Matrix4x4.CreateTranslation(0f, 0.5f, 1f)); + h.Objects.AddOrUpdate(new ClientObject + { + ObjectId = stowed, + Name = "Stowed weapon", + Type = ItemType.MeleeWeapon, + ContainerId = Player, + CurrentlyEquippedLocation = EquipMask.MeleeWeapon, + }); + + Assert.True(h.Query.IsWieldedPositionState(RemoteWeapon)); + Assert.True(h.Query.IsWieldedPositionState(OwnWeapon)); + Assert.False(h.Query.IsWieldedPositionState(ground)); + // IN_CONTAINER wins over a stale wielded location. + Assert.False(h.Query.IsWieldedPositionState(stowed)); + // Unknown weenie: retail's GetWeenieObject miss rejects at 0x005870DC. + Assert.False(h.Query.IsWieldedPositionState(0x7000_00FFu)); + } + + /// + /// The wielded-item pickup gate lives in + /// ItemHolder::AttemptToPlaceInContainer_IsItemLegal @ 0x005870C0, + /// NOT in the pick. Retail's pick is render-coupled and consults no + /// ownership or position state, so Slice 4's selection behavior stands. + /// + [Fact] + public void PickAndSelectionStayOpenOnARemotesWieldedItemTheGateWillRefuse() + { + var h = new Harness(); + WorldEntity wielder = h.Add( + Wielder, + new Vector3(0f, 0f, -10f), + ItemType.Creature, + SelectedObjectHealthPolicy.BfAttackable); + WorldEntity weapon = h.AddAttached( + RemoteWeapon, + wielder.Position, + ItemType.MeleeWeapon, + wielderId: Wielder, + childRoot: Matrix4x4.CreateTranslation(0f, 0f, -5f)); + h.PublishParts( + (wielder, wielder.Position), + (weapon, new Vector3(0f, 0f, -5f))); + + Assert.Equal(RemoteWeapon, h.Query.PickAtCursor(includeSelf: false)); + Assert.True(h.Query.TryCaptureIdentity(RemoteWeapon, out uint localId)); + Assert.Equal(weapon.Id, localId); + Assert.True(h.Query.IsCurrent(RemoteWeapon, weapon.Id)); + Assert.True(h.Query.TryGetInteractionTarget(RemoteWeapon, out _)); + Assert.NotNull(h.Query.ResolveVividTargetInfo(RemoteWeapon)); + Assert.Equal($"Object {RemoteWeapon:X8}", h.Query.Describe(RemoteWeapon)); + + // The pick predicates are untouched: the retail gate is the wielded + // position state combined with ownership, not pickability. + Assert.True(h.Query.IsPickupable(RemoteWeapon)); + Assert.True(h.Query.IsWieldedPositionState(RemoteWeapon)); + Assert.False(h.Objects.IsOwnedByObject(RemoteWeapon, Player)); + } + + /// + /// The world-pickup approach belongs to PositionState.IN_3D_VIEW + /// alone (ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680). + /// An attached child's bookkeeping pose is its wielder's root, so an + /// approach built from it would walk the player to the wielder. + /// + [Fact] + public void ApproachRefusesAnAttachedChildRatherThanAnchoringOnItsWielder() + { + var h = new Harness(); + const uint ground = 0x7000_0032u; + var wielderRoot = new Vector3(20f, 0f, 0f); + h.Add(ground, new Vector3(3f, 0f, 0f), ItemType.MeleeWeapon); + h.Add( + Wielder, + wielderRoot, + ItemType.Creature, + SelectedObjectHealthPolicy.BfAttackable); + h.AddAttached( + RemoteWeapon, + wielderRoot, + ItemType.MeleeWeapon, + wielderId: Wielder, + childRoot: Matrix4x4.CreateTranslation(20f, 0f, 1.2f)); + + Assert.False(h.Query.TryGetApproach(RemoteWeapon, out _)); + // The same call still succeeds for a genuine 3-D-view item. + Assert.True(h.Query.TryGetApproach(ground, out InteractionApproach loose)); + Assert.Equal(ground, loose.Target.ServerGuid); + } + private static SelectionCameraSnapshot Camera() { Matrix4x4 view = Matrix4x4.CreateLookAt( diff --git a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs index dc484e14..3697c088 100644 --- a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs +++ b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs @@ -1292,6 +1292,7 @@ public sealed class CurrentGameRuntimeAdapterTests public bool IsUseable(uint serverGuid) => serverGuid == target; public bool IsPickupable(uint serverGuid) => false; public bool IsWieldedByPlayer(uint serverGuid) => false; + public bool IsWieldedPositionState(uint serverGuid) => false; public bool TryGetApproach( uint serverGuid,