acdream/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryMultiPartTests.cs
Erik b52967def3 fix(physics): AP-156 — flood the BSP sphere where the geometry is, not at the part origin
The AP-152 retail review (docs/research/2026-08-06-ap152-review-retail.md)
FAILED `4abd1b5e` and is right. `ShadowObjectRegistry.BuildFloodSpheres` took
each physics-BSP part's ROOT BOUNDING SPHERE RADIUS
(FlatCollisionAssetBuilder.cs:393 -> LiveEntityCollisionBuilder.cs:137) and
centred it on the PART ORIGIN (ShadowShapeBuilder.cs:194), discarding the root
sphere's own Origin.

Re-measured independently against the installed client_portal.dat, reproducing
the reviewer's numbers exactly: 376 of 973 physics-BSP parts have
|origin| > radius/2, worst 20.762 m on a 27.708 m sphere (gfx 0x010036DD,
Setup 0x0200129A). Over the 172 Setups AP-152 moved onto that path the emitted
flood FAILED TO CONTAIN the object's own BSP sphere for 170 of them (73
CylSphere-bearing, 97 Sphere-bearing), worst shortfall 9.911 m on Setup
0x02000255 — whose one part's sphere sits 9.911 m above the part origin — and
for 43 the post-AP-152 flood was strictly SMALLER than the pre-AP-152 one.
Indoor flooding is 3-D (CellTransit.cs:601 routes every id & 0xFFFF >= 0x0100
candidate through FindTransitCellsSphere), so a tall prop or door slab was
absent from EnvCells it physically occupies and therefore never a broadphase
candidate there (TransitionTypes.cs:3763 iterates only entries already in the
cell). That is the #98 / #168 class AP-152 exists to remove.

Retail, re-disassembled from the PDB-paired binary (check_exe_pdb.py MATCH,
CodeView GUID 9e847e2f-777c-4bd9-886c-22256bb87f32), every address resolved
back through named-retail/symbols.json:

  CGfxObj::physics_sphere is [gfxobj+0x74] (physics_bsp is [+0x78], as
  CPartArray::CacheHasPhysicsBSP @0x00518110 reads at 0x00518127), and
  acclient pseudo-C 0x00534b5b assigns it BSPTREE::GetSphere(physics_bsp).

  BSPTREE::GetSphere @0x005397e0
    8b01        mov eax,[ecx]     ; BSPTREE::root_node
    83c004      add eax,4         ; past BSPNODE::vfptr -> CSphere sphere
  So retail's per-part flood sphere IS the BSP root bounding sphere,
  ORIGIN INCLUDED (acclient.h: BSPNODE { vfptr; CSphere sphere; ... },
  CSphere { Vector3 center; float radius; } -> radius at +0xc).

  CPhysicsObj::find_bbox_cell_list @0x00510fc0 adds the object's own cell and
  then walks the PART ARRAY: 0x00511012 call 0x518160
  (CPartArray::calc_cross_cells_static), which dispatches [edx+0x7c] with
  (num_parts, parts, cellarray). Its EnvCell body,
  CEnvCell::find_transit_cells @0x0052cae0:
    0x0052cb31  mov edx,[eax+0x20]   ; CPhysicsPart::gfxobj (CGfxObj**)
    0x0052cb36  mov esi,[ecx+0x74]   ; physics_sphere (else +0x90 drawing)
    0x0052cb4c  add eax,0x30         ; CPhysicsPart::pos
    0x0052cb5a  call Position::localtolocal   ; transform the sphere CENTRE
    0x0052cb65  fadd [esi+0xc]       ; only NOW the radius
  Retail transforms the centre through the part's own Position before it ever
  touches the radius. Carrying the radius alone is not an approximation of
  that; it is a different sphere.

Changes:

* `ShadowShape` gains `BoundsCenter` — the bounding sphere's centre in the
  shape's own local frame, scaled like LocalPosition and Radius. Zero for
  Cylinder/Sphere shapes, whose LocalPosition already IS their centre.

