acdream/tests/AcDream.Content.Tests/Issue334NeftetFormationCellMembershipTests.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

142 lines
6.2 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.Collections.Generic;
using System.Linq;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.World;
using DatReaderWriter;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Options;
using DatReaderWriter.Types;
namespace AcDream.Content.Tests;
/// <summary>
/// #334, replayed against the installed DATs from the user's own live
/// evidence (<c>334-neftet-probe.log</c>, 8,401 lines, 2026-08-06).
///
/// <para>
/// Standing inside the Neftet rock formation the broadphase reported
/// <c>inCell=2 exempt=2 reached=0</c> — the formation was not in the player's
/// cell at all. The one object that DID block, GfxObj <c>0x010046D8</c>
/// (root bounding sphere radius 69.471 m, bounds centre 34.977 m off the part
/// origin), was measured PRESENT in cells <c>0x8764000A</c> and
/// <c>0x87640012</c> and ABSENT from <c>0x87640011</c>, <c>0x87640019</c> and
/// <c>0x87630018</c>. Those five observations have exactly one explanation:
/// a 3×3 land-cell neighbourhood centred on <c>0x87640013</c> — the cell
/// under the object's own position — which is what
/// <c>CellTransit.AddAllOutsideCells</c> produces for ANY sphere, because its
/// <c>minRad = radius</c> / <c>maxRad = 24 - radius</c> boundary tests are
/// unconditionally true above 12 m and it only ever adds the eight
/// neighbours.
/// </para>
///
/// <para>
/// This test asserts the observed reality rather than a constant: the two
/// cells the probe measured EMPTY must be occupied, and the two it measured
/// POPULATED must stay occupied. It fails at <c>f0588725</c> (the sphere route
/// cannot reach cellY 0 from a centre at cellY 2) and passes with the box
/// route. The expected rectangle was derived from the object's own
/// <c>CGfxObj::gfx_bound_box</c> read out of <c>client_portal.dat</c>, not
/// from running the code under test: the box is 96 m × 96 m about a part
/// origin at block-local (63.78, 56.29), i.e. cell (2,2), so it spans cell
/// columns 0..4 on both axes.
/// </para>
/// </summary>
public sealed class Issue334NeftetFormationCellMembershipTests
{
private const uint NeftetLandblock = 0x87640000u;
private const uint NeftetLandblockInfo = 0x8764FFFEu;
private const uint FormationGfxObj = 0x010046D8u;
// The four cells named in the probe log, by their measured disposition.
private const uint MeasuredPresentA = 0x8764000Au; // lcoord (1081, 801)
private const uint MeasuredPresentB = 0x87640012u; // lcoord (1082, 801)
private const uint MeasuredAbsentA = 0x87640011u; // lcoord (1082, 800)
private const uint MeasuredAbsentB = 0x87640019u; // lcoord (1083, 800)
[Fact]
public void NeftetFormation_RegistersInTheCellsTheProbeMeasuredEmpty()
{
string? datDir = ContentConformanceDats.ResolveDatDir();
if (datDir is null)
return;
using var dats = new DatCollection(datDir, DatAccessType.Read);
Assert.True(
dats.Cell.TryGet<LandBlockInfo>(NeftetLandblockInfo, out LandBlockInfo? info)
&& info is not null,
"Neftet landblock info 0x8764FFFE is absent from the installed cell dat.");
// The probe's entity 0xC8764000 is stab index 0 of this landblock
// (LandblockStaticEntityIdAllocator's 0xCXXYYIII packing).
Stab formation = info!.Objects.First(o => o.Id == FormationGfxObj);
Assert.True(
dats.Portal.TryGet<GfxObj>(FormationGfxObj, out GfxObj? gfx) && gfx is not null,
"GfxObj 0x010046D8 is absent from the installed portal dat.");
// Control: the fixture must be non-degenerate on the axis under test.
// A box that fits inside its own bounding sphere makes the box route
// and the sphere route agree, and proves nothing.
var cache = new PhysicsDataCache();
cache.CacheGfxObj(FormationGfxObj, gfx!);
GfxObjPhysics? phys = cache.GetGfxObj(FormationGfxObj);
Assert.NotNull(phys);
Assert.NotNull(phys!.VisualBounds);
FlatGfxObjVisualBounds box = phys.VisualBounds!.Value;
float extentX = box.Max.X - box.Min.X;
float extentY = box.Max.Y - box.Min.Y;
float rootRadius = phys.BoundingSphere!.Radius;
Assert.True(
extentX > rootRadius && extentY > rootRadius,
$"Fixture is degenerate: extent ({extentX:F2}, {extentY:F2}) does not " +
$"exceed the root sphere radius {rootRadius:F3}.");
Assert.True(
extentX > 48f && extentY > 48f,
$"Fixture cannot reach two cells away: extent ({extentX:F2}, {extentY:F2}).");
IReadOnlyList<ShadowShape> shapes =
ShadowShapeBuilder.FromLandblockBspParts(
new[] { new MeshRef(FormationGfxObj, Matrix4x4.Identity) },
isBuildingShell: false,
cache.GetGfxObj);
ShadowShape only = Assert.Single(shapes);
Assert.Equal(ShadowCollisionType.BSP, only.CollisionType);
var registry = new ShadowObjectRegistry { DataCache = cache };
const uint ownerId = 0xC8764000u;
registry.RegisterMultiPart(
ownerId,
formation.Frame.Origin,
formation.Frame.Orientation,
shapes,
0u,
EntityCollisionFlags.None,
worldOffsetX: 0f,
worldOffsetY: 0f,
landblockId: NeftetLandblock,
seedCellId: 0u,
isStatic: true);
var held = new List<uint>();
for (uint index = 1u; index <= 64u; index++)
{
uint cellId = NeftetLandblock | index;
if (registry.GetObjectsInCell(cellId).Any(e => e.EntityId == ownerId))
held.Add(cellId);
}
// The measured-populated pair must stay populated.
Assert.Contains(MeasuredPresentA, held);
Assert.Contains(MeasuredPresentB, held);
// The measured-EMPTY pair is the #334 fact.
Assert.Contains(MeasuredAbsentA, held);
Assert.Contains(MeasuredAbsentB, held);
// And the rectangle is the 5×5 the 96 m box spans about cell (2,2),
// clipped to this landblock's own 8×8 grid (the two columns below 0
// land in the neighbour blocks 0x8763 / 0x8664 and are counted there).
Assert.Equal(25, held.Count);
}
}