diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs
index 7f04768..a2f5a5c 100644
--- a/src/AcDream.Core/Physics/TransitionTypes.cs
+++ b/src/AcDream.Core/Physics/TransitionTypes.cs
@@ -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);
+ }
+
+ ///
+ /// Retail CTransition::check_other_cells (acclient_2013_pseudo_c.txt:272717): the
+ /// post-collision step. After the primary cell's ran against
+ /// the carried cell (sphere_path.check_cell), build the ordered cell array (retail
+ /// find_cell_list), collide every OTHER cell the sphere overlaps (multi-valued
+ /// collision), then retarget the carried cell to the ordered-pick containing cell
+ /// (var_4c @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).
+ ///
+ 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;
}
///