diff --git a/src/AcDream.Core/Physics/SpawnPlacementSettler.cs b/src/AcDream.Core/Physics/SpawnPlacementSettler.cs index 86263c87..519a6146 100644 --- a/src/AcDream.Core/Physics/SpawnPlacementSettler.cs +++ b/src/AcDream.Core/Physics/SpawnPlacementSettler.cs @@ -58,7 +58,28 @@ public static class SpawnPlacementSettler if (!settle.Ok || !settle.InContact) return false; - body.Position = settle.Position; + // #276: adopt the settle's RESOLVED cell, not just its position. + // Retail's CPhysicsObj::SetPositionInternal(CTransition const*) + // @0x00515330 commits both sphere_path.curr_pos.objcell_id and its + // frame, including EnvCells. + // + // A bare `body.Position = settle.Position` is sufficient only while + // the body is OUTDOOR: that setter mirrors the world delta into the + // landblock-local frame and LandDefs.AdjustToOutside then recomputes + // the 24 m cell index from it. It cannot do so for an EnvCell — an + // EnvCell id is not derivable from a position, so the mirror + // deliberately preserves it (PhysicsBody.cs, the `not (>= 1 and + // <= 0x40)` arm). settle.CellId is therefore the ONLY carrier of a + // cell identity across an indoor seam, and dropping it stranded the + // body in the placement cell until some later resolve corrected it. + // + // Identical guarded shape to the per-tick resolve writeback + // (RuntimeOrdinaryPhysicsUpdater): a transition reporting no cell + // falls back to the source cell rather than zeroing residency. + uint resolvedCellId = settle.CellId != 0 + ? settle.CellId + : cellId; + body.CommitTransitionPosition(resolvedCellId, settle.Position); PhysicsObjUpdate.CommitSetPositionTransition( body, settle.InContact, diff --git a/tests/AcDream.Core.Tests/Physics/SpawnPlacementSettlerTests.cs b/tests/AcDream.Core.Tests/Physics/SpawnPlacementSettlerTests.cs index a3e60a1c..2563d12e 100644 --- a/tests/AcDream.Core.Tests/Physics/SpawnPlacementSettlerTests.cs +++ b/tests/AcDream.Core.Tests/Physics/SpawnPlacementSettlerTests.cs @@ -75,6 +75,61 @@ public sealed class SpawnPlacementSettlerTests Assert.Equal(originalPosition, body.Position); } + /// + /// #276: the settle must adopt the transition's RESOLVED cell, not only + /// its position. Retail's + /// CPhysicsObj::SetPositionInternal(CTransition const*) + /// (0x00515330) commits both curr_pos.objcell_id and the + /// frame. + /// + /// + /// The body is seeded with an ENVCELL id over plain outdoor terrain, + /// which is what makes this discriminating. A bare + /// body.Position = settle.Position mirrors the world delta into + /// the local frame and lets AdjustToOutside recompute the cell — + /// but only for outdoor ids; the mirror deliberately PRESERVES an + /// EnvCell id, because one cannot be derived from a position. So the old + /// code leaves the stale EnvCell in place and only the resolved-cell + /// adoption reaches the outdoor cell the body physically settled in. + /// This is the "outdoor/EnvCell seam" half of #276, in the outward + /// direction. + /// + /// + [Fact] + public void SettleLeavingAnEnvCell_AdoptsTheTransitionsResolvedOutdoorCell() + { + const uint StaleEnvCell = Landblock | 0x0100u; + PhysicsEngine engine = BuildFlatEngine(); + PhysicsBody body = AirborneBody(new Vector3(12f, 12f, 0.25f)); + body.StageDormantCellFrame( + StaleEnvCell, + body.Position, + body.Position); + Assert.Equal(StaleEnvCell, body.CellPosition.ObjCellId); + + bool settled = SpawnPlacementSettler.TrySettle( + engine, + body, + body.Position, + Cell, + Radius, + Height, + ObjectInfoState.EdgeSlide, + movingEntityId: 0x70000001u, + static () => { }, + static () => { }); + + Assert.True(settled); + Assert.True(body.InContact); + + // The discriminating assertion: the body no longer claims the EnvCell + // it never physically occupied, and its cell is an OUTDOOR landcell + // (low word 1..0x40) of the same landblock it settled in. + Assert.NotEqual(StaleEnvCell, body.CellPosition.ObjCellId); + Assert.Equal(Landblock, body.CellPosition.ObjCellId & 0xFFFF0000u); + Assert.InRange(body.CellPosition.ObjCellId & 0xFFFFu, 1u, 0x40u); + } + private static bool TrySettle(PhysicsEngine engine, PhysicsBody body) => SpawnPlacementSettler.TrySettle( engine,