Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run
LiveEntityCollisionBuilder and LiveEntityDefaultPoseResolver move from AcDream.App.Physics to AcDream.Runtime.Physics with no behaviour change — diff-verified byte-identical shape math by both review lenses. The Build signature's App-record parameter is replaced by presentation-free primitives with identical guard semantics, INCLUDING the FinalPhysicsState read the contract had missed and the implementer surfaced rather than dropped. Visibility stays internal: Runtime's existing InternalsVisibleTo grants already cover every consumer, so the implementation's public widening is reverted per the architecture review's finding 11. The registration WIRING is deliberately WITHHELD. Both Opus lenses failed it, converging: a shadow registered at spawn freezes there (RuntimeRemotePhysicsUpdater is Runtime-homed but App-driven — nothing headless ticks it), so a walking NPC becomes a phantom obstacle at its spawn point while the real NPC still passes through the bot; three of five shadow-lifetime edges leaked (pickup leaves a permanent invisible collider, supersession orphans a duplicate, generation reset never unregisters and the K-ledger convergence oracle only checks retained shadows AFTER disposal clears them); and headless cannot resolve BSP collision assets at all, so doors and chests would still be walk-through. The frozen-shadow root was the SESSION LEAD's contract error (fact 3), not the implementer's. #330 stays OPEN, rewritten as the seven-point scope map the reviews produced — the honest overnight deliverable is that map, not a half-mechanism carrying new divergences. Suite 11,235 passed / 4 skipped / 0 failed (the withheld seam's two tests account for the delta from the implementation run's 11,237). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
214 lines
8.6 KiB
C#
214 lines
8.6 KiB
C#
using System.Numerics;
|
|
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 AcDream.Runtime.Physics;
|
|
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
|
|
? ShadowPartGeometry.Create(
|
|
new FlatCollisionSphere(new Vector3(0f, 0f, 0.5f), 1f),
|
|
null)
|
|
: (ShadowPartGeometry?)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.ServerGuid,
|
|
record.Generation,
|
|
record.WorldEntity!,
|
|
record.FinalPhysicsState,
|
|
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.ServerGuid,
|
|
record.Generation,
|
|
record.WorldEntity!,
|
|
record.FinalPhysicsState,
|
|
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");
|
|
}
|
|
}
|