From e5457f95529d8402db0fed98b6c054c26bd9a449 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 3 Jun 2026 09:36:19 +0200 Subject: [PATCH] =?UTF-8?q?fix(physics):=20Stage=201=20=E2=80=94=20collide?= =?UTF-8?q?-then-pick=20(remove=20pre-pick=20fork)=20=E2=80=94=20the=20fla?= =?UTF-8?q?p=20engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/AcDream.Core/Physics/TransitionTypes.cs | 161 +++++++++++--------- 1 file changed, 85 insertions(+), 76 deletions(-) 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; } ///