Retail synthesizes NO shape for a shapeless object, so the fix is deletion,
not a corrected height formula.
CPhysicsObj::FindObjCollisions @0x0050f050 dispatches exclusively -- BSP xor
CylSphere xor Sphere xor nothing. The BSP branch leaves via an unconditional
`jmp 0x50f2b0` at 0x0050f19d and cannot reach the primitive branches; a
CylSphere-bearing object that survives its loop returns rather than falling
through to the Sphere loop; and with zero cylspheres, zero spheres and no
physics BSP, `0x0050f22f je 0x50f31b` branches straight to the epilogue,
returning the OK_TS seeded at `0x0050f13b mov edi,1`. CPartArray::GetRadius
(0x005180a0) and GetHeight (0x005180b0) are absent from the function's entire
call set -- Setup.Radius/Height serve attack cones, cylinder_distance and
MoveTo, never collision geometry. Disassembled directly from the PDB-paired
binary (GUID 9e847e2f-777c-4bd9-886c-22256bb87f32) rather than read from the
Binary Ninja text, whose ebp_1 aliasing in this function is visibly corrupt.
THREE copies were deleted, not one. The AP-22 register row cited
LiveEntityCollisionBuilder.cs and ShadowShapeBuilder.cs; the latter never
reads Setup.Radius at all, and the row omitted both
LandblockPhysicsPublisher.PublishStaticEntity and
LandblockPhysicsContentBuilder.PublishStaticCollision -- the second being the
only copy the headless host executes. Fixing just the cited site would have
left headless statics on the invented footprint.
The branch was unreachable dead code, not a live approximation. A sweep of all
5,935 Setups in the installed client_portal.dat -- validated by byte
accounting (5,935/5,935 records consumed with an exact 20 + 48*numLights
residual tail, zero unexplained bytes) and independently reproduced by the
production FlatCollisionAssetBuilder.FlattenSetup path -- finds 0 Setups
satisfying the guard: every Setup with Radius > 0.0001 carries at least one
CylSphere or Sphere, and all 1,294 genuinely shapeless Setups have Radius
exactly 0. Buckets: 678 cylsphere, 3,605 sphere-only, 358 BSP-only, 1,294
shapeless, 4,282 with Radius > 0.0001. Nothing loses collision because nothing
gained it, so no visual gate is required.
Tests, all sabotage-verified in both directions:
- InstalledSetupCollisionReachabilityTests (new, Content) -- the negative
claim plus five EXTERNAL positive controls, so a broken enumeration cannot
satisfy it vacuously. Inverting the claim reddens it; emptying the
enumeration fails on the controls at 0 != 5935 rather than passing.
- ShapelessSetupWithRadius_ProducesNoRegistration (new, App) -- restoring the
deleted block reddens exactly this fact and nothing else.
- Build_PropagatesExactStateFlagsScaleAndFullSeedCell -- re-hosts the state /
PWD-flag / seed-cell coverage that rode on the deleted fallback test, whose
fixture (a Setup with a radius and no primitives) cannot exist in the DAT.
Flipping a FromPwdBitfield bit reddens it; so does swapping SeedCellId for
the landblock id.
Also corrects ShadowShapeBuilder's retail-anchor comment, which claimed each
part's find_obj_collisions tests "CylSpheres + GfxObj BSP".
CPhysicsPart::find_obj_collisions @0x0050d8d0 tests ONLY the GfxObj physics
BSP; CylSpheres are a Setup-level array reached via CPartArray::GetCylsphere.
That comment was the written justification for the additive emission now filed
as AP-152, so it is corrected here even though AP-152 is not fixed here.
AP-22 retired with evidence; AP-152 filed (live path emits primitives AND BSP
parts additively where retail is exclusive -- 172 of 5,935 Setups including
BSP doors; deliberately not folded in, it needs its own visual gate). Issue
#330 filed: the headless host registers no live-entity collision at all, a
pre-existing gap this survey established and nothing tracked.
Gates: Release build 0 errors / 0 warnings. Complete solution suite
11,195 passed / 4 skipped / 0 failed (baseline 11,193/4/0 at bcb66ccd; +1 App
for the added fact, +1 Content for the reachability test; the replaced test is
net zero). No new skips. Headless.Tests 89/89 exercises the site-3 copy.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
399 lines
15 KiB
C#
399 lines
15 KiB
C#
using System.Numerics;
|
|
using AcDream.App.Physics;
|
|
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;
|
|
using DatReaderWriter.Types;
|
|
|
|
namespace AcDream.App.Tests.Physics;
|
|
|
|
public sealed class LiveEntityCollisionBuilderTests
|
|
{
|
|
private const uint Guid = 0x70000100u;
|
|
private const uint Cell = 0x01010001u;
|
|
|
|
[Fact]
|
|
public void EffectivePartResolution_IsSetupAndVisualOptionIndependent()
|
|
{
|
|
uint[] resolved = LiveEntityCollisionBuilder.ResolveEffectivePartIdentities(
|
|
[0x01001000u, 0x01002000u],
|
|
id => id == 0x01001000u ? 0x01001001u : id);
|
|
|
|
Assert.Equal([0x01001001u, 0x01002000u], resolved);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Re-hosts the state/flag/seed-cell coverage that used to ride on the
|
|
/// deleted Setup-radius fallback (AP-22). The old fixture was a Setup with
|
|
/// a radius and zero primitives, which cannot exist in client_portal.dat —
|
|
/// all 1,294 shapeless Setups have Radius exactly 0. This uses a
|
|
/// DAT-possible single-CylSphere Setup instead.
|
|
/// </summary>
|
|
[Fact]
|
|
public void Build_PropagatesExactStateFlagsScaleAndFullSeedCell()
|
|
{
|
|
var setup = new Setup();
|
|
setup.CylSpheres.Add(new CylSphere
|
|
{
|
|
Origin = Vector3.Zero,
|
|
Radius = 0.4f,
|
|
Height = 1.2f,
|
|
});
|
|
WorldSession.EntitySpawn spawn = Spawn(
|
|
scale: 2f,
|
|
itemType: (uint)ItemType.Creature,
|
|
descriptionFlags: 0x28u);
|
|
var record = LiveEntityTestFixture.CreateExactProjectionRecord(spawn);
|
|
record.FinalPhysicsState =
|
|
PhysicsStateFlags.Hidden | PhysicsStateFlags.Static;
|
|
WorldEntity entity = Entity();
|
|
record.WorldEntity = entity;
|
|
var builder = Builder();
|
|
|
|
LiveEntityCollisionRegistration registration = Assert.IsType<LiveEntityCollisionRegistration>(
|
|
builder.Build(entity, setup, Array.Empty<uint>(), spawn, record, new Vector3(192f, -192f, 0f)));
|
|
|
|
ShadowShape shape = Assert.Single(registration.Shapes);
|
|
Assert.Equal(ShadowCollisionType.Cylinder, shape.CollisionType);
|
|
Assert.Equal(0.8f, shape.Radius);
|
|
Assert.Equal(2.4f, shape.CylHeight);
|
|
Assert.Equal((uint)record.FinalPhysicsState, registration.State);
|
|
Assert.True(registration.Flags.HasFlag(EntityCollisionFlags.HasWeenie));
|
|
Assert.True(registration.Flags.HasFlag(EntityCollisionFlags.IsCreature));
|
|
Assert.True(registration.Flags.HasFlag(EntityCollisionFlags.IsPlayer));
|
|
Assert.True(registration.Flags.HasFlag(EntityCollisionFlags.IsPK));
|
|
Assert.Equal(Cell, registration.LandblockId);
|
|
Assert.Equal(Cell, registration.SeedCellId);
|
|
Assert.Equal((192f, -192f), (registration.WorldOffsetX, registration.WorldOffsetY));
|
|
}
|
|
|
|
/// <summary>
|
|
/// AP-22: retail synthesizes no shape for a Setup with no primitives and
|
|
/// no physics-BSP part, whatever its summary Radius/Height say.
|
|
/// <c>CPhysicsObj::FindObjCollisions</c> (0x0050f050) branches to the
|
|
/// epilogue at <c>0x0050f22f je 0x50f31b</c> and returns the seeded
|
|
/// OK_TS; <c>CPartArray::GetRadius</c>/<c>GetHeight</c> are absent from
|
|
/// its call set. This is the direct inverse of the deleted
|
|
/// RadiusFallback fact.
|
|
/// </summary>
|
|
[Fact]
|
|
public void ShapelessSetupWithRadius_ProducesNoRegistration()
|
|
{
|
|
var setup = new Setup { Radius = 0.75f, Height = 2.5f };
|
|
WorldSession.EntitySpawn spawn = Spawn(scale: 2f);
|
|
var record = LiveEntityTestFixture.CreateExactProjectionRecord(spawn);
|
|
WorldEntity entity = Entity();
|
|
record.WorldEntity = entity;
|
|
|
|
Assert.Null(Builder().Build(
|
|
entity,
|
|
setup,
|
|
Array.Empty<uint>(),
|
|
spawn,
|
|
record,
|
|
new Vector3(192f, -192f, 0f)));
|
|
}
|
|
|
|
[Fact]
|
|
public void BspOnlyPart_UsesRealScaledPhysicsBoundingRadius()
|
|
{
|
|
const uint part = 0x0100ABCDu;
|
|
var setup = new Setup();
|
|
setup.Parts.Add(part);
|
|
WorldSession.EntitySpawn spawn = Spawn(scale: 1.5f);
|
|
var record = LiveEntityTestFixture.CreateExactProjectionRecord(spawn);
|
|
WorldEntity entity = Entity();
|
|
record.WorldEntity = entity;
|
|
var builder = new LiveEntityCollisionBuilder(
|
|
id => id == part,
|
|
id => id == part ? 3f : null,
|
|
PoseResolver());
|
|
|
|
LiveEntityCollisionRegistration registration = Assert.IsType<LiveEntityCollisionRegistration>(
|
|
builder.Build(entity, setup, [part], spawn, record, Vector3.Zero));
|
|
|
|
ShadowShape shape = Assert.Single(registration.Shapes);
|
|
Assert.Equal(ShadowCollisionType.BSP, shape.CollisionType);
|
|
Assert.Equal(4.5f, shape.Radius);
|
|
Assert.Equal(part, shape.GfxObjId);
|
|
}
|
|
|
|
[Fact]
|
|
public void EffectiveReplacementWithoutPhysicsBsp_RemovesBasePartCollision()
|
|
{
|
|
const uint basePart = 0x0100AB01u;
|
|
const uint replacement = 0x0100AB02u;
|
|
var setup = new Setup();
|
|
setup.Parts.Add(basePart);
|
|
WorldSession.EntitySpawn spawn = Spawn(scale: 1f);
|
|
var record = LiveEntityTestFixture.CreateExactProjectionRecord(spawn);
|
|
WorldEntity entity = Entity();
|
|
record.WorldEntity = entity;
|
|
var builder = new LiveEntityCollisionBuilder(
|
|
id => id == basePart,
|
|
_ => 1f,
|
|
PoseResolver());
|
|
|
|
Assert.Null(builder.Build(
|
|
entity,
|
|
setup,
|
|
[replacement],
|
|
spawn,
|
|
record,
|
|
Vector3.Zero));
|
|
}
|
|
|
|
[Fact]
|
|
public void EffectiveReplacementWithPhysicsBsp_AddsReplacementCollision()
|
|
{
|
|
const uint basePart = 0x0100AB11u;
|
|
const uint replacement = 0x0100AB12u;
|
|
var setup = new Setup();
|
|
setup.Parts.Add(basePart);
|
|
WorldSession.EntitySpawn spawn = Spawn(scale: 1f);
|
|
var record = LiveEntityTestFixture.CreateExactProjectionRecord(spawn);
|
|
WorldEntity entity = Entity();
|
|
record.WorldEntity = entity;
|
|
var builder = new LiveEntityCollisionBuilder(
|
|
id => id == replacement,
|
|
id => id == replacement ? 2.25f : null,
|
|
PoseResolver());
|
|
|
|
LiveEntityCollisionRegistration registration =
|
|
Assert.IsType<LiveEntityCollisionRegistration>(builder.Build(
|
|
entity,
|
|
setup,
|
|
[replacement],
|
|
spawn,
|
|
record,
|
|
Vector3.Zero));
|
|
|
|
ShadowShape shape = Assert.Single(registration.Shapes);
|
|
Assert.Equal(replacement, shape.GfxObjId);
|
|
Assert.Equal(2.25f, shape.Radius);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReconcileAppearance_VisibleReplacementWithoutBspRemovesOldPayload()
|
|
{
|
|
const uint basePart = 0x0100AC01u;
|
|
const uint replacement = 0x0100AC02u;
|
|
var setup = new Setup();
|
|
setup.Parts.Add(basePart);
|
|
WorldSession.EntitySpawn spawn = Spawn(scale: 1f);
|
|
var record = LiveEntityTestFixture.CreateExactProjectionRecord(spawn);
|
|
WorldEntity entity = Entity();
|
|
record.WorldEntity = entity;
|
|
var builder = new LiveEntityCollisionBuilder(
|
|
id => id == basePart,
|
|
_ => 1f,
|
|
PoseResolver());
|
|
LiveEntityCollisionRegistration initial =
|
|
Assert.IsType<LiveEntityCollisionRegistration>(builder.Build(
|
|
entity, setup, [basePart], spawn, record, Vector3.Zero));
|
|
var registry = new ShadowObjectRegistry();
|
|
LiveEntityCollisionBuilder.Register(registry, initial);
|
|
|
|
LiveEntityCollisionRegistration? changed = builder.Build(
|
|
entity, setup, [replacement], spawn, record, Vector3.Zero,
|
|
retainEmptyPayload: true);
|
|
LiveEntityCollisionBuilder.ReconcileAppearance(
|
|
registry, entity.Id, changed, suspendIfNew: false);
|
|
|
|
Assert.Equal(1, registry.RetainedRegistrationCount);
|
|
Assert.DoesNotContain(
|
|
registry.GetObjectsInCell(Cell),
|
|
entry => entry.EntityId == entity.Id);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReconcileAppearance_SuspendedReplacementRetainsNewPayloadUntilRestore()
|
|
{
|
|
const uint basePart = 0x0100AC11u;
|
|
const uint replacement = 0x0100AC12u;
|
|
var setup = new Setup();
|
|
setup.Parts.Add(basePart);
|
|
WorldSession.EntitySpawn spawn = Spawn(scale: 1f);
|
|
var record = LiveEntityTestFixture.CreateExactProjectionRecord(spawn);
|
|
WorldEntity entity = Entity();
|
|
record.WorldEntity = entity;
|
|
var builder = new LiveEntityCollisionBuilder(
|
|
id => id == basePart || id == replacement,
|
|
_ => 1f,
|
|
PoseResolver());
|
|
LiveEntityCollisionRegistration initial =
|
|
Assert.IsType<LiveEntityCollisionRegistration>(builder.Build(
|
|
entity, setup, [basePart], spawn, record, Vector3.Zero));
|
|
var registry = new ShadowObjectRegistry();
|
|
LiveEntityCollisionBuilder.Register(registry, initial);
|
|
Assert.True(registry.Suspend(entity.Id));
|
|
LiveEntityCollisionRegistration changed =
|
|
Assert.IsType<LiveEntityCollisionRegistration>(builder.Build(
|
|
entity, setup, [replacement], spawn, record, Vector3.Zero));
|
|
|
|
LiveEntityCollisionBuilder.ReconcileAppearance(
|
|
registry, entity.Id, changed, suspendIfNew: true);
|
|
|
|
Assert.Equal(1, registry.RetainedRegistrationCount);
|
|
Assert.Equal(1, registry.SuspendedRegistrationCount);
|
|
Assert.DoesNotContain(
|
|
registry.GetObjectsInCell(Cell),
|
|
entry => entry.EntityId == entity.Id);
|
|
|
|
LiveEntityCollisionRegistration empty =
|
|
Assert.IsType<LiveEntityCollisionRegistration>(builder.Build(
|
|
entity,
|
|
setup,
|
|
[0x0100AC13u],
|
|
spawn,
|
|
record,
|
|
Vector3.Zero,
|
|
retainEmptyPayload: true));
|
|
LiveEntityCollisionBuilder.ReconcileAppearance(
|
|
registry, entity.Id, empty, suspendIfNew: true);
|
|
LiveEntityCollisionBuilder.ReconcileAppearance(
|
|
registry, entity.Id, changed, suspendIfNew: true);
|
|
Assert.Equal(1, registry.RetainedRegistrationCount);
|
|
Assert.Equal(1, registry.SuspendedRegistrationCount);
|
|
|
|
registry.UpdatePosition(
|
|
entity.Id,
|
|
entity.Position,
|
|
entity.Rotation,
|
|
worldOffsetX: 0f,
|
|
worldOffsetY: 0f,
|
|
landblockId: Cell,
|
|
seedCellId: Cell);
|
|
ShadowEntry restored = Assert.Single(
|
|
registry.GetObjectsInCell(Cell),
|
|
entry => entry.EntityId == entity.Id);
|
|
Assert.Equal(replacement, restored.GfxObjId);
|
|
Assert.Equal(0, registry.SuspendedRegistrationCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void ReconcileAppearance_ExistingMembershipDoesNotDependOnNewFlood()
|
|
{
|
|
const uint basePart = 0x0100AC21u;
|
|
const uint replacement = 0x0100AC22u;
|
|
var setup = new Setup();
|
|
setup.Parts.Add(basePart);
|
|
WorldSession.EntitySpawn spawn = Spawn(scale: 1f);
|
|
var record = LiveEntityTestFixture.CreateExactProjectionRecord(spawn);
|
|
WorldEntity entity = Entity();
|
|
record.WorldEntity = entity;
|
|
var builder = new LiveEntityCollisionBuilder(
|
|
id => id == basePart || id == replacement,
|
|
_ => 1f,
|
|
PoseResolver());
|
|
LiveEntityCollisionRegistration initial =
|
|
Assert.IsType<LiveEntityCollisionRegistration>(builder.Build(
|
|
entity, setup, [basePart], spawn, record, Vector3.Zero));
|
|
var registry = new ShadowObjectRegistry();
|
|
LiveEntityCollisionBuilder.Register(registry, initial);
|
|
LiveEntityCollisionRegistration changed =
|
|
Assert.IsType<LiveEntityCollisionRegistration>(builder.Build(
|
|
entity, setup, [replacement], spawn, record, Vector3.Zero))
|
|
with
|
|
{
|
|
LandblockId = 0u,
|
|
SeedCellId = 0u,
|
|
};
|
|
|
|
LiveEntityCollisionBuilder.ReconcileAppearance(
|
|
registry, entity.Id, changed, suspendIfNew: false);
|
|
|
|
ShadowEntry entry = Assert.Single(
|
|
registry.GetObjectsInCell(Cell),
|
|
candidate => candidate.EntityId == entity.Id);
|
|
Assert.Equal(replacement, entry.GfxObjId);
|
|
}
|
|
|
|
[Fact]
|
|
public void ShapelessSetup_ProducesNoRegistration()
|
|
{
|
|
WorldSession.EntitySpawn spawn = Spawn(scale: 1f);
|
|
var record = LiveEntityTestFixture.CreateExactProjectionRecord(spawn);
|
|
WorldEntity entity = Entity();
|
|
record.WorldEntity = entity;
|
|
|
|
Assert.Null(Builder().Build(
|
|
entity,
|
|
new Setup(),
|
|
Array.Empty<uint>(),
|
|
spawn,
|
|
record,
|
|
Vector3.Zero));
|
|
}
|
|
|
|
[Fact]
|
|
public void Build_RejectsDifferentRecordIncarnation()
|
|
{
|
|
WorldSession.EntitySpawn spawn = Spawn(scale: 1f);
|
|
var expected = LiveEntityTestFixture.CreateExactProjectionRecord(spawn);
|
|
var other = LiveEntityTestFixture.CreateExactProjectionRecord(
|
|
spawn with { InstanceSequence = 2 });
|
|
WorldEntity entity = Entity();
|
|
expected.WorldEntity = entity;
|
|
other.WorldEntity = entity;
|
|
|
|
Assert.Throws<InvalidOperationException>(() => Builder().Build(
|
|
entity,
|
|
new Setup { Radius = 1f },
|
|
Array.Empty<uint>(),
|
|
spawn,
|
|
other,
|
|
Vector3.Zero));
|
|
}
|
|
|
|
private static LiveEntityCollisionBuilder Builder() => new(
|
|
_ => false,
|
|
_ => null,
|
|
PoseResolver());
|
|
|
|
private static LiveEntityDefaultPoseResolver PoseResolver() => new(
|
|
_ => null,
|
|
new NullAnimationLoader(),
|
|
dumpMotion: false);
|
|
|
|
private static WorldEntity Entity() => new()
|
|
{
|
|
Id = 101u,
|
|
ServerGuid = Guid,
|
|
SourceGfxObjOrSetupId = 0x02000100u,
|
|
Position = new Vector3(4f, 5f, 6f),
|
|
Rotation = Quaternion.Identity,
|
|
MeshRefs = Array.Empty<MeshRef>(),
|
|
ParentCellId = Cell,
|
|
};
|
|
|
|
private static WorldSession.EntitySpawn Spawn(
|
|
float scale,
|
|
uint? itemType = null,
|
|
uint? descriptionFlags = null) =>
|
|
new(
|
|
Guid,
|
|
new CreateObject.ServerPosition(Cell, 4f, 5f, 6f, 1f, 0f, 0f, 0f),
|
|
0x02000100u,
|
|
Array.Empty<CreateObject.AnimPartChange>(),
|
|
Array.Empty<CreateObject.TextureChange>(),
|
|
Array.Empty<CreateObject.SubPaletteSwap>(),
|
|
BasePaletteId: null,
|
|
ObjScale: scale,
|
|
Name: "collision fixture",
|
|
ItemType: itemType,
|
|
MotionState: null,
|
|
MotionTableId: null,
|
|
PhysicsState: 0u,
|
|
ObjectDescriptionFlags: descriptionFlags,
|
|
InstanceSequence: 1);
|
|
|
|
private sealed class NullAnimationLoader : IAnimationLoader
|
|
{
|
|
public Animation? LoadAnimation(uint id) => null;
|
|
}
|
|
}
|