acdream/tests/AcDream.Core.Tests/Physics/Issue334BspBoxCellMembershipTests.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

412 lines
20 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// #334: a physics-BSP object's outdoor cell membership is the FILLED
/// RECTANGLE of land cells its authored bounding box spans, not a fixed 3×3
/// neighbourhood.
///
/// <para>
/// Retail chain, disassembled from the PDB-paired 2013-09-06 binary:
/// <c>CPhysicsObj::calc_cross_cells</c> @0x00515230
/// (<c>0x00515285 test dword [esi+0xa8],0x10000</c>) →
/// <c>find_bbox_cell_list</c> @0x00510fc0 →
/// <c>CPartArray::calc_cross_cells_static</c> @0x00518160 →
/// <c>[vtbl+0x7c]</c> → <c>CLandCell::find_transit_cells</c> @0x00533840 →
/// <c>add_all_outside_cells</c> @0x00533360 → <c>add_cell_block</c>
/// @0x005331d0.
/// </para>
///
/// <para>
/// EVERY fixture here has an XY extent that EXCEEDS its own bounding-sphere
/// radius. That is the axis under test: a box that fits inside its sphere
/// makes the new path and the old 3×3 agree, and proves nothing. The sphere
/// radius is deliberately kept at 1 m so no assertion below can be satisfied
/// by the sphere route — retail's outdoor sphere reach is hard-capped at ±1
/// cell for ANY radius (<c>check_add_cell_boundary</c> compares against
/// <c>radius</c> and <c>24 - radius</c>, both unconditionally true above
/// 12 m, and only ever adds the eight neighbours).
/// </para>
/// </summary>
public sealed class Issue334BspBoxCellMembershipTests
{
// Landblock (0xA9, 0xB4). Global lcoord origin = (0xA9*8, 0xB4*8).
private const uint LbId = 0xA9B40000u;
private const int GxBase = 0xA9 * 8; // 1352
private const int GyBase = 0xB4 * 8; // 1440
/// <summary>Full outdoor cell id from a GLOBAL lcoord, hand-derived from
/// retail's <c>add_cell_block</c> packing at <c>0x0053320a</c>-<c>0x0053322e</c>:
/// <c>(((x&gt;&gt;3)&lt;&lt;8) | (y&gt;&gt;3)) &lt;&lt; 16 | ((x&amp;7)*8 + (y&amp;7) + 1)</c>.
/// Written out here rather than calling LandDefs so the expectation does
/// not re-encode the code under test.</summary>
private static uint Cell(int gx, int gy)
=> (uint)(((((gx >> 3) << 8) | (gy >> 3)) << 16) | ((gx & 7) * 8 + (gy & 7) + 1));
private static ShadowShape BspPart(
Vector3 boxMin,
Vector3 boxMax,
float sphereRadius = 1f,
Vector3 sphereCentre = default,
Vector3 localPosition = default,
Quaternion localRotation = default)
=> ShadowShape.Bsp(
gfxObjId: 0x010046D8u,
localPosition: localPosition,
localRotation: localRotation == default ? Quaternion.Identity : localRotation,
scale: 1f,
localGeometry: ShadowPartGeometry.Create(
new FlatCollisionSphere(sphereCentre, sphereRadius),
new FlatGfxObjVisualBounds(
boxMin,
boxMax,
(boxMin + boxMax) * 0.5f,
((boxMax - boxMin) * 0.5f).Length(),
(boxMax - boxMin) * 0.5f)));
/// <summary>The sphere-only configuration the port replaced: box collapses
/// to the sphere's own AABB. Used as the in-test control that the fixture
/// is non-degenerate.</summary>
private static ShadowShape SphereOnlyPart(
float sphereRadius,
Vector3 sphereCentre = default,
Vector3 localPosition = default)
=> ShadowShape.Bsp(
gfxObjId: 0x010046D8u,
localPosition: localPosition,
localRotation: Quaternion.Identity,
scale: 1f,
localGeometry: ShadowPartGeometry.Create(
new FlatCollisionSphere(sphereCentre, sphereRadius),
null));
private static List<uint> Rectangle(
Vector3 entityWorldPos,
uint seedCellId,
params ShadowShape[] shapes)
{
var boxes = shapes
.Select(s => ShadowPartBox.FromShape(s, entityWorldPos, Quaternion.Identity))
.ToList();
var candidates = new CellArray();
CellTransit.AddAllOutsideCellsFromParts(
boxes, seedCellId, Vector3.Zero, candidates);
return candidates.OrderedIds.ToList();
}
// ── T1 ────────────────────────────────────────────────────────────────
/// <summary>
/// A 100 m × 100 m box on a 1 m sphere spans five land cells per axis.
/// Sabotage: drop the box and flood from the sphere
/// (<see cref="SphereOnlyPart"/>) → one cell. The 5-per-axis span is
/// unreachable from ANY sphere, of any radius, through the 3×3 path.
/// </summary>
[Fact]
public void T1_HundredMetreBox_SpansFiveCellsPerAxis()
{
// Entity centred on cell (1,1): world (36, 36). Box ±50 m → world
// -14..86 per axis → floor(-14/24) = -1 .. floor(86/24) = 3, i.e.
// block-local cell columns -1..3, five per axis.
var shape = BspPart(new Vector3(-50f, -50f, -3f), new Vector3(50f, 50f, 3f));
List<uint> cells = Rectangle(new Vector3(36f, 36f, 0f), LbId | 10u, shape);
var expected = new List<uint>();
for (int x = GxBase - 1; x <= GxBase + 3; x++)
for (int y = GyBase - 1; y <= GyBase + 3; y++)
expected.Add(Cell(x, y));
Assert.Equal(25, cells.Count);
Assert.Equal(expected.OrderBy(v => v), cells.OrderBy(v => v));
// Control: the same part described only by its 1 m sphere collapses.
List<uint> sphereOnly = Rectangle(
new Vector3(36f, 36f, 0f), LbId | 10u, SphereOnlyPart(1f));
Assert.Single(sphereOnly);
Assert.Equal(Cell(GxBase + 1, GyBase + 1), sphereOnly[0]);
}
// ── T2 ────────────────────────────────────────────────────────────────
/// <summary>
/// The rectangle is FILLED and unioned ACROSS PARTS, not per part. Retail
/// combines the four DELTA accumulators over every part and calls
/// <c>add_cell_block</c> ONCE (<c>0x00533614</c>), so an L-shaped object
/// registers in the cells that close its L — cells its geometry never
/// enters.
///
/// <para>
/// The fixture is an L on purpose: one arm along +X, one along +Y. A
/// DIAGONAL fixture cannot detect the per-part sabotage, because retail
/// seeds the accumulators to ZERO (<c>0x00533390</c>), so each part's own
/// rectangle already spans from the base cell to that part — and for a
/// diagonal pair the two per-part rectangles union back to the same square.
/// Sabotage: emit one rectangle per part → the corner (3,3) disappears.
/// </para>
/// </summary>
[Fact]
public void T2_LShapedPartArray_ClaimsTheCornerThatClosesTheL()
{
var box = (Min: new Vector3(-5f, -5f, -2f), Max: new Vector3(5f, 5f, 2f));
var anchor = BspPart(box.Min, box.Max);
var eastArm = BspPart(box.Min, box.Max, localPosition: new Vector3(48f, 0f, 0f));
var northArm = BspPart(box.Min, box.Max, localPosition: new Vector3(0f, 48f, 0f));
var entity = new Vector3(36f, 36f, 0f);
List<uint> cells = Rectangle(entity, LbId | 10u, anchor, eastArm, northArm);
uint corner = Cell(GxBase + 3, GyBase + 3);
Assert.Contains(corner, cells);
Assert.Equal(9, cells.Count);
// Control: the corner is not reachable from any part's own rectangle,
// so the containment above cannot be satisfied by a per-part union.
Assert.DoesNotContain(corner, Rectangle(entity, LbId | 10u, anchor));
Assert.DoesNotContain(corner, Rectangle(entity, LbId | 10u, anchor, eastArm));
Assert.DoesNotContain(corner, Rectangle(entity, LbId | 10u, anchor, northArm));
}
// ── T3 ────────────────────────────────────────────────────────────────
/// <summary>
/// The rectangle crosses landblock boundaries freely: <c>add_cell_block</c>
/// works in GLOBAL lcoords and re-derives the block prefix per cell
/// (<c>0x0053320a</c>), so cells beyond column 7 carry the NEIGHBOUR
/// landblock's id. Sabotage: clamp the rectangle to the seed landblock →
/// the 0xAAB4 / 0xA9B5 rows vanish.
/// </summary>
[Fact]
public void T3_BoxPastTheBlockEdge_ProducesNeighbourLandblockCellIds()
{
// Entity on cell (7,7): world (180, 180). Box ±30 m → world 150..210
// → cell columns 6..8; column 8 is the neighbour block's column 0.
var shape = BspPart(new Vector3(-30f, -30f, -2f), new Vector3(30f, 30f, 2f));
List<uint> cells = Rectangle(new Vector3(180f, 180f, 0f), LbId | 64u, shape);
Assert.Equal(9, cells.Count);
Assert.Contains(Cell(GxBase + 7, GyBase + 7), cells); // 0xA9B40040
Assert.Contains(Cell(GxBase + 8, GyBase + 7), cells); // 0xAAB4xxxx
Assert.Contains(Cell(GxBase + 7, GyBase + 8), cells); // 0xA9B5xxxx
Assert.Contains(Cell(GxBase + 8, GyBase + 8), cells); // 0xAAB5xxxx
Assert.Contains(cells, id => (id & 0xFFFF0000u) == 0xAAB40000u);
Assert.Contains(cells, id => (id & 0xFFFF0000u) == 0xA9B50000u);
Assert.Contains(cells, id => (id & 0xFFFF0000u) == 0xAAB50000u);
}
// ── T4 ────────────────────────────────────────────────────────────────
/// <summary>
/// Map bounds. <c>add_cell_block</c> rejects any coordinate outside
/// <c>[0, 0x7f8)</c> (<c>0x005331f0</c>-<c>0x00533206</c>). Sabotage: drop
/// the clamp → cells wrap into the far corner of the map or produce id 0.
/// </summary>
[Fact]
public void T4_RectangleAtTheMapCorners_EmitsNothingOutsideTheMap()
{
// SW corner: landblock (0,0), entity on cell (0,0), box ±50 m reaches
// three cells into negative lcoords on both axes.
var box = BspPart(new Vector3(-50f, -50f, -2f), new Vector3(50f, 50f, 2f));
List<uint> sw = Rectangle(new Vector3(12f, 12f, 0f), 0x00000001u, box);
Assert.All(sw, id => Assert.NotEqual(0u, id));
Assert.Equal(9, sw.Count); // x 0..2 × y 0..2 survive
Assert.Contains(0x00000001u, sw);
// NE corner: landblock (254,254) — lcoords 2032..2039, the last legal
// row before 0x7f8 = 2040.
const uint neLb = 0xFEFE0000u;
int neGx = 254 * 8, neGy = 254 * 8;
List<uint> ne = Rectangle(new Vector3(180f, 180f, 0f), neLb | 64u, box);
Assert.All(ne, id => Assert.NotEqual(0u, id));
Assert.Equal(9, ne.Count); // x 2035..2037+ y likewise
Assert.Contains(Cell(neGx + 7, neGy + 7), ne);
Assert.DoesNotContain(Cell(2040 & 0x7FF, 2040 & 0x7FF), ne);
}
// ── T5 ────────────────────────────────────────────────────────────────
/// <summary>
/// <c>BBox::LocalToGlobal</c> @0x005b2120 re-fits through ALL EIGHT
/// corners, so a rotated box grows. Sabotage: transform only
/// <c>min</c> and <c>max</c> → the X overhang of a yawed asymmetric box
/// is lost and its westernmost cell disappears.
/// </summary>
[Fact]
public void T5_RotatedAsymmetricBox_KeepsTheCornerOverhangMinMaxWouldLose()
{
// Asymmetric box: X 0..60, Y 0..4. Yawed 37 degrees about Z the four
// XY corners land at (0,0), (47.92,36.11), (-2.41,3.19), (45.52,39.30);
// the true AABB therefore starts at x = -2.41, which is the corner a
// min/max-only transform (which sees only (0,0) and (45.52,39.30))
// cannot produce.
Quaternion yaw37 = Quaternion.CreateFromAxisAngle(
Vector3.UnitZ, 37f * MathF.PI / 180f);
var shape = BspPart(
new Vector3(0f, 0f, 0f), new Vector3(60f, 4f, 2f),
localRotation: yaw37);
// Entity at world x = 48 → the true box spans 45.59..95.92, crossing
// into cell column 1; the min/max-only box starts at exactly 48.0,
// which is column 2.
List<uint> cells = Rectangle(new Vector3(48f, 12f, 0f), LbId | 17u, shape);
Assert.Contains(Cell(GxBase + 1, GyBase + 0), cells);
Assert.Contains(Cell(GxBase + 3, GyBase + 2), cells);
// Control: unrotated, the same box starts at exactly x = 48 and never
// reaches column 1 — so the containment above is the rotation's doing.
var unrotated = BspPart(new Vector3(0f, 0f, 0f), new Vector3(60f, 4f, 2f));
List<uint> flat = Rectangle(new Vector3(48f, 12f, 0f), LbId | 17u, unrotated);
Assert.DoesNotContain(Cell(GxBase + 1, GyBase + 0), flat);
}
// ── T9 ────────────────────────────────────────────────────────────────
/// <summary>
/// <c>floor</c>, not truncation: retail calls <c>floor</c> then
/// <c>_ftol2</c> (<c>0x0053353c</c> / <c>0x00533542</c>). Sabotage:
/// <c>(int)(v / 24f)</c> → for a box overhanging the block's SW corner,
/// <c>-8/24</c> truncates to 0 and the previous landblock's column 7 is
/// silently dropped.
/// </summary>
[Fact]
public void T9_BoxOverhangingTheBlockOrigin_ReachesTheNegativeColumn()
{
var shape = BspPart(new Vector3(-20f, -20f, -2f), new Vector3(20f, 20f, 2f));
// Entity at world (12, 12): the box spans -8..32, whose floor is -1.
List<uint> cells = Rectangle(new Vector3(12f, 12f, 0f), LbId | 1u, shape);
Assert.Contains(Cell(GxBase - 1, GyBase - 1), cells); // 0xA8B3, cell 64
Assert.Contains(Cell(GxBase - 1, GyBase + 0), cells);
Assert.Contains(Cell(GxBase + 0, GyBase - 1), cells);
// -8..32 → floor gives columns -1..1, three per axis.
Assert.Equal(9, cells.Count);
Assert.Contains(Cell(GxBase + 1, GyBase + 1), cells);
}
// ── T8 ────────────────────────────────────────────────────────────────
/// <summary>
/// <c>adjust_to_outside</c> failing (map edge / invalid id) makes retail
/// return before <c>get_landcell</c> and add nothing
/// (<c>0x005333eb</c> select → gid 0 → <c>0x00533417 je</c>). Sabotage:
/// drop the null check → an exception or a bogus rectangle at lcoord 0.
/// </summary>
[Fact]
public void T8_BasePositionOffTheMap_AddsNothingAndDoesNotThrow()
{
var shape = BspPart(new Vector3(-5f, -5f, -2f), new Vector3(5f, 5f, 2f));
var boxes = new List<ShadowPartBox>
{
ShadowPartBox.FromShape(
shape, new Vector3(-100000f, -100000f, 0f), Quaternion.Identity),
};
var candidates = new CellArray();
bool added = CellTransit.AddAllOutsideCellsFromParts(
boxes, 0x00000001u, Vector3.Zero, candidates);
Assert.False(added);
Assert.Empty(candidates.OrderedIds);
}
// ── P2 / T6 ───────────────────────────────────────────────────────────
/// <summary>
/// Non-BSP invariance. A cylinder-only owner must still take retail's
/// cylsphere branch — <c>CObjCell::find_cell_list</c> @0x0052b9f0 — and
/// produce exactly the sphere flood's cell set. Sabotage: route every
/// owner through the box path → the sets diverge (the cylinder's box is
/// its own ±radius extent, which spans a different rectangle).
/// </summary>
[Fact]
public void T6_CylinderOnlyOwner_MatchesTheUntouchedSphereFlood()
{
var cylinder = ShadowShape.Cylinder(
gfxObjId: 0u,
localPosition: Vector3.Zero,
localRotation: Quaternion.Identity,
scale: 1f,
// r = 12 at the exact centre of cell (1,1) is the configuration in
// which the two routes DISAGREE: check_add_cell_boundary's tests
// are STRICT (pointX > 24-r, pointX < r), so 12 > 12 and 12 < 12
// both fail and the sphere claims exactly one cell — while the
// same extent as a BOX spans 24..48, whose floor is columns 1 AND
// 2. A fixture at any other radius makes the routes agree and
// proves nothing.
radius: 12f,
cylHeight: 24f);
var reg = new ShadowObjectRegistry();
const uint ownerId = 0x334001u;
var worldPos = new Vector3(36f, 36f, 50f);
reg.RegisterMultiPart(
ownerId, worldPos, Quaternion.Identity,
new[] { cylinder }, 0u, EntityCollisionFlags.None,
0f, 0f, LbId);
IReadOnlyList<uint> expected = CellTransit.BuildShadowCellSet(
new PhysicsDataCache(),
LbId | 10u,
new[]
{
new DatReaderWriter.Types.Sphere { Origin = worldPos, Radius = 12f },
},
1,
isStatic: false);
// Control: the golden must be the SINGLE cell only the sphere route
// produces, so the equality below cannot be satisfied by the box route.
Assert.Equal(new[] { LbId | 10u }, expected);
var actual = new List<uint>();
foreach (uint id in expected)
{
if (reg.GetObjectsInCell(id).Any(e => e.EntityId == ownerId))
actual.Add(id);
}
Assert.NotEmpty(expected);
Assert.Equal(expected.OrderBy(v => v), actual.OrderBy(v => v));
// And nothing outside it: the cylinder claims no cell the sphere
// flood did not.
for (uint index = 1u; index <= 64u; index++)
{
uint cellId = LbId | index;
bool held = reg.GetObjectsInCell(cellId).Any(e => e.EntityId == ownerId);
Assert.Equal(expected.Contains(cellId), held);
}
}
// ── P1 / dispatch ─────────────────────────────────────────────────────
/// <summary>
/// The dispatch itself: a BSP-bearing owner registered through
/// <see cref="ShadowObjectRegistry.RegisterMultiPart"/> lands in EVERY cell
/// of its box rectangle — not the nine of the sphere neighbourhood. This
/// is the end-to-end #334 fact at the production entry point.
/// </summary>
[Fact]
public void RegisterMultiPart_BspBearingOwner_OccupiesTheFullBoxRectangle()
{
var shape = BspPart(new Vector3(-50f, -50f, -3f), new Vector3(50f, 50f, 3f));
var reg = new ShadowObjectRegistry();
const uint ownerId = 0x334002u;
reg.RegisterMultiPart(
ownerId, new Vector3(36f, 36f, 0f), Quaternion.Identity,
new[] { shape }, 0u, EntityCollisionFlags.None,
0f, 0f, LbId, seedCellId: LbId | 10u);
int held = 0;
for (int x = GxBase - 1; x <= GxBase + 3; x++)
for (int y = GyBase - 1; y <= GyBase + 3; y++)
{
uint cellId = Cell(x, y);
Assert.Contains(
reg.GetObjectsInCell(cellId),
e => e.EntityId == ownerId);
held++;
}
Assert.Equal(25, held);
}
}