acdream/tests/AcDream.Core.Tests/Physics/ShadowSetPositionCommitTests.cs
Erik 13fcf38138 fix(physics): port retail's find_bbox_cell_list outdoor extent walk (#334)
acdream had never implemented retail's SECOND cell-membership algorithm.
CPhysicsObj::calc_cross_cells @0x00515230 tests HAS_PHYSICS_BSP_PS at
0x00515285 and jumps (0x0051528f jne 0x515305) to find_bbox_cell_list
@0x00510fc0 for a BSP-bearing object; everything below that jump is the
OTHER algorithm, CObjCell::find_cell_list, and that is all we had. Every
object, BSP-bearing or not, was routed through it.

That path's outdoor expansion is a HARD CAP of one cell in each direction.
CellTransit.AddAllOutsideCells computes minRad = radius, maxRad = 24 - radius
and adds at most the eight neighbours of the sphere's own cell, so for any
radius >= 12 m both boundary tests are unconditionally true and the result is
exactly 3x3. Widening the radius or adding a second sphere is mechanically
incapable of adding a tenth cell. The user's live probe measured the
consequence directly: standing inside a Neftet formation, inCell=2 exempt=2
reached=0 -- the geometry was not a candidate at all.

The port. AddAllOutsideCellsFromParts is CLandCell::add_all_outside_cells
@0x00533360 plus add_cell_block @0x005331d0: base landcell from the FIRST
part's own adjust_to_outside, baseX/baseY within-block, each part's authored
CGfxObj::gfx_bound_box re-fit through all eight corners
(BBox::LocalToGlobal @0x005b2120), floor(v / square_length) where
square_length = 0x7c920c = 24.0f, four accumulators seeded to zero, ONE
rectangle unioned across all parts, FILLED, in GLOBAL lcoords so it crosses
landblocks freely, clamped only to [0, 0x7f8).
BuildShadowCellSetFromParts is find_bbox_cell_list's worklist.
RegisterMultiPart dispatches on the same flag retail does, and
BuildFloodSpheres' BSP arm is deleted rather than left unreachable.

Disassembled from the PDB-paired 2013-09-06 binary, not read from Binary
Ninja: BN mis-renders four separate constructs inside add_all_outside_cells
alone -- a dropped `and eax,0xffff` on baseX, a neg/sbb/and select shown as
identically zero, a wrong get_landcell argument, and both x87 flag tests as
`unimplemented {test ah}`.

ShadowPartGeometry pairs the BSP root sphere with the authored box so no
resolver can answer one and leave the other call site to synthesize a
substitute -- the AP-156 invariant applied a second time, since that split is
what produced AP-156 and then this. The box comes from
FlatGfxObjVisualBounds, already computed by exactly CGfxObj::init_end's
algorithm and already in the prepared package: no bake change, no DAT re-read.

Cost, measured over the installed DATs before any code was written: 1,258
physics-BSP GfxObjs, cells/object p50 4, p90 4, p99 12, max 49. The port is
CHEAPER than the old 3x3 = 9 for 98.97% of them. Row totals (shapes x cells)
over all 1,031 landblocks with BSP owners fall 97,173 -> 15,607 (0.161x);
dense Arwic 0xC6A9 falls 342 -> 43. One landblock more than doubles.

Precondition confirmed before pinning any expected cell set: 0x010046D8's box
is 96 m x 96 m about cell (2,2) = 0x87640013, which independently corroborates
the 3x3-centred-there diagnosis, and its rectangle does contain 0x87640011 and
0x87640019 -- the two cells the probe measured empty.

Register: AP-156's outdoor half CLOSED and its risk column CORRECTED (it read
"extra broadphase candidates, never a missed one", which generalised the indoor
direction to the whole row and is why #334 sat inside it unnoticed). AP-159 +
issue #335 file the unported indoor arm; AD-49 records the seed-time rectangle.
Issue #336 files a fourth load-sensitive test flake seen once during the gate.

Ten tests, every one sabotage-verified in both directions across eight
mutations (dispatch, 8-corner refit, floor-vs-truncation, union-vs-per-part,
map clamp, adjust guard, landblock clamp, box-path-for-everything). The
strongest is an installed-DAT replay of the user's own probe evidence.
Suite 11,208 -> 11,218 passed / 4 skipped / 0 failed; the +10 is exactly the
new tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 19:07:06 +02:00

471 lines
18 KiB
C#

