diff --git a/docs/plans/2026-07-23-world-interaction-completion.md b/docs/plans/2026-07-23-world-interaction-completion.md index 6db49c58..4598c6b9 100644 --- a/docs/plans/2026-07-23-world-interaction-completion.md +++ b/docs/plans/2026-07-23-world-interaction-completion.md @@ -407,10 +407,20 @@ Named retail references and executable pseudocode are recorded in ## Slice 4 — equipped-child world picking -**Status:** research complete 2026-07-29 (named-retail oracle + gap analysis); -implementation next. Owner shape per the program table: pure -world-query/picking policy plus presentation anchor. No wire, physics, -renderer, or `EquippedChildRenderController` changes. +**Status:** implemented 2026-07-29, pending the two-client visual gate. Owner +shape per the program table held: pure world-query/picking policy plus +presentation anchor. No wire, physics, renderer, or +`EquippedChildRenderController` changes. `LiveEntityRuntime` gained scoped +`TryGetAttachedProjectedRecord` / `TryGetPickEligibleRecord` predicates; +`TryGetInteractionEligibleRecord` and the `_visible` set are untouched, so +radar, auto-target, and `CombatAttackTargetSource` remain wielded-item free +(regression-asserted). `WorldSelectionQuery` takes the composed child root as +an injected `Func` beside the selection-sphere hook, wired in +`LivePresentationComposition` from `EntityEffectPoseRegistry.TryGetRootPose`. +The own-wielded `sr_Use` gate (`0x004E5BE9`) ships through the new +`IWorldSelectionQuery.IsWieldedByPlayer`. Gates: App tests 3,951/3 skips, +complete Release solution 9,783/5 skips, connected world-lifecycle gate +`RESULT=PASS`. ### The retail mechanism diff --git a/src/AcDream.App/Composition/LivePresentationComposition.cs b/src/AcDream.App/Composition/LivePresentationComposition.cs index b1cfe102..a89a786c 100644 --- a/src/AcDream.App/Composition/LivePresentationComposition.cs +++ b/src/AcDream.App/Composition/LivePresentationComposition.cs @@ -724,7 +724,18 @@ internal sealed class LivePresentationCompositionPhase } return (sphere.Origin, sphere.Radius); } - }); + }, + // CPhysicsObj::UpdateChild @ 0x00512D50 recomposes an equipped + // child's own m_position from Frame::combine(parent part frame, + // holding frame) every tick; SmartBox::GetObjectBoundingBox @ + // 0x00452E20 anchors the selection sphere on that own frame. + // EquippedChildRenderController publishes exactly that composed + // root here each frame, so selection borrows it rather than the + // parent-derived bookkeeping pose. + localEntityId => + d.EffectPoses.TryGetRootPose(localEntityId, out Matrix4x4 childRoot) + ? childRoot + : null); var radarSnapshotProvider = new RadarSnapshotProvider( d.EntityObjects.Objects, liveEntities, diff --git a/src/AcDream.App/Interaction/SelectionInteractionController.cs b/src/AcDream.App/Interaction/SelectionInteractionController.cs index 8085d957..534fd46f 100644 --- a/src/AcDream.App/Interaction/SelectionInteractionController.cs +++ b/src/AcDream.App/Interaction/SelectionInteractionController.cs @@ -159,7 +159,13 @@ internal sealed class SelectionInteractionController string label = _query.Describe(guid); Console.WriteLine($"[B.4b] pick guid=0x{guid:X8} name={label}"); _toast?.Invoke($"Selected: {label}"); - if (useImmediately) + // The sr_Use branch of RecvNotice_SmartBoxObjectFound @ 0x004E5AD0 + // guards ItemHolder::UseObject with + // `if (found->pwd._wielderID != SmartBox::player_id)` at 0x004E5BE9. + // Clicking your own wielded weapon selects and flashes it but sends no + // Use. Equipped-child picking makes that click reachable, so the gate + // ships with it. + if (useImmediately && !_query.IsWieldedByPlayer(guid)) EnqueueIdentityBound( RuntimeQueuedInteractionKind.Activate, guid, diff --git a/src/AcDream.App/Interaction/WorldSelectionQuery.cs b/src/AcDream.App/Interaction/WorldSelectionQuery.cs index 6bbe9872..efa9b877 100644 --- a/src/AcDream.App/Interaction/WorldSelectionQuery.cs +++ b/src/AcDream.App/Interaction/WorldSelectionQuery.cs @@ -47,6 +47,7 @@ internal interface IWorldSelectionQuery ClosestCombatTarget? FindClosestHostileMonster(); bool IsUseable(uint serverGuid); bool IsPickupable(uint serverGuid); + bool IsWieldedByPlayer(uint serverGuid); bool TryGetApproach(uint serverGuid, out InteractionApproach approach); Vector3? GetCombatCameraTargetPoint(uint serverGuid); } @@ -105,6 +106,7 @@ internal sealed class WorldSelectionQuery private readonly Func _playerPose; private readonly Func _setupCylinder; private readonly Func _selectionSphere; + private readonly Func _childRootPose; public WorldSelectionQuery( LiveEntityRuntime liveEntities, @@ -115,7 +117,8 @@ internal sealed class WorldSelectionQuery Func cursor, Func playerPose, Func setupCylinder, - Func selectionSphere) + Func selectionSphere, + Func childRootPose) { _liveEntities = liveEntities ?? throw new ArgumentNullException(nameof(liveEntities)); _objects = objects ?? throw new ArgumentNullException(nameof(objects)); @@ -126,6 +129,7 @@ internal sealed class WorldSelectionQuery _playerPose = playerPose ?? throw new ArgumentNullException(nameof(playerPose)); _setupCylinder = setupCylinder ?? throw new ArgumentNullException(nameof(setupCylinder)); _selectionSphere = selectionSphere ?? throw new ArgumentNullException(nameof(selectionSphere)); + _childRootPose = childRootPose ?? throw new ArgumentNullException(nameof(childRootPose)); } public uint? PickAtCursor(bool includeSelf) @@ -144,8 +148,12 @@ internal sealed class WorldSelectionQuery camera.View, camera.Projection, includeSelf ? 0u : _playerGuid()); + // Render::GfxObjUnderSelectionRay @ 0x0054C740 records the winning + // part's own physobj id, so an equipped child resolves to the CHILD's + // GUID. There is no parent fallback in the retail path: a hit whose + // identity is no longer current is simply discarded. return hit is { } found - && _liveEntities.TryGetInteractionEligibleRecord( + && _liveEntities.TryGetPickEligibleRecord( found.ServerGuid, found.LocalEntityId, out _) @@ -175,7 +183,7 @@ internal sealed class WorldSelectionQuery uint serverGuid, out WorldInteractionTarget target) { - if (_liveEntities.TryGetInteractionEligibleRecord( + if (_liveEntities.TryGetPickEligibleRecord( serverGuid, out LiveEntityRecord record) && record.WorldEntity is { } entity) @@ -192,7 +200,7 @@ internal sealed class WorldSelectionQuery => IsCurrent(target.ServerGuid, target.LocalEntityId); public bool IsCurrent(uint serverGuid, uint localEntityId) - => _liveEntities.TryGetInteractionEligibleRecord(serverGuid, localEntityId, out _); + => _liveEntities.TryGetPickEligibleRecord(serverGuid, localEntityId, out _); public ItemType GetItemType(uint serverGuid) => _objects.Get(serverGuid)?.Type ?? ItemType.None; @@ -268,12 +276,16 @@ internal sealed class WorldSelectionQuery // ACCWeenieObject::selectedID intact while suppressing its own marker // for self, player-owned objects, and objects whose current state is // IN_CONTAINER. ContainerId is our authoritative placement projection - // for that retail state. + // for that retail state. PositionState.WIELDED is a distinct retail + // state (acclient.h:6802), so a remote character's wielded item keeps + // its marker while the local player's own wielded item is suppressed + // by the shared IsOwnedByObject test. if (serverGuid == playerGuid || target is null || _objects.IsOwnedByObject(serverGuid, playerGuid) || target.ContainerId != 0u - || !_liveEntities.TryGetSpatiallyProjectedRecord(serverGuid, out _) + || !(_liveEntities.TryGetSpatiallyProjectedRecord(serverGuid, out _) + || _liveEntities.TryGetAttachedProjectedRecord(serverGuid, out _)) || !TryGetSelectionSphere(serverGuid, out Vector3 center, out float radius)) { return null; @@ -286,10 +298,25 @@ internal sealed class WorldSelectionQuery } /// - /// SmartBox::GetObjectBoundingBox @ 0x00452E20 delegates to - /// CPhysicsObj::GetSelectionSphere @ 0x0050EA40 and uses a 0.1-unit - /// fallback when Setup authored no sphere. + /// SmartBox::GetObjectBoundingBox @ 0x00452E20 pushes the object's OWN + /// m_position (Render::positionPush(3, &obj->m_position)) + /// and delegates to CPhysicsObj::GetSelectionSphere @ 0x0050EA40, which + /// scales the Setup sphere by that object's own part-array scale + /// (CPartArray::GetSelectionSphere @ 0x00518B80). A 0.1-unit fallback is + /// used when Setup authored no sphere. /// + /// + /// For an equipped child the object's own m_position is the frame + /// CPhysicsObj::UpdateChild @ 0x00512D50 composes each tick as + /// Frame::combine(parent part frame, holding frame). acdream stores + /// the PARENT's root in the child projection's Position/Rotation + /// (EquippedChildRenderController.ApplyParentWorldPose) because the child's + /// MeshRefs are parent-relative, so the exact equivalent anchor is the + /// composed child root already published per frame to + /// EntityEffectPoseRegistry (EquippedChildRenderController.PublishChildPose). + /// There is no parent fallback: with no published child root the child has + /// no live composed frame this tick and has no sphere. + /// public bool TryGetSelectionSphere( uint serverGuid, out Vector3 worldCenter, @@ -300,7 +327,17 @@ internal sealed class WorldSelectionQuery if (!_liveEntities.TryGetWorldEntity(serverGuid, out WorldEntity entity)) return false; - worldCenter = entity.Position; + bool attached = + _liveEntities.TryGetAttachedProjectedRecord(serverGuid, out _); + Matrix4x4 childRoot = Matrix4x4.Identity; + if (attached) + { + if (_childRootPose(entity.Id) is not { } published) + return false; + childRoot = published; + } + + worldCenter = attached ? childRoot.Translation : entity.Position; worldRadius = 0.1f; if (!_liveEntities.TryGetSnapshot(serverGuid, out var spawn) || spawn.SetupTableId is not uint setupId @@ -310,9 +347,18 @@ internal sealed class WorldSelectionQuery return true; } - float scale = entity.Scale > 0f ? entity.Scale : 1f; + // An attached projection never carries its wire ObjScale on the + // WorldEntity — the child's scale is baked into its part transforms by + // EquippedChildAttachment.TryComposePoseInto, leaving RootLocal rigid. + // The spawn record is therefore the authoritative part-array scale, + // exactly as EquippedChildRenderController.TryRealize reads it. + float scale = attached + ? (spawn.ObjScale is { } childScale && childScale > 0f ? childScale : 1f) + : (entity.Scale > 0f ? entity.Scale : 1f); Vector3 localCenter = sphere.Origin * scale; - worldCenter = entity.Position + Vector3.Transform(localCenter, entity.Rotation); + worldCenter = attached + ? Vector3.Transform(localCenter, childRoot) + : entity.Position + Vector3.Transform(localCenter, entity.Rotation); worldRadius = sphere.Radius * scale; return true; } @@ -326,6 +372,22 @@ internal sealed class WorldSelectionQuery return false; } + /// + /// Retail's sr_Use branch of + /// UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @ + /// 0x004E5AD0 compares the found object's pwd._wielderID against + /// SmartBox::player_id at 0x004E5BE9 and calls + /// ItemHolder::UseObject only when they differ. Selection and the + /// click lighting pulse still happen for the player's own wielded item. + /// + public bool IsWieldedByPlayer(uint serverGuid) + { + uint playerGuid = _playerGuid(); + return playerGuid != 0u + && _objects.Get(serverGuid) is { } item + && item.WielderId == playerGuid; + } + /// ItemHolder::DetermineUseResult @ 0x00588460 pickup gate. public bool IsPickupable(uint serverGuid) { diff --git a/src/AcDream.App/World/LiveEntityRuntime.cs b/src/AcDream.App/World/LiveEntityRuntime.cs index 378164ef..5bac67ef 100644 --- a/src/AcDream.App/World/LiveEntityRuntime.cs +++ b/src/AcDream.App/World/LiveEntityRuntime.cs @@ -1178,7 +1178,77 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource } /// - /// Resolves a top-level object that currently participates in picking, + /// Resolves a current equipped-child projection whose composed frame is + /// live. Retail installs an equipped item as a first-class + /// CPhysicsObj with its own object id and part array + /// (CPhysicsObj::add_child @ 0x0050F870 via + /// CSetup::GetHoldingLocation @ 0x005213F0), and + /// CPhysicsObj::UpdateChild @ 0x00512D50 recomposes + /// Frame::combine(parent part frame, holding frame) into that child's + /// own m_position every frame. An attached projection therefore has + /// real world presence even though it is deliberately absent from the + /// interaction/radar/auto-target visible set. + /// + public bool TryGetAttachedProjectedRecord( + uint serverGuid, + out LiveEntityRecord record) + { + if (_projections.TryGetCurrent(serverGuid, out LiveEntityRecord found) + && found.WorldEntity is not null + && found.ProjectionKind is LiveEntityProjectionKind.Attached + && found.IsSpatiallyProjected) + { + record = found; + return true; + } + + record = null!; + return false; + } + + /// + /// Resolves an object that currently participates in mouse picking. + /// Retail's only candidacy rule is "a drawn part whose owning physics + /// object has a nonzero id" (CPhysicsPart::Draw @ 0x0050D7A0 guards + /// on CPhysicsPart::get_physobj_id @ 0x0050D490, and + /// Render::GfxObjUnderSelectionRay @ 0x0054C740 accumulates the + /// hit under that id) — there is no parent redirection and no + /// wielded-specific gate, so a click on a wielded weapon returns the + /// weapon's own GUID. Picking therefore admits attached projections on top + /// of the ordinary top-level visible set. This is deliberately NOT the + /// interaction-eligible predicate: radar, auto-target, and + /// MoveTo/Sticky establishment must stay wielded-item free. + /// + public bool TryGetPickEligibleRecord( + uint serverGuid, + out LiveEntityRecord record) + => TryGetInteractionEligibleRecord(serverGuid, out record) + || TryGetAttachedProjectedRecord(serverGuid, out record); + + /// + /// Pick eligibility bound to one logical incarnation. A stale published + /// frame must never retarget a replacement which reused the server GUID. + /// + public bool TryGetPickEligibleRecord( + uint serverGuid, + uint localEntityId, + out LiveEntityRecord record) + { + if (serverGuid != 0u + && localEntityId != 0u + && TryGetPickEligibleRecord(serverGuid, out LiveEntityRecord found) + && found.WorldEntity!.Id == localEntityId) + { + record = found; + return true; + } + + record = null!; + return false; + } + + /// + /// Resolves a top-level object that currently participates in /// targeting, radar, and wire-driven MoveTo/Sticky establishment. /// Pending, attached, and Hidden projections are intentionally excluded. /// diff --git a/tests/AcDream.App.Tests/Combat/CombatCameraTargetSourceTests.cs b/tests/AcDream.App.Tests/Combat/CombatCameraTargetSourceTests.cs index 40932669..24f89987 100644 --- a/tests/AcDream.App.Tests/Combat/CombatCameraTargetSourceTests.cs +++ b/tests/AcDream.App.Tests/Combat/CombatCameraTargetSourceTests.cs @@ -60,6 +60,7 @@ public sealed class CombatCameraTargetSourceTests public ClosestCombatTarget? FindClosestHostileMonster() => null; public bool IsUseable(uint serverGuid) => false; public bool IsPickupable(uint serverGuid) => false; + public bool IsWieldedByPlayer(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 bdafd341..5f3e838a 100644 --- a/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs +++ b/tests/AcDream.App.Tests/Interaction/SelectionInteractionControllerTests.cs @@ -28,6 +28,7 @@ public sealed class SelectionInteractionControllerTests public bool Hostile { get; set; } public bool Useable { get; set; } = true; public bool Pickupable { get; set; } = true; + public bool WieldedByPlayer { get; set; } public bool CaptureIdentity { get; set; } = true; public uint LocalEntityId { get; set; } = 101u; public ClosestCombatTarget? Closest { get; set; } @@ -57,6 +58,7 @@ public sealed class SelectionInteractionControllerTests public ClosestCombatTarget? FindClosestHostileMonster() => Closest; public bool IsUseable(uint serverGuid) => Useable; public bool IsPickupable(uint serverGuid) => Pickupable; + public bool IsWieldedByPlayer(uint serverGuid) => WieldedByPlayer; public Vector3? GetCombatCameraTargetPoint(uint serverGuid) => null; public bool TryGetApproach(uint serverGuid, out InteractionApproach approach) @@ -717,6 +719,33 @@ public sealed class SelectionInteractionControllerTests Assert.Empty(h.PendingPlacements); } + /// + /// The sr_Use branch of RecvNotice_SmartBoxObjectFound @ 0x004E5AD0 guards + /// ItemHolder::UseObject with + /// if (found->pwd._wielderID != SmartBox::player_id) at + /// 0x004E5BE9. Selection and the click pulse still happen. + /// + [Fact] + public void DoubleClickOnTheOwnWieldedObjectSelectsAndPulsesWithoutUse() + { + var control = new Harness(); + control.Query.Picked = Target; + control.Controller.HandleInputAction(InputAction.SelectDblLeft); + control.Controller.DrainOutbound(); + Assert.NotEmpty(control.Transport.Uses); + + var h = new Harness(); + h.Query.Picked = Target; + h.Query.WieldedByPlayer = true; + + h.Controller.HandleInputAction(InputAction.SelectDblLeft); + h.Controller.DrainOutbound(); + + Assert.Equal(Target, h.Selection.SelectedObjectId); + Assert.Contains("pulse", h.Query.Events); + Assert.Empty(h.Transport.Uses); + } + [Fact] public void DragReleasePulsesTheDropTargetWithoutChangingSelection() { diff --git a/tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs b/tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs index 7af12843..633c2f02 100644 --- a/tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs +++ b/tests/AcDream.App.Tests/Interaction/WorldSelectionQueryTests.cs @@ -3,6 +3,7 @@ using AcDream.App.Interaction; using AcDream.App.Rendering; using AcDream.App.Rendering.Selection; using AcDream.App.Streaming; +using AcDream.App.UI.Layout; using AcDream.App.World; using AcDream.Core.Combat; using AcDream.Core.Items; @@ -19,6 +20,9 @@ public sealed class WorldSelectionQueryTests { private const uint Player = 0x5000_0001u; private const uint Target = 0x7000_0001u; + private const uint Wielder = 0x7000_0100u; + private const uint RemoteWeapon = 0x7000_0101u; + private const uint OwnWeapon = 0x7000_0102u; private sealed class Resources : ILiveEntityResourceLifecycle { @@ -43,6 +47,12 @@ public sealed class WorldSelectionQueryTests public readonly WorldSelectionQuery Query; public PlayerInteractionPose? PlayerPose = new(0x0101_0001u, Vector3.Zero); + /// + /// Stands in for EntityEffectPoseRegistry: the composed equipped-child + /// root EquippedChildRenderController.PublishChildPose emits per frame. + /// + public readonly Dictionary ChildRoots = new(); + public Harness() { var spatial = new GpuWorldState(); @@ -61,7 +71,10 @@ public sealed class WorldSelectionQueryTests () => new Vector2(400f, 300f), () => PlayerPose, (_, _) => (0.5f, 2f), - _ => (new Vector3(1f, 0f, 0f), 2f)); + _ => (new Vector3(1f, 0f, 0f), 2f), + localEntityId => ChildRoots.TryGetValue(localEntityId, out Matrix4x4 root) + ? root + : null); Add(Player, Vector3.Zero, ItemType.Creature, SelectedObjectHealthPolicy.BfPlayer); } @@ -98,17 +111,66 @@ public sealed class WorldSelectionQueryTests return entity; } + /// + /// Materializes an equipped child exactly as + /// EquippedChildRenderController.TryRealize does: an Attached + /// projection whose bookkeeping Position/Rotation carry the PARENT's + /// composed world root, with the child's own composed root published + /// separately for effects and selection. + /// + public WorldEntity AddAttached( + uint guid, + Vector3 parentWorldPosition, + ItemType type, + uint wielderId, + Matrix4x4? childRoot = null, + float? objScale = null) + { + WorldSession.EntitySpawn spawn = Spawn(guid, 1) with + { + ItemType = (uint)type, + ObjScale = objScale, + }; + Runtime.RegisterLiveEntity(spawn); + WorldEntity entity = Runtime.MaterializeLiveEntity( + guid, + 0x0101_0001u, + id => Entity( + id, + guid, + parentWorldPosition, + 1f, + Quaternion.Identity), + LiveEntityProjectionKind.Attached)!; + Objects.AddOrUpdate(new ClientObject + { + ObjectId = guid, + Name = $"Object {guid:X8}", + Type = type, + WielderId = wielderId, + }); + if (childRoot is { } root) + ChildRoots[entity.Id] = root; + return entity; + } + public void Publish(WorldEntity entity) + => PublishParts((entity, entity.Position)); + + public void PublishParts(params (WorldEntity Entity, Vector3 PartWorld)[] parts) { SelectionCameraSnapshot camera = Camera(); Scene.BeginFrame(); Scene.SetViewFrustum(FrustumPlanes.FromViewProjection( camera.View * camera.Projection)); - Scene.AddVisiblePart( - entity, - 0, - 0x0100_0001u, - Matrix4x4.CreateTranslation(entity.Position)); + foreach ((WorldEntity entity, Vector3 partWorld) in parts) + { + Scene.AddVisiblePart( + entity, + 0, + 0x0100_0001u, + Matrix4x4.CreateTranslation(partWorld)); + } Scene.CompleteFrame(); } } @@ -388,6 +450,299 @@ public sealed class WorldSelectionQueryTests Assert.Null(h.Query.ResolveVividTargetInfo(Target)); } + /// + /// Render::GfxObjUnderSelectionRay @ 0x0054C740 records each hit under the + /// drawn part's own CPhysicsObj id (CPhysicsPart::get_physobj_id @ + /// 0x0050D490), and an equipped child is a first-class CPhysicsObj + /// (CPhysicsObj::add_child @ 0x0050F870). The closest hit therefore names + /// the WEAPON, not its wielder. + /// + [Fact] + public void PickResolvesTheEquippedChildRatherThanItsWielder() + { + 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); + } + + /// + /// Retail's pick keeps only the closest winner; there is no second-choice + /// walk to the wielder when the winner is gone. + /// + [Fact] + public void PickRejectsAWithdrawnEquippedChildWithoutFallingBackToTheWielder() + { + 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.Runtime.WithdrawLiveEntityProjection(RemoteWeapon)); + + Assert.Null(h.Query.PickAtCursor(includeSelf: false)); + Assert.False(h.Query.IsCurrent(RemoteWeapon, weapon.Id)); + Assert.False(h.Query.TryGetInteractionTarget(RemoteWeapon, out _)); + } + + [Fact] + public void PickRejectsAnEquippedChildWhoseIncarnationWasReplaced() + { + 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((weapon, new Vector3(0f, 0f, -5f))); + Assert.Equal(RemoteWeapon, h.Query.PickAtCursor(includeSelf: false)); + + Assert.True(h.Runtime.UnregisterLiveEntity( + new DeleteObject.Parsed(RemoteWeapon, 1), + isLocalPlayer: false)); + WorldEntity replacement = h.AddAttached( + RemoteWeapon, + wielder.Position, + ItemType.MeleeWeapon, + wielderId: Wielder, + childRoot: Matrix4x4.CreateTranslation(0f, 0f, -5f)); + + Assert.NotEqual(weapon.Id, replacement.Id); + Assert.Null(h.Query.PickAtCursor(includeSelf: false)); + } + + /// + /// SmartBox::GetObjectBoundingBox @ 0x00452E20 pushes the picked object's + /// OWN m_position, which CPhysicsObj::UpdateChild @ 0x00512D50 composes + /// from Frame::combine(parent part frame, holding frame). The marker must + /// therefore track the hand, not the wielder's feet. + /// + [Fact] + public void VividTargetMarkerAnchorsOnTheComposedChildRoot() + { + var h = new Harness(); + var wielderRoot = new Vector3(1f, 2f, 3f); + h.Add( + Wielder, + wielderRoot, + ItemType.Creature, + SelectedObjectHealthPolicy.BfAttackable); + h.AddAttached( + RemoteWeapon, + wielderRoot, + ItemType.MeleeWeapon, + wielderId: Wielder, + childRoot: Matrix4x4.CreateTranslation(7f, 8f, 9f)); + + VividTargetInfo? marker = h.Query.ResolveVividTargetInfo(RemoteWeapon); + + Assert.NotNull(marker); + // Setup sphere origin (1,0,0) composed onto the child's own root. + Assert.Equal(new Vector3(8f, 8f, 9f), marker!.Value.SelectionSphereCenter); + Assert.Equal(2f, marker.Value.SelectionSphereRadius); + // The parent-derived bookkeeping pose would have produced (2,2,3). + Assert.NotEqual( + wielderRoot + Vector3.UnitX, + marker.Value.SelectionSphereCenter); + } + + /// + /// No parent fallback: a child with no composed root published this tick + /// has no live frame of its own and therefore no selection sphere. + /// + [Fact] + public void VividTargetMarkerRefusesAnEquippedChildWithNoComposedRoot() + { + var h = new Harness(); + var wielderRoot = new Vector3(1f, 2f, 3f); + h.Add( + Wielder, + wielderRoot, + ItemType.Creature, + SelectedObjectHealthPolicy.BfAttackable); + WorldEntity weapon = h.AddAttached( + RemoteWeapon, + wielderRoot, + ItemType.MeleeWeapon, + wielderId: Wielder, + childRoot: Matrix4x4.CreateTranslation(7f, 8f, 9f)); + + Assert.True(h.ChildRoots.Remove(weapon.Id)); + + Assert.Null(h.Query.ResolveVividTargetInfo(RemoteWeapon)); + Assert.False(h.Query.TryGetSelectionSphere(RemoteWeapon, out _, out _)); + } + + /// + /// VividTargetIndicator::SetSelected @ 0x004F5CE0 suppresses only + /// self/player-owned/IN_CONTAINER. A remote character's wielded item keeps + /// its brackets; the local player's own wielded item does not. + /// + [Fact] + public void VividTargetMarkerSuppressesOnlyThePlayersOwnWieldedChild() + { + var h = new Harness(); + h.Add( + Wielder, + new Vector3(1f, 2f, 3f), + ItemType.Creature, + SelectedObjectHealthPolicy.BfAttackable); + h.AddAttached( + RemoteWeapon, + new Vector3(1f, 2f, 3f), + ItemType.MeleeWeapon, + wielderId: Wielder, + childRoot: Matrix4x4.CreateTranslation(7f, 8f, 9f)); + h.AddAttached( + OwnWeapon, + Vector3.Zero, + ItemType.MeleeWeapon, + wielderId: Player, + childRoot: Matrix4x4.CreateTranslation(0f, 0.5f, 1f)); + + Assert.NotNull(h.Query.ResolveVividTargetInfo(RemoteWeapon)); + Assert.Null(h.Query.ResolveVividTargetInfo(OwnWeapon)); + Assert.True(h.Query.IsWieldedByPlayer(OwnWeapon)); + Assert.False(h.Query.IsWieldedByPlayer(RemoteWeapon)); + } + + /// + /// CPhysicsObj::SetLighting @ 0x00511A80 is non-recursive: it lights the + /// clicked object's own part array only, so clicking a weapon never + /// flashes its wielder. + /// + [Fact] + public void LightingPulseStartsOnTheEquippedChildIdentityOnly() + { + 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.Query.BeginLightingPulse(RemoteWeapon); + + Assert.True(h.Scene.TryGetLighting(RemoteWeapon, weapon.Id, out _)); + Assert.False(h.Scene.TryGetLighting(Wielder, wielder.Id, out _)); + } + + /// + /// Regression guard for the Slice 4 scope boundary: picking is widened, + /// the interaction/radar/auto-target visible set is NOT. Retail's radar + /// has no wielded blips. + /// + [Fact] + public void EquippedChildStaysOutOfTheInteractionAndRadarVisibleSet() + { + 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)); + + Assert.False(h.Runtime.TryGetInteractionEligibleEntity(RemoteWeapon, out _)); + Assert.False(h.Runtime.TryGetInteractionEligibleRecord(RemoteWeapon, out _)); + Assert.False(h.Runtime.TryGetInteractionEligibleRecord( + RemoteWeapon, + weapon.Id, + out _)); + Assert.DoesNotContain( + h.Runtime.VisibleRecords, + record => record.ServerGuid == RemoteWeapon); + Assert.Contains( + h.Runtime.VisibleRecords, + record => record.ServerGuid == Wielder); + + // Picking, and only picking, admits the same child. + Assert.True(h.Runtime.TryGetPickEligibleRecord(RemoteWeapon, out _)); + Assert.True(h.Runtime.TryGetPickEligibleRecord( + RemoteWeapon, + weapon.Id, + out _)); + Assert.False(h.Runtime.TryGetPickEligibleRecord( + RemoteWeapon, + weapon.Id + 1u, + out _)); + } + + /// + /// CPartArray::GetSelectionSphere @ 0x00518B80 scales the authored sphere + /// by the object's own part-array scale. An attached child carries that + /// scale on its spawn record, not on its parent-derived WorldEntity. + /// + [Fact] + public void EquippedChildSelectionSphereUsesItsOwnSpawnScale() + { + var h = new Harness(); + h.Add( + Wielder, + Vector3.Zero, + ItemType.Creature, + SelectedObjectHealthPolicy.BfAttackable); + h.AddAttached( + RemoteWeapon, + Vector3.Zero, + ItemType.MeleeWeapon, + wielderId: Wielder, + childRoot: Matrix4x4.CreateTranslation(7f, 8f, 9f), + objScale: 2f); + + Assert.True(h.Query.TryGetSelectionSphere( + RemoteWeapon, + out Vector3 center, + out float radius)); + + Assert.Equal(new Vector3(9f, 8f, 9f), center); + Assert.Equal(4f, radius); + } + 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 f201f112..dc484e14 100644 --- a/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs +++ b/tests/AcDream.App.Tests/Runtime/CurrentGameRuntimeAdapterTests.cs @@ -1291,6 +1291,7 @@ public sealed class CurrentGameRuntimeAdapterTests new(target, DistanceSquared: 4f); public bool IsUseable(uint serverGuid) => serverGuid == target; public bool IsPickupable(uint serverGuid) => false; + public bool IsWieldedByPlayer(uint serverGuid) => false; public bool TryGetApproach( uint serverGuid,