using System; using System.Collections.Generic; using System.Linq; using System.Numerics; using AcDream.Core.Physics; using Xunit; namespace AcDream.Core.Tests.Physics; /// /// #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. /// /// /// Retail chain, disassembled from the PDB-paired 2013-09-06 binary: /// CPhysicsObj::calc_cross_cells @0x00515230 /// (0x00515285 test dword [esi+0xa8],0x10000) → /// find_bbox_cell_list @0x00510fc0 → /// CPartArray::calc_cross_cells_static @0x00518160 → /// [vtbl+0x7c]CLandCell::find_transit_cells @0x00533840 → /// add_all_outside_cells @0x00533360 → add_cell_block /// @0x005331d0. /// /// /// /// 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 (check_add_cell_boundary compares against /// radius and 24 - radius, both unconditionally true above /// 12 m, and only ever adds the eight neighbours). /// /// 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 /// Full outdoor cell id from a GLOBAL lcoord, hand-derived from /// retail's add_cell_block packing at 0x0053320a-0x0053322e: /// (((x>>3)<<8) | (y>>3)) << 16 | ((x&7)*8 + (y&7) + 1). /// Written out here rather than calling LandDefs so the expectation does /// not re-encode the code under test. 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))); /// 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. 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 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 ──────────────────────────────────────────────────────────────── /// /// 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 /// () → one cell. The 5-per-axis span is /// unreachable from ANY sphere, of any radius, through the 3×3 path. /// [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 cells = Rectangle(new Vector3(36f, 36f, 0f), LbId | 10u, shape); var expected = new List(); 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 sphereOnly = Rectangle( new Vector3(36f, 36f, 0f), LbId | 10u, SphereOnlyPart(1f)); Assert.Single(sphereOnly); Assert.Equal(Cell(GxBase + 1, GyBase + 1), sphereOnly[0]); } // ── T2 ──────────────────────────────────────────────────────────────── /// /// The rectangle is FILLED and unioned ACROSS PARTS, not per part. Retail /// combines the four DELTA accumulators over every part and calls /// add_cell_block ONCE (0x00533614), so an L-shaped object /// registers in the cells that close its L — cells its geometry never /// enters. /// /// /// 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 (0x00533390), 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. /// /// [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 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 ──────────────────────────────────────────────────────────────── /// /// The rectangle crosses landblock boundaries freely: add_cell_block /// works in GLOBAL lcoords and re-derives the block prefix per cell /// (0x0053320a), so cells beyond column 7 carry the NEIGHBOUR /// landblock's id. Sabotage: clamp the rectangle to the seed landblock → /// the 0xAAB4 / 0xA9B5 rows vanish. /// [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 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 ──────────────────────────────────────────────────────────────── /// /// Map bounds. add_cell_block rejects any coordinate outside /// [0, 0x7f8) (0x005331f0-0x00533206). Sabotage: drop /// the clamp → cells wrap into the far corner of the map or produce id 0. /// [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 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 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 ──────────────────────────────────────────────────────────────── /// /// BBox::LocalToGlobal @0x005b2120 re-fits through ALL EIGHT /// corners, so a rotated box grows. Sabotage: transform only /// min and max → the −X overhang of a yawed asymmetric box /// is lost and its westernmost cell disappears. /// [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 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 flat = Rectangle(new Vector3(48f, 12f, 0f), LbId | 17u, unrotated); Assert.DoesNotContain(Cell(GxBase + 1, GyBase + 0), flat); } // ── T9 ──────────────────────────────────────────────────────────────── /// /// floor, not truncation: retail calls floor then /// _ftol2 (0x0053353c / 0x00533542). Sabotage: /// (int)(v / 24f) → for a box overhanging the block's SW corner, /// -8/24 truncates to 0 and the previous landblock's column 7 is /// silently dropped. /// [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 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 ──────────────────────────────────────────────────────────────── /// /// adjust_to_outside failing (map edge / invalid id) makes retail /// return before get_landcell and add nothing /// (0x005333eb select → gid 0 → 0x00533417 je). Sabotage: /// drop the null check → an exception or a bogus rectangle at lcoord 0. /// [Fact] public void T8_BasePositionOffTheMap_AddsNothingAndDoesNotThrow() { var shape = BspPart(new Vector3(-5f, -5f, -2f), new Vector3(5f, 5f, 2f)); var boxes = new List { 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 ─────────────────────────────────────────────────────────── /// /// Non-BSP invariance. A cylinder-only owner must still take retail's /// cylsphere branch — CObjCell::find_cell_list @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). /// [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 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(); 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 ───────────────────────────────────────────────────── /// /// The dispatch itself: a BSP-bearing owner registered through /// 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. /// [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); } }