Slice 4 made a remote character's wielded weapon selectable, which made the
pickup chain reachable end to end for the first time: SelectionPickUp on
another player's weapon captured identity, passed ValidatePickupTarget (which
checked only the Stuck flag and the small-item mask, and a MeleeWeapon clears
both), installed a real non-autonomous approach through
PlayerInteractionMovementSink, and then sent a pickup request the server
rejects. Retail does none of that.
ItemHolder::AttemptToPlaceInContainer @ 0x00588140 runs
AttemptToPlaceInContainer_IsItemLegal @ 0x005870C0 first, at 0x00588173 --
ahead of container legality, auto-merge, the container walk, and the only
CM_Inventory::Event_PutItemInContainer emitter
(ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680). IsItemLegal's arm at
0x005872B7 rejects `!ACCWeenieObject::IsOwnedByPlayer(item) &&
item->pwd._location != 0` with one local
ECM_UI::SendNotice_DisplayStringInfo(0x1a, ...), and
CPlayerSystem::PlaceInBackpack @ 0x0055D8C0 then withdraws the waiting slot it
had published (SetWaitingState(obj, 0) + SendNotice_EndPendingInPlayer at
0x0055D918). No request, no movement. acdream had never ported that arm; it
was harmless while wielded children were unpickable and stopped being harmless
at f6db964f.
The notice is data_7e2228, "The %s is being wielded by someone else!" -- WITH
the exclamation mark. IsItemLegal's six strings occupy one contiguous literal
block, 0x007e21f0 through 0x007e234c, one per arm in reverse code order, and
the two neighbours already ported here (0x007e227c "The %s cannot be picked
up!" at 0x00587264, 0x007e22b4 "You cannot pick up creatures!" at 0x005871f4)
pin it. The punctuation-free 0x007cd350 variant belongs to the wield/wear
block and is emitted from a different function at 0x00560aef.
pwd._location is the PublicWeenieDesc CurrentWieldedLocation field
(acclient.h:37175), which acdream projects as
ClientObject.CurrentlyEquippedLocation, and ACCWeenieObject::IsOwnedByPlayer
@ 0x0058D160 is IsOwnedByObject(this, player_id) -- already ported as
ClientObjectTable.IsOwnedByObject @ 0x0058CEB0 and reached here through the
existing ItemInteractionController.IsOwnedByPlayer. The arm reads pwd._location
verbatim rather than adding a WielderId belt-and-braces test, because retail's
predicate is the thing being ported.
The player's OWN wielded item is IsOwnedByPlayer, so retail passes it and takes
a different route. ACCWeenieObject::DeterminePositionState @ 0x0058BE70 gives
it PositionState.WIELDED (acclient.h:6802) rather than IN_3D_VIEW, and
UIAttemptPutInContainer records IR_PICK_UP only for IN_3D_VIEW, treating
WIELDED and IN_CONTAINER alike as a plain IR_PUT_IN_CONTAINER transfer. So an
own-wielded item is unwielded in place: the request goes out immediately with
no approach, joining the existing current-ground-object shortcut. The shortcut
carries an ownership conjunct so it can never outrun the 0x005872B7 gate.
TryGetApproach now refuses attached children outright, for the same
IN_3D_VIEW reason. An Attached projection's bookkeeping WorldEntity.Position
carries the PARENT's composed root (EquippedChildRenderController
.ApplyParentWorldPose), not the child frame CPhysicsObj::UpdateChild @
0x00512D50 composes, so an approach built from it walked toward the wielder.
Slice 4 de-parented the marker anchor but left this one parent-derived; no
approach can anchor on a wielder now.
The pick predicates are deliberately untouched. Picking, selecting, examining,
lighting-pulse identity, and the vivid-marker anchor on a remote's wielded
weapon all behave exactly as Slice 4 shipped them -- retail's sr_Select and
sr_Examine branches of RecvNotice_SmartBoxObjectFound @ 0x004E5AD0 never
consult IsItemLegal. The gate is the transaction, not the pick.
f6db964f's message asserted the slice introduced no deviation and owed no
retail-divergence-register row. That was wrong: the unported 0x005872B7 arm
was a deviation it made reachable. This commit ports the arm in full, matches
retail on the own-wielded path, and removes the parent-derived approach
anchor, so the record is corrected here and no register row is owed.
Gates: dotnet build green; AcDream.App.Tests 3,960 passed / 3 skipped;
complete Release solution 9,792 passed / 5 skipped;
tools\run-connected-world-lifecycle-gate.ps1 -SkipBuild RESULT=PASS.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
968 lines
34 KiB
C#
968 lines
34 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,
|
|
EquipMask equippedLocation = EquipMask.MeleeWeapon)
|
|
{
|
|
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,
|
|
// CreateObject's PublicWeenieDesc carries CurrentWieldedLocation
|
|
// alongside Wielder for every equipped child; retail's
|
|
// pwd._location is what DeterminePositionState reads.
|
|
CurrentlyEquippedLocation = equippedLocation,
|
|
});
|
|
if (childRoot is { } root)
|
|
ChildRoots[entity.Id] = root;
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// <c>ACCWeenieObject::DeterminePositionState @ 0x0058BE70</c>:
|
|
/// BEING_REMOVED, else IN_CONTAINER when <c>pwd._containerID != 0</c>, else
|
|
/// <c>pwd._location != 0</c> selects WIELDED over IN_3D_VIEW. Both the
|
|
/// pickup-legality arm at <c>0x005872B7</c> and the IR_PICK_UP-vs-
|
|
/// IR_PUT_IN_CONTAINER split in
|
|
/// <c>ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680</c> read it.
|
|
/// </summary>
|
|
[Fact]
|
|
public void WieldedPositionStateFollowsRetailsContainerThenLocationOrder()
|
|
{
|
|
var h = new Harness();
|
|
const uint ground = 0x7000_0030u;
|
|
const uint stowed = 0x7000_0031u;
|
|
h.Add(ground, Vector3.UnitX, ItemType.MeleeWeapon);
|
|
h.Add(
|
|
Wielder,
|
|
new Vector3(0f, 0f, -10f),
|
|
ItemType.Creature,
|
|
SelectedObjectHealthPolicy.BfAttackable);
|
|
h.AddAttached(
|
|
RemoteWeapon,
|
|
new Vector3(0f, 0f, -10f),
|
|
ItemType.MeleeWeapon,
|
|
wielderId: Wielder,
|
|
childRoot: Matrix4x4.CreateTranslation(0f, 0f, -5f));
|
|
h.AddAttached(
|
|
OwnWeapon,
|
|
Vector3.Zero,
|
|
ItemType.MeleeWeapon,
|
|
wielderId: Player,
|
|
childRoot: Matrix4x4.CreateTranslation(0f, 0.5f, 1f));
|
|
h.Objects.AddOrUpdate(new ClientObject
|
|
{
|
|
ObjectId = stowed,
|
|
Name = "Stowed weapon",
|
|
Type = ItemType.MeleeWeapon,
|
|
ContainerId = Player,
|
|
CurrentlyEquippedLocation = EquipMask.MeleeWeapon,
|
|
});
|
|
|
|
Assert.True(h.Query.IsWieldedPositionState(RemoteWeapon));
|
|
Assert.True(h.Query.IsWieldedPositionState(OwnWeapon));
|
|
Assert.False(h.Query.IsWieldedPositionState(ground));
|
|
// IN_CONTAINER wins over a stale wielded location.
|
|
Assert.False(h.Query.IsWieldedPositionState(stowed));
|
|
// Unknown weenie: retail's GetWeenieObject miss rejects at 0x005870DC.
|
|
Assert.False(h.Query.IsWieldedPositionState(0x7000_00FFu));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The wielded-item pickup gate lives in
|
|
/// <c>ItemHolder::AttemptToPlaceInContainer_IsItemLegal @ 0x005870C0</c>,
|
|
/// NOT in the pick. Retail's pick is render-coupled and consults no
|
|
/// ownership or position state, so Slice 4's selection behavior stands.
|
|
/// </summary>
|
|
[Fact]
|
|
public void PickAndSelectionStayOpenOnARemotesWieldedItemTheGateWillRefuse()
|
|
{
|
|
var h = new Harness();
|
|
WorldEntity wielder = h.Add(
|
|
Wielder,
|
|
new Vector3(0f, 0f, -10f),
|
|
ItemType.Creature,
|
|
SelectedObjectHealthPolicy.BfAttackable);
|
|
WorldEntity weapon = h.AddAttached(
|
|
RemoteWeapon,
|
|
wielder.Position,
|
|
ItemType.MeleeWeapon,
|
|
wielderId: Wielder,
|
|
childRoot: Matrix4x4.CreateTranslation(0f, 0f, -5f));
|
|
h.PublishParts(
|
|
(wielder, wielder.Position),
|
|
(weapon, new Vector3(0f, 0f, -5f)));
|
|
|
|
Assert.Equal(RemoteWeapon, h.Query.PickAtCursor(includeSelf: false));
|
|
Assert.True(h.Query.TryCaptureIdentity(RemoteWeapon, out uint localId));
|
|
Assert.Equal(weapon.Id, localId);
|
|
Assert.True(h.Query.IsCurrent(RemoteWeapon, weapon.Id));
|
|
Assert.True(h.Query.TryGetInteractionTarget(RemoteWeapon, out _));
|
|
Assert.NotNull(h.Query.ResolveVividTargetInfo(RemoteWeapon));
|
|
Assert.Equal($"Object {RemoteWeapon:X8}", h.Query.Describe(RemoteWeapon));
|
|
|
|
// The pick predicates are untouched: the retail gate is the wielded
|
|
// position state combined with ownership, not pickability.
|
|
Assert.True(h.Query.IsPickupable(RemoteWeapon));
|
|
Assert.True(h.Query.IsWieldedPositionState(RemoteWeapon));
|
|
Assert.False(h.Objects.IsOwnedByObject(RemoteWeapon, Player));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The world-pickup approach belongs to <c>PositionState.IN_3D_VIEW</c>
|
|
/// alone (<c>ACCWeenieObject::UIAttemptPutInContainer @ 0x0058D680</c>).
|
|
/// An attached child's bookkeeping pose is its wielder's root, so an
|
|
/// approach built from it would walk the player to the wielder.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ApproachRefusesAnAttachedChildRatherThanAnchoringOnItsWielder()
|
|
{
|
|
var h = new Harness();
|
|
const uint ground = 0x7000_0032u;
|
|
var wielderRoot = new Vector3(20f, 0f, 0f);
|
|
h.Add(ground, new Vector3(3f, 0f, 0f), ItemType.MeleeWeapon);
|
|
h.Add(
|
|
Wielder,
|
|
wielderRoot,
|
|
ItemType.Creature,
|
|
SelectedObjectHealthPolicy.BfAttackable);
|
|
h.AddAttached(
|
|
RemoteWeapon,
|
|
wielderRoot,
|
|
ItemType.MeleeWeapon,
|
|
wielderId: Wielder,
|
|
childRoot: Matrix4x4.CreateTranslation(20f, 0f, 1.2f));
|
|
|
|
Assert.False(h.Query.TryGetApproach(RemoteWeapon, out _));
|
|
// The same call still succeeds for a genuine 3-D-view item.
|
|
Assert.True(h.Query.TryGetApproach(ground, out InteractionApproach loose));
|
|
Assert.Equal(ground, loose.Target.ServerGuid);
|
|
}
|
|
|
|
private static SelectionCameraSnapshot Camera()
|
|
{
|
|
Matrix4x4 view = Matrix4x4.CreateLookAt(
|
|
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);
|
|
}
|
|
}
|