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

@ -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);
}
}
}