From 4ca7230b36be9eecad58dbbd5fc0c35e73754e33 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 31 Jul 2026 12:40:50 +0200 Subject: [PATCH] fix(physics): hold retail cell across inner retries --- src/AcDream.Core/Physics/PhysicsEngine.cs | 118 ++++++++++++++---- src/AcDream.Core/Physics/TransitionTypes.cs | 64 ++++++---- .../IndoorContactPlaneRetentionTests.cs | 2 +- .../TransitionInsertIntoCellRetryTests.cs | 67 +++++----- 4 files changed, 168 insertions(+), 83 deletions(-) diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index e78d74df..72347120 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -385,33 +385,97 @@ public sealed class PhysicsEngine float localX = worldX - lb.WorldOffsetX; float localY = worldY - lb.WorldOffsetY; if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f) - { - var sample = lb.Terrain.SampleSurfacePolygon(localX, localY); - var vertices = new TerrainTriangleVertices( - OffsetTerrainVertex(sample.Vertices.V0, lb), - OffsetTerrainVertex(sample.Vertices.V1, lb), - OffsetTerrainVertex(sample.Vertices.V2, lb)); - - var normal = sample.Normal; - float d = -Vector3.Dot(normal, vertices[0]); - var plane = new System.Numerics.Plane(normal, d); - - float waterDepth = lb.Terrain.SampleWaterDepth(localX, localY); - bool isWater = waterDepth >= 0.45f; - uint lowCellId = lb.Terrain.ComputeOutdoorCellId(localX, localY); - uint fullCellId = (kvp.Key & 0xFFFF0000u) | lowCellId; - - return new TerrainWalkableSample( - plane, - vertices, - waterDepth, - isWater, - fullCellId); - } + return BuildTerrainWalkableSample(kvp.Key, lb, localX, localY); } return null; } + /// + /// Samples only the fixed outdoor cell supplied to retail + /// CTransition::insert_into_cell. The target point may move into a + /// neighboring cell during a retry, but retail continues dispatching the + /// captured CObjCell* until that inner call returns. + /// + internal TerrainWalkableSample? SampleTerrainWalkableInCell( + uint cellId, + float worldX, + float worldY) + { + uint lowCellId = cellId & 0xFFFFu; + if (lowCellId is < 1u or > 0x40u) + return null; + + foreach (var kvp in _landblocks) + { + uint requestedPrefix = cellId & 0xFFFF0000u; + if (requestedPrefix != 0u && + (kvp.Key & 0xFFFF0000u) != requestedPrefix) + continue; + + LandblockPhysics lb = kvp.Value; + float localX = worldX - lb.WorldOffsetX; + float localY = worldY - lb.WorldOffsetY; + if (requestedPrefix == 0u && + (localX < 0f || localX >= 192f || + localY < 0f || localY >= 192f)) + { + continue; + } + int cellIndex = (int)lowCellId - 1; + int cellX = cellIndex / TerrainSurface.CellsPerSide; + int cellY = cellIndex % TerrainSurface.CellsPerSide; + float minX = cellX * TerrainSurface.CellSize; + float minY = cellY * TerrainSurface.CellSize; + float maxX = minX + TerrainSurface.CellSize; + float maxY = minY + TerrainSurface.CellSize; + + if (localX < minX || localX >= maxX || + localY < minY || localY >= maxY) + { + return null; + } + + return BuildTerrainWalkableSample( + kvp.Key, + lb, + localX, + localY); + } + + return null; + } + + private static TerrainWalkableSample BuildTerrainWalkableSample( + uint landblockId, + LandblockPhysics landblock, + float localX, + float localY) + { + TerrainSurfacePolygon sample = landblock.Terrain.SampleSurfacePolygon( + localX, + localY); + var vertices = new TerrainTriangleVertices( + OffsetTerrainVertex(sample.Vertices.V0, landblock), + OffsetTerrainVertex(sample.Vertices.V1, landblock), + OffsetTerrainVertex(sample.Vertices.V2, landblock)); + + Vector3 normal = sample.Normal; + float d = -Vector3.Dot(normal, vertices[0]); + var plane = new System.Numerics.Plane(normal, d); + + float waterDepth = landblock.Terrain.SampleWaterDepth(localX, localY); + bool isWater = waterDepth >= 0.45f; + uint lowCellId = landblock.Terrain.ComputeOutdoorCellId(localX, localY); + uint fullCellId = (landblockId & 0xFFFF0000u) | lowCellId; + + return new TerrainWalkableSample( + plane, + vertices, + waterDepth, + isWater, + fullCellId); + } + private static Vector3 OffsetTerrainVertex(Vector3 vertex, LandblockPhysics landblock) => new( vertex.X + landblock.WorldOffsetX, @@ -466,11 +530,11 @@ public sealed class PhysicsEngine } /// - /// TEST-ONLY outdoor cell re-derive. The single caller is - /// Transition.FindEnvCollisions's cache-null fallback + /// TEST-ONLY outdoor cell re-derive. The sole caller is + /// Transition.RunCheckOtherCellsAndAdvance's cache-null fallback /// (PhysicsEngineTests run engines without a , - /// so is unavailable). Production - /// membership flows exclusively through the collide-then-pick advance + /// so is unavailable). Normal + /// production membership flows exclusively through the collide-then-pick advance /// (RunCheckOtherCellsAndAdvanceFindCellSet). /// /// diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index b8aa4093..f98776a0 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -2138,7 +2138,7 @@ public sealed class Transition TransitionState state = TransitionState.OK; for (int attempt = 0; attempt < numAttempts; attempt++) { - state = FindPrimaryCellCollisions(engine, attempt); + state = FindPrimaryCellCollisions(engine, cellId, attempt); if (state is TransitionState.OK or TransitionState.Collided) return state; @@ -2159,31 +2159,30 @@ public sealed class Transition /// private TransitionState FindPrimaryCellCollisions( PhysicsEngine engine, + uint cellId, int innerAttempt) { - TransitionState actualEnvironment = FindEnvCollisions(engine); - uint currentCellId = SpherePath.CheckCellId; TransitionState environment = ObservePrimaryCellPhase( engine, TransitionCellCollisionPhase.Environment, - currentCellId, - actualEnvironment); + cellId, + FindEnvCollisions(engine, cellId)); if (environment != TransitionState.OK) return environment; TransitionState building = ObservePrimaryCellPhase( engine, TransitionCellCollisionPhase.Building, - currentCellId, - FindBuildingCollisions(engine, currentCellId)); + cellId, + FindBuildingCollisions(engine, cellId)); if (building != TransitionState.OK) return building; TransitionState objects = ObservePrimaryCellPhase( engine, TransitionCellCollisionPhase.Objects, - currentCellId, - FindObjCollisionsInCell(engine, currentCellId)); + cellId, + FindObjCollisionsInCell(engine, cellId)); DumpPhase2(innerAttempt, environment, objects); return objects; } @@ -3125,7 +3124,9 @@ public sealed class Transition } } - internal TransitionState FindEnvCollisions(PhysicsEngine engine) + internal TransitionState FindEnvCollisions( + PhysicsEngine engine, + uint primaryCellId) { var sp = SpherePath; var ci = CollisionInfo; @@ -3153,23 +3154,19 @@ public sealed class Transition // 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) - sp.SetCheckPos(sp.CheckPos, resolvedOutdoorCellId); - } - + // insert_into_cell receives one CObjCell* from transitional_insert + // and invokes that same object's virtual find_collisions method for + // every inner retry. primaryCellId is therefore deliberately fixed + // even if a response mutates sphere_path.check_cell; only the next + // outer transitional_insert attempt captures the replacement cell. // ── Indoor cell BSP collision ──────────────────────────────────── // If the player is in an indoor cell (low 16 bits >= 0x0100), // query the CellStruct's PhysicsBSP for wall/floor/ceiling collision. // ACE: EnvCell.find_env_collisions -> CellStructure.PhysicsBSP.find_collisions - uint cellLow = sp.CheckCellId & 0xFFFFu; + uint cellLow = primaryCellId & 0xFFFFu; if (cellLow >= 0x0100 && engine.DataCache is not null) { - var cellPhysics = engine.DataCache.GetCellStruct(sp.CheckCellId); + var cellPhysics = engine.DataCache.GetCellStruct(primaryCellId); // AP-71 (Campaign P Slice P4, 2026-07-30): retail CEnvCell:: // find_env_collisions (pc:309576) calls check_entry_restrictions @@ -3241,7 +3238,7 @@ public sealed class Transition // so we degrade to "translation-only" instead of the prior // "both broken". Console.WriteLine(System.FormattableString.Invariant( - $"[indoor-bsp] WARN cellPhysics.WorldTransform did not decompose cleanly for cell 0x{sp.CheckCellId:X8} — falling back to identity rotation")); + $"[indoor-bsp] WARN cellPhysics.WorldTransform did not decompose cleanly for cell 0x{primaryCellId:X8} — falling back to identity rotation")); cellRotation = Quaternion.Identity; cellOrigin = cellPhysics.WorldTransform.Translation; } @@ -3270,7 +3267,7 @@ public sealed class Transition : System.FormattableString.Invariant( $"n=({hit.Plane.Normal.X:F3},{hit.Plane.Normal.Y:F3},{hit.Plane.Normal.Z:F3}) sides={hit.SidesType}"); Console.WriteLine(System.FormattableString.Invariant( - $"[indoor-bsp] cell=0x{sp.CheckCellId:X8} wpos=({footCenter.X:F3},{footCenter.Y:F3},{footCenter.Z:F3}) lpos=({localCenter.X:F3},{localCenter.Y:F3},{localCenter.Z:F3}) lprev=({localCurrCenter.X:F3},{localCurrCenter.Y:F3},{localCurrCenter.Z:F3}) r={sphereRadius:F3} result={cellState} ") + $"[indoor-bsp] cell=0x{primaryCellId:X8} wpos=({footCenter.X:F3},{footCenter.Y:F3},{footCenter.Z:F3}) lpos=({localCenter.X:F3},{localCenter.Y:F3},{localCenter.Z:F3}) lprev=({localCurrCenter.X:F3},{localCurrCenter.Y:F3},{localCurrCenter.Z:F3}) r={sphereRadius:F3} result={cellState} ") + polyDesc); } @@ -3314,7 +3311,10 @@ public sealed class Transition // 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); + var terrainWalkable = engine.SampleTerrainWalkableInCell( + primaryCellId, + footCenter.X, + footCenter.Y); if (terrainWalkable is not null) { // Per-point water depth: 0.9 on fully water cells, 0.45 on partial- @@ -3355,7 +3355,21 @@ public sealed class Transition PhysicsEngine engine, Vector3 footCenter, float sphereRadius) { var sp = SpherePath; - if (engine.DataCache is null) return TransitionState.OK; + + // Test-only engines can omit the cell registry required for retail's + // find_cell_list. Preserve their outdoor membership update here at + // the post-primary boundary, never from inside the fixed-cell + // insert_into_cell transaction. + if (engine.DataCache is null) + { + uint resolvedOutdoorCellId = engine.ResolveCellId( + sp.GlobalSphere[0].Origin, + sphereRadius, + sp.CheckCellId); + if (resolvedOutdoorCellId != sp.CheckCellId) + sp.SetCheckPos(sp.CheckPos, resolvedOutdoorCellId); + return TransitionState.OK; + } // Retail check_other_cells (acclient_2013_pseudo_c.txt:272735) calls each // other cell's find_collisions with `this`, so it reads the CURRENT diff --git a/tests/AcDream.Core.Tests/Physics/IndoorContactPlaneRetentionTests.cs b/tests/AcDream.Core.Tests/Physics/IndoorContactPlaneRetentionTests.cs index eeaf753e..a643df16 100644 --- a/tests/AcDream.Core.Tests/Physics/IndoorContactPlaneRetentionTests.cs +++ b/tests/AcDream.Core.Tests/Physics/IndoorContactPlaneRetentionTests.cs @@ -292,7 +292,7 @@ public class IndoorContactPlaneRetentionTests t.SpherePath.SetCheckPos(newPos, IndoorCellId); // Simulate FindEnvCollisions as the physics loop calls it. - t.FindEnvCollisions(engine); + t.FindEnvCollisions(engine, IndoorCellId); } // ── Assert ──────────────────────────────────────────────────────────── diff --git a/tests/AcDream.Core.Tests/Physics/TransitionInsertIntoCellRetryTests.cs b/tests/AcDream.Core.Tests/Physics/TransitionInsertIntoCellRetryTests.cs index b97985c1..446871ef 100644 --- a/tests/AcDream.Core.Tests/Physics/TransitionInsertIntoCellRetryTests.cs +++ b/tests/AcDream.Core.Tests/Physics/TransitionInsertIntoCellRetryTests.cs @@ -190,39 +190,68 @@ public sealed class TransitionInsertIntoCellRetryTests } [Fact] - public void NoDataCache_CrossLandblockRepick_UsesRefreshedPrimaryCell() + public void NestedRetry_FixesPrimaryCellUntilNextOuterAttempt() { - PhysicsEngine engine = BuildCrossLandblockEngine(); + PhysicsEngine engine = BuildEngine(preparedFlat: false); var cells = new List(); var phases = new List(); + int environmentCalls = 0; + const uint nextOuterCell = 0xA9B40002u; engine.TransitionCellCollisionTestHook = - (_, phase, cellId, actual) => + (transition, phase, cellId, actual) => { phases.Add(phase); cells.Add(cellId); + + if (phase == TransitionCellCollisionPhase.Environment) + { + environmentCalls++; + if (environmentCalls == 1) + { + transition.SpherePath.SetCheckPos( + transition.SpherePath.CheckPos, + nextOuterCell); + } + + if (environmentCalls <= 3) + return TransitionState.Adjusted; + } + return actual; }; - Vector3 current = new(191.99f, 10f, 5f); - Vector3 target = new(192.01f, 10f, 5f); + Vector3 current = new(10f, 10f, 5f); + Vector3 target = current + new Vector3(0.05f, 0f, 0f); ResolveResult result = ResolveAirborne( engine, current, target, - 0xA9B40039u); + Cell); Assert.True(result.Ok); Assert.Equal(target, result.Position); - Assert.Equal(0xAAB40001u, result.CellId); Assert.Equal( new[] { + TransitionCellCollisionPhase.Environment, + TransitionCellCollisionPhase.Environment, + TransitionCellCollisionPhase.Environment, TransitionCellCollisionPhase.Environment, TransitionCellCollisionPhase.Building, TransitionCellCollisionPhase.Objects, }, phases); - Assert.All(cells, cellId => Assert.Equal(0xAAB40001u, cellId)); + Assert.Equal( + new[] + { + Cell, + Cell, + Cell, + nextOuterCell, + nextOuterCell, + nextOuterCell, + }, + cells); } private static ResolveResult ResolveAirborne( @@ -309,26 +338,4 @@ public sealed class TransitionInsertIntoCellRetryTests return engine; } - private static PhysicsEngine BuildCrossLandblockEngine() - { - var engine = new PhysicsEngine { DataCache = null }; - var heights = new byte[81]; - var heightTable = new float[256]; - Array.Fill(heightTable, -1000f); - engine.AddLandblock( - 0xA9B4FFFFu, - new TerrainSurface(heights, heightTable), - Array.Empty(), - Array.Empty(), - 0f, - 0f); - engine.AddLandblock( - 0xAAB4FFFFu, - new TerrainSurface(heights, heightTable), - Array.Empty(), - Array.Empty(), - 192f, - 0f); - return engine; - } }