using System.Collections.Immutable;
using System.Numerics;
using AcDream.Core.Physics;
namespace AcDream.Core.Tests.Physics;
public sealed class ShadowSetPositionCommitTests
{
private const uint Landblock = 0xA9B40000u;
private const uint Cell1 = Landblock | 0x0001u;
private const uint Cell9 = Landblock | 0x0009u;
[Fact]
public void PreparedAuthoredMoveKeepsOldRowsUntilAtomicApply()
{
var registry = RegisteredSingle();
var moved = new Vector3(36f, 12f, 50f);
Assert.True(registry.TryPrepareSetPosition(
1u,
moved,
Quaternion.Identity,
Cell9,
0f,
0f,
PhysicsShadowCommitAction.Replace,
[Cell9],
provenShapeless: false,
suspendOwner: false,
out var prepared));
Assert.Equal(new Vector3(12f, 12f, 50f),
Assert.Single(registry.GetObjectsInCell(Cell1)).Position);
Assert.Empty(registry.GetObjectsInCell(Cell9));
Assert.True(registry.TryApplySetPosition(prepared!, out var receipt));
Assert.True(receipt.Mutated);
Assert.Empty(registry.GetObjectsInCell(Cell1));
Assert.Equal(moved,
Assert.Single(registry.GetObjectsInCell(Cell9)).Position);
}
[Fact]
public void PreparedSuspendedMoveRetainsNewCanonicalPosition()
{
var registry = RegisteredSingle();
var moved = new Vector3(36f, 12f, 50f);
Assert.True(registry.TryPrepareSetPosition(
1u,
moved,
Quaternion.Identity,
Cell9,
0f,
0f,
PhysicsShadowCommitAction.Replace,
[Cell9],
provenShapeless: false,
suspendOwner: true,
out var prepared));
Assert.Single(registry.GetObjectsInCell(Cell1));
Assert.True(registry.TryApplySetPosition(prepared!, out _));
Assert.Empty(registry.GetObjectsInCell(Cell1));
Assert.Empty(registry.GetObjectsInCell(Cell9));
Assert.Equal(1, registry.SuspendedRegistrationCount);
Assert.Equal(moved, prepared!.OwnerState!.Registration.EntityWorldPos);
Assert.Equal(Cell9, prepared.OwnerState.Registration.SeedCellId);
}
[Fact]
public void PreparedCrossPrefixDispatchesMembershipThenMutationOnce()
{
const uint otherCell = 0xA9B50001u;
var registry = RegisteredSingle();
var callbacks = new List<string>();
registry.OwnerPrefixMembershipChanged += (owner, prefix) =>
callbacks.Add($"prefix:{owner:X8}:{prefix:X8}");
registry.OwnerMutated += (owner, version) =>
callbacks.Add($"owner:{owner:X8}:{version}");
Assert.True(registry.TryPrepareSetPosition(
1u,
new Vector3(204f, 12f, 50f),
Quaternion.Identity,
otherCell,
192f,
0f,
PhysicsShadowCommitAction.Replace,
[otherCell],
provenShapeless: false,
suspendOwner: false,
out var prepared));
Assert.True(registry.TryApplySetPosition(prepared!, out var receipt));
Assert.Empty(callbacks);
registry.DispatchSetPositionCommit(receipt);
registry.DispatchSetPositionCommit(receipt);
Assert.Equal(
[
"prefix:00000001:A9B40000",
"prefix:00000001:A9B50000",
$"owner:00000001:{receipt.OwnerVersion}",
], callbacks);
}
[Fact]
public void TwoOwnerReceiptsDispatchExactlyOnceInReverseOrder()
{
const uint otherCell = 0xA9B50001u;
var registry = RegisteredSingle();
registry.Register(
2u, 0x01000002u, new Vector3(13f, 12f, 50f),
Quaternion.Identity, 1f, 0f, 0f, Landblock,
seedCellId: Cell1, isStatic: false);
var owners = new List<uint>();
registry.OwnerMutated += (owner, _) => owners.Add(owner);
Assert.True(registry.TryPrepareSetPosition(
1u, new Vector3(204f, 12f, 50f), Quaternion.Identity,
otherCell, 192f, 0f, PhysicsShadowCommitAction.Replace,
[otherCell], provenShapeless: false, suspendOwner: false,
out var first));
Assert.True(registry.TryApplySetPosition(first!, out var firstReceipt));
Assert.True(registry.TryPrepareSetPosition(
2u, new Vector3(205f, 12f, 50f), Quaternion.Identity,
otherCell, 192f, 0f, PhysicsShadowCommitAction.Replace,
[otherCell], provenShapeless: false, suspendOwner: false,
out var second));
Assert.True(registry.TryApplySetPosition(second!, out var secondReceipt));
registry.DispatchSetPositionCommit(secondReceipt);
registry.DispatchSetPositionCommit(firstReceipt);
registry.DispatchSetPositionCommit(secondReceipt);
registry.DispatchSetPositionCommit(firstReceipt);
Assert.Equal([2u, 1u], owners);
Assert.Equal(0, registry.PendingSetPositionDispatchCount);
}
[Fact]
public void PrefixObserverMutationCannotRegressFinalOwnerVersion()
{
const uint otherCell = 0xA9B50001u;
var registry = RegisteredSingle();
var versions = new List<ulong>();
var prefixes = new List<uint>();
bool mutated = false;
registry.OwnerPrefixMembershipChanged += (owner, prefix) =>
{
prefixes.Add(prefix);
if (mutated)
return;
mutated = true;
registry.UpdatePhysicsState(
owner,
(uint)PhysicsStateFlags.Hidden);
};
registry.OwnerMutated += (_, version) => versions.Add(version);
Assert.True(registry.TryPrepareSetPosition(
1u, new Vector3(204f, 12f, 50f), Quaternion.Identity,
otherCell, 192f, 0f, PhysicsShadowCommitAction.Replace,
[otherCell], provenShapeless: false, suspendOwner: false,
out var prepared));
Assert.True(registry.TryApplySetPosition(prepared!, out var receipt));
registry.DispatchSetPositionCommit(receipt);
Assert.Single(versions);
Assert.Equal(registry.GetOwnerVersion(1u), versions[0]);
Assert.Equal([Landblock], prefixes);
}
[Fact]
public void PreparedShapelessRequiresExplicitDispositionAndCreatesNoRows()
{
var registry = new ShadowObjectRegistry();
Assert.False(registry.TryPrepareSetPosition(
77u,
Vector3.One,
Quaternion.Identity,
Cell1,
0f,
0f,
PhysicsShadowCommitAction.Replace,
[Cell1],
provenShapeless: false,
suspendOwner: false,
out _));
Assert.True(registry.TryPrepareSetPosition(
77u,
Vector3.One,
Quaternion.Identity,
Cell1,
0f,
0f,
PhysicsShadowCommitAction.Replace,
[Cell1],
provenShapeless: true,
suspendOwner: false,
out var prepared));
Assert.True(registry.TryApplySetPosition(prepared!, out var receipt));
Assert.False(receipt.Mutated);
Assert.Empty(registry.GetObjectsInCell(Cell1));
Assert.False(registry.TryApplySetPosition(prepared!, out _));
}
[Fact]
public void ClearInvalidatesPreparedUnappliedShapelessCommit()
{
var registry = new ShadowObjectRegistry();
Assert.True(registry.TryPrepareSetPosition(
77u,
Vector3.One,
Quaternion.Identity,
Cell1,
0f,
0f,
PhysicsShadowCommitAction.Replace,
[Cell1],
provenShapeless: true,
suspendOwner: false,
out var prepared));
registry.Clear();
Assert.False(registry.TryApplySetPosition(prepared!, out _));
Assert.Equal(0, registry.PendingSetPositionDispatchCount);
Assert.Empty(registry.GetObjectsInCell(Cell1));
}
[Fact]
public void PreparedCommitRejectsStaleGlobalRevisionWithoutMutation()
{
var registry = RegisteredSingle();
Assert.True(registry.TryPrepareSetPosition(
1u, new Vector3(36f, 12f, 50f), Quaternion.Identity,
Cell9, 0f, 0f, PhysicsShadowCommitAction.Replace, [Cell9],
provenShapeless: false, suspendOwner: false, out var prepared));
registry.Register(
2u, 0x01000002u, new Vector3(13f, 12f, 50f),
Quaternion.Identity, 1f, 0f, 0f, Landblock,
seedCellId: Cell1, isStatic: false);
Vector3 before = registry.GetObjectsInCell(Cell1)
.Single(entry => entry.EntityId == 1u).Position;
Assert.False(registry.TryApplySetPosition(prepared!, out _));
Assert.Equal(before, registry.GetObjectsInCell(Cell1)
.Single(entry => entry.EntityId == 1u).Position);
Assert.Empty(registry.GetObjectsInCell(Cell9));
}
[Fact]
public void PreparedCommitRejectsStaleOwnerVersionWithoutMutation()
{
var registry = RegisteredSingle();
Assert.True(registry.TryPrepareSetPosition(
1u, new Vector3(36f, 12f, 50f), Quaternion.Identity,
Cell9, 0f, 0f, PhysicsShadowCommitAction.Replace, [Cell9],
provenShapeless: false, suspendOwner: false, out var prepared));
registry.UpdatePhysicsState(1u, (uint)PhysicsStateFlags.Hidden);
ShadowEntry before = Assert.Single(registry.GetObjectsInCell(Cell1));
Assert.False(registry.TryApplySetPosition(prepared!, out _));
Assert.Equal(before, Assert.Single(registry.GetObjectsInCell(Cell1)));
Assert.Empty(registry.GetObjectsInCell(Cell9));
}
[Fact]
public void PreparedApplyTailAllocatesZeroManagedBytes()
{
// Warm the generic owner-value and dictionary replacement paths.
var warm = RegisteredSingle();
Assert.True(warm.TryPrepareSetPosition(
1u, new Vector3(36f, 12f, 50f), Quaternion.Identity,
Cell9, 0f, 0f, PhysicsShadowCommitAction.Replace, [Cell9],
provenShapeless: false, suspendOwner: false, out var warmPrepared));
Assert.True(warm.TryApplySetPosition(warmPrepared!, out _));
var registry = RegisteredSingle();
Assert.True(registry.TryPrepareSetPosition(
1u, new Vector3(36f, 12f, 50f), Quaternion.Identity,
Cell9, 0f, 0f, PhysicsShadowCommitAction.Replace, [Cell9],
provenShapeless: false, suspendOwner: false, out var prepared));
long before = GC.GetAllocatedBytesForCurrentThread();
bool applied = registry.TryApplySetPosition(prepared!, out _);
long allocated = GC.GetAllocatedBytesForCurrentThread() - before;
Assert.True(applied);
Assert.Equal(0, allocated);
}
[Fact]
public void DeferredDispatchRejectsReceiptAfterInterveningOwnerMutation()
{
const uint otherCell = 0xA9B50001u;
var registry = RegisteredSingle();
var versions = new List<ulong>();
var prefixes = new List<uint>();
registry.OwnerMutated += (_, version) => versions.Add(version);
registry.OwnerPrefixMembershipChanged += (_, prefix) =>
prefixes.Add(prefix);
Assert.True(registry.TryPrepareSetPosition(
1u, new Vector3(204f, 12f, 50f), Quaternion.Identity,
otherCell, 192f, 0f, PhysicsShadowCommitAction.Replace,
[otherCell], provenShapeless: false, suspendOwner: false,
out var prepared));
Assert.True(registry.TryApplySetPosition(prepared!, out var receipt));
registry.UpdatePhysicsState(1u, (uint)PhysicsStateFlags.Hidden);
ulong current = registry.GetOwnerVersion(1u);
registry.DispatchSetPositionCommit(receipt);
Assert.Equal([current], versions);
Assert.Empty(prefixes);
}
[Fact]
public void NoneRefreshesFrameWithoutChangingExactMembership()
{
var registry = RegisteredSingle();
ulong version = registry.GetOwnerVersion(1u);
var moved = new Vector3(14f, 12f, 50f);
registry.CommitSetPosition(
1u, moved, Quaternion.Identity, Cell1, 0f, 0f,
PhysicsShadowCommitAction.None, []);
ShadowEntry entry = Assert.Single(registry.GetObjectsInCell(Cell1));
Assert.Equal(moved, entry.Position);
Assert.Empty(registry.GetObjectsInCell(Cell9));
Assert.Equal(version + 1UL, registry.GetOwnerVersion(1u));
}
[Fact]
public void ReplaceDeduplicatesExactCellsAndPreserveRetainsThem()
{
var registry = RegisteredSingle();
var replaced = new Vector3(36f, 12f, 50f);
registry.CommitSetPosition(
1u, replaced, Quaternion.Identity, Cell9, 0f, 0f,
PhysicsShadowCommitAction.Replace,
ImmutableArray.Create(Cell9, Cell9, 0u));
Assert.Empty(registry.GetObjectsInCell(Cell1));
Assert.Equal(replaced,
Assert.Single(registry.GetObjectsInCell(Cell9)).Position);
var preserved = new Vector3(38f, 12f, 50f);
registry.CommitSetPosition(
1u, preserved, Quaternion.Identity, Cell9, 0f, 0f,
PhysicsShadowCommitAction.Preserve, []);
Assert.Equal(preserved,
Assert.Single(registry.GetObjectsInCell(Cell9)).Position);
var emptyReplace = new Vector3(40f, 12f, 50f);
registry.CommitSetPosition(
1u, emptyReplace, Quaternion.Identity, Cell9, 0f, 0f,
PhysicsShadowCommitAction.Replace, []);
Assert.Equal(emptyReplace,
Assert.Single(registry.GetObjectsInCell(Cell9)).Position);
}
[Fact]
public void RecalculateUsesCanonicalFloodInsteadOfRetainedCells()
{
var registry = RegisteredSingle();
var moved = new Vector3(36f, 12f, 50f);
registry.CommitSetPosition(
1u, moved, Quaternion.Identity, Cell9, 0f, 0f,
PhysicsShadowCommitAction.Recalculate, []);
Assert.Empty(registry.GetObjectsInCell(Cell1));
Assert.Equal(moved,
Assert.Single(registry.GetObjectsInCell(Cell9)).Position);
}
[Fact]
public void SuspendedPreserveRestoresExactRowsAndConsumesReceipt()
{
var registry = RegisteredSingle();
Assert.True(registry.Suspend(1u));
Assert.Equal(0, registry.TotalRegistered);
Assert.Equal(1, registry.SuspendedRegistrationCount);
var moved = new Vector3(15f, 12f, 50f);
registry.CommitSetPosition(
1u, moved, Quaternion.Identity, Cell1, 0f, 0f,
PhysicsShadowCommitAction.Preserve, []);
Assert.Equal(1, registry.TotalRegistered);
Assert.Equal(0, registry.SuspendedRegistrationCount);
Assert.Equal(moved,
Assert.Single(registry.GetObjectsInCell(Cell1)).Position);
}
[Fact]
public void SuspendedReceiptCopiesAcrossCollisionRootAndClearsOnTeardown()
{
var source = RegisteredSingle();
Assert.True(source.Suspend(1u));
var destination = new ShadowObjectRegistry();
Assert.False(destination.RefreshRetainedOwnerFrom(
source, 1u, Landblock, out ulong version));
Assert.Equal(source.GetOwnerVersion(1u), version);
Assert.Equal(1, destination.RetainedRegistrationCount);
Assert.Equal(1, destination.SuspendedRegistrationCount);
destination.CommitSetPosition(
1u, new Vector3(16f, 12f, 50f), Quaternion.Identity,
Cell1, 0f, 0f, PhysicsShadowCommitAction.None, []);
Assert.Single(destination.GetObjectsInCell(Cell1));
destination.Deregister(1u);
Assert.Equal(0, destination.RetainedRegistrationCount);
Assert.Equal(0, destination.SuspendedRegistrationCount);
destination.Clear();
Assert.Equal(0, destination.TotalRegistered);
}
[Fact]
public void MultiPartNoneRefreshesEachPartFromRootTransform()
{
var registry = new ShadowObjectRegistry();
IReadOnlyList<ShadowShape> shapes =
[
Shape(0x01000001u, new Vector3(1f, 0f, 0f)),
Shape(0x01000002u, new Vector3(0f, 1f, 0f)),
];
registry.RegisterMultiPart(
2u, new Vector3(12f, 12f, 50f), Quaternion.Identity,
shapes, 0u, EntityCollisionFlags.None, 0f, 0f, Landblock,
Cell1);
Quaternion rotation = Quaternion.CreateFromAxisAngle(
Vector3.UnitZ, MathF.PI / 2f);
var root = new Vector3(14f, 12f, 50f);
registry.CommitSetPosition(
2u, root, rotation, Cell1, 0f, 0f,
PhysicsShadowCommitAction.None, []);
ShadowEntry[] entries = registry.GetObjectsInCell(Cell1)
.Where(entry => entry.EntityId == 2u)
.OrderBy(entry => entry.GfxObjId)
.ToArray();
Assert.Equal(2, entries.Length);
Assert.Equal(root + Vector3.Transform(shapes[0].LocalPosition, rotation),
entries[0].Position);
Assert.Equal(root + Vector3.Transform(shapes[1].LocalPosition, rotation),
entries[1].Position);
}
private static ShadowObjectRegistry RegisteredSingle()
{
var registry = new ShadowObjectRegistry();
registry.Register(
1u, 0x01000001u, new Vector3(12f, 12f, 50f),
Quaternion.Identity, 1f, 0f, 0f, Landblock,
seedCellId: Cell1, isStatic: false);
return registry;
}
private static ShadowShape Shape(uint gfxObjId, Vector3 local)
=> ShadowShape.Bsp(
gfxObjId,
local,
Quaternion.Identity,
scale: 1f,
localGeometry: ShadowPartGeometry.Create(new FlatCollisionSphere(Vector3.Zero, 0.25f), null));
}