* `ShadowShapeBuilder.FromSetup` gains a `physicsBspBounds` resolver that
  supplies radius AND centre from ONE call, replacing the placeholder radius
  plus a downstream substitution. `LiveEntityCollisionBuilder` now holds a
  single `Func<uint, FlatCollisionSphere?>` and derives its dispatch predicate
  from it, so the gate and the geometry cannot disagree and the radius cannot
  be taken while the origin is dropped. That split is what produced this bug;
  it no longer exists.

* `FromLandblockBspParts` carries the centre too. A landblock-baked part array
  is the same CPartArray walk, so stair runs, fences and rock clusters had the
  identical defect. Both storage forms (flat BSP and the graph fallback) are
  covered.

* `BuildFloodSpheres` places each sphere at
  partWorldPos + rotate(BoundsCenter, partWorldRot), composed exactly as the
  ShadowEntry rows are.

* The 10-sphere clamp now applies to the CYLSPHERE branch only. Retail's clamp
  is inside CObjCell::find_cell_list @0x0052b9f0
  (0x0052ba21 cmp eax,0xa / 0x0052ba28 mov ebp,0xa); the BSP walk has none and
  the sorting-sphere overload @0x0052b990 takes one sphere. 7 installed Setups
  carry more than 10 physics-BSP parts (max 49, Setup 0x02001A91) and their
  tail parts were dropped from the flood entirely. Without this the new
  containment assertion would have covered shapes production never floods
  from.

Register. AP-155 was two divergences with different code paths, populations
and gates under one id; it is NARROWED to its static-publication half and its
flood half is split out as AP-156 WITH ITS DIRECTION CORRECTED. AP-155(b)
recorded the approximation as over-inclusive — "floods MORE cells rather than
fewer, the safe direction for membership" — and that false direction was the
stated reason the residual was safe to defer. It was under-inclusive for 170
of 172. AP-156 records the correction, this fix, and the one genuine residual:
acdream's sphere-vs-portal traversal where retail walks each part's sphere
against the cell's own portal planes. AP-155(b)'s "acdream approximates
retail's bounding BOX" was wrong too — find_bbox_cell_list forms no box.
AP-157 filed for the review's F4: retail's third branch floods from ONE
CPartArray::GetSortingSphere @0x00518b00 ([partArray+0x54]+0x70 =
CSetup::sorting_sphere; 4,154 of 5,935 installed Setups carry a non-zero one)
where acdream floods from every Sphere shape, and acdream's cylinder flood
ignores CylHeight. Deliberately NOT bundled here: different branch, disjoint
population, different live gate. Active AP rows 107 -> 109, literal count.

Tests. Both flood tests the review named substituted a CONCENTRIC Radius = 14f
at LocalPosition = Zero — the one configuration in which the defect cannot
appear. Every fixture is now off-centre by default, and
`FromSetup_CylSphereAndBspSetup_FloodsTheBspFootprint` drives the production
`physicsBspBounds` seam instead of hand-substituting. Five new facts: the
flood centres on BoundsCenter not the part origin; it rotates BoundsCenter by
the part rotation; it caps cylspheres at ten but never the BSP parts; the
landblock path carries the scaled centre in both storage forms; and an
installed-DAT containment sweep asserting every emitted BSP flood sphere
contains that part's real bounding sphere at entity scale 1.75, behind four
external controls — 973 parts, 376 off-centre, 172 affected, and 170
would-fail-if-the-origin-were-discarded, the last of which fails if the
population ever stops exercising the field.

