feat(interaction): Slice 4 - equipped-child world picking

A click on a remote character's wielded weapon reported nothing. The picker
was already correct: RetailSelectionScene publishes every drawn part under its
own live-entity server GUID and RetailWorldPicker returns the weapon as the
polygon winner. The failure was downstream eligibility - WorldSelectionQuery
required TryGetInteractionEligibleRecord, whose _visible set admits
LiveEntityProjectionKind.World only, so the winning hit was discarded.

Retail has no such gate. Render::GfxObjUnderSelectionRay @ 0x0054C740
accumulates each hit under the drawn part's own physics-object id
(CPhysicsPart::get_physobj_id @ 0x0050D490), and CPhysicsPart::Draw @
0x0050D7A0 admits any drawn part whose physobj id is nonzero. An equipped item
is a first-class CPhysicsObj with its own id and part array
(CPhysicsObj::add_child @ 0x0050F870 via CSetup::GetHoldingLocation @
0x005213F0). There is no parent redirection and no wielded-specific rule, so a
click on a wielded weapon returns THE WEAPON'S GUID. PositionState.WIELDED is
distinct from IN_CONTAINER (acclient.h:6802), so container suppression never
hid a wielded selection either.

LiveEntityRuntime gains two scoped predicates: TryGetAttachedProjectedRecord
(a current Attached projection that is spatially projected) and
TryGetPickEligibleRecord (that arm plus today's World visible-set arm, with
the same WorldEntity.Id staleness recheck). TryGetInteractionEligibleRecord
and the _visible set are deliberately NOT widened - they feed radar,
auto-target, sticky/MoveTo establishment, and CombatAttackTargetSource, and
retail's radar has no wielded blips. A regression test asserts an attached
child stays out of that set while picking admits it.

Marker anchoring had the twin problem. SmartBox::GetObjectBoundingBox @
0x00452E20 pushes the picked object's OWN m_position - which for a child is
the frame CPhysicsObj::UpdateChild @ 0x00512D50 recomposes each tick as
Frame::combine(parent part frame, holding frame) - and
CPartArray::GetSelectionSphere @ 0x00518B80 scales the authored sphere by that
object's own part-array scale. acdream stores the PARENT's root in the child
projection's Position/Rotation because the child's MeshRefs are
parent-relative, which put the vivid brackets at the wielder's feet. The
composed child root is already published per frame to EntityEffectPoseRegistry
by EquippedChildRenderController.PublishChildPose, so selection now borrows it
through an injected Func<uint, Matrix4x4?> wired in LivePresentationComposition
beside the existing selection-sphere hook. There is no parent fallback: a child
with no published composed root has no live frame this tick and no sphere. Its
part-array scale comes from the spawn record, the same source
EquippedChildRenderController.TryRealize reads, because an Attached WorldEntity
carries the parent-derived pose rather than its own ObjScale.

The sr_Use branch of RecvNotice_SmartBoxObjectFound @ 0x004E5AD0 guards
ItemHolder::UseObject with `found->pwd._wielderID != SmartBox::player_id` at
0x004E5BE9 while still selecting and flashing. Equipped-child picking makes
that click reachable, so the gate ships with it as
IWorldSelectionQuery.IsWieldedByPlayer.

CPhysicsObj::SetLighting @ 0x00511A80 is non-recursive, so the pulse lights the
clicked object's own part array only - clicking a weapon never flashes its
wielder. That follows from routing the pulse identity through the same
predicate.

RetailWorldPicker, RetailSelectionScene, WbDrawDispatcher, and
EquippedChildRenderController are untouched, as are all wire and physics paths.

