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,