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>
842 lines
29 KiB
C#
842 lines
29 KiB
C#
using System.Numerics;
|
|
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;
|
|
using AcDream.Core.Net;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.Selection;
|
|
using AcDream.Core.World;
|
|
using DatReaderWriter.DBObjs;
|
|
|
|
namespace AcDream.App.Tests.Interaction;
|
|
|
|
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
|
|
{
|
|
public void Register(WorldEntity entity) { }
|
|
public void Unregister(WorldEntity entity) { }
|
|
}
|
|
|
|
private sealed class GeometrySource(RetailSelectionMesh mesh)
|
|
: IRetailSelectionGeometrySource
|
|
{
|
|
public RetailSelectionMesh? Resolve(uint gfxObjId) => mesh;
|
|
}
|
|
|
|
private sealed record Animation(WorldEntity Entity, uint CurrentMotion)
|
|
: ILiveEntityAnimationRuntime;
|
|
|
|
private sealed class Harness
|
|
{
|
|
public readonly ClientObjectTable Objects = new();
|
|
public readonly LiveEntityRuntime Runtime;
|
|
public readonly RetailSelectionScene Scene;
|
|
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();
|
|
spatial.AddLandblock(new LoadedLandblock(
|
|
0x0101_FFFFu,
|
|
new LandBlock(),
|
|
Array.Empty<WorldEntity>()));
|
|
Runtime = LiveEntityRuntimeFixture.Create(spatial, new Resources());
|
|
Scene = new RetailSelectionScene(new GeometrySource(Mesh()));
|
|
Query = new WorldSelectionQuery(
|
|
Runtime,
|
|
Objects,
|
|
Scene,
|
|
() => Player,
|
|
Camera,
|
|
() => new Vector2(400f, 300f),
|
|
() => PlayerPose,
|
|
(_, _) => (0.5f, 2f),
|
|
_ => (new Vector3(1f, 0f, 0f), 2f),
|
|
localEntityId => ChildRoots.TryGetValue(localEntityId, out Matrix4x4 root)
|
|
? root
|
|
: null);
|
|
|
|
Add(Player, Vector3.Zero, ItemType.Creature, SelectedObjectHealthPolicy.BfPlayer);
|
|
}
|
|
|
|
public WorldEntity Add(
|
|
uint guid,
|
|
Vector3 position,
|
|
ItemType type,
|
|
uint publicFlags = 0u,
|
|
uint? useability = null,
|
|
uint objectDescriptionFlags = 0u,
|
|
ushort instance = 1,
|
|
float scale = 1f,
|
|
Quaternion? rotation = null)
|
|
{
|
|
WorldSession.EntitySpawn spawn = Spawn(guid, instance) with
|
|
{
|
|
ItemType = (uint)type,
|
|
Useability = useability,
|
|
ObjectDescriptionFlags = objectDescriptionFlags,
|
|
};
|
|
Runtime.RegisterLiveEntity(spawn);
|
|
WorldEntity entity = Runtime.MaterializeLiveEntity(
|
|
guid,
|
|
0x0101_0001u,
|
|
id => Entity(id, guid, position, scale, rotation ?? Quaternion.Identity))!;
|
|
Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = guid,
|
|
Name = $"Object {guid:X8}",
|
|
Type = type,
|
|
PublicWeenieBitfield = publicFlags,
|
|
});
|
|
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(partWorld));
|
|
}
|
|
Scene.CompleteFrame();
|
|
}
|
|
}
|
|
|
|
[Fact]
|
|
public void PickRejectsPublishedPartAfterGuidWasReused()
|
|
{
|
|
var h = new Harness();
|
|
WorldEntity oldTarget = h.Add(Target, new Vector3(0f, 0f, -5f), ItemType.Misc);
|
|
h.Publish(oldTarget);
|
|
Assert.Equal(Target, h.Query.PickAtCursor(includeSelf: false));
|
|
|
|
Assert.True(h.Runtime.UnregisterLiveEntity(
|
|
new DeleteObject.Parsed(Target, 1),
|
|
isLocalPlayer: false));
|
|
WorldEntity replacement = h.Add(
|
|
Target,
|
|
new Vector3(0f, 0f, -5f),
|
|
ItemType.Misc,
|
|
instance: 1);
|
|
|
|
Assert.NotEqual(oldTarget.Id, replacement.Id);
|
|
Assert.Null(h.Query.PickAtCursor(includeSelf: false));
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0)]
|
|
[InlineData(1)]
|
|
[InlineData(2)]
|
|
public void PickRejectsPublishedPartAfterVisibilityLifetimeEnds(int transition)
|
|
{
|
|
var h = new Harness();
|
|
WorldEntity target = h.Add(Target, new Vector3(0f, 0f, -5f), ItemType.Misc);
|
|
h.Publish(target);
|
|
Assert.Equal(Target, h.Query.PickAtCursor(includeSelf: false));
|
|
|
|
switch (transition)
|
|
{
|
|
case 0:
|
|
Assert.True(h.Runtime.TryApplyState(
|
|
new SetState.Parsed(
|
|
Target,
|
|
(uint)(PhysicsStateFlags.ReportCollisions | PhysicsStateFlags.Hidden),
|
|
InstanceSequence: 1,
|
|
StateSequence: 2),
|
|
out _));
|
|
break;
|
|
case 1:
|
|
Assert.True(h.Runtime.WithdrawLiveEntityProjection(Target));
|
|
break;
|
|
default:
|
|
Assert.True(h.Runtime.UnregisterLiveEntity(
|
|
new DeleteObject.Parsed(Target, 1),
|
|
isLocalPlayer: false));
|
|
break;
|
|
}
|
|
|
|
Assert.Null(h.Query.PickAtCursor(includeSelf: false));
|
|
}
|
|
|
|
[Fact]
|
|
public void ClosestTargetIncludesOnlyVisibleLivingHostileMonsters()
|
|
{
|
|
var h = new Harness();
|
|
h.Add(
|
|
0x7000_0010u,
|
|
new Vector3(8f, 0f, 0f),
|
|
ItemType.Creature,
|
|
SelectedObjectHealthPolicy.BfAttackable);
|
|
h.Add(
|
|
0x7000_0011u,
|
|
new Vector3(2f, 0f, 0f),
|
|
ItemType.Creature,
|
|
publicFlags: 0u);
|
|
h.Add(
|
|
0x7000_0012u,
|
|
new Vector3(1f, 0f, 0f),
|
|
ItemType.Misc,
|
|
SelectedObjectHealthPolicy.BfAttackable);
|
|
h.Add(
|
|
0x7000_0013u,
|
|
new Vector3(3f, 0f, 0f),
|
|
ItemType.Creature,
|
|
SelectedObjectHealthPolicy.BfAttackable | SelectedObjectHealthPolicy.BfPlayer);
|
|
WorldEntity pet = h.Add(
|
|
0x7000_0014u,
|
|
new Vector3(4f, 0f, 0f),
|
|
ItemType.Creature,
|
|
SelectedObjectHealthPolicy.BfAttackable);
|
|
h.Objects.Get(pet.ServerGuid)!.PetOwnerId = Player;
|
|
WorldEntity dead = h.Add(
|
|
0x7000_0015u,
|
|
new Vector3(5f, 0f, 0f),
|
|
ItemType.Creature,
|
|
SelectedObjectHealthPolicy.BfAttackable);
|
|
h.Runtime.SetAnimationRuntime(
|
|
dead.ServerGuid,
|
|
new Animation(dead, MotionCommand.Dead));
|
|
WorldEntity hidden = h.Add(
|
|
0x7000_0016u,
|
|
new Vector3(6f, 0f, 0f),
|
|
ItemType.Creature,
|
|
SelectedObjectHealthPolicy.BfAttackable);
|
|
Assert.True(h.Runtime.TryApplyState(
|
|
new SetState.Parsed(
|
|
hidden.ServerGuid,
|
|
(uint)(PhysicsStateFlags.ReportCollisions | PhysicsStateFlags.Hidden),
|
|
InstanceSequence: 1,
|
|
StateSequence: 2),
|
|
out _));
|
|
WorldEntity pending = h.Add(
|
|
0x7000_0017u,
|
|
new Vector3(7f, 0f, 0f),
|
|
ItemType.Creature,
|
|
SelectedObjectHealthPolicy.BfAttackable);
|
|
Assert.True(h.Runtime.WithdrawLiveEntityProjection(pending.ServerGuid));
|
|
|
|
ClosestCombatTarget? closest = h.Query.FindClosestHostileMonster();
|
|
|
|
Assert.Equal(0x7000_0010u, closest?.ServerGuid);
|
|
Assert.Equal(64f, closest?.DistanceSquared);
|
|
}
|
|
|
|
[Theory]
|
|
[InlineData(0.59f, true)]
|
|
[InlineData(0.61f, false)]
|
|
[InlineData(7.49f, false)]
|
|
[InlineData(7.50f, true)]
|
|
public void ApproachUsesRetailGroundItemRadiusAndAceChargeBoundary(
|
|
float distance,
|
|
bool expectedBoundary)
|
|
{
|
|
var h = new Harness();
|
|
h.Add(Target, new Vector3(distance, 0f, 0f), ItemType.Misc);
|
|
|
|
Assert.True(h.Query.TryGetApproach(Target, out InteractionApproach approach));
|
|
|
|
if (distance < 1f)
|
|
Assert.Equal(expectedBoundary, approach.IsCloseRange);
|
|
else
|
|
Assert.Equal(expectedBoundary, approach.CanCharge);
|
|
Assert.Equal(0.6f, approach.UseRadius);
|
|
Assert.Equal(0.5f, approach.TargetRadius);
|
|
Assert.Equal(2f, approach.TargetHeight);
|
|
}
|
|
|
|
[Fact]
|
|
public void PickupAndUseabilityPreserveIndependentRetailGates()
|
|
{
|
|
var h = new Harness();
|
|
const uint stuckComponent = 0x7000_0020u;
|
|
const uint looseComponent = 0x7000_0021u;
|
|
h.Add(
|
|
stuckComponent,
|
|
Vector3.UnitX,
|
|
ItemType.SpellComponents,
|
|
useability: 1u,
|
|
objectDescriptionFlags: 0x0004u);
|
|
h.Add(
|
|
looseComponent,
|
|
Vector3.UnitX * 2f,
|
|
ItemType.SpellComponents,
|
|
useability: 1u);
|
|
|
|
Assert.False(h.Query.IsUseable(stuckComponent));
|
|
Assert.False(h.Query.IsPickupable(stuckComponent));
|
|
Assert.False(h.Query.IsUseable(looseComponent));
|
|
Assert.True(h.Query.IsPickupable(looseComponent));
|
|
}
|
|
|
|
[Fact]
|
|
public void UseabilityUsesRetailLowNoBitForAbsentAndExplicitValues()
|
|
{
|
|
var h = new Harness();
|
|
const uint absent = 0x7000_0022u;
|
|
const uint zero = 0x7000_0023u;
|
|
const uint no = 0x7000_0024u;
|
|
const uint neverWalk = 0x7000_0025u;
|
|
h.Add(absent, Vector3.UnitX, ItemType.Gem);
|
|
h.Add(zero, Vector3.UnitX * 2f, ItemType.Gem, useability: 0u);
|
|
h.Add(no, Vector3.UnitX * 3f, ItemType.Gem, useability: ItemUseability.No);
|
|
h.Add(
|
|
neverWalk,
|
|
Vector3.UnitX * 4f,
|
|
ItemType.Gem,
|
|
useability: ItemUseability.NeverWalk);
|
|
|
|
Assert.True(h.Query.IsUseable(absent));
|
|
Assert.True(h.Query.IsUseable(zero));
|
|
Assert.False(h.Query.IsUseable(no));
|
|
Assert.True(h.Query.IsUseable(neverWalk));
|
|
}
|
|
|
|
[Fact]
|
|
public void SelectionSphereAppliesSetupOffsetScaleAndRotation()
|
|
{
|
|
var h = new Harness();
|
|
h.Add(
|
|
Target,
|
|
new Vector3(10f, 20f, 3f),
|
|
ItemType.Misc,
|
|
scale: 2f,
|
|
rotation: Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI / 2f));
|
|
|
|
Assert.True(h.Query.TryGetSelectionSphere(Target, out Vector3 center, out float radius));
|
|
|
|
Assert.True(Vector3.Distance(new Vector3(10f, 22f, 3f), center) < 0.0001f);
|
|
Assert.Equal(4f, radius);
|
|
}
|
|
|
|
[Fact]
|
|
public void VividTargetMarkerWaitsForFreshProjectionWhenDropMovePrecedesPosition()
|
|
{
|
|
var h = new Harness();
|
|
WorldEntity entity =
|
|
h.Add(Target, new Vector3(3f, 4f, 0f), ItemType.Misc);
|
|
|
|
Assert.NotNull(h.Query.ResolveVividTargetInfo(Target));
|
|
|
|
Assert.True(h.Objects.MoveItem(Target, Player, newSlot: 0));
|
|
Assert.True(h.Runtime.WithdrawLiveEntityProjection(Target));
|
|
Assert.Null(h.Query.ResolveVividTargetInfo(Target));
|
|
|
|
// ACE may confirm InventoryPutObjectIn3D before sending the fresh
|
|
// Position. The retained entity still has the old ground pose here.
|
|
Assert.True(h.Objects.MoveItem(Target, 0u, newSlot: -1));
|
|
Assert.Null(h.Query.ResolveVividTargetInfo(Target));
|
|
|
|
var newPosition = new Vector3(30f, 40f, 2f);
|
|
entity.SetPosition(newPosition);
|
|
Assert.True(h.Runtime.RebucketLiveEntity(Target, 0x0101_0001u));
|
|
|
|
var marker = h.Query.ResolveVividTargetInfo(Target);
|
|
Assert.NotNull(marker);
|
|
Assert.Equal(newPosition + Vector3.UnitX, marker.Value.SelectionSphereCenter);
|
|
}
|
|
|
|
[Fact]
|
|
public void VividTargetMarkerUsesFreshPoseWhenPositionPrecedesDropMove()
|
|
{
|
|
var h = new Harness();
|
|
WorldEntity entity =
|
|
h.Add(Target, new Vector3(3f, 4f, 0f), ItemType.Misc);
|
|
Assert.True(h.Objects.MoveItem(Target, Player, newSlot: 0));
|
|
Assert.True(h.Runtime.WithdrawLiveEntityProjection(Target));
|
|
|
|
var newPosition = new Vector3(30f, 40f, 2f);
|
|
entity.SetPosition(newPosition);
|
|
Assert.True(h.Runtime.RebucketLiveEntity(Target, 0x0101_0001u));
|
|
|
|
// The spatial pose is current, but ownership continues to suppress the
|
|
// marker until ServerSaysMoveItem publishes the world placement.
|
|
Assert.Null(h.Query.ResolveVividTargetInfo(Target));
|
|
|
|
Assert.True(h.Objects.MoveItem(Target, 0u, newSlot: -1));
|
|
var marker = h.Query.ResolveVividTargetInfo(Target);
|
|
Assert.NotNull(marker);
|
|
Assert.Equal(newPosition + Vector3.UnitX, marker.Value.SelectionSphereCenter);
|
|
}
|
|
|
|
[Fact]
|
|
public void VividTargetMarkerRejectsRetainedProjectionInsideExternalContainer()
|
|
{
|
|
var h = new Harness();
|
|
const uint corpse = 0x7000_0040u;
|
|
h.Add(Target, new Vector3(3f, 4f, 0f), ItemType.Misc);
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = corpse,
|
|
Name = "Corpse",
|
|
Type = ItemType.Container,
|
|
});
|
|
|
|
Assert.True(h.Objects.MoveItem(Target, corpse, newSlot: 0));
|
|
|
|
Assert.False(h.Objects.IsOwnedByObject(Target, Player));
|
|
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(
|
|
Vector3.Zero,
|
|
-Vector3.UnitZ,
|
|
Vector3.UnitY);
|
|
Matrix4x4 projection = Matrix4x4.CreatePerspectiveFieldOfView(
|
|
MathF.PI / 3f,
|
|
4f / 3f,
|
|
0.1f,
|
|
100f);
|
|
return new SelectionCameraSnapshot(view, projection, new Vector2(800f, 600f));
|
|
}
|
|
|
|
private static RetailSelectionMesh Mesh()
|
|
=> new(
|
|
Vector3.Zero,
|
|
2f,
|
|
[new RetailSelectionPolygon(
|
|
[
|
|
new(-1f, -1f, 0f),
|
|
new( 1f, -1f, 0f),
|
|
new( 1f, 1f, 0f),
|
|
new(-1f, 1f, 0f),
|
|
],
|
|
SingleSided: false)]);
|
|
|
|
private static WorldEntity Entity(
|
|
uint id,
|
|
uint guid,
|
|
Vector3 position,
|
|
float scale,
|
|
Quaternion rotation)
|
|
=> new()
|
|
{
|
|
Id = id,
|
|
ServerGuid = guid,
|
|
SourceGfxObjOrSetupId = 0x0200_0001u,
|
|
Position = position,
|
|
Rotation = rotation,
|
|
Scale = scale,
|
|
MeshRefs = [],
|
|
};
|
|
|
|
private static WorldSession.EntitySpawn Spawn(uint guid, ushort instance)
|
|
{
|
|
var position = new CreateObject.ServerPosition(
|
|
0x0101_0001u,
|
|
10f,
|
|
10f,
|
|
5f,
|
|
1f,
|
|
0f,
|
|
0f,
|
|
0f);
|
|
var physics = new PhysicsSpawnData(
|
|
RawState: (uint)PhysicsStateFlags.ReportCollisions,
|
|
Position: position,
|
|
Movement: null,
|
|
AnimationFrame: null,
|
|
SetupTableId: 0x0200_0001u,
|
|
MotionTableId: 0x0900_0001u,
|
|
SoundTableId: null,
|
|
PhysicsScriptTableId: null,
|
|
Parent: null,
|
|
Children: null,
|
|
Scale: null,
|
|
Friction: null,
|
|
Elasticity: null,
|
|
Translucency: null,
|
|
Velocity: null,
|
|
Acceleration: null,
|
|
AngularVelocity: null,
|
|
DefaultScriptType: null,
|
|
DefaultScriptIntensity: null,
|
|
Timestamps: new PhysicsTimestamps(1, 1, 1, 1, 0, 1, 0, 1, instance));
|
|
return new WorldSession.EntitySpawn(
|
|
guid,
|
|
position,
|
|
0x0200_0001u,
|
|
[],
|
|
[],
|
|
[],
|
|
null,
|
|
null,
|
|
"fixture",
|
|
null,
|
|
null,
|
|
0x0900_0001u,
|
|
PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
|
|
InstanceSequence: instance,
|
|
MovementSequence: 1,
|
|
ServerControlSequence: 1,
|
|
PositionSequence: 1,
|
|
Physics: physics);
|
|
}
|
|
}
|