T6 (BR-7) C4: straddle-only outside-add (A6.P5 widening DELETED) + #90 stickiness removed

The two remaining flagged workarounds retired, per the BR-7 plan +
the WF1 [MEDIUM] correction (re-gate, do NOT delete the outside-add):

1. A6.P5 hasExitPortal topology widening DELETED. Outdoor cells enter the
   collision cell array ONLY on the retail straddle gate - |dist| <
   radius + F_EPSILON against an exterior portal plane
   (CEnvCell::find_transit_cells Ghidra 0x0052c820, gate 0052c9d6,
   live-binary verified) - the same flag that already gated the
   membership pick (#112 rider). The widening existed so outdoor-
   registered doors stayed findable from indoor cells under the old flat
   registry query; with per-cell shadow lists the door is found in the
   straddle-admitted outdoor cell's own list (tick-13558 pin holds).
   The hasExitPortal out-param + plumbing deleted from
   FindTransitCellsSphere; the AddAllOutsideCells call in
   BuildCellSetAndPickContaining re-gated on exitOutsideStraddle
   (once-per-walk = retail CELLARRAY.added_outside).

2. #90 ResolveCellId sphere-overlap stickiness REMOVED (the 4ca3596
   workaround, deferred-to-A6.P4 in the physics digest). It was dead
   code: the method's only caller is FindEnvCollisions' cache-null TEST
   fallback, and the indoor branch (where the stickiness lived) required
   a non-null DataCache. Production membership flows exclusively through
   the collide-then-pick advance whose ordered-array hysteresis (current
   cell at index 0, interior-wins-break) is the retail mechanism the
   workaround approximated. ResolveCellId reduced to the bare
   prefix-preserving outdoor re-derive, documented test-only.

Test updates (pins of the deleted behaviors inverted to retail):
- A6P5_BuildCellSetFromIndoorStart_ReachesDoorOutdoorCell (asserted the
  topology widening verbatim) -> DeepInteriorSphere_NoStraddle_
  AddsNoOutdoorCells: a deep-interior sphere admits NO outdoor cells.
- A6P5_BuildCellSetFromAlcove... -> AlcoveSphere_StraddlesExitPortal_
  ReachesDoorOutdoorCell (the captured alcove position genuinely
  straddles - the retail-positive half).
- Issue112MembershipTests straddle pin + the second-sphere straddle test
  updated to the single-flag signature.

Suites: Core 1416/0/2, App 225, UI 420, Net 294 - green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-11 14:44:49 +02:00
parent dbfbf8506c
commit ca4b482f8b
5 changed files with 101 additions and 205 deletions

View file