Nine sabotages, each reverted and re-verified:
  A drop BoundsCenter from the flood       -> 3 Core
  B rotate by entity rot, not part rot     -> 1 Core (the rotation fact only)
  C FromSetup discards the origin          -> 1 Core + 2 App + 1 Content
     (the shipped defect, now caught in three projects)
  D drop entScale on BoundsCenter          -> 2 App + 1 Content
  E landblock flat branch drops the centre -> 1 Core
  F landblock graph branch drops it        -> 1 Core
  G drop partScale on the landblock centre -> 1 Core
  H re-apply the 10-cap to every branch    -> 1 Core
  I remove the cylsphere cap               -> 1 Core
AP-152's own two sabotages re-run against this tree: the step-0 gate disabled
still reddens exactly its five facts with Headless 89/89 green, and
cylinder-first flooding still reddens exactly one.

Clean Release build after deleting all 44 bin/obj: 0 errors, 21 pre-existing
warnings. Complete suite 11,208 passed / 4 skipped / 0 failed, +5 on the
11,203 baseline at 4abd1b5e — Core 4264 -> 4268, Content 126 -> 127, App
unchanged (one rename, not an addition). No new skips.

NOT yet gated live. This moves shadow-cell membership for real objects, in
both directions, and the connected session must look for both: props and doors
that START blocking from a neighbouring cell (the 73 CylSphere+BSP Setups),
AND ones that STOP blocking (the 99 Sphere+BSP Setups can shrink; 43 shrink
below their pre-4abd1b5e size, which is the regression this fixes). Tall
indoor props and door slabs — the ones whose sphere sits metres above the part
origin — are where the change is largest.

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

472 lines
20 KiB
C#

