fix(physics): S1B — indoor cell membership admits on the part BOX, as retail does (#335, AP-159 narrowed)
Some checks are pending
Headless portability / portable-headless (ubuntu-latest) (push) Waiting to run
Headless portability / portable-headless (windows-latest) (push) Waiting to run
Headless portability / linux-graphical (push) Waiting to run
Headless portability / linux-vulkan (push) Waiting to run

CellTransit.FindTransitCellsBox ports CEnvCell::find_transit_cells'
part-array overload @0x0052cae0 line-for-line: per-portal x per-part
order, the sphere cheap-reject at F_EPSILON+radius, the box admit whose
"Straddle or crossing-side" rule is exactly retail's `eax != side` under
the PDB Sidedness enum, leads-outside placed AFTER the admit, the
unconditional unloaded-neighbour hint without the sphere overload's
re-test, the destination box_intersects_cell gate with its deliberate
no-break, and add_all_outside_cells after the loop. The box-vs-cell BSP
traversal lands in BOTH representations behind the flat-authoritative
dispatcher with a graph referee whose 20,000 installed comparisons are
pinned by assertion (review F5), zero mismatch.

Dual Opus review: PASS on both lenses. The mandatory D0 pseudocode pass
caught that the contract's own supplementary note misattributed the box
block to the sphere overload — it belongs to a SECOND
check_building_transit overload @0x0052c680, whose portal-side
convention is INVERTED and whose admit differs; the pseudocode doc now
records that trap plus two byte confirmations made at review:
which_side @0x00444720 is strictly > eps for POSITIVE, and
intersect_box's in-plane early exit returns CROSSING(3)
(jp @0x005aa1bc -> mov eax,3), settling review items b1/b2 for the
future bridge porter. The bridge itself stays unported as AP-159's
explicit remainder.

The review also retired #335's severity premise honestly: "over-
inclusive only, never a missed one" is wrong at production shape ratios,
where the box (whole-vertex AABB) legitimately exceeds the sphere
(physics-polygon root sphere). Measured, both populations: rigged
(box << sphere) — 1,520 placements, 978 cells removed, 0 added;
production-ratio (box >= sphere) — 950 placements, 20 removed, 1 ADDED
through the loaded-neighbour gate, which is retail's direction, not a
defect. The no-op guard (review F4) asserts removal is nonzero so an
unwired admit cannot pass silently.

Process note: the implementer authored against this session's worktree
at bec5c69d, 25 commits stale — the recorded worktree-base class. All
six files were byte-identical between bases, the diff transplanted
losslessly, and every verdict-bearing run (referee, direction sweeps,
this clean-room) was re-executed on current main. S2's uncommitted
phase-1 edits were stashed for this landing so the suite verdicts
exactly one changeset.

Also untracks 341-slope-capture.jsonl (an accidental add) and
gitignores it.

Clean-room suite: 11,248 passed / 6 skipped / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-07 07:46:57 +02:00
parent 1b2580be4c
commit b3e43d22c9
14 changed files with 1600 additions and 4914 deletions

1
.gitignore vendored
View file

@ -108,3 +108,4 @@ studio-shots/
# Campaign V capture/evidence output - session-local, never tracked (423 MB lesson, 2026-07-29)
artifacts/
341-slope-capture.jsonl

File diff suppressed because it is too large Load diff

View file

@ -593,7 +593,21 @@ Two additions close that:
## #335 — The INDOOR half of retail's part-array `find_transit_cells` is not ported: an EnvCell neighbour is admitted on a SPHERE test where retail uses a BOX
**Status:** OPEN
**Status:** CLOSED 2026-08-07 (Campaign S S1B). The indoor part-array arm is
ported (`CellTransit.FindTransitCellsBox`), dual-reviewed PASS, sabotage-
verified, with the box-vs-cell BSP traversal in both representations under a
pinned 20,000-comparison installed referee. **The severity line below
("over-inclusive only... never a missed one") is RETIRED with the port:** at
production shape ratios the box legitimately exceeds the sphere (whole-vertex
AABB vs physics-polygon root sphere), and the measured sweep shows the
loaded-neighbour gate ADDING a cell the sphere test missed (1 in 950
production-ratio placements) — retail-correct in both directions. Remainders
(building bridge with its inverted portal-side convention, both its traps
byte-settled; the one-ULP WhichSide tie) live in the narrowed AP-159 row.
**Original entry:**
**Status (original):** OPEN
**Severity:** low. Over-inclusive only — extra broadphase candidates indoors, never a missed one. The opposite direction (the outdoor half) was #334 and is closed.
**Filed:** 2026-08-06, at the #334 fix.
**Component:** physics / cell membership

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,290 @@
# AP-159 / #335 — pseudocode: the INDOOR box-admit half of retail's part-array `find_transit_cells`
**Date:** 2026-08-07. Written per the mandatory grep-named → decompile → pseudocode →
port workflow, BEFORE any C# is touched, per the pinned contract
`docs/research/2026-08-07-ap159-s1b-contract.md`.
## 0. Resolving the contract's flagged overload-signature ambiguity
The contract's Oracle map says:
> **Adjacent overload** @0x0052c820 (lines 309968310126) also contains a box
> refinement block (@0x0052c76c0x0052c7d2). Its Binary Ninja signature
> (`CSphere const* arg4`) is inconsistent with its body indexing parts — a
> known BN artifact class.
Direct disassembly reading resolves this. The box-refinement block at
addresses **0x0052c76c0x0052c7d2** does **not** belong to the function
declared at 0x0052c820 — its addresses are numerically *lower* than that
declaration and it is textually printed *before* it in the dump. It belongs
to the **preceding** function, which starts at line 309867:
```
0052c680 void __thiscall CEnvCell::check_building_transit(
class CEnvCell const* this, int32_t arg2, uint32_t const arg3,
class CPhysicsPart** arg4, class CELLARRAY* arg5)
```
This is a **second, part-array overload of `check_building_transit`**
(distinct from the sphere overload at 0x0052c5d0, lines 309827309863, which
is what `CellTransit.CheckBuildingTransit` already ports). Its signature
genuinely takes `CPhysicsPart** arg4` — fully consistent with a body that
indexes parts (`arg4[eax_1]`, `arg4[var_4c_1]`). There is no BN artifact here;
the confusion was address-adjacency between two *different* functions that
happen to sit back-to-back in the binary and share a demangled base name with
their sibling overloads.
The function actually declared at **0x0052c820** (lines 309968310122) is the
**sphere overload of `find_transit_cells`** — signature `CSphere const* arg4`
is correct, and its body is pure sphere math (`Frame::globaltolocal`,
`CCellStruct::sphere_intersects_cell`, the ±eps straddle test for exterior
portals). This is **already ported** as `CellTransit.FindTransitCellsSphere`
and needs no changes. It contains no box-refinement block at all.
The function the contract's D1/D2 actually needs — the **part-array
overload of `find_transit_cells`**, cited correctly by address
(**0x0052cae0**, lines 310127310257) — is a **third, separate function**
from either of the above, and its disassembly matches the #335 ISSUES.md
entry (the true oracle of record) line-for-line: `Position::localtolocal`
sphere cheap-reject → `CPhysicsPart::GetBoundingBox` + `BBox::LocalToLocal` +
`Plane::intersect_box` box admit → `other_cell_id==0xFFFFFFFF` leads-outside
check → `CCellPortal::GetOtherCell` (threading `do_not_load_cells`) →
`CCellStruct::box_intersects_cell` destination gate → `add_all_outside_cells`
after the portal loop. **No contradiction with the #335 entry exists; only
the contract's supplementary "Adjacent overload" note was misattributed.**
This section performs the disentanglement the contract's mandatory D0 step
asked for.
**Consequence for D2's building-bridge decision:** the TRUE box-refinement
counterpart for the outdoor→indoor building bridge is the part-array
`check_building_transit` @0x0052c680 (the function this section just
identified), not the sphere overload @0x0052c5d0 the contract names. Its
control flow operates on a **single fixed portal per call** (index `ebp`
supplied by the caller) rather than looping `this->num_portals`, and its
cheap-reject branch structure (`if (ebp_1==1) {...} else if (ebp_1!=0)
{label:...} else {...}`) has its own three-way-looking shape that does not
cleanly decompose into the same two-branch (`PortalSide ? : `) form the
part-array `find_transit_cells` uses — porting it would mean building a new,
differently-shaped traversal, not "reusing D1's primitive with a small
diff." Per the contract's explicit D2 allowance, this is left unported and
reported as the explicit remainder (see the implementation report).
## 1. `CEnvCell::find_transit_cells` (part-array overload, @0x0052cae0, pc:310127310257)
Verbatim shape, addresses inline:
```
find_transit_cells(this, numParts, parts[], cellArray):
exitOutside = false
for each portal in this.Portals: # do-while, pc:310140310252
portalPlane = ResolvePlane(this, portal) # this->portals[i].portal.plane
for each part in parts: # do-while, pc:310147310246
if part == null: continue
sphere = part.GfxObj.PhysicsSphere ?? part.GfxObj.DrawingSphere # 0x0052cb34-0052cb45
if sphere == null: continue
# --- cheap reject (sphere) --------------------------------
center = this.Pos.LocalToLocal(part.Pos, sphere.Center) # Position::localtolocal @0x0052cb5a
rad = sphere.Radius + F_EPSILON # 0x0052cb65, F_EPSILON=0.000199999995f
dist = Dot(center, portalPlane.N) + portalPlane.D # 0x0052cba4
portalSideRaw = portal.portal_side # this->portals[i].portal_side (int, retail live field)
if portalSideRaw == 1: # 0x0052cba7 — matches our PortalInfo.PortalSide == true
if dist < -rad: continue # skip this part 0x0052cbb2/0x0052cbc8 (BN artifact
# collapsed the two-branch shape into a spurious
# 3-way if/elseif/else; the real shape is two branches
# both jumping to the SAME box-test label)
else: # portalSideRaw == 0, PortalSide == false
if dist > rad: continue
# --- box admit ---------------------------------------------
box = part.GfxObj.PhysicsBox # CPhysicsPart::GetBoundingBox @0x0050d600, 0x0052cbdd
localBox = BBox.LocalToLocal(box, part.Pos, this.Pos) # @0x005b1e60, 0x0052cbf9
sidedness = portalPlane.intersect_box(localBox) # Plane::intersect_box @0x005aa170, 0x0052cc05
crossingSide = portal.PortalSide ? Positive : Negative
if sidedness != Straddle and sidedness != crossingSide:
continue # box fully on the "still inside this cell" side — 0x0052cc0c falls through
# --- destination resolution (only when box crosses) --------
if portal.OtherCellId == 0xFFFFFFFF: # 0x0052cc21
exitOutside = true
break # out of the PART loop; proceed to next portal — 0x0052cc7e/0x0052ccaa
otherCell = CCellPortal.GetOtherCell(portal, do_not_load_cells) # 0x0052cc2b, threads cellArray+4
if otherCell == null:
cellArray.add_cell(portal.OtherCellId, null) # unconditional load hint — 0x0052cca5
break # next portal — 0x0052ccaa
destBox = BBox.LocalToLocal(box, part.Pos, otherCell.Pos) # 0x0052cc4a
if CCellStruct.box_intersects_cell(otherCell.structure, destBox): # 0x0052cc61 → BSPTREE @0x0053c880
cellArray.add_cell(otherCell.ID, otherCell)
break # next portal — 0x0052cc8d
# else: this part's box didn't actually reach the other cell's
# geometry — continue the PART loop (retest remaining parts
# against the SAME portal/destination) — 0x0052cc63, no break
if exitOutside:
CLandCell.add_all_outside_cells(numParts, parts, cellArray) # 0x0052ccea — already ported (#334)
```
Cross-checked against `docs/ISSUES.md` #335 entry (oracle of record) — every
step (14) and every cited address matches. Also cross-checked against the
**pre-existing** ACE-sourced pseudocode at
`docs/research/acclient_indoor_transitions_pseudocode.md` §"EnvCell.find_transit_cells
(parts/AABB variant)" (written 2026-04/05, predates named-retail), which
independently derives the identical shape from ACE's C# port
(`EnvCell.cs:245-309`) — including the exact `Positive`/`Negative` sidedness
naming this doc uses below. Two independent sources (raw PDB-paired
disassembly and an ACE C#-port cross-reference) agree; this doc treats the
agreement as confirmation, not as a new independent fact.
### Structural difference from the already-ported sphere overload
`CellTransit.FindTransitCellsSphere` special-cases exterior portals
(`OtherCellId == 0xFFFF`) with a dedicated symmetric straddle test **before**
touching the loaded/unloaded-neighbour logic (matches the sphere overload's
own `if (*ecx != 0xffffffff)` branch at the very top of its per-portal body).
The part-array overload does **not** do this: cheap-reject and box-admit run
uniformly for every portal (interior or exterior); only **after** the box
passes admit does it check `other_cell_id==0xFFFFFFFF` to decide
interior-vs-exterior handling. This is why D2 needs a genuinely new method
(`FindTransitCellsBox`) rather than a small patch to the existing
`FindTransitCellsSphere`.
## 2. `Plane::intersect_box` (@0x005aa170, pc:439037439122)
Classifies a box against a plane using **all 8 corners**, short-circuiting
per retail's fixed enumeration order (`min`, then all 7 combinations of
{min,max}³ in the specific order the compiler emitted — order does not
affect the boolean-classification result since it is an AND of per-corner
agreement, only which corner short-circuits first). Epsilon is
`F_EPSILON = 0.000199999995f` throughout, matching
`Plane::which_side` @0x00444720 (PDB-recovered `Sidedness` enum: `Positive`
when `dist >= +eps`, an early-return "clearly negative" case the disassembly
calls out at the very first corner test, and a third `Straddle`/on-plane
case when `-eps <= dist < +eps`).
```
ClassifyBox(plane, box): # shared math for D1
corners = the box's 8 corners (any fixed enumeration; result is order-independent)
side0 = WhichSide(plane, corners[0], F_EPSILON)
if side0 == Straddle: return Straddle
for corner in corners[1..7]:
if WhichSide(plane, corner, F_EPSILON) != side0:
return Straddle
return side0 # Positive or Negative — box is uniformly on one side
WhichSide(plane, point, eps):
dist = Dot(plane.N, point) + plane.D
if dist >= eps: return Positive
if dist < -eps: return Negative
return Straddle
```
**Result contract** (matches the contract's note, re-derived independently
from the raw disassembly and cross-checked): the caller at
`find_transit_cells`'s 0x0052cc0c tests `classification != portal_side_raw`;
the caller at `check_building_transit`'s 0x0052c7a0 tests `classification ==
3 || classification == side`. Both are consistent with `ClassifyBox`
returning `Straddle` whenever corners disagree (never a bare 0/1 in that
case) and `Positive`/`Negative` only when **all 8 corners agree**. `Straddle`
is by construction never equal to a 0/1 `portal_side_raw`, so "!= portal_side"
the two callers' admit rules are NOT equivalent — dual review finding F9/R6. For the pure-side case they are OPPOSITE: `find_transit_cells` (`eax != side`) admits {Positive, Straddle} when side==1, while `check_building_transit` (`eax == 3 || eax == side`) admits {Straddle, Negative} — physically expected (reach-beyond-the-portal vs inside-this-building-cell are opposite questions), and the trap the future bridge porter must not fall into.
**PortalSide mapping** (verified two ways — direct disassembly branch
structure of the part-array `find_transit_cells`'s cheap-reject, AND the
pre-existing `acclient_indoor_transitions_pseudocode.md` §"PortalSide flag
semantics" cross-reference against ACE): retail's raw `portal_side` field is
`1` exactly when our `PortalInfo.PortalSide == true`. The admit rule,
restated without needing that raw integer at all:
```
crossingSide = portal.PortalSide ? Positive : Negative
ADMIT box-crosses-this-portal iff sidedness == Straddle OR sidedness == crossingSide
```
## 3. `BSPNODE::box_intersects_cell_bsp` (@0x0053c880, pc:325993326087)
Structurally the box-shaped sibling of the already-ported
`BSPNODE::point_inside_cell_bsp` (:325508, `BSPQuery.PointInsideCellBsp`) and
`BSPNODE::sphere_intersects_cell_bsp` (:325546, `BSPQuery.SphereIntersectsCellBsp`):
an iterative walk down `pos_node` only, rejecting (returning "outside") the
instant the box is found to lie entirely on the **negative** side of a
splitting plane, and treating a null `pos_node` (or a leaf) as "inside."
Epsilon is again `F_EPSILON` (line 326001: `0.000199999995f`), reusing the
SAME 8-corner box-vs-plane classification as `Plane::intersect_box` — the
disassembly literally duplicates the corner-enumeration idiom.
```
BoxIntersectsCellBsp(node, boxMin, boxMax):
while node is not a leaf:
if ClassifyBox(node.SplittingPlane, boxMin, boxMax) == Negative:
return false # box entirely behind this splitting plane
if node.PosNode is null:
return true # solid interior — matches point/sphere siblings
node = node.PosNode
return true # reached a leaf
```
This is the **one new traversal shipped in two representations** per the
Slice I4/I5 house rule: `BSPQuery.BoxIntersectsCellBsp` over `CellBSPNode`
(the graph) and `FlatBspQuery.BoxIntersectsCellBsp` over
`FlatCellContainmentBsp`/`FlatCellBspNode` (the flat production shadow),
sharing the `ClassifyBox`/`WhichSide` math (added to `BSPQuery` as internal
statics, exactly the existing pattern `FlatBspQuery` already uses for
polygon math like `PolygonHitsSpherePrecise`).
`CCellStruct::box_intersects_cell` (@0x00533910, pc:317675) is a bare
tailcall into `BSPTREE::box_intersects_cell_bsp` (@0x005398b0, pc:323249),
itself a bare tailcall into the function above — no additional logic, same
shape as `sphere_intersects_cell``sphere_intersects_cell_bsp` already
established in this codebase.
## 4. Box transform: retail `BBox::LocalToLocal` (@0x005b1e60)
Needed at two call sites in §1 (`localBox` into `this` cell's frame,
`destBox` into `otherCell`'s frame). Same shape already ported for the
OUTDOOR extent walk's `BBox::LocalToGlobal` (`ShadowPartBox.RefitTo`,
#334): an eight-corner transform-and-refit, not a min/max-only transform (a
rotated box grows, conservatively — retail's own deliberate direction).
`LocalToLocal` differs from `LocalToGlobal` only in that the **destination**
frame carries its own rotation (a cell's `WorldTransform`/
`InverseWorldTransform`), not just a translation offset. D2 adds a sibling
`ShadowPartBox.RefitToLocal(Matrix4x4 worldToLocal, ...)` that composes the
part's world placement (`WorldPosition`/`WorldRotation`, identical to
`RefitTo`) and then applies the destination's full `Matrix4x4` transform
per corner before taking min/max — i.e. `RefitTo` is the degenerate case of
`RefitToLocal` where the destination frame has no rotation (world axes).
## 5. Deliverable mapping
| Contract deliverable | Implementation |
|---|---|
| D1 box primitives, graph | `BSPQuery.ClassifyBox`/`WhichSide` (shared), `BSPQuery.BoxIntersectsCellBsp` |
| D1 box primitives, flat | `FlatBspQuery.BoxIntersectsCellBsp` (delegates classification math to `BSPQuery`) |
| D1 dispatcher (matches existing `SphereIntersectsCell`/`PointInsideCell` shape) | `CollisionTraversal.BoxIntersectsCell` |
| D1 referee | `tests/AcDream.Core.Tests/Physics/BoxIntersectsCellBspDifferentialTests.cs` |
| D2 box transform | `ShadowPartBox.RefitToLocal` |
| D2 indoor-arm rewire | `CellTransit.FindTransitCellsBox` (new), called from `BuildShadowCellSetFromParts`'s indoor branch in place of `FindTransitCellsSphere` |
| D2 building-bridge remainder | Left unported (see §0); reported as the explicit remainder |
| D3 | `tests/AcDream.Core.Tests/Physics/CellTransitFindTransitCellsBoxTests.cs` (synthetic sabotage + inverse guard) + a direction-assertion sweep test |
---
## Byte confirmations (added at the dual review, 2026-08-07 — PDB-paired binary)
- **`Plane::which_side` @0x00444720, full decode:** `fcom dist, eps; test ah,0x41;
jnz` — POSITIVE (0) iff `dist > eps` STRICTLY; then `fcompp dist, -eps; test
ah,0x05; jnp` — NEGATIVE (1) iff `dist < -eps` strictly; IN_PLANE (2)
otherwise (unordered also lands IN_PLANE). The port's `>= eps` boundary tie
is a one-ULP divergence, noted in AP-159's landing record.
- **`Plane::intersect_box` @0x005aa170, first-corner-in-plane early exit:** the
`jp` at 0x005aa1bc targets 0x005aa2e2 = `mov eax, 3` — the early exit returns
**CROSSING (3)**, not IN_PLANE (2). Both live call sites admit 3, so the S1B
port is unaffected; the future `check_building_transit` port inherits this as
a settled fact instead of review item b2.
- `acclient.h:2527`: `Sidedness { POSITIVE=0, NEGATIVE=1, IN_PLANE=2,
CROSSING=3 }` — the header answer the PlaneSide doc previously called
unobservable. `which_side` returns at most 2; `intersect_box` is the only
producer of 3.

View file

@ -1106,6 +1106,115 @@ public static class BSPQuery
: true;
}
// =========================================================================
// PUBLIC: box_intersects_cell_bsp (AP-159 / #335, 2026-08-07)
// Retail: Plane::intersect_box @0x005aa170 (pc:439037), Plane::which_side
// @0x00444720 (pc:75027), BSPNODE::box_intersects_cell_bsp @0x0053c880
// (pc:325993). See docs/research/2026-08-07-ap159-pseudocode.md §2-3 for
// the full disassembly-backed derivation, including the PortalSide/raw
// portal_side mapping and the "== 3 || == side" caller contract.
// =========================================================================
/// <summary>
/// Retail <c>F_EPSILON</c> (0x007c8c70, 0.000199999995f) — the epsilon
/// <c>Plane::which_side</c> and <c>BSPNODE::box_intersects_cell_bsp</c>
/// both use for their plane-distance classification.
/// </summary>
internal const float BoxPlaneEpsilon = 0.000199999995f;
/// <summary>
/// Retail <c>Sidedness</c> (PDB-recovered enum; <c>Plane::which_side</c>
/// @0x00444720 returns these three values verbatim: 0 for clearly in
/// front of the plane, 1 for clearly behind, else the on-plane/straddle
/// case). Renamed <c>Straddle</c> here for clarity — retail's own value
/// for that third case IS pinned: acclient.h:2527 Sidedness { POSITIVE=0, NEGATIVE=1, IN_PLANE=2, CROSSING=3 }; which_side returns at most 2 (pc:75045) while intersect_box's straddle/early-exit sentinel is 3 (byte-confirmed: the in-plane early exit at 0x005aa1bc lands on mov eax,3). Collapsing both onto one Straddle is safe because every consumer tests only distinctness-from-a-pure-side
/// (masked by an early, unassigned-looking return the disassembly shows
/// is shared with <c>which_side</c>'s own straddle path).
/// </summary>
internal enum PlaneSide
{
Positive = 0,
Negative = 1,
Straddle = 2,
}
/// <summary>
/// Retail <c>Plane::which_side</c> @0x00444720 (pc:75027) — classify one
/// point against a plane with epsilon slack.
/// </summary>
internal static PlaneSide WhichSide(in Plane plane, Vector3 point, float eps)
{
float dist = Vector3.Dot(plane.Normal, point) + plane.D;
if (dist >= eps) return PlaneSide.Positive;
if (dist < -eps) return PlaneSide.Negative;
return PlaneSide.Straddle;
}
/// <summary>
/// Retail <c>Plane::intersect_box</c> @0x005aa170 (pc:439037) AND the
/// box-vs-plane test embedded in <c>BSPNODE::box_intersects_cell_bsp</c>
/// @0x0053c880 (pc:325993) — both classify a box against a plane by
/// corners in a fixed enumeration; the all-same-side result is order-
/// result is order-independent, it is an AND of per-corner agreement)
/// and returning <see cref="PlaneSide.Straddle"/> the instant any corner
/// disagrees with the first corner tested, else the shared side. Shared
/// here because both callers need identical semantics — this is the
/// "shared math" D1 of the AP-159/#335 contract asks for.
/// </summary>
// Cold-path shape note (review F15): retail classifies corner 0 first and
// short-circuits; this port materialises all 8 corners up front. Identical
// results, ~7 extra Vector3 constructions per node in the accept case —
// registration-flood only, never per-resolve.
internal static PlaneSide ClassifyBox(in Plane plane, Vector3 min, Vector3 max)
{
Span<Vector3> corners =
[
new Vector3(min.X, min.Y, min.Z),
new Vector3(max.X, max.Y, max.Z),
new Vector3(min.X, min.Y, max.Z),
new Vector3(min.X, max.Y, min.Z),
new Vector3(max.X, min.Y, min.Z),
new Vector3(max.X, min.Y, max.Z),
new Vector3(min.X, max.Y, max.Z),
new Vector3(max.X, max.Y, min.Z),
];
PlaneSide side0 = WhichSide(plane, corners[0], BoxPlaneEpsilon);
if (side0 == PlaneSide.Straddle) return PlaneSide.Straddle;
for (int i = 1; i < corners.Length; i++)
{
if (WhichSide(plane, corners[i], BoxPlaneEpsilon) != side0)
return PlaneSide.Straddle;
}
return side0;
}
/// <summary>
/// Retail <c>BSPNODE::box_intersects_cell_bsp</c> @0x0053c880
/// (pc:325993) — the box-shaped sibling of <see cref="PointInsideCellBsp"/>
/// and <see cref="SphereIntersectsCellBsp"/>: an iterative walk down
/// <c>PosNode</c> only, rejecting the instant the box is found to lie
/// entirely on the NEGATIVE side of a splitting plane (via
/// <see cref="ClassifyBox"/>), treating a null <c>PosNode</c> or a leaf
/// as "inside." Reached from <c>CCellStruct::box_intersects_cell</c>
/// @0x00533910 (pc:317675, bare tailcall) → <c>BSPTREE::box_intersects_cell_bsp</c>
/// @0x005398b0 (pc:323249, bare tailcall) → this function.
/// </summary>
public static bool BoxIntersectsCellBsp(CellBSPNode? node, Vector3 min, Vector3 max)
{
if (node is null) return true;
if (node.Type == BSPNodeType.Leaf) return true;
if (ClassifyBox(node.SplittingPlane, min, max) == PlaneSide.Negative)
return false;
return node.PosNode is not null
? BoxIntersectsCellBsp(node.PosNode, min, max)
: true;
}
// =========================================================================
// BSP TREE-LEVEL HELPERS
//

View file

@ -215,6 +215,151 @@ public static class CellTransit
}
}
/// <summary>
/// AP-159 / #335 (2026-08-07). Indoor half of retail's part-array
/// <c>CEnvCell::find_transit_cells</c> @0x0052cae0 (pc:310127310257) —
/// the box-admitting sibling of <see cref="FindTransitCellsSphere"/>
/// used ONLY by the part-array flood
/// (<see cref="BuildShadowCellSetFromParts"/>'s indoor arm). Per portal ×
/// per part: sphere cheap-reject (same shape as the sphere overload,
/// same <see cref="FEpsilon"/>) → box admit
/// (<c>Plane::intersect_box</c>, <see cref="BSPQuery.ClassifyBox"/>) →
/// if the box crosses: exterior portal sets <paramref name="exitOutside"/>,
/// else resolve the other cell (unconditional load-hint add when
/// unloaded — the box admit already proved crossing, unlike the sphere
/// overload's unloaded path which re-tests distance) and gate the add on
/// <c>CCellStruct::box_intersects_cell</c>
/// (<see cref="CollisionTraversal.BoxIntersectsCell"/>).
///
/// <para>
/// Structural difference from <see cref="FindTransitCellsSphere"/>:
/// retail's part-array overload does NOT special-case exterior portals
/// up front — cheap-reject and box-admit run uniformly for every portal,
/// and only AFTER the box passes admit does it check
/// <c>other_cell_id==0xFFFFFFFF</c>. See
/// docs/research/2026-08-07-ap159-pseudocode.md §1 for the full
/// disassembly-backed derivation.
/// </para>
/// </summary>
/// <param name="worldParts">Per-part world-placed authored boxes — SAME
/// parts, SAME order as <paramref name="worldPartSpheres"/> (both are
/// built from the identical BSP-filtered shape list in
/// <c>ShadowObjectRegistry</c>, so index i always names the same
/// part).</param>
/// <param name="worldPartSpheres">Per-part world-placed BSP root
/// spheres — the cheap-reject input.</param>
public static void FindTransitCellsBox(
PhysicsDataCache cache,
CellPhysics currentCell,
uint currentCellId,
IReadOnlyList<ShadowPartBox> worldParts,
IReadOnlyList<Sphere> worldPartSpheres,
ICollection<uint> candidates,
out bool exitOutside)
{
exitOutside = false;
int partCount = Math.Min(worldParts.Count, worldPartSpheres.Count);
if (partCount == 0) return;
uint lbPrefix = currentCellId & 0xFFFF0000u;
for (int portalIndex = 0;
portalIndex < currentCell.Portals.Count;
portalIndex++)
{
PortalInfo portal = currentCell.Portals[portalIndex];
if (!TryGetPortalPlane(
currentCell,
portalIndex,
portal,
out Plane portalPlane))
{
continue;
}
for (int i = 0; i < partCount; i++)
{
Sphere sphere = worldPartSpheres[i];
// --- cheap reject (sphere) ---------------------------------
// Same shape as FindTransitCellsSphere's exterior-portal
// straddle test's pad, but ONE-DIRECTIONAL and gated on
// PortalSide (matches the existing "conservative unloaded-
// cell hint" idiom below, and the raw retail branch
// structure at 0x0052cba7/0x0052cbbd — a BN artifact
// collapsed the real two-branch shape into a spurious
// three-way if/elseif/else that both share the SAME box-test
// target).
float rad = sphere.Radius + FEpsilon;
var localCenter = Vector3.Transform(
sphere.Origin, currentCell.InverseWorldTransform);
float dist =
Vector3.Dot(localCenter, portalPlane.Normal) +
portalPlane.D;
bool passesCheapReject = portal.PortalSide
? dist > -rad
: dist < rad;
if (!passesCheapReject)
continue;
// --- box admit -----------------------------------------------
ShadowPartBox partBox = worldParts[i];
partBox.RefitToLocal(
currentCell.InverseWorldTransform,
out Vector3 localBoxMin,
out Vector3 localBoxMax);
BSPQuery.PlaneSide sidedness =
BSPQuery.ClassifyBox(portalPlane, localBoxMin, localBoxMax);
BSPQuery.PlaneSide crossingSide = portal.PortalSide
? BSPQuery.PlaneSide.Positive
: BSPQuery.PlaneSide.Negative;
bool crosses =
sidedness == BSPQuery.PlaneSide.Straddle ||
sidedness == crossingSide;
if (!crosses)
continue;
// --- destination resolution (box crosses this portal) -------
if (portal.OtherCellId == 0xFFFF)
{
exitOutside = true;
break; // next portal
}
uint otherId = lbPrefix | portal.OtherCellId;
RecordUnionOnlyProbe(candidates, otherId);
var otherCell = cache.GetCellStruct(otherId);
if (otherCell is null ||
!CollisionTraversal.HasCellContainment(cache, otherCell))
{
// Unconditional load hint — the box admit test already
// proved crossing, unlike the sphere overload's unloaded
// path (which has no admit test to rely on and so
// re-tests distance).
candidates.Add(otherId);
break; // next portal
}
partBox.RefitToLocal(
otherCell.InverseWorldTransform,
out Vector3 destBoxMin,
out Vector3 destBoxMax);
if (CollisionTraversal.BoxIntersectsCell(
cache, otherCell, destBoxMin, destBoxMax))
{
candidates.Add(otherId);
break; // next portal
}
// Box didn't actually reach the other cell's geometry —
// retest remaining parts against the SAME portal/destination
// (retail 0x0052cc63: no break).
}
}
}
/// <summary>
/// Resolves the portal plane from whichever immutable representation owns
/// this cell. Graph fixtures retain the DAT polygon dictionary; production
@ -443,7 +588,7 @@ public static class CellTransit
Vector3 currentBlockOrigin,
ICollection<uint> candidates)
{
if (worldParts is null || worldParts.Count == 0)
if (worldParts is null)
return false;
// 0x005333a2-0x005333dd: the base gid is the FIRST part's landcell.
@ -845,16 +990,16 @@ public static class CellTransit
/// </para>
///
/// <para>
/// DIVERGENCE (registered, AP-159): the INDOOR half of retail's part-array
/// overload — box-vs-portal-plane
/// AP-159 / #335 (2026-08-07, Campaign S slice S1B): the INDOOR half of
/// retail's part-array overload — box-vs-portal-plane
/// (<c>BBox::LocalToLocal</c> @0x005b1e60 + <c>Plane::intersect_box</c>
/// @0x005aa170 at <c>0x0052cbf9</c>/<c>0x0052cc05</c>) and
/// <c>CCellStruct::box_intersects_cell</c> @0x00533910 — is NOT ported
/// here. Indoor candidates keep the sphere-vs-portal traversal
/// <see cref="FindTransitCellsSphere"/> already runs, from the same
/// per-part BSP root spheres, which is byte-for-byte the behaviour every
/// BSP object had before #334. AP-156's row already names that port as its
/// open residual; #334 is the OUTDOOR half of it.
/// <c>CCellStruct::box_intersects_cell</c> @0x00533910 — is now ported
/// as <see cref="FindTransitCellsBox"/>, called from the loop below in
/// place of the sphere traversal <see cref="FindTransitCellsSphere"/>
/// every BSP object used before this fix. #334 ported the OUTDOOR half;
/// this closes AP-156's remaining residual. See
/// docs/research/2026-08-07-ap159-pseudocode.md for the derivation.
/// </para>
/// </summary>
/// <param name="worldParts">Per-part world-placed authored boxes — the
@ -920,9 +1065,16 @@ public static class CellTransit
var cell = cache.GetCellStruct(cellId);
if (cell is null) continue; // 0x00511009 null cell pointer
if (sphereCount == 0) continue;
FindTransitCellsSphere(
cache, cell, cellId, worldPartSpheres!, sphereCount,
// AP-159 / #335 (2026-08-07): the indoor arm now runs
// retail's part-array find_transit_cells box-admit test
// (CellTransit.FindTransitCellsBox) instead of the sphere
// traversal every BSP object used before this fix. worldParts
// and worldPartSpheres are built from the identical
// BSP-filtered shape list in ShadowObjectRegistry, so they
// are always the same length and order.
if (sphereCount == 0 || worldParts.Count == 0) continue;
FindTransitCellsBox(
cache, cell, cellId, worldParts, worldPartSpheres!,
candidates, out bool exitStraddle);
if (exitStraddle && !outdoorAdded)

View file

@ -261,6 +261,12 @@ internal sealed class CollisionShadowVerifier
float radius) =>
$"center={Format(center)};radius={Bits(radius)}";
/// <summary>AP-159 / #335: input formatter for the box-vs-cell-BSP shadow sample.</summary>
internal static string FormatInput(
Vector3 min,
Vector3 max) =>
$"min={Format(min)};max={Format(max)}";
internal static string FormatInput(
Vector3 center,
float radius,

View file

@ -624,6 +624,134 @@ internal static class CollisionTraversal
return graphResult;
}
/// <summary>
/// AP-159 / #335 (2026-08-07). Box-shaped sibling of
/// <see cref="SphereIntersectsCell"/> — same flat-authority /
/// graph-referee shadow-sample dispatch shape, for the new
/// <c>CCellStruct::box_intersects_cell</c> port
/// (<see cref="BSPQuery.BoxIntersectsCellBsp"/> /
/// <see cref="FlatBspQuery.BoxIntersectsCellBsp"/>) that
/// <c>CellTransit.FindTransitCellsBox</c> uses for the destination-cell
/// gate in retail's part-array <c>find_transit_cells</c>.
/// </summary>
internal static bool BoxIntersectsCell(
PhysicsDataCache cache,
CellPhysics cell,
Vector3 localMin,
Vector3 localMax)
{
if (UseFlat(cache))
{
FlatCellContainmentBsp flat = cell.FlatContainmentBsp ??
throw MissingFlat("cell containment");
bool flatAuthorityResult = FlatBspQuery.BoxIntersectsCellBsp(
flat,
localMin,
localMax);
CollisionShadowVerifier? flatShadow = cache.CollisionShadow;
if (flatShadow is null ||
!flatShadow.TrySample(out long flatAuthoritySample))
return flatAuthorityResult;
bool graphRefereeResult = false;
Exception? graphRefereeFault = null;
flatShadow.BeginGraphPass();
try
{
graphRefereeResult = BSPQuery.BoxIntersectsCellBsp(
cell.CellBSP?.Root,
localMin,
localMax);
}
catch (Exception fault)
{
graphRefereeFault = fault;
}
finally
{
flatShadow.EndGraphPass();
}
string flatAuthorityInput = CollisionShadowVerifier.FormatInput(
localMin,
localMax);
if (graphRefereeFault is null)
{
flatShadow.RecordBoolean(
flatAuthoritySample,
"BoxIntersectsCell",
cell.SourceId,
graphRefereeResult,
flatAuthorityResult,
flatAuthorityInput);
}
else
{
flatShadow.RecordFault(
flatAuthoritySample,
"BoxIntersectsCell",
cell.SourceId,
graphRefereeFault,
flatAuthorityInput,
"flat");
}
return flatAuthorityResult;
}
CollisionShadowVerifier? shadow = cache.CollisionShadow;
if (shadow is null || !shadow.TrySample(out long sample))
{
return BSPQuery.BoxIntersectsCellBsp(
cell.CellBSP?.Root,
localMin,
localMax);
}
bool flatResult = false;
Exception? flatFault = null;
shadow.BeginFlatPass();
try
{
flatResult = FlatBspQuery.BoxIntersectsCellBsp(
cell.FlatContainmentBsp ??
throw MissingFlat("cell containment"),
localMin,
localMax);
}
catch (Exception fault)
{
flatFault = fault;
}
finally
{
shadow.EndFlatPass();
}
bool graphResult = BSPQuery.BoxIntersectsCellBsp(
cell.CellBSP?.Root,
localMin,
localMax);
string input = CollisionShadowVerifier.FormatInput(localMin, localMax);
if (flatFault is null)
{
shadow.RecordBoolean(
sample,
"BoxIntersectsCell",
cell.SourceId,
graphResult,
flatResult,
input);
}
else
{
shadow.RecordFault(
sample,
"BoxIntersectsCell",
cell.SourceId,
flatFault,
input);
}
return graphResult;
}
internal static TransitionState FindCollisions(
PhysicsDataCache cache,
CellPhysics cell,

View file

@ -476,6 +476,51 @@ internal static class FlatBspQuery
: true;
}
/// <summary>
/// Flat port of retail <c>BSPNODE::box_intersects_cell_bsp</c>
/// (0x0053C880). AP-159 / #335 (2026-08-07). Shares
/// <see cref="BSPQuery.ClassifyBox"/> for the box-vs-plane classification
/// math, exactly like this file's polygon methods share
/// <see cref="BSPQuery.PolygonHitsSpherePrecise"/>. See
/// docs/research/2026-08-07-ap159-pseudocode.md §3.
/// </summary>
public static bool BoxIntersectsCellBsp(
FlatCellContainmentBsp tree,
Vector3 min,
Vector3 max)
{
ArgumentNullException.ThrowIfNull(tree);
return BoxIntersectsCellBsp(tree, tree.RootIndex, min, max);
}
private static bool BoxIntersectsCellBsp(
FlatCellContainmentBsp tree,
int nodeIndex,
Vector3 min,
Vector3 max)
{
if (nodeIndex < 0)
return true;
FlatCellBspNode node = tree.Nodes[nodeIndex];
if (node.Type == BSPNodeType.Leaf)
return true;
if (BSPQuery.ClassifyBox(node.SplittingPlane, min, max) ==
BSPQuery.PlaneSide.Negative)
{
return false;
}
return node.PositiveChildIndex >= 0
? BoxIntersectsCellBsp(
tree,
node.PositiveChildIndex,
min,
max)
: true;
}
/// <summary>
/// Flat static sphere/polygon overlap shadow query.
/// </summary>

View file

@ -761,8 +761,8 @@ public sealed class ShadowObjectRegistry
/// the radius from at <c>0x0052cb65 fadd [esi+0xc]</c>.
///
/// <para>
/// These drive ONLY the indoor half of the BSP flood and the outdoor
/// building bridge (<c>CEnvCell::check_building_transit</c> @0x0052c5d0),
/// These drive the indoor BSP flood's CHEAP REJECT (the admit is the part BOX
/// since AP-159's S1B port) and the outdoor building bridge (<c>CEnvCell::check_building_transit</c> @0x0052c5d0),
/// which still use the sphere traversal — the AP-159 residual. The
/// outdoor expansion uses <see cref="BuildFloodPartBoxes"/> and never
/// these. No cap: <c>find_bbox_cell_list</c> walks every part, bounded

View file

@ -168,4 +168,33 @@ public readonly record struct ShadowPartBox
max = Vector3.Max(max, world);
}
}
/// <summary>
/// AP-159 / #335 (2026-08-07). Retail <c>BBox::LocalToLocal</c>
/// @0x005b1e60 — the indoor sibling of <see cref="RefitTo"/>'s
/// <c>BBox::LocalToGlobal</c>. Differs only in that the DESTINATION
/// frame carries its own rotation (a cell's own orientation), not just a
/// translation offset: <see cref="RefitTo"/> is the degenerate case of
/// this method where the destination frame has no rotation (world axes).
/// Same eight-corner transform-and-refit discipline — a rotated box
/// grows, conservatively, matching retail's own direction.
/// </summary>
/// <param name="worldToLocal">The destination frame's world-to-local
/// transform — e.g. a cell's <c>InverseWorldTransform</c>.</param>
public void RefitToLocal(Matrix4x4 worldToLocal, out Vector3 min, out Vector3 max)
{
min = new Vector3(float.MaxValue);
max = new Vector3(float.MinValue);
for (int corner = 0; corner < 8; corner++)
{
var local = new Vector3(
(corner & 1) == 0 ? LocalMin.X : LocalMax.X,
(corner & 2) == 0 ? LocalMin.Y : LocalMax.Y,
(corner & 4) == 0 ? LocalMin.Z : LocalMax.Z);
Vector3 world = Vector3.Transform(local, WorldRotation) + WorldPosition;
Vector3 dest = Vector3.Transform(world, worldToLocal);
min = Vector3.Min(min, dest);
max = Vector3.Max(max, dest);
}
}
}

View file

@ -0,0 +1,299 @@
using System.Linq;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Tests.Conformance;
using DatReaderWriter;
using DatReaderWriter.Enums;
using DatReaderWriter.Options;
using DatReaderWriter.Types;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// AP-159 / #335 (2026-08-07), Campaign S slice S1B, D1. Exact differential
/// referee for the new <c>BoxIntersectsCellBsp</c> traversal, shipped in both
/// representations (<see cref="BSPQuery.BoxIntersectsCellBsp"/> over the
/// graph, <see cref="FlatBspQuery.BoxIntersectsCellBsp"/> over the flat
/// production shadow) per the Slice I4/I5 house rule: a new traversal in two
/// representations ships with an exact differential referee, same inputs
/// through both, asserting identical verdicts — following the pattern in
/// <see cref="FlatBspQueryDifferentialTests"/>.
/// </summary>
public sealed class BoxIntersectsCellBspDifferentialTests
{
[Fact]
public void NullRoot_LeafRoot_ReturnTrue_BothRepresentations()
{
FlatCellContainmentBsp emptyFlat =
FlatCollisionAssetBuilder.FlattenCellContainmentBsp(null);
Assert.True(BSPQuery.BoxIntersectsCellBsp(
null, new Vector3(-1f), new Vector3(1f)));
Assert.True(FlatBspQuery.BoxIntersectsCellBsp(
emptyFlat, new Vector3(-1f), new Vector3(1f)));
var leaf = new CellBSPNode
{
Type = BSPNodeType.Leaf,
LeafIndex = 5,
};
FlatCellContainmentBsp leafFlat =
FlatCollisionAssetBuilder.FlattenCellContainmentBsp(leaf);
Assert.True(BSPQuery.BoxIntersectsCellBsp(
leaf, new Vector3(-1f), new Vector3(1f)));
Assert.True(FlatBspQuery.BoxIntersectsCellBsp(
leafFlat, new Vector3(-1f), new Vector3(1f)));
}
[Fact]
public void SingleSplittingPlane_UniformPositiveNegativeAndStraddle_MatchGraphAndRetailSemantics()
{
// One internal node: splitting plane x=0 (normal +X), PosNode a leaf
// (matching the point/sphere siblings' "PosNode is where the
// interior lives" shape), no NegNode consulted.
var leaf = new CellBSPNode { Type = BSPNodeType.Leaf, LeafIndex = 1 };
var root = new CellBSPNode
{
Type = BSPNodeType.BPIn,
SplittingPlane = new Plane(Vector3.UnitX, 0f),
PosNode = leaf,
};
FlatCellContainmentBsp flat =
FlatCollisionAssetBuilder.FlattenCellContainmentBsp(root);
// Box entirely positive (min.x > 0): admitted (descends to leaf -> true).
AssertBoxEqual(root, flat, new Vector3(1f, -1f, -1f), new Vector3(2f, 1f, 1f), expectTrue: true);
// Box entirely negative (max.x < -eps, well clear): rejected (false)
// — the case retail's box_intersects_cell_bsp actually distinguishes
// from the sphere/point siblings.
AssertBoxEqual(root, flat, new Vector3(-2f, -1f, -1f), new Vector3(-1f, 1f, 1f), expectTrue: false);
// Box straddling x=0: admitted (true) — a straddling box is never
// "entirely negative."
AssertBoxEqual(root, flat, new Vector3(-0.5f, -1f, -1f), new Vector3(0.5f, 1f, 1f), expectTrue: true);
// Exact epsilon boundary: max.x just inside -eps (entirely negative
// by the tiniest margin) vs just outside (straddling by the tiniest
// margin). F_EPSILON = 0.000199999995f (BSPQuery.BoxPlaneEpsilon).
const float eps = 0.000199999995f;
AssertBoxEqual(
root, flat,
new Vector3(-1f, -1f, -1f), new Vector3(-eps - 0.0001f, 1f, 1f),
expectTrue: false);
AssertBoxEqual(
root, flat,
new Vector3(-1f, -1f, -1f), new Vector3(-eps + 0.0001f, 1f, 1f),
expectTrue: true);
}
[Fact]
public void DeepChain_MultipleNodeTypesAndChildNullTermination_MatchGraphBits()
{
// A deep PosNode chain (mirrors the point/sphere differential's
// "Depth=256" coverage) with a mix of splitting-plane orientations
// (X, Y, Z, and a non-axis-aligned normal) so every node the box
// must pass through exercises a different plane, terminating in a
// leaf.
var leaf = new CellBSPNode { Type = BSPNodeType.Leaf, LeafIndex = 42 };
CellBSPNode graph = leaf;
Vector3[] normals =
[
Vector3.UnitX,
Vector3.UnitY,
Vector3.UnitZ,
Vector3.Normalize(new Vector3(1f, 1f, 1f)),
];
const int Depth = 200;
for (int i = 0; i < Depth; i++)
{
graph = new CellBSPNode
{
Type = BSPNodeType.BPIn,
SplittingPlane = new Plane(normals[i % normals.Length], 1_000f),
PosNode = graph,
};
}
FlatCellContainmentBsp flat =
FlatCollisionAssetBuilder.FlattenCellContainmentBsp(graph);
// Boxes far on the positive side of every plane in the chain
// (dist = Dot(N,p)+D; D=+1000 puts a box near the origin at
// dist≈+1000, deeply positive) — must reach the terminal leaf
// through every node.
AssertBoxEqual(graph, flat, new Vector3(-1f), new Vector3(1f), expectTrue: true);
// A box deeply negative along every axis (and therefore deeply
// negative against whichever of the four normals sits at the root
// of the chain) must reject immediately without ever reaching the
// terminal leaf.
AssertBoxEqual(
graph, flat,
new Vector3(-2000f, -2000f, -2000f), new Vector3(-1900f, -1900f, -1900f),
expectTrue: false);
}
[Fact]
public void RandomizedSyntheticSweep_ArbitraryBoxesAgainstBranchingTree_MatchGraphBits()
{
// A branching tree (unlike the linear chains above) built from a
// handful of axis-aligned splitting planes at different offsets, so
// a box can be admitted or rejected at different depths depending on
// its extent — closer to what an installed EnvCell's containment BSP
// actually looks like than a single linear chain.
var leafA = new CellBSPNode { Type = BSPNodeType.Leaf, LeafIndex = 1 };
var leafB = new CellBSPNode { Type = BSPNodeType.Leaf, LeafIndex = 2 };
var midY = new CellBSPNode
{
Type = BSPNodeType.BPIn,
SplittingPlane = new Plane(Vector3.UnitY, -3f),
PosNode = leafB,
};
var midX = new CellBSPNode
{
Type = BSPNodeType.BPIn,
SplittingPlane = new Plane(Vector3.UnitX, -3f),
PosNode = midY,
};
var root = new CellBSPNode
{
Type = BSPNodeType.BPIn,
SplittingPlane = new Plane(Vector3.UnitZ, -3f),
PosNode = midX,
};
_ = leafA; // referenced only to document the tree shape; unreachable via PosNode-only walk
FlatCellContainmentBsp flat =
FlatCollisionAssetBuilder.FlattenCellContainmentBsp(root);
var random = new Random(0x4150_3135);
for (int i = 0; i < 20_000; i++)
{
Vector3 a = new(
NextFloat(random, -6f, 6f),
NextFloat(random, -6f, 6f),
NextFloat(random, -6f, 6f));
Vector3 extent = new(
NextFloat(random, 0f, 4f),
NextFloat(random, 0f, 4f),
NextFloat(random, 0f, 4f));
Vector3 min = a;
Vector3 max = a + extent;
bool graphResult = BSPQuery.BoxIntersectsCellBsp(root, min, max);
bool flatResult = FlatBspQuery.BoxIntersectsCellBsp(flat, min, max);
Assert.True(
graphResult == flatResult,
$"iteration {i}: min={min}, max={max}, graph={graphResult}, flat={flatResult}.");
}
}
[Fact]
public void InstalledDat_RandomizedBoxSweepOverEnvCellContainmentBsps_HasZeroMismatch()
{
string? datDirectory = ConformanceDats.ResolveDatDir();
if (datDirectory is null)
return;
using var dats = new DatCollection(datDirectory, DatAccessType.Read);
var random = new Random(0x4230_5820);
int cellsSwept = 0;
int comparisons = 0;
foreach (uint cellId in new[]
{
0x8A02_016Eu,
0x8A02_017Au,
0xA9B4_013Fu,
0xA9B4_0150u,
0xA9B4_0159u,
0xA9B4_015Au,
0xA9B4_0161u,
0xA9B4_0162u,
0xA9B4_0164u,
0xA9B4_0166u,
})
{
var cache = new PhysicsDataCache();
ConformanceDats.LoadEnvCell(dats, cache, cellId);
CellPhysics source = Assert.IsType<CellPhysics>(
cache.GetCellStruct(cellId));
FlatCellContainmentBsp flatContainment =
FlatCollisionAssetBuilder.FlattenCellContainmentBsp(
source.CellBSP?.Root);
if (source.CellBSP?.Root is null)
continue;
cellsSwept++;
// Anchor boxes at the cell's own resolved physics-polygon
// vertices (same anchor strategy as
// FlatBspQueryDifferentialTests.InstalledDat_LargeRandomizedSweep)
// — this exercises boxes actually near the containment BSP's own
// splitting planes rather than boxes chosen independently of the
// cell's geometry.
Vector3[] anchors = source.Resolved.Count > 0
? source.Resolved.Values
.SelectMany(p => p.Vertices.ToArray())
.ToArray()
: [Vector3.Zero];
for (int iteration = 0; iteration < 2_000; iteration++)
{
Vector3 anchor = anchors[random.Next(anchors.Length)];
float halfExtent = (iteration % 9) switch
{
0 => BSPQuery.BoxPlaneEpsilon,
1 => 0.01f,
2 => 0.5f,
_ => NextFloat(random, 0.05f, 2.5f),
};
Vector3 jitter = new(
NextFloat(random, -1.5f, 1.5f),
NextFloat(random, -1.5f, 1.5f),
NextFloat(random, -1.5f, 1.5f));
Vector3 center = anchor + jitter;
Vector3 min = center - new Vector3(halfExtent);
Vector3 max = center + new Vector3(halfExtent);
bool graphResult = BSPQuery.BoxIntersectsCellBsp(
source.CellBSP?.Root, min, max);
bool flatResult = FlatBspQuery.BoxIntersectsCellBsp(
flatContainment, min, max);
comparisons++;
Assert.True(
graphResult == flatResult,
$"cell 0x{cellId:X8}, iteration {iteration}: " +
$"min={min}, max={max}, graph={graphResult}, flat={flatResult}.");
}
}
// Review F5 (2026-08-07): the counts are PINNED, not merely reported.
// The original `cellsSwept == 0 || comparisons > 0` let a run where 9
// of the 10 fixture cells lacked a containment BSP pass with a tenth
// of the claimed coverage. If a future DAT change breaks a fixture
// cell, this fails loudly and the fixture list gets re-picked — that
// is the correct outcome, not an inconvenience.
Console.WriteLine(
$"box-differential installed sweep: cellsSwept={cellsSwept} comparisons={comparisons}");
Assert.Equal(10, cellsSwept);
Assert.Equal(20_000, comparisons);
}
private static void AssertBoxEqual(
CellBSPNode? graph,
FlatCellContainmentBsp flat,
Vector3 min,
Vector3 max,
bool expectTrue)
{
bool graphResult = BSPQuery.BoxIntersectsCellBsp(graph, min, max);
bool flatResult = FlatBspQuery.BoxIntersectsCellBsp(flat, min, max);
Assert.Equal(expectTrue, graphResult);
Assert.Equal(expectTrue, flatResult);
Assert.Equal(graphResult, flatResult);
}
private static float NextFloat(Random random, float minimum, float maximum)
=> minimum + (float)random.NextDouble() * (maximum - minimum);
}

View file

@ -0,0 +1,511 @@
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.Tests.Conformance;
using DatReaderWriter;
using DatReaderWriter.Enums;
using DatReaderWriter.Options;
using DatReaderWriter.Types;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// AP-159 / #335 (2026-08-07), Campaign S slice S1B, D2/D3. Conformance and
/// direction tests for <see cref="CellTransit.FindTransitCellsBox"/> — the
/// box-admitting INDOOR arm the D2 rewire installs in place of
/// <see cref="CellTransit.FindTransitCellsSphere"/> for
/// <see cref="CellTransit.BuildShadowCellSetFromParts"/>.
/// </summary>
public sealed class CellTransitFindTransitCellsBoxTests
{
private static CellPhysics MakeCellWithPortalAtRightWall(
Matrix4x4 worldTransform, uint otherCellId, ushort flags)
{
// Portal poly at local x=2.5 (right wall), normal +X. Same shape as
// CellTransitFindTransitCellsSphereTests' fixture, so the sphere-only
// "pre-fix" comparison below is directly reading the same geometry
// the existing FindTransitCellsSphere conformance suite already
// trusts.
var portalPolyA = new ResolvedPolygon
{
Id = 10,
Vertices = new[]
{
new Vector3(2.5f, -2.5f, 0f),
new Vector3(2.5f, 2.5f, 0f),
new Vector3(2.5f, 2.5f, 5f),
new Vector3(2.5f, -2.5f, 5f),
},
Plane = new Plane(new Vector3(1, 0, 0), -2.5f), // x = 2.5
NumPoints = 4,
SidesType = CullMode.None,
};
Matrix4x4.Invert(worldTransform, out var inv);
return new CellPhysics
{
WorldTransform = worldTransform,
InverseWorldTransform = inv,
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
PortalPolygons = new Dictionary<ushort, ResolvedPolygon> { [10] = portalPolyA },
Portals = new[]
{
new PortalInfo(otherCellId: (ushort)otherCellId, polygonId: 10, flags: flags),
},
};
}
private static CellBSPTree SinglePlaneCellBsp()
{
var leaf = new CellBSPNode { Type = BSPNodeType.Leaf };
return new CellBSPTree
{
Root = new CellBSPNode
{
// Local x >= 0 is inside this synthetic cell.
Type = BSPNodeType.BPIn,
SplittingPlane = new Plane(new Vector3(1f, 0f, 0f), 0f),
PosNode = leaf,
},
};
}
// ── D3.1: pre-fix admits, post-fix does not, with in-session sabotage ──
/// <summary>
/// D3.1. A part whose SPHERE reaches the portal plane (passing the
/// cheap-reject, same as the pre-existing "near portal" sphere fixture)
/// but whose authored BOX is small and does not reach the plane at all.
/// Pre-fix (<see cref="CellTransit.FindTransitCellsSphere"/>, still
/// directly callable and unmodified) admits cell B. Post-fix
/// (<see cref="CellTransit.FindTransitCellsBox"/>, what
/// <see cref="CellTransit.BuildShadowCellSetFromParts"/> now calls) does
/// not.
/// </summary>
[Fact]
public void SphereReachesPortal_BoxDoesNot_PreFixAdmitsPostFixRejects()
{
var cellA = MakeCellWithPortalAtRightWall(Matrix4x4.Identity, otherCellId: 0x0101, flags: 0);
var cellBT = Matrix4x4.CreateTranslation(new Vector3(5f, 0f, 0f));
Matrix4x4.Invert(cellBT, out var cellBInv);
// No CellBSP on cell B, deliberately: this exercises the SAME
// unloaded-neighbour hint path CellTransitFindTransitCellsSphereTests.
// SphereInsideCellA_NearPortal_AddsCellB relies on. Giving cell B a
// containment BSP here would switch FindTransitCellsSphere onto the
// sphere_intersects_cell branch instead, which this fixture's sphere
// position was never designed to satisfy.
var cellB = new CellPhysics
{
WorldTransform = cellBT,
InverseWorldTransform = cellBInv,
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
};
var cache = new PhysicsDataCache();
cache.RegisterCellStructForTest(0xA9B40100u, cellA);
cache.RegisterCellStructForTest(0xA9B40101u, cellB);
// Same part origin as CellTransitFindTransitCellsSphereTests'
// "near portal" case: local x=2.0, sphere radius=0.5 -> reaches
// x=2.5 (the portal plane). The authored BOX around that same part
// origin is +-0.1m, so its max.x = 2.1 - nowhere near the plane.
var partWorldPos = new Vector3(2.0f, 0f, 2.5f);
var sphere = new Sphere { Origin = partWorldPos, Radius = 0.5f };
var box = new ShadowPartBox[]
{
MakeBox(new Vector3(-0.1f), new Vector3(0.1f), partWorldPos, Quaternion.Identity),
};
// Pre-fix: the sphere-only traversal still exists, unmodified.
var preFixCandidates = new HashSet<uint>();
CellTransit.FindTransitCellsSphere(
cache, cellA, currentCellId: 0xA9B40100u,
partWorldPos, sphereRadius: 0.5f, preFixCandidates, out bool preFixExitOutside);
Assert.Contains(0xA9B40101u, preFixCandidates);
Assert.False(preFixExitOutside);
// Post-fix: the box-admitting traversal the D2 rewire installs.
var postFixCandidates = new HashSet<uint>();
CellTransit.FindTransitCellsBox(
cache, cellA, currentCellId: 0xA9B40100u,
box, new[] { sphere }, postFixCandidates, out bool postFixExitOutside);
Assert.DoesNotContain(0xA9B40101u, postFixCandidates);
Assert.False(postFixExitOutside);
// End-to-end at the production entry point: BuildShadowCellSetFromParts
// (which now calls FindTransitCellsBox internally) must NOT include
// cell B either.
IReadOnlyList<uint> endToEnd = CellTransit.BuildShadowCellSetFromParts(
cache, seedCellId: 0xA9B40100u, box, new[] { sphere }, isStatic: false);
Assert.DoesNotContain(0xA9B40101u, endToEnd);
}
// ── D3.2: inverse guard — a box that DOES cross admits, unchanged ──────
/// <summary>
/// D3.2. The inverse guard: a part whose box genuinely crosses the
/// portal plane is admitted by BOTH the pre-fix sphere test and the
/// post-fix box test — over-inclusion strictly shrinks the admitted set,
/// it never drops a genuine crossing.
/// </summary>
[Fact]
public void BoxCrossesPortal_AdmittedBeforeAndAfter_UnloadedNeighbour()
{
var cellA = MakeCellWithPortalAtRightWall(Matrix4x4.Identity, otherCellId: 0x0101, flags: 0);
var cellBT = Matrix4x4.CreateTranslation(new Vector3(5f, 0f, 0f));
Matrix4x4.Invert(cellBT, out var cellBInv);
// No CellBSP -- exercises the unloaded-neighbour hint path on the
// pre-fix side and the unconditional load-hint add on the post-fix
// side, same as SphereReachesPortal_BoxDoesNot_PreFixAdmitsPostFixRejects.
var cellB = new CellPhysics
{
WorldTransform = cellBT,
InverseWorldTransform = cellBInv,
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
};
var cache = new PhysicsDataCache();
cache.RegisterCellStructForTest(0xA9B40100u, cellA);
cache.RegisterCellStructForTest(0xA9B40101u, cellB);
var partWorldPos = new Vector3(2.0f, 0f, 2.5f);
var sphere = new Sphere { Origin = partWorldPos, Radius = 0.5f };
// A box that genuinely spans past x=2.5 in cell A's local frame:
// local box +-0.7m around the part origin -> max.x = 2.7.
var box = new ShadowPartBox[]
{
MakeBox(new Vector3(-0.7f), new Vector3(0.7f), partWorldPos, Quaternion.Identity),
};
var preFixCandidates = new HashSet<uint>();
CellTransit.FindTransitCellsSphere(
cache, cellA, currentCellId: 0xA9B40100u,
partWorldPos, sphereRadius: 0.5f, preFixCandidates, out bool preFixExitOutside);
Assert.Contains(0xA9B40101u, preFixCandidates);
Assert.False(preFixExitOutside);
var postFixCandidates = new HashSet<uint>();
CellTransit.FindTransitCellsBox(
cache, cellA, currentCellId: 0xA9B40100u,
box, new[] { sphere }, postFixCandidates, out bool postFixExitOutside);
Assert.Contains(0xA9B40101u, postFixCandidates);
Assert.False(postFixExitOutside);
}
/// <summary>
/// D3.2 (loaded-neighbour variant). Retail's part-array overload applies
/// the SAME cheap-reject + box-admit test whether the destination is
/// loaded or not (see docs/research/2026-08-07-ap159-pseudocode.md §1's
/// "Structural difference" note) -- unlike the sphere overload, which
/// skips straight to <c>sphere_intersects_cell</c> for a loaded
/// neighbour with no admit gate at all. This fixture positions the box
/// so it passes cell A's admit test AND genuinely lands inside cell B's
/// real containment BSP, exercising
/// <see cref="CollisionTraversal.BoxIntersectsCell"/>'s loaded path.
/// </summary>
[Fact]
public void BoxCrossesPortal_AdmittedBeforeAndAfter_LoadedNeighbourGate()
{
var cellA = MakeCellWithPortalAtRightWall(Matrix4x4.Identity, otherCellId: 0x0101, flags: 0);
var cellBT = Matrix4x4.CreateTranslation(new Vector3(3f, 0f, 0f));
Matrix4x4.Invert(cellBT, out var cellBInv);
var cellB = new CellPhysics
{
WorldTransform = cellBT,
InverseWorldTransform = cellBInv,
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
CellBSP = SinglePlaneCellBsp(),
};
var cache = new PhysicsDataCache();
cache.RegisterCellStructForTest(0xA9B40100u, cellA);
cache.RegisterCellStructForTest(0xA9B40101u, cellB);
// Cell A local dist = x-2.5 in [0.1, 0.7] -> uniformly positive,
// past the portal. Cell B local dist = x-3.0 in [-0.4, 0.2] ->
// straddles cell B's own containment plane at local x=0, so the
// box genuinely lands inside cell B's volume.
var partWorldPos = new Vector3(2.9f, 0f, 2.5f);
var sphere = new Sphere { Origin = partWorldPos, Radius = 0.5f };
var box = new ShadowPartBox[]
{
MakeBox(new Vector3(-0.3f), new Vector3(0.3f), partWorldPos, Quaternion.Identity),
};
var preFixCandidates = new HashSet<uint>();
CellTransit.FindTransitCellsSphere(
cache, cellA, currentCellId: 0xA9B40100u,
partWorldPos, sphereRadius: 0.5f, preFixCandidates, out bool preFixExitOutside);
Assert.Contains(0xA9B40101u, preFixCandidates);
Assert.False(preFixExitOutside);
var postFixCandidates = new HashSet<uint>();
CellTransit.FindTransitCellsBox(
cache, cellA, currentCellId: 0xA9B40100u,
box, new[] { sphere }, postFixCandidates, out bool postFixExitOutside);
Assert.Contains(0xA9B40101u, postFixCandidates);
Assert.False(postFixExitOutside);
}
// ── D3.3: direction assertion over an installed-DAT sweep ──────────────
/// <summary>
/// D3.3. Over real installed EnvCells (real portals, real containment
/// BSPs), for a randomized population of synthetic BSP-part placements
/// near each portal (deliberately using an OVERSIZED sphere against a
/// TIGHT authored box, mirroring the real-world AP-156 divergence
/// pattern), the box-admitting membership set is a SUBSET of the
/// sphere-only set for every swept object — never a superset. Reports
/// objects swept, cells removed, cells added (must be zero) via the test
/// output (xunit console capture); see the AP-159 implementation report
/// for the exact counts from this run.
/// </summary>
[Fact]
public void InstalledDat_RandomizedPartSweepNearRealPortals_PostFixMembershipIsSubsetOfPreFix()
{
string? datDirectory = ConformanceDats.ResolveDatDir();
if (datDirectory is null)
return;
using var dats = new DatCollection(datDirectory, DatAccessType.Read);
var cache = new PhysicsDataCache();
uint[] cellIds =
[
0x8A02_016Eu,
0x8A02_017Au,
0xA9B4_013Fu,
0xA9B4_0150u,
0xA9B4_0159u,
0xA9B4_015Au,
0xA9B4_0161u,
0xA9B4_0162u,
0xA9B4_0164u,
0xA9B4_0166u,
];
foreach (uint cellId in cellIds)
ConformanceDats.LoadEnvCell(dats, cache, cellId);
var random = new Random(0x3335_5330);
int objectsSwept = 0;
int cellsRemovedTotal = 0;
int cellsAddedTotal = 0;
int objectsWithChangedMembership = 0;
var worstExamples = new List<(string Object, int Before, int After, int Removed)>();
foreach (uint cellId in cellIds)
{
CellPhysics cell = Assert.IsType<CellPhysics>(cache.GetCellStruct(cellId));
if (cell.Portals.Count == 0 || cell.PortalPolygons is null)
continue;
foreach (PortalInfo portal in cell.Portals)
{
if (!cell.PortalPolygons.TryGetValue(portal.PolygonId, out ResolvedPolygon? portalPoly) ||
portalPoly.Vertices.Length == 0)
{
continue;
}
Vector3 localAnchor = Vector3.Zero;
foreach (Vector3 v in portalPoly.Vertices)
localAnchor += v;
localAnchor /= portalPoly.Vertices.Length;
for (int iteration = 0; iteration < 40; iteration++)
{
// Jitter the anchor along the portal's own local frame so
// some placements land squarely on one side, some
// straddle, and some sit right at the plane -- the
// population a real object population would produce.
Vector3 jitter = new(
NextFloat(random, -0.6f, 0.6f),
NextFloat(random, -0.6f, 0.6f),
NextFloat(random, -0.6f, 0.6f));
Vector3 localPartPos = localAnchor + jitter;
Vector3 worldPartPos = Vector3.Transform(
localPartPos, cell.WorldTransform);
// Deliberately oversized sphere vs a tight authored box --
// the AP-156 divergence pattern this contract closes.
float sphereRadius = NextFloat(random, 0.3f, 1.2f);
float boxHalfExtent = NextFloat(random, 0.02f, 0.25f);
var sphere = new Sphere
{
Origin = worldPartPos,
Radius = sphereRadius,
};
var boxes = new ShadowPartBox[]
{
MakeBox(
new Vector3(-boxHalfExtent),
new Vector3(boxHalfExtent),
worldPartPos,
Quaternion.Identity),
};
var preFix = new HashSet<uint>();
CellTransit.FindTransitCellsSphere(
cache, cell, cellId, worldPartPos, sphereRadius,
preFix, out _);
var postFix = new HashSet<uint>();
CellTransit.FindTransitCellsBox(
cache, cell, cellId, boxes, new[] { sphere },
postFix, out _);
objectsSwept++;
var removed = new List<uint>();
var added = new List<uint>();
foreach (uint id in preFix)
if (!postFix.Contains(id)) removed.Add(id);
foreach (uint id in postFix)
if (!preFix.Contains(id)) added.Add(id);
cellsRemovedTotal += removed.Count;
cellsAddedTotal += added.Count;
// Review F3 (2026-08-07): zero-added holds for THIS
// population BY CONSTRUCTION (box rigged far smaller than
// sphere, the AP-156 pattern). It is NOT a structural
// invariant of the port: in production the box is the
// whole-vertex-array AABB while the sphere bounds only
// the physics polygons, so the real box can EXCEED the
// real sphere and the loaded-neighbour gate can admit
// cells the sphere test would not — which is retail's
// behaviour, not a defect. The production-ratio
// population below measures that direction honestly.
Assert.True(
added.Count == 0,
$"cell 0x{cellId:X8} portal->0x{portal.OtherCellId:X4} " +
$"iteration {iteration}: post-fix ADDED cell(s) " +
$"{string.Join(",", added.Select(id => $"0x{id:X8}"))} " +
"under a box RIGGED smaller than the sphere -- for this " +
"population the admit must strictly shrink.");
if (removed.Count > 0)
{
objectsWithChangedMembership++;
string label =
$"cell=0x{cellId:X8},portal->0x{portal.OtherCellId:X4}," +
$"iter={iteration},pos=({worldPartPos.X:F2}," +
$"{worldPartPos.Y:F2},{worldPartPos.Z:F2})," +
$"r={sphereRadius:F3},box=+-{boxHalfExtent:F3}";
worstExamples.Add((label, preFix.Count, postFix.Count, removed.Count));
}
}
}
}
worstExamples.Sort((a, b) => b.Removed.CompareTo(a.Removed));
Assert.Equal(0, cellsAddedTotal);
Assert.True(objectsSwept > 0, "installed-DAT sweep found no portals to test.");
// Review F4 (2026-08-07): without this, the sweep passes identically
// if the box admit is a NO-OP returning the sphere set bit-for-bit.
// The rigged population guarantees real shrinkage exists in installed
// data (41% of placements in the landing run), so zero here means the
// rewire came unwired.
Assert.True(
cellsRemovedTotal > 0,
$"the box admit removed no cells across {objectsSwept} rigged " +
"placements — indistinguishable from an unwired no-op.");
Console.WriteLine(
$"direction sweep (rigged population): objectsSwept={objectsSwept} " +
$"changed={objectsWithChangedMembership} removed={cellsRemovedTotal} " +
$"added={cellsAddedTotal}");
// Review F3/F6 (2026-08-07): a SECOND population at production-like
// ratios (box >= sphere — the whole-vertex-array AABB vs the
// physics-BSP root sphere), plus a loaded-branch counter. No
// zero-added assertion here — adds are RETAIL-CORRECT in this
// direction; the numbers are printed so the register row's severity
// stays honest, and the loaded-neighbour gate's reach is measured
// rather than presumed.
int prodSwept = 0, prodAdded = 0, prodRemoved = 0, loadedBranchHits = 0;
var prodRandom = new Random(0x3335_5331);
foreach (uint cellId in cellIds)
{
CellPhysics? cell = cache.GetCellStruct(cellId) as CellPhysics;
if (cell is null || cell.Portals.Count == 0 || cell.PortalPolygons is null)
continue;
foreach (PortalInfo portal in cell.Portals)
{
if (!cell.PortalPolygons.TryGetValue(portal.PolygonId, out ResolvedPolygon? prodPoly) ||
prodPoly.Vertices.Length == 0)
{
continue;
}
Vector3 localAnchor = Vector3.Zero;
foreach (Vector3 v in prodPoly.Vertices)
localAnchor += v;
localAnchor /= prodPoly.Vertices.Length;
for (int iteration = 0; iteration < 25; iteration++)
{
Vector3 jitter = new(
NextFloat(prodRandom, -0.6f, 0.6f),
NextFloat(prodRandom, -0.6f, 0.6f),
NextFloat(prodRandom, -0.6f, 0.6f));
Vector3 worldPartPos = Vector3.Transform(
localAnchor + jitter, cell.WorldTransform);
float sphereRadius = NextFloat(prodRandom, 0.2f, 0.6f);
float boxHalfExtent = NextFloat(prodRandom, sphereRadius, sphereRadius * 2.5f);
var sphere = new Sphere { Origin = worldPartPos, Radius = sphereRadius };
var boxes = new ShadowPartBox[]
{
MakeBox(new Vector3(-boxHalfExtent), new Vector3(boxHalfExtent),
worldPartPos, Quaternion.Identity),
};
var preFix = new HashSet<uint>();
CellTransit.FindTransitCellsSphere(
cache, cell, cellId, worldPartPos, sphereRadius, preFix, out _);
var postFix = new HashSet<uint>();
CellTransit.FindTransitCellsBox(
cache, cell, cellId, boxes, new[] { sphere }, postFix, out _);
prodSwept++;
foreach (uint id in postFix)
if (!preFix.Contains(id))
{
prodAdded++;
if (cache.GetCellStruct(id) is not null) loadedBranchHits++;
}
foreach (uint id in preFix)
if (!postFix.Contains(id)) prodRemoved++;
}
}
}
Console.WriteLine(
$"direction sweep (production-ratio population): swept={prodSwept} " +
$"added={prodAdded} removed={prodRemoved} loadedBranchAdds={loadedBranchHits}");
Assert.True(prodSwept > 0);
}
private static ShadowPartBox MakeBox(
Vector3 localMin,
Vector3 localMax,
Vector3 worldPosition,
Quaternion worldRotation)
{
var shape = ShadowShape.Bsp(
gfxObjId: 0x010046D8u,
localPosition: Vector3.Zero,
localRotation: Quaternion.Identity,
scale: 1f,
localGeometry: ShadowPartGeometry.Create(
new FlatCollisionSphere(Vector3.Zero, 0.01f),
new FlatGfxObjVisualBounds(
localMin,
localMax,
(localMin + localMax) * 0.5f,
((localMax - localMin) * 0.5f).Length(),
(localMax - localMin) * 0.5f)));
return ShadowPartBox.FromShape(shape, worldPosition, worldRotation);
}
private static float NextFloat(Random random, float minimum, float maximum)
=> minimum + (float)random.NextDouble() * (maximum - minimum);
}