@ -290,84 +290,38 @@ public sealed class PhysicsEngine
cg.CurrCell = cell;
}
/// <summary>
/// TEST-ONLY outdoor cell re-derive. The single caller is
/// <c>Transition.FindEnvCollisions</c>'s cache-null fallback
/// (PhysicsEngineTests run engines without a <see cref="DataCache"/>,
/// so <see cref="CellTransit.FindCellSet"/> is unavailable). Production
/// membership flows exclusively through the collide-then-pick advance
/// (<c>RunCheckOtherCellsAndAdvance</c> → <c>FindCellSet</c>).
///
/// <para>
/// BR-7 / A6.P4 C4 (2026-06-11): the former indoor branch — including
/// the #90 sphere-overlap stickiness workaround (4ca3596) and the
/// building-transit promotion — was DEAD CODE on this path (it required
/// a non-null DataCache; the only caller guarantees null) and is
/// removed. #90's doorway ping-pong concern is owned by the retail
/// ordered-pick hysteresis (current cell at array index 0,
/// interior-wins-break; CellTransit.BuildCellSetAndPickContaining) —
/// the workaround is retired, closing the digest's deferred-removal
/// item.
/// </para>
///
/// <para>Preserves the L.2e prefix-preservation fix (always apply the
/// matched landblock's high-16 prefix even when
/// <paramref name="fallbackCellId"/> arrived bare-low-byte).</para>
/// </summary>
internal uint ResolveCellId(Vector3 worldPos, float sphereRadius, uint fallbackCellId)
{
if (fallbackCellId == 0) return 0;
uint fallbackLow = fallbackCellId & 0xFFFFu;
// Indoor fallback ids pass through unchanged — identical to the old
// dead path's `DataCache is null → return fallbackCellId` outcome.
if ((fallbackCellId & 0xFFFFu) >= 0x0100u) return fallbackCellId;
if (fallbackLow >= 0x0100u)
{
// Indoor branch needs DataCache to look up cells; outdoor uses
// _landblocks (no DataCache dependency).
if (DataCache is null) return fallbackCellId;
// ── Cell-stickiness REVERTED (A6.P3 slice 3 v3, 2026-05-22) ──
// Slice 3 v1 (sphere-overlap, 8898166) over-corrected — held
// player in cellar even when transitioning out at the ramp top.
// Slice 3 v2 (point-in, 3e140cf) closed the ping-pong at the
// inn doorway (data confirmed) BUT prevented the player from
// reaching the top of the cellar ramp (the stuck spot
// transitioned from "ping-pong at top" to "never reach top").
//
// Reverting to no-stickiness for now. The ping-pong at the inn
// doorway returns but is a lesser evil than blocking cellar-up
// entirely. Issue #98 cellar-up has a deeper bug that needs
// separate investigation (BSP step-physics or AdjustOffset
// slope-projection at the cottage main floor boundary).
//
// Slice 3 work remains valuable as research evidence; the fix
// shape was wrong. Issue #90 stays as workaround until a
// better stickiness mechanism is designed (probably needs to
// be GATED by some "near cell boundary" check rather than
// applied unconditionally).
// Fallback cell no longer valid → re-resolve via portal-graph BFS.
uint indoorResult = CellTransit.FindCellList(DataCache, worldPos, sphereRadius, fallbackCellId);
// ISSUES #83 / Phase A1.7 (2026-05-21): verify the indoor result
// actually contains the player. CellTransit.FindCellList falls back
// to currentCellId when no candidate cell's CellBSP contains the
// sphere center — but this happens even when the player has walked
// OUTSIDE the entire portal-connected indoor cell graph (e.g.,
// exited through an unblocked wall or doorway gap). In that state
// the player's CellId is stuck on an indoor cell whose BSP is
// far away, every indoor-bsp query returns OK (NodeIntersects
// fails at root), and no walls block.
//
// If the resolved indoor cell's BSP does NOT contain the sphere
// center, fall through to the outdoor cell resolution below — it
// will compute the correct landcell from the terrain grid and
// optionally re-enter an indoor cell via CheckBuildingTransit.
var indoorCell = DataCache.GetCellStruct(indoorResult);
if (indoorCell?.CellBSP?.Root is null)
return indoorResult; // render root (CurrCell) set by the player's UpdateCellId // Can't verify (no CellBSP); trust FindCellList.
// Issue #90 fix (2026-05-20): use SPHERE-overlap instead of POINT-in
// for the indoor verification. The previous point-only check caused
// a per-frame ping-pong at the inn doorway: indoor BSP push-back
// moved the sphere CENTER a few cm outside the indoor CellBSP
// volume → point-only check returned false → fell through to outdoor
// → next tick re-promoted to indoor → wall hit → push-back →
// outdoor → repeat. Net visual behavior: "walls walk through"
// because outdoor ticks bypass indoor BSP entirely. With sphere-
// overlap, the player stays classified indoor as long as ANY part
// of the foot sphere still overlaps the indoor cell volume.
//
// Retail oracle: CCellStruct::sphere_intersects_cell at
// acclient_2013_pseudo_c.txt:317666 →
// BSPTREE::sphere_intersects_cell_bsp at :323267.
var localCenter = Vector3.Transform(worldPos, indoorCell.InverseWorldTransform);
if (BSPQuery.SphereIntersectsCellBsp(indoorCell.CellBSP.Root, localCenter, sphereRadius))
return indoorResult; // render root (CurrCell) set by the player's UpdateCellId
// Fall through to outdoor resolution: player has FULLY left the
// indoor portal-connected graph (sphere no longer overlaps).
}
// Outdoor seed: use terrain grid to compute the prefixed cell id.
// Preserves the L.2e prefix-preservation fix (always apply the matched
// landblock's high-16 prefix even when fallbackCellId arrived bare-low-byte).
foreach (var kvp in _landblocks)
{
var lb = kvp.Value;
@ -376,29 +330,7 @@ public sealed class PhysicsEngine
if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
{
uint lowCellId = lb.Terrain.ComputeOutdoorCellId(localX, localY);
uint outdoorCellId = (kvp.Key & 0xFFFF0000u) | lowCellId;
// Outdoor→indoor entry: if this landcell has a cached building,
// check whether the sphere has crossed into one of its interior
// EnvCells via the building's portals.
if (DataCache is not null)
{
var building = DataCache.GetBuilding(outdoorCellId);
if (building is not null)
{
var candidates = new System.Collections.Generic.HashSet<uint>();
CellTransit.CheckBuildingTransit(
DataCache, building, worldPos, sphereRadius, candidates);
if (candidates.Count > 0)
{
// First candidate wins — building portal containment is
// mutually exclusive in retail (one interior cell per portal).
foreach (var c in candidates) return c;
}
}
}
return outdoorCellId; // render root (CurrCell) set by the player's UpdateCellId
return (kvp.Key & 0xFFFF0000u) | lowCellId;
}
}