The slice REMOVES an undocumented deviation (Attached projections excluded
from pick eligibility versus retail's part-id pick) and introduces none, so no
retail-divergence-register row is owed in either direction.

Gates: dotnet build green; AcDream.App.Tests 3,951 passed / 3 skipped;
complete Release solution 9,783 passed / 5 skipped;
tools\run-connected-world-lifecycle-gate.ps1 RESULT=PASS.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-29 18:30:25 +02:00
parent 9fdfe68c7f
commit f6db964fd5
9 changed files with 570 additions and 25 deletions

View file

@ -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<uint, Matrix4x4?>` 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

View file

@ -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,

View file

@ -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,

View file

@ -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<PlayerInteractionPose?> _playerPose;
private readonly Func<uint, WorldEntity, (float Radius, float Height)> _setupCylinder;
private readonly Func<uint, (Vector3 Origin, float Radius)?> _selectionSphere;
private readonly Func<uint, Matrix4x4?> _childRootPose;
public WorldSelectionQuery(
LiveEntityRuntime liveEntities,
@ -115,7 +117,8 @@ internal sealed class WorldSelectionQuery
Func<Vector2> cursor,
Func<PlayerInteractionPose?> playerPose,
Func<uint, WorldEntity, (float Radius, float Height)> setupCylinder,
Func<uint, (Vector3 Origin, float Radius)?> selectionSphere)
Func<uint, (Vector3 Origin, float Radius)?> selectionSphere,
Func<uint, Matrix4x4?> 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
}
/// <summary>
/// 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
/// <c>m_position</c> (<c>Render::positionPush(3, &amp;obj-&gt;m_position)</c>)
/// 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.
/// </summary>
/// <remarks>
/// For an equipped child the object's own m_position is the frame
/// CPhysicsObj::UpdateChild @ 0x00512D50 composes each tick as
/// <c>Frame::combine(parent part frame, holding frame)</c>. 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.
/// </remarks>
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;
}
/// <summary>
/// Retail's sr_Use branch of
/// <c>UIElement_SmartBoxWrapper::RecvNotice_SmartBoxObjectFound @
/// 0x004E5AD0</c> compares the found object's <c>pwd._wielderID</c> against
/// <c>SmartBox::player_id</c> at <c>0x004E5BE9</c> and calls
/// <c>ItemHolder::UseObject</c> only when they differ. Selection and the
/// click lighting pulse still happen for the player's own wielded item.
/// </summary>
public bool IsWieldedByPlayer(uint serverGuid)
{
uint playerGuid = _playerGuid();
return playerGuid != 0u
&& _objects.Get(serverGuid) is { } item
&& item.WielderId == playerGuid;
}
/// <summary>ItemHolder::DetermineUseResult @ 0x00588460 pickup gate.</summary>
public bool IsPickupable(uint serverGuid)
{

View file

@ -1178,7 +1178,77 @@ public sealed class LiveEntityRuntime : ILiveEntityRadarSource
}
/// <summary>
/// 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
/// <c>CPhysicsObj</c> with its own object id and part array
/// (<c>CPhysicsObj::add_child @ 0x0050F870</c> via
/// <c>CSetup::GetHoldingLocation @ 0x005213F0</c>), and
/// <c>CPhysicsObj::UpdateChild @ 0x00512D50</c> recomposes
/// <c>Frame::combine(parent part frame, holding frame)</c> into that child's
/// own <c>m_position</c> every frame. An attached projection therefore has
/// real world presence even though it is deliberately absent from the
/// interaction/radar/auto-target visible set.
/// </summary>
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;
}
/// <summary>
/// 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" (<c>CPhysicsPart::Draw @ 0x0050D7A0</c> guards
/// on <c>CPhysicsPart::get_physobj_id @ 0x0050D490</c>, and
/// <c>Render::GfxObjUnderSelectionRay @ 0x0054C740</c> 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.
/// </summary>
public bool TryGetPickEligibleRecord(
uint serverGuid,
out LiveEntityRecord record)
=> TryGetInteractionEligibleRecord(serverGuid, out record)
|| TryGetAttachedProjectedRecord(serverGuid, out record);
/// <summary>
/// Pick eligibility bound to one logical incarnation. A stale published
/// frame must never retarget a replacement which reused the server GUID.
/// </summary>
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;
}
/// <summary>
/// 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.
/// </summary>

View file

@ -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;

View file

@ -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);
}
/// <summary>
/// The sr_Use branch of RecvNotice_SmartBoxObjectFound @ 0x004E5AD0 guards
/// ItemHolder::UseObject with
/// <c>if (found-&gt;pwd._wielderID != SmartBox::player_id)</c> at
/// 0x004E5BE9. Selection and the click pulse still happen.
/// </summary>
[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()
{

View file

@ -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);
/// <summary>
/// Stands in for EntityEffectPoseRegistry: the composed equipped-child
/// root EquippedChildRenderController.PublishChildPose emits per frame.
/// </summary>
public readonly Dictionary<uint, Matrix4x4> 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;
}
/// <summary>
/// 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.
/// </summary>
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));
foreach ((WorldEntity entity, Vector3 partWorld) in parts)
{
Scene.AddVisiblePart(
entity,
0,
0x0100_0001u,
Matrix4x4.CreateTranslation(entity.Position));
Matrix4x4.CreateTranslation(partWorld));
}
Scene.CompleteFrame();
}
}
@ -388,6 +450,299 @@ public sealed class WorldSelectionQueryTests
Assert.Null(h.Query.ResolveVividTargetInfo(Target));
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// Retail's pick keeps only the closest winner; there is no second-choice
/// walk to the wielder when the winner is gone.
/// </summary>
[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));
}
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// No parent fallback: a child with no composed root published this tick
/// has no live frame of its own and therefore no selection sphere.
/// </summary>
[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 _));
}
/// <summary>
/// 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.
/// </summary>
[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));
}
/// <summary>
/// CPhysicsObj::SetLighting @ 0x00511A80 is non-recursive: it lights the
/// clicked object's own part array only, so clicking a weapon never
/// flashes its wielder.
/// </summary>
[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 _));
}
/// <summary>
/// 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.
/// </summary>
[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 _));
}
/// <summary>
/// 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.
/// </summary>
[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(

View file

@ -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,