fix(physics): #297 — keep the PWD bitfield live so PK status reaches the client
The user typed @pklite and then walked straight through other PKLite players. Root cause: ClientObject.PublicWeenieBitfield was written exactly once, from the 0xF745 CreateObject parse, and never refreshed. ACE's only PK-change message is PropertyInt.PlayerKillerStatus (134) over 0x02CE/0x02CD, which we parsed and stored into Properties.Ints[134] but never translated back into the bitfield — and ACE never re-sends a PublicWeenieDesc at all (EnqueueBroadcastUpdateObject has zero live callers), so that property is the ONLY signal a client can learn from. Both sides of the collision test read the frozen value, so CollisionExemption's "4c. both PKLite -> collide" rule could never fire. Retail's missing port: PublicWeenieDesc::SetPlayerKillerStatus @0x005AC7C0 rewrites _bitfield in place — PK(4) -> (b & 0xfddfffff) | 0x20; PKLite(0x40) -> (b & 0xffdfffdf) | 0x2000000; Free(0x20) -> (b & 0xfdffffdf) | 0x200000; else b &= 0xfddfffdf. Mutually exclusive, verified byte-for-byte, with input values confirmed against retail's own PKStatusEnum (acclient.h:6412-6427), not just ACE's. Driven from ACCWeenieObject::OnStatUpdated @0x0058DF20 case 0x86. The fix rewrites the value at its source rather than patching consumers. Two review rounds were needed because the first pass missed that there are TWO snapshot stores: InboundPhysicsStateController keeps its own private _snapshots dictionary, and every untimestamped-field merge (ApplyAcceptedObjDesc and friends) reads `old` from THAT store, not from RuntimeEntityRecord.Snapshot. Refreshing only the active record left the target-side shadow flags correct until the remote's next equip or unequip — ACE broadcasts an ObjDesc on every one — at which point the appearance path rebuilt the registration from the frozen spawn and dropped the bit permanently. The regression test demanded by review is what surfaced that; it is verified discriminating (reverting gives Actual: 8 instead of 33554440). Five stores now hold this value, kept coherent from one source by two ObjectUpdated subscribers plus the appearance-rebuild path. The two shadow-flag writers are the same invalidation applied at the two edges that can invalidate it, not competing authorities — review enumerated every drift path and closed each. That coherence invariant is new as of this commit and is recorded as register row AP-134, with AP-133 as the precedent for filing a row when the danger is a future writer rather than current behaviour. Also corrects TS-23's retirement narrative, which claimed every mover-flags call site read the mover's "real" PK bits from 2026-07-30. The bits existed but their source was frozen, so that only became true here; the site enumeration also missed RuntimeSetPositionMoverPreparation, a seventh site that decodes the snapshot directly. Unblocks #298 (melee/missile admission needs the local player's own PKLite bit). Follow-ups filed: #300 (Properties.Ints[134] vs bitfield mirror gap), #301 (same defect class for radar blip colour and radar behaviour), #302 (a pre-existing PortalProjection allocation-assertion flake, 1 in 6, found while verifying this gate), #303 (LiveEntityPvpBitfieldSync is App-resident but Runtime-owned-state). Gates: complete Release solution 10,895 passed / 4 skipped / 0 failed (baseline 10,887 including #299). Adversarial + retail-conformance review PASS after one FAIL round. Every new test discrimination-verified by reverting the fix. Connected acceptance NOT run — needs a live two-client PKLite session. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
88348f6791
commit
9b1e6fc637
19 changed files with 1329 additions and 5 deletions
|
|
@ -0,0 +1,208 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Physics;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
||||
namespace AcDream.App.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// #297 second edge: <see cref="LiveEntityPvpBitfieldSync"/> keeps a live
|
||||
/// entity's <see cref="ShadowObjectRegistry"/> collision flags synced with
|
||||
/// the <see cref="ClientObjectTable"/>'s live
|
||||
/// <see cref="ClientObject.PublicWeenieBitfield"/> after a
|
||||
/// PropertyInt(PlayerKillerStatus) update, instead of staying frozen at
|
||||
/// whatever the spawn-time CreateObject captured.
|
||||
/// </summary>
|
||||
public sealed class LiveEntityPvpBitfieldSyncTests
|
||||
{
|
||||
private sealed class RecordingResources : ILiveEntityResourceLifecycle
|
||||
{
|
||||
public void Register(WorldEntity entity) { }
|
||||
public void Unregister(WorldEntity entity) { }
|
||||
}
|
||||
|
||||
private static WorldEntity Entity(uint id, uint guid) => new()
|
||||
{
|
||||
Id = id,
|
||||
ServerGuid = guid,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
};
|
||||
|
||||
// Mirrors LiveEntityRuntimeTests.Spawn's proven Register+Materialize
|
||||
// shape verbatim: a PhysicsSpawnData block whose timestamps/position
|
||||
// agree exactly with the flattened EntitySpawn fields is required by
|
||||
// RuntimeEntityObjectLifetime.HasConsistentCreateIdentityAndParent.
|
||||
private static WorldSession.EntitySpawn Spawn(uint guid, uint cell)
|
||||
{
|
||||
var position = new CreateObject.ServerPosition(cell, 10f, 10f, 5f, 1f, 0f, 0f, 0f);
|
||||
var timestamps = new PhysicsTimestamps(1, 1, 1, 1, 0, 1, 0, 1, 1);
|
||||
var physics = new PhysicsSpawnData(
|
||||
RawState: (uint)PhysicsStateFlags.ReportCollisions,
|
||||
Position: position,
|
||||
Movement: null,
|
||||
AnimationFrame: null,
|
||||
SetupTableId: 0x02000001u,
|
||||
MotionTableId: 0x09000001u,
|
||||
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: timestamps);
|
||||
return new WorldSession.EntitySpawn(
|
||||
guid,
|
||||
position,
|
||||
0x02000001u,
|
||||
Array.Empty<CreateObject.AnimPartChange>(),
|
||||
Array.Empty<CreateObject.TextureChange>(),
|
||||
Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||
null,
|
||||
null,
|
||||
"fixture",
|
||||
(uint)ItemType.Creature,
|
||||
null,
|
||||
0x09000001u,
|
||||
PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
|
||||
InstanceSequence: 1,
|
||||
MovementSequence: 1,
|
||||
ServerControlSequence: 1,
|
||||
PositionSequence: 1,
|
||||
Physics: physics);
|
||||
}
|
||||
|
||||
/// <summary>Registers and materializes one live entity, mirroring
|
||||
/// <c>LiveEntityRuntimeTests.RegisterRebucketWithdrawAndRestore_UsesOneLogicalCreate</c>'s
|
||||
/// proven Register+Materialize sequence.</summary>
|
||||
private static (LiveEntityRuntime Runtime, WorldEntity Entity) MaterializedEntity(uint guid)
|
||||
{
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(new LoadedLandblock(
|
||||
0x0101FFFFu, new LandBlock(), Array.Empty<WorldEntity>()));
|
||||
var runtime = LiveEntityRuntimeFixture.Create(spatial, new RecordingResources());
|
||||
WorldSession.EntitySpawn spawn = Spawn(guid, cell: 0x01010001u);
|
||||
|
||||
runtime.RegisterLiveEntity(spawn);
|
||||
WorldEntity? entity = runtime.MaterializeLiveEntity(
|
||||
spawn.Guid,
|
||||
spawn.Position!.Value.LandblockId,
|
||||
id => Entity(id, spawn.Guid));
|
||||
return (runtime, entity!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObjectUpdated_WithLiveBitfield_RefreshesRegisteredTargetFlags()
|
||||
{
|
||||
(LiveEntityRuntime runtime, WorldEntity entity) = MaterializedEntity(0x70000010u);
|
||||
|
||||
var shadows = new ShadowObjectRegistry();
|
||||
shadows.Register(
|
||||
entity.Id,
|
||||
0x01000005u,
|
||||
new Vector3(12f, 12f, 50f),
|
||||
Quaternion.Identity,
|
||||
1f,
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f,
|
||||
landblockId: 0xA9B40000u,
|
||||
flags: EntityCollisionFlags.HasWeenie | EntityCollisionFlags.IsPlayer,
|
||||
seedCellId: 0xA9B40001u);
|
||||
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = 0x70000010u,
|
||||
PublicWeenieBitfield = 0x8u, // BF_PLAYER only — spawn-time snapshot
|
||||
});
|
||||
|
||||
using var sync = new LiveEntityPvpBitfieldSync(objects, runtime, shadows);
|
||||
|
||||
// Live PropertyInt(PlayerKillerStatus) = PKLite arrives.
|
||||
objects.UpdateIntProperty(
|
||||
0x70000010u,
|
||||
ClientObjectTable.PlayerKillerStatusPropertyId,
|
||||
value: PlayerKillerStatusBitfield.PkLite);
|
||||
|
||||
ShadowEntry after = Assert.Single(shadows.GetObjectsInCell(0xA9B40001u));
|
||||
Assert.Equal(
|
||||
EntityCollisionFlags.HasWeenie
|
||||
| EntityCollisionFlags.IsPlayer
|
||||
| EntityCollisionFlags.IsPKLite,
|
||||
after.Flags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObjectUpdated_UnrelatedProperty_NoBitfield_DoesNotThrowOrRegisterUnknownEntity()
|
||||
{
|
||||
var spatial = new GpuWorldState();
|
||||
var runtime = LiveEntityRuntimeFixture.Create(spatial, new RecordingResources());
|
||||
var shadows = new ShadowObjectRegistry();
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = 0x70000011u });
|
||||
|
||||
using var sync = new LiveEntityPvpBitfieldSync(objects, runtime, shadows);
|
||||
|
||||
// No PublicWeenieBitfield yet, and no matching live entity — must
|
||||
// no-op harmlessly rather than throw.
|
||||
objects.UpdateIntProperty(0x70000011u, propertyId: 18u, value: 1);
|
||||
|
||||
Assert.Equal(0, shadows.TotalRegistered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_UnsubscribesFromObjectUpdated()
|
||||
{
|
||||
(LiveEntityRuntime runtime, WorldEntity entity) = MaterializedEntity(0x70000012u);
|
||||
|
||||
var shadows = new ShadowObjectRegistry();
|
||||
shadows.Register(
|
||||
entity.Id,
|
||||
0x01000005u,
|
||||
new Vector3(12f, 12f, 50f),
|
||||
Quaternion.Identity,
|
||||
1f,
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f,
|
||||
landblockId: 0xA9B40000u,
|
||||
flags: EntityCollisionFlags.HasWeenie | EntityCollisionFlags.IsPlayer,
|
||||
seedCellId: 0xA9B40001u);
|
||||
|
||||
var objects = new ClientObjectTable();
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = 0x70000012u,
|
||||
PublicWeenieBitfield = 0x8u,
|
||||
});
|
||||
|
||||
var sync = new LiveEntityPvpBitfieldSync(objects, runtime, shadows);
|
||||
sync.Dispose();
|
||||
|
||||
objects.UpdateIntProperty(
|
||||
0x70000012u,
|
||||
ClientObjectTable.PlayerKillerStatusPropertyId,
|
||||
value: PlayerKillerStatusBitfield.PkLite);
|
||||
|
||||
// Disposed sync must not have reacted — flags stay exactly as
|
||||
// Register captured (no IsPKLite bit picked up).
|
||||
ShadowEntry after = Assert.Single(shadows.GetObjectsInCell(0xA9B40001u));
|
||||
Assert.Equal(
|
||||
EntityCollisionFlags.HasWeenie | EntityCollisionFlags.IsPlayer,
|
||||
after.Flags);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
using System.Numerics;
|
||||
using AcDream.App.Physics;
|
||||
using AcDream.App.Streaming;
|
||||
using AcDream.App.World;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.World;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Entities;
|
||||
using DatReaderWriter.DBObjs;
|
||||
|
||||
namespace AcDream.App.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// #297 F1 (review round 2): <c>LiveEntityCollisionBuilder.Build</c> (invoked
|
||||
/// from <c>LiveEntityHydrationController.OnAppearance</c> on every
|
||||
/// equip/dequip ObjDesc) rebuilds a live entity's shadow-registry collision
|
||||
/// flags from <c>spawn.ObjectDescriptionFlags</c>. Before
|
||||
/// <see cref="RuntimeEntityPvpBitfieldSnapshotSync"/> existed, that spawn was
|
||||
/// the FROZEN CreateObject-time value — a plain equip/dequip after a live
|
||||
/// PropertyInt(PlayerKillerStatus) update would silently revert the
|
||||
/// shadow-registry PKLite flag, restoring the exact #297 symptom
|
||||
/// (walk-through) even though <see cref="LiveEntityPvpBitfieldSync"/> had
|
||||
/// already fixed it once. This test drives the REAL production sequence —
|
||||
/// register, apply the live PK update, apply an ObjDesc through the gated
|
||||
/// pipeline, rebuild collision, reconcile into the registry — and asserts
|
||||
/// the shadow flags survive the rebuild.
|
||||
/// </summary>
|
||||
public sealed class PvpBitfieldSurvivesAppearanceRebuildTests
|
||||
{
|
||||
private const uint Guid = 0x70000030u;
|
||||
private const uint Cell = 0x01010001u;
|
||||
|
||||
private sealed class RecordingResources : ILiveEntityResourceLifecycle
|
||||
{
|
||||
public void Register(WorldEntity entity) { }
|
||||
public void Unregister(WorldEntity entity) { }
|
||||
}
|
||||
|
||||
private sealed class NullAnimationLoader : IAnimationLoader
|
||||
{
|
||||
public Animation? LoadAnimation(uint id) => null;
|
||||
}
|
||||
|
||||
private static WorldEntity EntityFactory(uint id, uint guid) => new()
|
||||
{
|
||||
Id = id,
|
||||
ServerGuid = guid,
|
||||
SourceGfxObjOrSetupId = 0x02000001u,
|
||||
Position = Vector3.Zero,
|
||||
Rotation = Quaternion.Identity,
|
||||
MeshRefs = Array.Empty<MeshRef>(),
|
||||
ParentCellId = Cell,
|
||||
};
|
||||
|
||||
private static WorldSession.EntitySpawn Spawn(
|
||||
uint? objectDescriptionFlags,
|
||||
ushort objDescSequence)
|
||||
{
|
||||
var position = new CreateObject.ServerPosition(Cell, 10f, 10f, 5f, 1f, 0f, 0f, 0f);
|
||||
var timestamps = new PhysicsTimestamps(1, 1, 1, 1, 0, 1, 0, objDescSequence, 1);
|
||||
var physics = new PhysicsSpawnData(
|
||||
RawState: (uint)PhysicsStateFlags.ReportCollisions,
|
||||
Position: position,
|
||||
Movement: null,
|
||||
AnimationFrame: null,
|
||||
SetupTableId: 0x02000001u,
|
||||
MotionTableId: 0x09000001u,
|
||||
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: timestamps);
|
||||
return new WorldSession.EntitySpawn(
|
||||
Guid,
|
||||
position,
|
||||
0x02000001u,
|
||||
Array.Empty<CreateObject.AnimPartChange>(),
|
||||
Array.Empty<CreateObject.TextureChange>(),
|
||||
Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||
null,
|
||||
null,
|
||||
"fixture",
|
||||
(uint)ItemType.Creature,
|
||||
null,
|
||||
0x09000001u,
|
||||
PhysicsState: (uint)PhysicsStateFlags.ReportCollisions,
|
||||
InstanceSequence: 1,
|
||||
MovementSequence: 1,
|
||||
ServerControlSequence: 1,
|
||||
PositionSequence: 1,
|
||||
ObjectDescriptionFlags: objectDescriptionFlags,
|
||||
Physics: physics);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObjDescAfterPkUpdate_RebuildKeepsLivePkLiteFlag()
|
||||
{
|
||||
var spatial = new GpuWorldState();
|
||||
spatial.AddLandblock(new LoadedLandblock(
|
||||
0x0101FFFFu, new LandBlock(), Array.Empty<WorldEntity>()));
|
||||
var lifetime = new RuntimeEntityObjectLifetime();
|
||||
lifetime.BindEventContext(
|
||||
static () => new RuntimeGenerationToken(1UL),
|
||||
static () => 1UL);
|
||||
var runtime = new LiveEntityRuntime(spatial, new RecordingResources(), lifetime);
|
||||
|
||||
WorldSession.EntitySpawn spawn = Spawn(objectDescriptionFlags: 0x8u, objDescSequence: 1);
|
||||
runtime.RegisterLiveEntity(spawn);
|
||||
WorldEntity entity = runtime.MaterializeLiveEntity(
|
||||
spawn.Guid, Cell, id => EntityFactory(id, spawn.Guid))!;
|
||||
Assert.True(runtime.TryGetRecord(Guid, out LiveEntityRecord record));
|
||||
|
||||
// Seed the object table exactly like CreateObject would.
|
||||
lifetime.Objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = Guid,
|
||||
PublicWeenieBitfield = 0x8u, // BF_PLAYER only
|
||||
});
|
||||
|
||||
// Live PropertyInt(PlayerKillerStatus) = PKLite arrives.
|
||||
lifetime.Objects.UpdateIntProperty(
|
||||
Guid,
|
||||
ClientObjectTable.PlayerKillerStatusPropertyId,
|
||||
value: PlayerKillerStatusBitfield.PkLite);
|
||||
|
||||
// Sanity: RuntimeEntityPvpBitfieldSnapshotSync already keeps the
|
||||
// canonical snapshot live — this is the source-of-truth fix.
|
||||
Assert.Equal(0x2000008u, record.Snapshot.ObjectDescriptionFlags);
|
||||
|
||||
var setup = new Setup();
|
||||
setup.Parts.Add(0x0100AB01u);
|
||||
var builder = new LiveEntityCollisionBuilder(
|
||||
id => id == 0x0100AB01u,
|
||||
id => id == 0x0100AB01u ? 1f : null,
|
||||
new LiveEntityDefaultPoseResolver(
|
||||
_ => null,
|
||||
new NullAnimationLoader(),
|
||||
dumpMotion: false));
|
||||
var registry = new ShadowObjectRegistry();
|
||||
|
||||
// Initial shadow registration, mirroring CreateObject-time behavior.
|
||||
LiveEntityCollisionRegistration initial = Assert.IsType<LiveEntityCollisionRegistration>(
|
||||
builder.Build(entity, setup, [], record.Snapshot, record, Vector3.Zero));
|
||||
LiveEntityCollisionBuilder.Register(registry, initial);
|
||||
ShadowEntry beforeObjDesc = Assert.Single(registry.GetObjectsInCell(Cell));
|
||||
Assert.True(beforeObjDesc.Flags.HasFlag(EntityCollisionFlags.IsPKLite));
|
||||
|
||||
// F1 regression: an ObjDesc (equip/dequip) arrives AFTER the PK
|
||||
// update. InboundPhysicsStateController.ApplyAcceptedObjDesc merges
|
||||
// only ModelData/Physics-timestamp fields onto the OLD snapshot;
|
||||
// ObjectDescriptionFlags carries through from whatever `old` was —
|
||||
// which must already be live thanks to the snapshot-sync fix.
|
||||
var update = new ObjDescEvent.Parsed(
|
||||
Guid,
|
||||
new CreateObject.ModelData(
|
||||
0x04000001u,
|
||||
Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||
Array.Empty<CreateObject.TextureChange>(),
|
||||
Array.Empty<CreateObject.AnimPartChange>()),
|
||||
InstanceSequence: 1,
|
||||
ObjDescSequence: 2);
|
||||
Assert.True(runtime.TryApplyObjDesc(update, out WorldSession.EntitySpawn accepted));
|
||||
Assert.Equal(0x2000008u, accepted.ObjectDescriptionFlags);
|
||||
Assert.Equal(0x2000008u, record.Snapshot.ObjectDescriptionFlags);
|
||||
|
||||
// The appearance-rebuild path: LiveEntityCollisionBuilder.Build runs
|
||||
// again with the freshly-accepted spawn and the rebuilt collision
|
||||
// replaces the shadow registration (mirrors
|
||||
// LiveEntityAppearanceBinding.PrepareCollision/CommitCollision ->
|
||||
// LiveEntityCollisionBuilder.ReconcileAppearance).
|
||||
LiveEntityCollisionRegistration rebuilt = Assert.IsType<LiveEntityCollisionRegistration>(
|
||||
builder.Build(entity, setup, [], accepted, record, Vector3.Zero));
|
||||
LiveEntityCollisionBuilder.ReconcileAppearance(
|
||||
registry, entity.Id, rebuilt, suspendIfNew: false);
|
||||
|
||||
ShadowEntry afterObjDesc = Assert.Single(registry.GetObjectsInCell(Cell));
|
||||
Assert.True(
|
||||
afterObjDesc.Flags.HasFlag(EntityCollisionFlags.IsPKLite),
|
||||
"the ObjDesc-triggered appearance rebuild must not revert a live PK-status change");
|
||||
}
|
||||
}
|
||||
|
|
@ -337,6 +337,55 @@ public sealed class ClientObjectTableTests
|
|||
Assert.Equal(0, repo.Get(0x500000ADu)!.Properties.Ints[ClientObjectTable.CurrentWieldedLocationPropertyId]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateIntProperty_playerKillerStatus_setsPublicWeenieBitfield()
|
||||
{
|
||||
// #297: PropertyInt.PlayerKillerStatus (134) is retail's ONLY live
|
||||
// PK-status signal — arriving over either PublicUpdatePropertyInt
|
||||
// (0x02CE, remote guid) or PrivateUpdatePropertyInt (0x02CD, self,
|
||||
// routed to the player's own guid by ObjectTableWiring). Both routes
|
||||
// funnel through this one UpdateIntProperty call, so this pins the
|
||||
// shared translation both routes rely on.
|
||||
var repo = new ClientObjectTable();
|
||||
repo.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = 0x500000AFu,
|
||||
PublicWeenieBitfield = 0x8u, // BF_PLAYER only
|
||||
});
|
||||
ClientObject? fired = null;
|
||||
repo.ObjectUpdated += i => fired = i;
|
||||
|
||||
bool ok = repo.UpdateIntProperty(
|
||||
0x500000AFu,
|
||||
ClientObjectTable.PlayerKillerStatusPropertyId,
|
||||
value: PlayerKillerStatusBitfield.PkLite);
|
||||
|
||||
Assert.True(ok);
|
||||
Assert.Equal(0x2000008u, repo.Get(0x500000AFu)!.PublicWeenieBitfield);
|
||||
Assert.Equal(
|
||||
PlayerKillerStatusBitfield.PkLite,
|
||||
repo.Get(0x500000AFu)!.Properties.Ints[ClientObjectTable.PlayerKillerStatusPropertyId]);
|
||||
Assert.NotNull(fired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdateIntProperty_playerKillerStatus_nullBitfield_treatsMissingAsZero()
|
||||
{
|
||||
// An object whose CreateObject omitted the PWD bitfield entirely
|
||||
// (WeenieHeaderFlag absent) must not throw/no-op on the first live
|
||||
// PK-status update — retail's own PublicWeenieDesc bitfield starts
|
||||
// zero-initialized.
|
||||
var repo = new ClientObjectTable();
|
||||
repo.AddOrUpdate(new ClientObject { ObjectId = 0x500000B4u });
|
||||
|
||||
Assert.True(repo.UpdateIntProperty(
|
||||
0x500000B4u,
|
||||
ClientObjectTable.PlayerKillerStatusPropertyId,
|
||||
value: PlayerKillerStatusBitfield.Pk));
|
||||
|
||||
Assert.Equal(0x20u, repo.Get(0x500000B4u)!.PublicWeenieBitfield);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ClientObject_NewFields_DefaultAndSettable()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,89 @@
|
|||
using AcDream.Core.Items;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Items;
|
||||
|
||||
/// <summary>
|
||||
/// #297: pins <see cref="PlayerKillerStatusBitfield.Apply"/> — the ported
|
||||
/// retail <c>PublicWeenieDesc::SetPlayerKillerStatus@0x005AC7C0</c> bit
|
||||
/// rewrite — byte-for-byte against
|
||||
/// <c>acclient_2013_pseudo_c.txt:441868-441890</c>. Covers all four arms
|
||||
/// (PK / PKLite / Free / else-clears-all) and mutual exclusivity.
|
||||
/// </summary>
|
||||
public sealed class PlayerKillerStatusBitfieldTests
|
||||
{
|
||||
[Fact]
|
||||
public void Pk_SetsPkBit_ClearsFreeAndPkLite()
|
||||
{
|
||||
// Starting bitfield already has Free(0x200000) and PKLite(0x2000000)
|
||||
// set (shouldn't happen in practice, but the mask must still clear
|
||||
// them — mutual exclusivity is enforced by the rewrite, not by the
|
||||
// caller).
|
||||
uint result = PlayerKillerStatusBitfield.Apply(
|
||||
bitfield: 0x2200000u,
|
||||
pkStatus: PlayerKillerStatusBitfield.Pk);
|
||||
Assert.Equal(0x20u, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PkLite_SetsPkLiteBit_ClearsPkAndFree()
|
||||
{
|
||||
uint result = PlayerKillerStatusBitfield.Apply(
|
||||
bitfield: 0x200020u,
|
||||
pkStatus: PlayerKillerStatusBitfield.PkLite);
|
||||
Assert.Equal(0x2000000u, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Free_SetsFreeBit_ClearsPkAndPkLite()
|
||||
{
|
||||
uint result = PlayerKillerStatusBitfield.Apply(
|
||||
bitfield: 0x2000020u,
|
||||
pkStatus: PlayerKillerStatusBitfield.Free);
|
||||
Assert.Equal(0x200000u, result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OtherValue_ClearsAllThreeBits()
|
||||
{
|
||||
// Retail's implicit "NPK" default (any arg2 not in {4, 0x20, 0x40})
|
||||
// clears PK/Free/PKLite and sets nothing.
|
||||
uint result = PlayerKillerStatusBitfield.Apply(
|
||||
bitfield: 0x2200020u,
|
||||
pkStatus: 0);
|
||||
Assert.Equal(0u, result);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(PlayerKillerStatusBitfield.Pk)]
|
||||
[InlineData(PlayerKillerStatusBitfield.PkLite)]
|
||||
[InlineData(PlayerKillerStatusBitfield.Free)]
|
||||
[InlineData(0)]
|
||||
public void NeverTouchesUnrelatedBits(int pkStatus)
|
||||
{
|
||||
// BF_PLAYER (0x8) and every other PWD bit must survive every arm —
|
||||
// the retail masks only ever touch bits 5/21/25.
|
||||
const uint unrelatedBits = 0x8u | 0x100u | 0x400000u;
|
||||
uint result = PlayerKillerStatusBitfield.Apply(
|
||||
bitfield: unrelatedBits,
|
||||
pkStatus: pkStatus);
|
||||
Assert.Equal(unrelatedBits, result & unrelatedBits);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MutualExclusivity_TransitioningPkToFreeToPkLite()
|
||||
{
|
||||
uint bitfield = 0u;
|
||||
bitfield = PlayerKillerStatusBitfield.Apply(bitfield, PlayerKillerStatusBitfield.Pk);
|
||||
Assert.Equal(0x20u, bitfield);
|
||||
|
||||
bitfield = PlayerKillerStatusBitfield.Apply(bitfield, PlayerKillerStatusBitfield.Free);
|
||||
Assert.Equal(0x200000u, bitfield);
|
||||
|
||||
bitfield = PlayerKillerStatusBitfield.Apply(bitfield, PlayerKillerStatusBitfield.PkLite);
|
||||
Assert.Equal(0x2000000u, bitfield);
|
||||
|
||||
bitfield = PlayerKillerStatusBitfield.Apply(bitfield, pkStatus: -1);
|
||||
Assert.Equal(0u, bitfield);
|
||||
}
|
||||
}
|
||||
|
|
@ -202,6 +202,116 @@ public class ShadowObjectRegistryTests
|
|||
Assert.Equal(0x44u, restored.State);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// UpdatePwdBitfieldFlags (#297 target-side refresh)
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
[Fact]
|
||||
public void UpdatePwdBitfieldFlags_ReplacesOnlyPwdDerivedBits()
|
||||
{
|
||||
// Simulates CreateObject-time registration (IsPlayer decoded from the
|
||||
// spawn bitfield, IsCreature derived from ItemType, HasWeenie set by
|
||||
// the builder) followed by a live PropertyInt(PlayerKillerStatus)
|
||||
// arrival that goes PKLite. IsCreature/HasWeenie must survive
|
||||
// untouched; IsPlayer must survive because the new bitfield still
|
||||
// carries BF_PLAYER.
|
||||
var reg = new ShadowObjectRegistry();
|
||||
const uint entityId = 60u;
|
||||
reg.Register(
|
||||
entityId,
|
||||
0x01000005u,
|
||||
new Vector3(12f, 12f, 50f),
|
||||
Quaternion.Identity,
|
||||
1f,
|
||||
OffX,
|
||||
OffY,
|
||||
LbId,
|
||||
flags: EntityCollisionFlags.HasWeenie
|
||||
| EntityCollisionFlags.IsCreature
|
||||
| EntityCollisionFlags.IsPlayer,
|
||||
seedCellId: LbId | 1u);
|
||||
|
||||
reg.UpdatePwdBitfieldFlags(entityId, pwdBitfield: 0x2000008u); // BF_PLAYER | PKLite
|
||||
|
||||
ShadowEntry entry = Assert.Single(reg.GetObjectsInCell(LbId | 1u));
|
||||
Assert.Equal(
|
||||
EntityCollisionFlags.HasWeenie
|
||||
| EntityCollisionFlags.IsCreature
|
||||
| EntityCollisionFlags.IsPlayer
|
||||
| EntityCollisionFlags.IsPKLite,
|
||||
entry.Flags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdatePwdBitfieldFlags_ClearsStalePkStateWhenBitfieldNoLongerCarriesIt()
|
||||
{
|
||||
var reg = new ShadowObjectRegistry();
|
||||
const uint entityId = 61u;
|
||||
reg.Register(
|
||||
entityId,
|
||||
0x01000005u,
|
||||
new Vector3(12f, 12f, 50f),
|
||||
Quaternion.Identity,
|
||||
1f,
|
||||
OffX,
|
||||
OffY,
|
||||
LbId,
|
||||
flags: EntityCollisionFlags.HasWeenie
|
||||
| EntityCollisionFlags.IsPlayer
|
||||
| EntityCollisionFlags.IsPKLite,
|
||||
seedCellId: LbId | 1u);
|
||||
|
||||
// Target reverts to NPK — bitfield no longer carries the PKLite bit.
|
||||
reg.UpdatePwdBitfieldFlags(entityId, pwdBitfield: 0x8u); // BF_PLAYER only
|
||||
|
||||
ShadowEntry entry = Assert.Single(reg.GetObjectsInCell(LbId | 1u));
|
||||
Assert.Equal(
|
||||
EntityCollisionFlags.HasWeenie | EntityCollisionFlags.IsPlayer,
|
||||
entry.Flags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdatePwdBitfieldFlags_UnregisteredEntity_NoOp()
|
||||
{
|
||||
var reg = new ShadowObjectRegistry();
|
||||
// Must not throw for an entity with no live shadow registration
|
||||
// (e.g. an off-screen/never-materialized object).
|
||||
reg.UpdatePwdBitfieldFlags(999u, pwdBitfield: 0x2000000u);
|
||||
Assert.Equal(0, reg.TotalRegistered);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void UpdatePwdBitfieldFlags_ThenCollisionExemption_BothPkLiteNowCollide()
|
||||
{
|
||||
// End-to-end #297 payoff: a target registered at spawn as a plain
|
||||
// (non-PK) player, then a live PropertyInt(PlayerKillerStatus)
|
||||
// update makes it PKLite. Before the target-side refresh existed,
|
||||
// CollisionExemption would keep reading the frozen spawn-time
|
||||
// flags and the pair would walk through each other forever.
|
||||
var reg = new ShadowObjectRegistry();
|
||||
const uint entityId = 62u;
|
||||
reg.Register(
|
||||
entityId,
|
||||
0x01000005u,
|
||||
new Vector3(12f, 12f, 50f),
|
||||
Quaternion.Identity,
|
||||
1f,
|
||||
OffX,
|
||||
OffY,
|
||||
LbId,
|
||||
flags: EntityCollisionFlags.HasWeenie | EntityCollisionFlags.IsPlayer,
|
||||
seedCellId: LbId | 1u);
|
||||
|
||||
var moverState = ObjectInfoState.IsPlayer | ObjectInfoState.IsPKLite;
|
||||
ShadowEntry before = Assert.Single(reg.GetObjectsInCell(LbId | 1u));
|
||||
Assert.True(CollisionExemption.ShouldSkip(before.State, before.Flags, moverState));
|
||||
|
||||
reg.UpdatePwdBitfieldFlags(entityId, pwdBitfield: 0x2000008u); // BF_PLAYER | PKLite
|
||||
|
||||
ShadowEntry after = Assert.Single(reg.GetObjectsInCell(LbId | 1u));
|
||||
Assert.False(CollisionExemption.ShouldSkip(after.State, after.Flags, moverState));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ReplaceMultiPartPayload_VisibleOwnerKeepsExistingCellMembership()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,189 @@
|
|||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Runtime.Entities;
|
||||
|
||||
namespace AcDream.Runtime.Tests.Entities;
|
||||
|
||||
/// <summary>
|
||||
/// #297 review round 2 (preferred fix): <see cref="RuntimeEntityPvpBitfieldSnapshotSync"/>
|
||||
/// keeps <c>RuntimeEntityRecord.Snapshot.ObjectDescriptionFlags</c> live
|
||||
/// against <see cref="ClientObject.PublicWeenieBitfield"/>, so every
|
||||
/// downstream reader of the canonical snapshot (ObjDesc/appearance rebuild,
|
||||
/// placement/teleport mover-flags resolution, the vivid target indicator)
|
||||
/// inherits the live PK/PKLite/Free state instead of the value frozen at
|
||||
/// CreateObject.
|
||||
/// </summary>
|
||||
public sealed class RuntimeEntityPvpBitfieldSnapshotSyncTests
|
||||
{
|
||||
private const uint Guid = 0x70000020u;
|
||||
|
||||
private static WorldSession.EntitySpawn Spawn(uint? objectDescriptionFlags) => new(
|
||||
Guid,
|
||||
new CreateObject.ServerPosition(0x0101FFFFu, 10f, 10f, 5f, 1f, 0f, 0f, 0f),
|
||||
0x02000001u,
|
||||
Array.Empty<CreateObject.AnimPartChange>(),
|
||||
Array.Empty<CreateObject.TextureChange>(),
|
||||
Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||
null,
|
||||
null,
|
||||
"fixture",
|
||||
(uint)ItemType.Creature,
|
||||
null,
|
||||
0x09000001u,
|
||||
ObjectDescriptionFlags: objectDescriptionFlags);
|
||||
|
||||
[Fact]
|
||||
public void ObjectUpdated_WithLiveBitfield_RewritesSnapshotObjectDescriptionFlags()
|
||||
{
|
||||
var entities = new RuntimeEntityDirectory();
|
||||
var objects = new ClientObjectTable();
|
||||
using var sync = new RuntimeEntityPvpBitfieldSnapshotSync(entities, objects);
|
||||
|
||||
WorldSession.EntitySpawn spawn = Spawn(0x8u); // BF_PLAYER only
|
||||
entities.AcceptCreate(spawn); // seeds InboundPhysicsStateController's own retained copy
|
||||
RuntimeEntityRecord record = entities.AddActive(spawn);
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = Guid,
|
||||
PublicWeenieBitfield = 0x8u,
|
||||
});
|
||||
|
||||
objects.UpdateIntProperty(
|
||||
Guid,
|
||||
ClientObjectTable.PlayerKillerStatusPropertyId,
|
||||
value: PlayerKillerStatusBitfield.PkLite);
|
||||
|
||||
Assert.Equal(0x2000008u, record.Snapshot.ObjectDescriptionFlags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObjectUpdated_UnrelatedProperty_DoesNotRewriteSnapshot()
|
||||
{
|
||||
var entities = new RuntimeEntityDirectory();
|
||||
var objects = new ClientObjectTable();
|
||||
using var sync = new RuntimeEntityPvpBitfieldSnapshotSync(entities, objects);
|
||||
|
||||
WorldSession.EntitySpawn spawn = Spawn(0x8u);
|
||||
entities.AcceptCreate(spawn);
|
||||
RuntimeEntityRecord record = entities.AddActive(spawn);
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = Guid,
|
||||
PublicWeenieBitfield = 0x8u,
|
||||
});
|
||||
|
||||
// Effects (18) is unrelated to PK status; PublicWeenieBitfield is
|
||||
// untouched, so the idempotency guard must skip the rewrite.
|
||||
objects.UpdateIntProperty(Guid, ClientObjectTable.UiEffectsPropertyId, value: 4);
|
||||
|
||||
Assert.Equal(0x8u, record.Snapshot.ObjectDescriptionFlags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObjectUpdated_NoPublicWeenieBitfieldYet_NoOp()
|
||||
{
|
||||
var entities = new RuntimeEntityDirectory();
|
||||
var objects = new ClientObjectTable();
|
||||
using var sync = new RuntimeEntityPvpBitfieldSnapshotSync(entities, objects);
|
||||
|
||||
RuntimeEntityRecord record = entities.AddActive(Spawn(null));
|
||||
objects.AddOrUpdate(new ClientObject { ObjectId = Guid });
|
||||
|
||||
// No PublicWeenieBitfield yet (CreateObject omitted it) — the
|
||||
// handler must no-op rather than throw or write a bogus 0.
|
||||
objects.UpdateIntProperty(Guid, ClientObjectTable.UiEffectsPropertyId, value: 1);
|
||||
|
||||
Assert.Null(record.Snapshot.ObjectDescriptionFlags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObjectUpdated_NoMatchingActiveEntity_NoOp()
|
||||
{
|
||||
var entities = new RuntimeEntityDirectory();
|
||||
var objects = new ClientObjectTable();
|
||||
using var sync = new RuntimeEntityPvpBitfieldSnapshotSync(entities, objects);
|
||||
|
||||
// Item exists in the object table but has no active Runtime entity
|
||||
// (e.g. an inventory item) — must not throw.
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = 0x80000001u,
|
||||
PublicWeenieBitfield = 0x8u,
|
||||
});
|
||||
|
||||
objects.UpdateIntProperty(
|
||||
0x80000001u,
|
||||
ClientObjectTable.PlayerKillerStatusPropertyId,
|
||||
value: PlayerKillerStatusBitfield.Pk);
|
||||
|
||||
Assert.Equal(0, entities.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObjDescAfterPkUpdate_MergedSnapshotKeepsLiveBitfield()
|
||||
{
|
||||
// #297 F1: InboundPhysicsStateController.ApplyAcceptedObjDesc merges
|
||||
// a new ObjDesc onto _snapshots[guid] — a DIFFERENT store than the
|
||||
// active RuntimeEntityRecord.Snapshot this class also rewrites.
|
||||
// Without RuntimeEntityDirectory.TryRefreshObjectDescriptionFlags
|
||||
// keeping BOTH stores in lockstep, this ObjDesc would silently
|
||||
// revert the live PK-status bits back to whatever CreateObject
|
||||
// originally carried.
|
||||
var entities = new RuntimeEntityDirectory();
|
||||
var objects = new ClientObjectTable();
|
||||
using var sync = new RuntimeEntityPvpBitfieldSnapshotSync(entities, objects);
|
||||
|
||||
WorldSession.EntitySpawn spawn = Spawn(0x8u);
|
||||
entities.AcceptCreate(spawn);
|
||||
entities.AddActive(spawn);
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = Guid,
|
||||
PublicWeenieBitfield = 0x8u,
|
||||
});
|
||||
|
||||
objects.UpdateIntProperty(
|
||||
Guid,
|
||||
ClientObjectTable.PlayerKillerStatusPropertyId,
|
||||
value: PlayerKillerStatusBitfield.PkLite);
|
||||
|
||||
var update = new ObjDescEvent.Parsed(
|
||||
Guid,
|
||||
new CreateObject.ModelData(
|
||||
0x04000001u,
|
||||
Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||
Array.Empty<CreateObject.TextureChange>(),
|
||||
Array.Empty<CreateObject.AnimPartChange>()),
|
||||
InstanceSequence: 0,
|
||||
ObjDescSequence: 1);
|
||||
Assert.True(entities.TryApplyObjDesc(update, out WorldSession.EntitySpawn accepted));
|
||||
Assert.Equal(0x2000008u, accepted.ObjectDescriptionFlags);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_UnsubscribesFromObjectUpdated()
|
||||
{
|
||||
var entities = new RuntimeEntityDirectory();
|
||||
var objects = new ClientObjectTable();
|
||||
var sync = new RuntimeEntityPvpBitfieldSnapshotSync(entities, objects);
|
||||
|
||||
WorldSession.EntitySpawn spawn = Spawn(0x8u);
|
||||
entities.AcceptCreate(spawn);
|
||||
RuntimeEntityRecord record = entities.AddActive(spawn);
|
||||
objects.AddOrUpdate(new ClientObject
|
||||
{
|
||||
ObjectId = Guid,
|
||||
PublicWeenieBitfield = 0x8u,
|
||||
});
|
||||
|
||||
sync.Dispose();
|
||||
objects.UpdateIntProperty(
|
||||
Guid,
|
||||
ClientObjectTable.PlayerKillerStatusPropertyId,
|
||||
value: PlayerKillerStatusBitfield.PkLite);
|
||||
|
||||
Assert.Equal(0x8u, record.Snapshot.ObjectDescriptionFlags);
|
||||
}
|
||||
}
|
||||
|
|
@ -113,6 +113,47 @@ public sealed class RuntimeSetPositionMoverPreparationTests
|
|||
command.ExpectedVelocityAuthorityVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LivePkStatusUpdate_ReachesMoverFlagsOnNextPlacement()
|
||||
{
|
||||
// #297 F2 (review round 2): RuntimeSetPositionMoverPreparer.TryBuild
|
||||
// reads record.Snapshot.ObjectDescriptionFlags directly — a
|
||||
// PLACEMENT/teleport/spawn-settle mover-flags resolve, distinct from
|
||||
// the per-tick sweep path that already read the live table via
|
||||
// EntityCollisionFlagsExt.ResolveMoverPvpState. Before
|
||||
// RuntimeEntityPvpBitfieldSnapshotSync existed, this record's
|
||||
// snapshot stayed frozen at whatever CreateObject captured, so a
|
||||
// PKLite remote teleporting or landing next to the local player
|
||||
// de-overlapped under the wrong (stale, non-PKLite) exemption.
|
||||
using var lifetime = new RuntimeEntityObjectLifetime();
|
||||
RuntimeEntityRecord record = CreateRecord(
|
||||
lifetime,
|
||||
objectDescriptionFlags: 0x8u); // BF_PLAYER only, no PK/PKLite yet
|
||||
|
||||
lifetime.Objects.AddOrUpdate(new AcDream.Core.Items.ClientObject
|
||||
{
|
||||
ObjectId = record.ServerGuid,
|
||||
PublicWeenieBitfield = 0x8u,
|
||||
});
|
||||
lifetime.Objects.UpdateIntProperty(
|
||||
record.ServerGuid,
|
||||
AcDream.Core.Items.ClientObjectTable.PlayerKillerStatusPropertyId,
|
||||
value: AcDream.Core.Items.PlayerKillerStatusBitfield.PkLite);
|
||||
|
||||
RuntimeEntityPlacementToken token = Begin(lifetime, record);
|
||||
ImmutableArray<FlatCollisionSphere> spheres =
|
||||
[new(new Vector3(1f, 2f, 3f), 0.1f)];
|
||||
FlatSetupCollision setup = Setup(spheres, 0f, 0f);
|
||||
RuntimeSetPositionMoverPreparation input =
|
||||
Input(RuntimeSetPositionMoverSetup.Resolved(SetupId, setup));
|
||||
|
||||
RuntimeSetPositionMoverPreparationStatus status = lifetime.Physics
|
||||
.SetPosition.PrepareMover(token, input, out var command);
|
||||
|
||||
Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared, status);
|
||||
Assert.True(command.Physics.MoverFlags.HasFlag(ObjectInfoState.IsPKLite));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LocalPortalKindAndAuthorityArePreservedWithoutInference()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue