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
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue