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
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:
parent
1b2580be4c
commit
b3e43d22c9
14 changed files with 1600 additions and 4914 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -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
|
|
@ -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
290
docs/research/2026-08-07-ap159-pseudocode.md
Normal file
290
docs/research/2026-08-07-ap159-pseudocode.md
Normal 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 309968–310126) also contains a box
|
||||
> refinement block (@0x0052c76c–0x0052c7d2). 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 **0x0052c76c–0x0052c7d2** 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 309827–309863, 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 309968–310122) 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 310127–310257) — 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:310127–310257)
|
||||
|
||||
Verbatim shape, addresses inline:
|
||||
|
||||
```
|
||||
find_transit_cells(this, numParts, parts[], cellArray):
|
||||
exitOutside = false
|
||||
for each portal in this.Portals: # do-while, pc:310140–310252
|
||||
portalPlane = ResolvePlane(this, portal) # this->portals[i].portal.plane
|
||||
for each part in parts: # do-while, pc:310147–310246
|
||||
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 (1–4) 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:439037–439122)
|
||||
|
||||
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:325993–326087)
|
||||
|
||||
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.
|
||||
|
|
@ -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
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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:310127–310257) —
|
||||
/// 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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue