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>
553 lines
21 KiB
C#
553 lines
21 KiB
C#
using System.Numerics;
|
|
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.Physics;
|
|
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.ServerGuid,
|
|
record.Generation,
|
|
record.WorldEntity!,
|
|
record.FinalPhysicsState,
|
|
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.ServerGuid,
|
|
record.Generation,
|
|
record.WorldEntity!,
|
|
record.FinalPhysicsState,
|
|
new Vector3(192f, -192f, 0f)));
|
|
}
|
|
|
|
/// <summary>
|
|
/// The BSP root bounding sphere reaches the shape WHOLE — radius and
|
|
/// centre, both scaled. Retail's <c>CGfxObj::physics_sphere</c> is
|
|
/// <c>BSPTREE::GetSphere(physics_bsp)</c> @0x005397e0, and
|
|
/// <c>CEnvCell::find_transit_cells</c> @0x0052cae0 transforms its centre
|
|
/// through the part's Position (<c>0x0052cb4c add eax,0x30</c>) before
|
|
/// reading the radius at <c>0x0052cb65</c>. Carrying only the radius is
|
|
/// AP-156: 428 of the 530 installed BSP-bearing Setups then flood from
|
|
/// spheres that do not contain their own physics polygons. (The row as
|
|
/// filed said "170 of 172"; 172 is AP-152's DISPATCH population, not
|
|
/// AP-156's containment population — corrected at the fix review.)
|
|
/// </summary>
|
|
[Fact]
|
|
public void BspOnlyPart_UsesRealScaledPhysicsBoundingSphere()
|
|
{
|
|
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 ? Bsp(3f, centerZ: 2f) : null,
|
|
PoseResolver());
|
|
|
|
LiveEntityCollisionRegistration registration = Assert.IsType<LiveEntityCollisionRegistration>(
|
|
builder.Build(
|
|
entity,
|
|
setup,
|
|
[part],
|
|
spawn,
|
|
record.ServerGuid,
|
|
record.Generation,
|
|
record.WorldEntity!,
|
|
record.FinalPhysicsState,
|
|
Vector3.Zero));
|
|
|
|
ShadowShape shape = Assert.Single(registration.Shapes);
|
|
Assert.Equal(ShadowCollisionType.BSP, shape.CollisionType);
|
|
Assert.Equal(4.5f, shape.Radius); // 3 m * 1.5
|
|
Assert.Equal(new Vector3(0f, 0f, 3f), shape.BoundsCenter); // 2 m * 1.5
|
|
Assert.Equal(part, shape.GfxObjId);
|
|
}
|
|
|
|
/// <summary>
|
|
/// AP-152. Every other fixture in this file is primitive-only or BSP-only,
|
|
/// so nothing at the App layer used to exercise the CylSphere+BSP
|
|
/// combination — 73 of the 172 affected installed Setups.
|
|
/// Retail's <c>CPhysicsObj::FindObjCollisions</c> @0x0050f050 tests
|
|
/// <c>HAS_PHYSICS_BSP_PS</c> at 0x0050f165 and leaves the BSP branch
|
|
/// through the unconditional <c>0x0050f19d jmp 0x50f2b0</c>, past both the
|
|
/// CylSphere loop (0x50f1a2) and the Sphere loop (0x50f21d);
|
|
/// <c>calc_cross_cells</c> @0x00515230 dispatches identically at
|
|
/// 0x00515285. The CylSphere must not survive, and the surviving BSP shape
|
|
/// must still receive the real scaled bounding radius.
|
|
/// </summary>
|
|
[Fact]
|
|
public void CylSphereAndPhysicsBspPart_EmitsOnlyTheScaledBspShape()
|
|
{
|
|
const uint part = 0x0100AC01u;
|
|
var setup = new Setup();
|
|
setup.Parts.Add(part);
|
|
setup.CylSpheres.Add(new CylSphere
|
|
{
|
|
Origin = Vector3.Zero,
|
|
Radius = 0.4f,
|
|
Height = 1.2f,
|
|
});
|
|
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 ? Bsp(3f, centerZ: 2f) : null,
|
|
PoseResolver());
|
|
|
|
LiveEntityCollisionRegistration registration =
|
|
Assert.IsType<LiveEntityCollisionRegistration>(builder.Build(
|
|
entity,
|
|
setup,
|
|
[part],
|
|
spawn,
|
|
record.ServerGuid,
|
|
record.Generation,
|
|
record.WorldEntity!,
|
|
record.FinalPhysicsState,
|
|
Vector3.Zero));
|
|
|
|
ShadowShape shape = Assert.Single(registration.Shapes);
|
|
Assert.Equal(ShadowCollisionType.BSP, shape.CollisionType);
|
|
Assert.Equal(4.5f, shape.Radius); // 3 m physics-BSP radius * 1.5 scale
|
|
Assert.Equal(new Vector3(0f, 0f, 3f), shape.BoundsCenter);
|
|
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 ? Bsp(1f) : null,
|
|
PoseResolver());
|
|
|
|
Assert.Null(builder.Build(
|
|
entity,
|
|
setup,
|
|
[replacement],
|
|
spawn,
|
|
record.ServerGuid,
|
|
record.Generation,
|
|
record.WorldEntity!,
|
|
record.FinalPhysicsState,
|
|
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 ? Bsp(2.25f) : null,
|
|
PoseResolver());
|
|
|
|
LiveEntityCollisionRegistration registration =
|
|
Assert.IsType<LiveEntityCollisionRegistration>(builder.Build(
|
|
entity,
|
|
setup,
|
|
[replacement],
|
|
spawn,
|
|
record.ServerGuid,
|
|
record.Generation,
|
|
record.WorldEntity!,
|
|
record.FinalPhysicsState,
|
|
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 ? Bsp(1f) : null,
|
|
PoseResolver());
|
|
LiveEntityCollisionRegistration initial =
|
|
Assert.IsType<LiveEntityCollisionRegistration>(builder.Build(
|
|
entity,
|
|
setup,
|
|
[basePart],
|
|
spawn,
|
|
record.ServerGuid,
|
|
record.Generation,
|
|
record.WorldEntity!,
|
|
record.FinalPhysicsState,
|
|
Vector3.Zero));
|
|
var registry = new ShadowObjectRegistry();
|
|
LiveEntityCollisionBuilder.Register(registry, initial);
|
|
|
|
LiveEntityCollisionRegistration? changed = builder.Build(
|
|
entity,
|
|
setup,
|
|
[replacement],
|
|
spawn,
|
|
record.ServerGuid,
|
|
record.Generation,
|
|
record.WorldEntity!,
|
|
record.FinalPhysicsState,
|
|
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 ? Bsp(1f) : null,
|
|
PoseResolver());
|
|
LiveEntityCollisionRegistration initial =
|
|
Assert.IsType<LiveEntityCollisionRegistration>(builder.Build(
|
|
entity,
|
|
setup,
|
|
[basePart],
|
|
spawn,
|
|
record.ServerGuid,
|
|
record.Generation,
|
|
record.WorldEntity!,
|
|
record.FinalPhysicsState,
|
|
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.ServerGuid,
|
|
record.Generation,
|
|
record.WorldEntity!,
|
|
record.FinalPhysicsState,
|
|
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.ServerGuid,
|
|
record.Generation,
|
|
record.WorldEntity!,
|
|
record.FinalPhysicsState,
|
|
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 ? Bsp(1f) : null,
|
|
PoseResolver());
|
|
LiveEntityCollisionRegistration initial =
|
|
Assert.IsType<LiveEntityCollisionRegistration>(builder.Build(
|
|
entity,
|
|
setup,
|
|
[basePart],
|
|
spawn,
|
|
record.ServerGuid,
|
|
record.Generation,
|
|
record.WorldEntity!,
|
|
record.FinalPhysicsState,
|
|
Vector3.Zero));
|
|
var registry = new ShadowObjectRegistry();
|
|
LiveEntityCollisionBuilder.Register(registry, initial);
|
|
LiveEntityCollisionRegistration changed =
|
|
Assert.IsType<LiveEntityCollisionRegistration>(builder.Build(
|
|
entity,
|
|
setup,
|
|
[replacement],
|
|
spawn,
|
|
record.ServerGuid,
|
|
record.Generation,
|
|
record.WorldEntity!,
|
|
record.FinalPhysicsState,
|
|
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.ServerGuid,
|
|
record.Generation,
|
|
record.WorldEntity!,
|
|
record.FinalPhysicsState,
|
|
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.ServerGuid,
|
|
other.Generation,
|
|
other.WorldEntity!,
|
|
other.FinalPhysicsState,
|
|
Vector3.Zero));
|
|
}
|
|
|
|
private static LiveEntityCollisionBuilder Builder() => new(
|
|
_ => null,
|
|
PoseResolver());
|
|
|
|
/// <summary>
|
|
/// A physics-BSP root bounding sphere. DELIBERATELY OFF-CENTRE by
|
|
/// default: a GfxObj's BSP is authored in the GfxObj's own coordinates
|
|
/// and its root sphere is usually NOT centred on that origin (376 of the
|
|
/// 973 installed physics-BSP parts sit further from it than half their
|
|
/// radius). A fixture pinned at <c>Vector3.Zero</c> cannot observe the
|
|
/// centre at all — which is how AP-156's discarded origin stayed green.
|
|
/// </summary>
|
|
private static ShadowPartGeometry? Bsp(float radius, float centerZ = 1.25f)
|
|
=> ShadowPartGeometry.Create(
|
|
new FlatCollisionSphere(new Vector3(0f, 0f, centerZ), radius),
|
|
null);
|
|
|
|
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;
|
|
}
|
|
}
|