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

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)