using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
public class ShadowObjectRegistryMultiPartTests
{
private const uint LbId = 0xA9B40000u;
private const float OffX = 0f;
private const float OffY = 0f;
private static IReadOnlyList<ShadowShape> DoorShapes() => new[]
{
new ShadowShape(
GfxObjId: 0u,
LocalPosition: new Vector3(0f, 0f, 0.018f),
LocalRotation: Quaternion.Identity,
Scale: 1.0f,
CollisionType: ShadowCollisionType.Cylinder,
Radius: 0.100f,
CylHeight: 0.200f),
new ShadowShape(
GfxObjId: 0x010044B5u,
LocalPosition: Vector3.Zero,
LocalRotation: Quaternion.Identity,
Scale: 1.0f,
CollisionType: ShadowCollisionType.BSP,
Radius: 2.0f,
CylHeight: 0f),
new ShadowShape(
GfxObjId: 0x010044B6u,
LocalPosition: Vector3.Zero,
LocalRotation: Quaternion.Identity,
Scale: 1.0f,
CollisionType: ShadowCollisionType.BSP,
Radius: 2.0f,
CylHeight: 0f),
new ShadowShape(
GfxObjId: 0x010044B6u,
LocalPosition: Vector3.Zero,
LocalRotation: Quaternion.Identity,
Scale: 1.0f,
CollisionType: ShadowCollisionType.BSP,
Radius: 2.0f,
CylHeight: 0f)
};
[Fact]
public void RegisterMultiPart_FourShapes_AllShareEntityId()
{
var reg = new ShadowObjectRegistry();
const uint doorEntityId = 0x000F4244u;
reg.RegisterMultiPart(
entityId: doorEntityId,
entityWorldPos: new Vector3(132.6f, 17.1f, 94.08f),
entityWorldRot: Quaternion.Identity,
shapes: DoorShapes(),
state: 0x10008u,
flags: EntityCollisionFlags.None,
worldOffsetX: OffX,
worldOffsetY: OffY,
landblockId: LbId);
int found = 0;
foreach (var entry in reg.AllEntriesForDebug())
{
if (entry.EntityId == doorEntityId) found++;
}
Assert.True(found >= 4,
$"Expected at least 4 entries for door entity (one per shape); found {found}");
}
[Fact]
public void RegisterMultiPart_EmptyShapeList_NoOp()
{
var reg = new ShadowObjectRegistry();
reg.RegisterMultiPart(
entityId: 0x1u,
entityWorldPos: Vector3.Zero,
entityWorldRot: Quaternion.Identity,
shapes: System.Array.Empty<ShadowShape>(),
state: 0u,
flags: EntityCollisionFlags.None,
worldOffsetX: OffX, worldOffsetY: OffY, landblockId: LbId);
Assert.Equal(0, reg.TotalRegistered);
}
[Fact]
public void Deregister_RemovesAllParts()
{
var reg = new ShadowObjectRegistry();
const uint doorEntityId = 0x000F4244u;
reg.RegisterMultiPart(doorEntityId, new Vector3(132.6f, 17.1f, 94.08f),
Quaternion.Identity, DoorShapes(), 0x10008u,
EntityCollisionFlags.None, OffX, OffY, LbId);
reg.Deregister(doorEntityId);
Assert.Equal(0, reg.TotalRegistered);
foreach (var entry in reg.AllEntriesForDebug())
Assert.NotEqual(doorEntityId, entry.EntityId);
}
[Fact]
public void UpdatePhysicsState_PropagatesEtherealToAllParts()
{
var reg = new ShadowObjectRegistry();
const uint doorEntityId = 0x000F4244u;
reg.RegisterMultiPart(doorEntityId, new Vector3(132.6f, 17.1f, 94.08f),
Quaternion.Identity, DoorShapes(), 0x10008u,
EntityCollisionFlags.None, OffX, OffY, LbId);
reg.UpdatePhysicsState(doorEntityId, 0x1000Cu); // 0x10008 | 0x4
int updated = 0;
foreach (var entry in reg.AllEntriesForDebug())
{
if (entry.EntityId != doorEntityId) continue;
Assert.Equal(0x1000Cu, entry.State);
updated++;
}
Assert.True(updated >= 4, $"Expected all parts updated, only {updated} were");
}
[Fact]
public void RegisterMultiPart_PartsAcrossMultipleCells_AllCellsListed()
{
var reg = new ShadowObjectRegistry();
// Two shapes 30m apart in X — must span two outdoor 24m cells.
var shapes = new[]
{
new ShadowShape(0u, new Vector3( 0f, 0f, 0f), Quaternion.Identity, 1f,
ShadowCollisionType.Cylinder, 1f, 2f),
new ShadowShape(0u, new Vector3(30f, 0f, 0f), Quaternion.Identity, 1f,
ShadowCollisionType.Cylinder, 1f, 2f),
};
reg.RegisterMultiPart(0x1u, new Vector3(12f, 12f, 50f), Quaternion.Identity,
shapes, 0u, EntityCollisionFlags.None, OffX, OffY, LbId);
// Part 1 at world (12, 12) → cell (0,0) = LbId | 1
// Part 2 at world (42, 12) → cell (1,0) = LbId | 9
var entriesIn1 = reg.GetObjectsInCell(LbId | 1u);
var entriesIn9 = reg.GetObjectsInCell(LbId | 9u);
Assert.Contains(entriesIn1, e => e.EntityId == 0x1u);
Assert.Contains(entriesIn9, e => e.EntityId == 0x1u);
}
[Fact]
public void Register_SingleShapeCompat_Unchanged()
{
var reg = new ShadowObjectRegistry();
reg.Register(42u, 0x01000001u, new Vector3(12f, 12f, 50f),
Quaternion.Identity, 1f, OffX, OffY, LbId);
Assert.Equal(1, reg.TotalRegistered);
Assert.Single(reg.GetObjectsInCell(LbId | 1u),
e => e.EntityId == 42u);
}
[Fact]
public void UpdatePosition_MovesAllPartsWithEntity()
{
var reg = new ShadowObjectRegistry();
const uint movingEntityId = 0xA1u;
var shapes = new[]
{
new ShadowShape(0u, new Vector3(0f, 0f, 0f), Quaternion.Identity, 1f,
ShadowCollisionType.Cylinder, 0.5f, 1f),
new ShadowShape(0u, new Vector3(1f, 0f, 0f), Quaternion.Identity, 1f,
ShadowCollisionType.Cylinder, 0.5f, 1f),
};
reg.RegisterMultiPart(movingEntityId, new Vector3(10f, 10f, 50f),
Quaternion.Identity, shapes, 0u,
EntityCollisionFlags.None, OffX, OffY, LbId);
// Move entity to (50, 10, 50). Parts should be at (50, 10, 50) and (51, 10, 50).
reg.UpdatePosition(movingEntityId,
new Vector3(50f, 10f, 50f), Quaternion.Identity,
OffX, OffY, LbId);
Vector3 expectedPart0 = new(50f, 10f, 50f);
Vector3 expectedPart1 = new(51f, 10f, 50f);
var atNew = reg.AllEntriesForDebug().Where(e => e.EntityId == movingEntityId).ToList();
Assert.Equal(2, atNew.Count);
bool found0 = atNew.Any(e => Vector3.Distance(e.Position, expectedPart0) < 0.01f);
bool found1 = atNew.Any(e => Vector3.Distance(e.Position, expectedPart1) < 0.01f);
Assert.True(found0 && found1,
"Expected both parts at new world positions (50, 10, 50) and (51, 10, 50)");
}
[Fact]
public void Deregister_ClearsEntityShapesCache_NoStaleUpdatePositionRebuild()
{
// A6.P4 door fix (2026-05-24) regression: after Deregister, a stray
// UpdatePosition with the same entityId must NOT resurrect the entity
// via the _entityShapes path. The Deregister cleanup added in Task 4
// (which folded Task 6 into the multi-part registration commit) clears
// _entityShapes[entityId] alongside the cell-list cleanup.
var reg = new ShadowObjectRegistry();
const uint doorEntityId = 0x000F4244u;
reg.RegisterMultiPart(doorEntityId, new Vector3(132.6f, 17.1f, 94.08f),
Quaternion.Identity, DoorShapes(), 0x10008u,
EntityCollisionFlags.None, OffX, OffY, LbId);
reg.Deregister(doorEntityId);
// Stray UpdatePosition should be a no-op now (no entry to find AND
// no _entityShapes entry to rebuild from).
reg.UpdatePosition(doorEntityId, new Vector3(200f, 200f, 50f),
Quaternion.Identity, OffX, OffY, LbId);
Assert.Equal(0, reg.TotalRegistered);
}
// ---------------------------------------------------------------------
// AP-152 — cross-cell dispatch. CPhysicsObj::calc_cross_cells @0x00515230
// tests HAS_PHYSICS_BSP_PS at 0x00515285 and routes a BSP-bearing object
// to CPhysicsObj::find_bbox_cell_list @0x00510fc0 through
// 0x0051528f jne 0x515305. The cylsphere branch (0x005152d1
// CObjCell::find_cell_list @0x0052b9f0) and the sorting-sphere branch
// (0x005152fb ... @0x0052b990) are BOTH below that jump and unreachable
// from it. BuildFloodSpheres used to prefer Cylinders over everything
// whenever any Cylinder was present, which is retail's SECOND priority
// applied ahead of its first.
// ---------------------------------------------------------------------
/// <summary>Cells of the landblock that hold at least one row for owner.</summary>
private static List<uint> OutdoorCellsHolding(ShadowObjectRegistry reg, uint ownerId)
{
var cells = new List<uint>();
for (uint index = 1u; index <= 64u; index++)
{
uint cellId = LbId | index;
if (reg.GetObjectsInCell(cellId).Any(e => e.EntityId == ownerId))
cells.Add(cellId);
}
return cells;
}
private static ShadowShape Cyl(float radius) => new(
GfxObjId: 0u, LocalPosition: Vector3.Zero, LocalRotation: Quaternion.Identity,
Scale: 1f, CollisionType: ShadowCollisionType.Cylinder,
Radius: radius, CylHeight: radius * 2f);
/// <summary>
/// A physics-BSP part shape. <paramref name="boundsCenter"/> defaults
/// OFF-CENTRE because that is the DAT-real configuration: a GfxObj's
/// physics BSP is authored in the GfxObj's own coordinates and 376 of the
/// 973 installed physics-BSP parts have a root bounding sphere further
/// from the part origin than half their own radius. A fixture pinned at
/// <c>Vector3.Zero</c> is the one configuration in which discarding the
/// centre is invisible.
/// </summary>
private static ShadowShape Bsp(
float radius,
Vector3 boundsCenter = default,
Vector3 localPosition = default,
Quaternion localRotation = default) => new(
GfxObjId: 0x010044B5u,
LocalPosition: localPosition,
LocalRotation: localRotation == default ? Quaternion.Identity : localRotation,
Scale: 1f, CollisionType: ShadowCollisionType.BSP,
Radius: radius, CylHeight: 0f,
BoundsCenter: boundsCenter == default ? new Vector3(0f, 6f, 0f) : boundsCenter);
private static List<uint> FloodCellsFor(params ShadowShape[] shapes)
{
var reg = new ShadowObjectRegistry();
const uint ownerId = 0xBEEF01u;
// Centre of the landblock's cell (1,1) so a 14 m footprint stays
// inside the block's own 8x8 outdoor grid on every side.
reg.RegisterMultiPart(
ownerId, new Vector3(36f, 36f, 50f), Quaternion.Identity,
shapes, 0x10008u, EntityCollisionFlags.None, OffX, OffY, LbId);
return OutdoorCellsHolding(reg, ownerId);
}
[Fact]
public void BuildFloodSpheres_BspBearingOwner_FloodsFromBspNotFromCylinder()
{
List<uint> cylinderOnly = FloodCellsFor(Cyl(0.5f));
List<uint> bspOnly = FloodCellsFor(Bsp(14f));
List<uint> mixed = FloodCellsFor(Cyl(0.5f), Bsp(14f));
// Controls: the two footprints must actually differ, or the fact below
// is satisfiable by any dispatch rule at all.
Assert.Equal([LbId | 10u], cylinderOnly);
Assert.True(bspOnly.Count > 1,
$"BSP footprint control failed: expected >1 cell, got {bspOnly.Count}");
// The fact: a mixed list floods from the BSP shapes, exactly as if the
// cylinder were not there. Retail 0x0051528f.
Assert.Equal(bspOnly, mixed);
Assert.NotEqual(cylinderOnly, mixed);
}
/// <summary>
/// AP-156. Retail's per-part cross-cell walk transforms the BSP root
/// bounding sphere's CENTRE through the part's own frame before it uses
/// the radius: <c>CGfxObj::physics_sphere</c> is
/// <c>BSPTREE::GetSphere(physics_bsp)</c> @0x005397e0 (root
/// <c>BSPNODE</c> + 4, past its vftable), and
/// <c>CEnvCell::find_transit_cells</c> @0x0052cae0 — the part-array
/// overload reached from <c>find_bbox_cell_list</c> @0x00510fc0 through
/// <c>CPartArray::calc_cross_cells_static</c> @0x00518160's
/// <c>[vtbl+0x7c]</c> — loads it at <c>0x0052cb36 mov esi,[ecx+0x74]</c>,
/// runs <c>Position::localtolocal</c> on it against
/// <c>[part+0x30]</c> (<c>0x0052cb4c add eax,0x30</c>), and only then
/// reads <c>[esi+0xc]</c> for the radius.
///
/// <para>
/// acdream used to flood from the part ORIGIN with the sphere's radius.
/// Over the installed DAT that failed to contain the object's own BSP
/// sphere for 170 of the 172 AP-152 Setups, shortfall up to 9.911 m
/// (0x02000255) — under-inclusive membership, the #98 / #168 class.
/// </para>
/// </summary>
[Fact]
public void BuildFloodSpheres_BspShape_CentresOnTheBoundsCentreNotThePartOrigin()
{
// Same sphere, three placements of the SAME 6 m radius:
// a) part origin at (0,0,0), bounds centre at the origin too;
// b) part origin at (0,0,0), bounds centre 30 m along +Y;
// c) part origin 30 m along +Y, bounds centre at the origin.
// (b) and (c) describe the identical world sphere, so they must flood
// the identical cells — and neither may equal (a).
List<uint> concentric = FloodCellsFor(
Bsp(6f, boundsCenter: new Vector3(0.001f, 0f, 0f)));
List<uint> viaBoundsCentre = FloodCellsFor(
Bsp(6f, boundsCenter: new Vector3(0f, 30f, 0f)));
List<uint> viaPartOrigin = FloodCellsFor(
Bsp(6f,
boundsCenter: new Vector3(0.001f, 0f, 0f),
localPosition: new Vector3(0f, 30f, 0f)));
// Control: the offset must actually move the footprint, or the
// equality below is satisfiable by ignoring BoundsCenter entirely.
Assert.NotEqual(concentric, viaBoundsCentre);
Assert.Equal(viaPartOrigin, viaBoundsCentre);
}
/// <summary>
/// Retail's 10-sphere clamp is on the CYLSPHERE branch and nowhere else.
/// <c>CObjCell::find_cell_list</c> @0x0052b9f0 clamps the cylsphere count
/// (<c>0x0052ba21 cmp eax,0xa</c> / <c>0x0052ba28 mov ebp,0xa</c>); the
/// BSP branch — <c>find_bbox_cell_list</c> @0x00510fc0 →
/// <c>CPartArray::calc_cross_cells_static</c> @0x00518160 →
/// <c>CEnvCell::find_transit_cells</c> @0x0052cae0 — walks every part with
/// no cap, and the sorting-sphere overload @0x0052b990 takes one sphere.
/// 7 installed Setups carry more than 10 physics-BSP parts (max 49,
/// Setup 0x02001A91) and landblock-baked stair runs and fences routinely
/// do, so clamping the BSP branch dropped their tail parts out of the
/// flood entirely.
/// </summary>
[Fact]
public void BuildFloodSpheres_CapsCylSpheresAtTenButNeverTheBspParts()
{
// Ten shapes on the owner's own cell, then an eleventh 72 m north.
var near = new Vector3(0f, 0f, 0f);
var far = new Vector3(0f, 72f, 0f);
uint ownCell = LbId | (uint)(1 * 8 + 1 + 1); // (x=1, y=1)
uint farCell = LbId | (uint)(1 * 8 + 4 + 1); // (x=1, y=4)
var bsp = new ShadowShape[11];
for (int i = 0; i < 10; i++)
bsp[i] = Bsp(1f, boundsCenter: new Vector3(0.001f, 0f, 0f));
bsp[10] = Bsp(1f, boundsCenter: far);
List<uint> bspCells = FloodCellsFor(bsp);
var cyls = new ShadowShape[11];
for (int i = 0; i < 10; i++)
cyls[i] = Cyl(1f) with { LocalPosition = near };
cyls[10] = Cyl(1f) with { LocalPosition = far };
List<uint> cylCells = FloodCellsFor(cyls);
Assert.Contains(ownCell, bspCells);
Assert.Contains(ownCell, cylCells);
// The eleventh BSP part still floods; the eleventh cylsphere does not.
Assert.Contains(farCell, bspCells);
Assert.DoesNotContain(farCell, cylCells);
}
/// <summary>
/// The BoundsCentre is expressed in the SHAPE's own frame, so the part's
/// LocalRotation must carry it — exactly as retail transforms the sphere
/// centre through <c>[part+0x30]</c>, the part's full Position, not just
/// its origin. A +Y offset on a part yawed 180 degrees must land at -Y.
/// </summary>
[Fact]
public void BuildFloodSpheres_BspShape_RotatesTheBoundsCentreByThePartRotation()
{
Quaternion yaw180 = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, MathF.PI);
List<uint> yawed = FloodCellsFor(
Bsp(6f, boundsCenter: new Vector3(0f, 20f, 0f), localRotation: yaw180));
List<uint> negatedUnrotated = FloodCellsFor(
Bsp(6f, boundsCenter: new Vector3(0f, -20f, 0f)));
List<uint> unrotated = FloodCellsFor(
Bsp(6f, boundsCenter: new Vector3(0f, 20f, 0f)));
Assert.NotEqual(unrotated, negatedUnrotated); // control
Assert.Equal(negatedUnrotated, yawed);
}
/// <summary>
/// The AP-152 delta end-to-end: a CylSphere+BSP Setup (73 of the 172
/// affected installed Setups are this shape) registered through the
/// production builder. Before the fix the emitted list carried both, and
/// BuildFloodSpheres' cylinder preference confined the owner to the
/// cylinder's cell while its slab BSP reached further — an object absent
/// from shadow cells it physically occupies, the #98 / #168 symptom class.
///
/// <para>
/// The BSP bounds are supplied through the production seam
/// (<c>physicsBspBounds</c>, the same resolver
/// <c>LiveEntityCollisionBuilder</c> passes) and are OFF-CENTRE, so the
/// flood is asserted where the geometry is rather than where the part
/// origin is.
/// </para>
/// </summary>
[Fact]
public void FromSetup_CylSphereAndBspSetup_FloodsTheBspFootprint()
{
const uint part = 0x010044B5u;
var setup = new DatReaderWriter.DBObjs.Setup
{
Parts = { part },
CylSpheres = { new DatReaderWriter.Types.CylSphere
{ Radius = 0.5f, Height = 1f, Origin = Vector3.Zero } },
};
// 14 m stands in for a slab wide enough to leave its own landcell;
// the +18 m Y offset stands in for the 376-of-973 installed parts
// whose root sphere is nowhere near the part origin.
var bounds = new FlatCollisionSphere(new Vector3(0f, 18f, 0f), 14f);
IReadOnlyList<ShadowShape> shapes = ShadowShapeBuilder.FromSetup(
setup,
entScale: 1f,
hasPhysicsBsp: id => id == part,
physicsBspBounds: id => id == part ? bounds : null);
ShadowShape only = Assert.Single(shapes);
Assert.Equal(ShadowCollisionType.BSP, only.CollisionType);
Assert.Equal(14f, only.Radius);
Assert.Equal(new Vector3(0f, 18f, 0f), only.BoundsCenter);
var reg = new ShadowObjectRegistry();
const uint ownerId = 0xBEEF02u;
reg.RegisterMultiPart(
ownerId, new Vector3(36f, 36f, 50f), Quaternion.Identity,
shapes, 0x10008u, EntityCollisionFlags.None, OffX, OffY, LbId);
List<uint> cells = OutdoorCellsHolding(reg, ownerId);
Assert.True(cells.Count > 1,
$"Expected the slab footprint to span more than its own landcell; got {cells.Count}");
// Landcells are 24 m. The slab's sphere is centred at y = 54 m with
// r = 14 m, so it spans y in [40, 68] — rows 1 and 2, and it comes no
// closer than 16 m to row 0 (y < 24). Flooding it around the PART
// ORIGIN instead (y = 36 m, span [22, 50]) would drag row 0 in.
Assert.Contains(LbId | (uint)(1 * 8 + 2 + 1), cells); // (x=1, y=2)
Assert.DoesNotContain(LbId | (uint)(1 * 8 + 0 + 1), cells); // (x=1, y=0)
}
}