fix(physics): Stage 1 — collide-then-pick (remove pre-pick fork) — the flap engine

The ordered pick alone did not stop the cottage doorway flap: the live [cell-transit]
log showed the cell faithfully following a position that cleanly oscillated between two
values at constant Z — the signature of a bistable membership<->collision feedback loop
(user's §4.4 #3, the forked collision).

Root cause: FindEnvCollisions RE-PICKED the cell from the TARGET position (old line 1958)
BEFORE running the primary collision, so the collision geometry (which cell BSP / terrain)
swapped the instant the pick flipped -> position shifts -> pick flips back.

Retail does NOT do this. CEnvCell::find_env_collisions (acclient_2013_pseudo_c.txt:309573)
collides against the cell it was called ON (sphere_path.check_cell, the carried seed) and
picks the NEW containing cell AFTER, in CTransition::check_other_cells (272717->272761:
check_cell = var_4c). Collide-then-pick.

This commit ports that order:
- remove the pre-pick (production); collide against the carried cell (indoor BSP block /
  terrain block unchanged);
- new shared RunCheckOtherCellsAndAdvance() runs the ordered FindCellSet pick +
  multi-valued CheckOtherCells + the carried-cell advance AFTER the primary collision, for
  BOTH indoor and outdoor seeds;
- the outdoor-seed post-step replaces the removed pre-pick's outdoor->indoor re-entry
  promotion (CheckBuildingTransit interior cell + its wall collision on the entry frame).

Cache-null unit-test fallback (ResolveCellId) kept. Full Core suite: 1293 pass / 5 fail =
the documented §10 baseline exactly (2 step-up + 3 door-collision), zero new breakage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-03 09:36:19 +02:00
parent 22a184ca68
commit e5457f9552

View file

@ -1944,23 +1944,22 @@ public sealed class Transition
Vector3 footCenter = sp.GlobalSphere[0].Origin;
float sphereRadius = sp.GlobalSphere[0].Radius;
// Phase W: cell membership comes from the SWEPT find_cell_list pick (retail
// CObjCell::find_cell_list), not a static re-derive. FindCellSet builds candidates anchored
// to the current cell (interior neighbors + outside cells via the exit portal + building
// re-entry cells) and picks the containing cell interior-wins. The commit to sp.CurCellId
// is gated by ValidateTransition (accept-on-move), so a push-back can't flip the cell.
// Retail does NOT re-pick the cell before colliding. CEnvCell::find_env_collisions
// (acclient_2013_pseudo_c.txt:309573) collides against the cell it was called ON —
// sphere_path.check_cell, the carried-forward (seeded) cell — and the NEW containing cell
// is picked AFTER, in check_other_cells (272717 → 272761 sets check_cell = var_4c).
//
// DataCache-null fallback: PhysicsEngineTests use engines without a DataCache (no cell
// registry). FindCellSet requires a cache, so we fall back to the old ResolveCellId
// outdoor re-derive in that case. In production DataCache is always set.
if (engine.DataCache is not null)
{
uint sweptCellId = CellTransit.FindCellSet(
engine.DataCache, sp.GlobalSphere, sp.NumSphere, sp.CheckCellId, out _);
if (sweptCellId != sp.CheckCellId)
sp.SetCheckPos(sp.CheckPos, sweptCellId);
}
else
// The previous pre-pick here re-derived the cell from the TARGET position BEFORE the
// primary collision, so the collision geometry (which BSP / which terrain) swapped the
// instant the pick flipped → a bistable membership↔collision feedback loop = the cottage
// doorway "flap" (2026-06-03 evidence: cell oscillated 0170↔0031↔0171 at constant Z while
// the foot Y bounced cleanly between two values). Removed: collide against the carried cell
// first (the indoor BSP block / the terrain block below), then advance the cell only in the
// post-collision step (RunCheckOtherCellsAndAdvance) — retail's collide-then-pick order.
//
// Cache-null fallback: PhysicsEngineTests use engines without a DataCache (no cell registry,
// so FindCellSet is unavailable). Keep the old outdoor re-derive for them only.
if (engine.DataCache is null)
{
uint resolvedOutdoorCellId = engine.ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, sp.CheckCellId);
if (resolvedOutdoorCellId != sp.CheckCellId)
@ -2071,52 +2070,16 @@ public sealed class Transition
return cellState;
}
// ── Phase A4 (2026-05-20): query every other cell ──────────
// Retail oracle: CTransition::check_other_cells at
// acclient_2013_pseudo_c.txt:272717-272798. The vestibule
// walls bug (cell 0xA9B40164 has only 4 polys; adjacent
// 0xA9B40157 has the actual walls) closes here.
//
uint containingCellId = CellTransit.FindCellSet(
engine.DataCache, sp.GlobalSphere, sp.NumSphere,
sp.CheckCellId, out var cellSet);
LogIssue98CellSetSummary(engine, containingCellId, cellSet, footCenter, sphereRadius);
var otherCellsState = CheckOtherCells(engine, footCenter, sphereRadius, cellSet);
if (otherCellsState != TransitionState.OK)
return otherCellsState;
// Retail CTransition::check_other_cells retargets
// sphere_path.check_cell to var_4c after the other-cell
// loop succeeds, then calls SPHEREPATH::adjust_check_pos.
// For indoor cells our SetCheckPos mirrors that id swap and
// refreshes the cached global sphere centers without moving.
if (containingCellId != sp.CheckCellId)
sp.SetCheckPos(sp.CheckPos, containingCellId);
// ──────────────────────────────────────────────────────────
// ── Indoor walkable handling — A6.P3 slice 1 (2026-05-22) ─
// Retail's CEnvCell::find_env_collisions (decomp
// acclient_2013_pseudo_c.txt:309573) returns OK after
// BSPTREE::find_collisions returns OK — NO call to
// set_contact_plane or any synthesis. ContactPlane is
// either:
// - Already valid from a previous frame's Path-6 land
// write inside BSPQuery.FindCollisions (Mechanism A).
// - Restored from LKCP by the per-transition Mechanism B
// in Transition.ValidateTransition (added in 5aba071,
// Task 4 of this slice).
//
// The old TryFindIndoorWalkablePlane synthesis path is
// removed here; the function definition is retained for
// now and is deleted in A6.P4 along with the #90
// workaround.
//
// If subsequent visual verification shows first-frame
// fall-through (LKCP invalid AND no Path-6 land happens
// for a flat-walk-only scenario), A6.P3 slice 2 adds
// Mechanism C (retail's frames_stationary_fall flat-CP
// synthesis at acclient_2013_pseudo_c.txt:272622+).
return TransitionState.OK;
// ── check_other_cells (retail collide-then-pick) ───────────
// The primary indoor BSP collision above ran against the carried
// cell (sp.CheckCellId). NOW pick the new containing cell, collide
// every OTHER cell the sphere overlaps, and advance the carried
// cell. Retail CTransition::check_other_cells (pseudo_c:272717).
// (Indoor walkable: retail CEnvCell::find_env_collisions returns OK
// after the BSP find_collisions — no set_contact_plane synthesis;
// ContactPlane comes from a prior Path-6 land or the per-transition
// LKCP restore in ValidateTransition.)
return RunCheckOtherCellsAndAdvance(engine, footCenter, sphereRadius);
}
}
@ -2135,22 +2098,68 @@ public sealed class Transition
// find_terrain_poly and uses walkable.Plane — the actual triangle's
// plane, not a reconstructed flat one. SampleTerrainWalkable returns
// that plane plus the triangle vertices needed by precipice slide.
// ── Outdoor PRIMARY collision: terrain (retail CLandCell::find_env_collisions) ──
// Runs against the carried (outdoor) cell. The post-collision pick below then promotes
// to an interior cell if the sphere has re-entered a building (replacing the removed
// pre-pick's outdoor→indoor promotion).
var terrainWalkable = engine.SampleTerrainWalkable(footCenter.X, footCenter.Y);
if (terrainWalkable is null)
return TransitionState.OK; // no terrain loaded here — allow pass-through
if (terrainWalkable is not null)
{
// Per-point water depth: 0.9 on fully water cells, 0.45 on partial-
// water near a water corner, 0.1 on partial-water near a dry corner,
// 0 on dry cells. ValidateWalkable adds this to its signed-distance
// check so the character is allowed to sink this far below the
// contact plane before the push-up fires. In retail, this is what
// makes characters appear submerged in water — there is NO separate
// water surface mesh; the character just sits lower than terrain.
var terrainState = ValidateWalkable(footCenter, sphereRadius, terrainWalkable.Value.Plane,
terrainWalkable.Value.IsWater,
terrainWalkable.Value.WaterDepth,
cellId: terrainWalkable.Value.CellId,
walkableVertices: terrainWalkable.Value.Vertices);
if (terrainState != TransitionState.OK)
return terrainState;
}
// else: no terrain loaded here — allow pass-through, but STILL run the post-collision
// pick so an outdoor-seeded sphere re-entering a building is promoted to the interior.
// Per-point water depth: 0.9 on fully water cells, 0.45 on partial-
// water near a water corner, 0.1 on partial-water near a dry corner,
// 0 on dry cells. ValidateWalkable adds this to its signed-distance
// check so the character is allowed to sink this far below the
// contact plane before the push-up fires. In retail, this is what
// makes characters appear submerged in water — there is NO separate
// water surface mesh; the character just sits lower than terrain.
return ValidateWalkable(footCenter, sphereRadius, terrainWalkable.Value.Plane,
terrainWalkable.Value.IsWater,
terrainWalkable.Value.WaterDepth,
cellId: terrainWalkable.Value.CellId,
walkableVertices: terrainWalkable.Value.Vertices);
// ── check_other_cells (retail collide-then-pick) ──
// Pick the new containing cell + collide every OTHER cell the sphere overlaps + advance
// the carried cell. For an outdoor seed this is the outdoor→indoor re-entry path (the
// ordered pick promotes to the interior cell via CheckBuildingTransit and collides its
// walls on the entry frame). Retail CTransition::check_other_cells (pseudo_c:272717).
return RunCheckOtherCellsAndAdvance(engine, footCenter, sphereRadius);
}
/// <summary>
/// Retail <c>CTransition::check_other_cells</c> (acclient_2013_pseudo_c.txt:272717): the
/// post-collision step. After the primary cell's <see cref="FindEnvCollisions"/> ran against
/// the carried cell (<c>sphere_path.check_cell</c>), build the ordered cell array (retail
/// <c>find_cell_list</c>), collide every OTHER cell the sphere overlaps (multi-valued
/// collision), then retarget the carried cell to the ordered-pick containing cell
/// (<c>var_4c</c> @272761). This is the collide-THEN-pick order — membership advances ONLY
/// here, never before the primary collision (the pre-pick that caused the doorway flap).
/// </summary>
private TransitionState RunCheckOtherCellsAndAdvance(
PhysicsEngine engine, Vector3 footCenter, float sphereRadius)
{
var sp = SpherePath;
if (engine.DataCache is null) return TransitionState.OK;
uint containingCellId = CellTransit.FindCellSet(
engine.DataCache, sp.GlobalSphere, sp.NumSphere, sp.CheckCellId, out var cellSet);
LogIssue98CellSetSummary(engine, containingCellId, cellSet, footCenter, sphereRadius);
var otherCellsState = CheckOtherCells(engine, footCenter, sphereRadius, cellSet);
if (otherCellsState != TransitionState.OK)
return otherCellsState;
// Retarget the carried cell to the ordered-pick containing cell (retail check_cell =
// var_4c, then adjust_check_pos). SetCheckPos mirrors that id swap + global-sphere
// refresh without moving the sphere.
if (containingCellId != sp.CheckCellId)
sp.SetCheckPos(sp.CheckPos, containingCellId);
return TransitionState.OK;
}
/// <summary>