diff --git a/src/AcDream.App/Input/PlayerModeAutoEntry.cs b/src/AcDream.App/Input/PlayerModeAutoEntry.cs index 4a7f8b44..1c702c3f 100644 --- a/src/AcDream.App/Input/PlayerModeAutoEntry.cs +++ b/src/AcDream.App/Input/PlayerModeAutoEntry.cs @@ -8,12 +8,13 @@ namespace AcDream.App.Input; /// GameWindow and ticked each frame from OnUpdate. /// /// -/// Why is this its own class? The auto-entry has three independent +/// Why is this its own class? The auto-entry has four independent /// preconditions (live session reaches InWorld, the player -/// entity has been streamed into the world dictionary, and the player -/// movement controller is constructible) plus a manual-override path +/// entity has been streamed into the world dictionary, the player +/// movement controller is constructible, and the terrain under the +/// spawn position has streamed in) plus a manual-override path /// (the user can flip into fly mode before the auto-entry fires — -/// their choice wins). All four interact with each other in a way +/// their choice wins). All five interact with each other in a way /// that's painful to test through GameWindow but trivial here against /// fakes. /// @@ -43,6 +44,7 @@ public sealed class PlayerModeAutoEntry private readonly Func _isLiveInWorld; private readonly Func _isPlayerEntityPresent; private readonly Func _isPlayerControllerReady; + private readonly Func _isSpawnGroundReady; private readonly Action _enterPlayerMode; private bool _armed; @@ -60,6 +62,12 @@ public sealed class PlayerModeAutoEntry /// PlayerMovementController is set up. Stays true once player mode /// is established; the auto-entry's job is to flip it from false /// to true exactly once. + /// True iff the terrain under the + /// player's spawn position has streamed into the physics engine. + /// #106 gate-2 (2026-06-09): entering player mode earlier integrates + /// gravity against an empty world and free-falls the player into the + /// void. Retail never has this state — it loads cells synchronously; + /// this hold is the async-streaming equivalent of that invariant. /// Action invoked on the firing /// tick. The same routine the manual Tab handler invokes (fly → /// player transition). Must construct the controller + chase @@ -69,11 +77,13 @@ public sealed class PlayerModeAutoEntry Func isLiveInWorld, Func isPlayerEntityPresent, Func isPlayerControllerReady, + Func isSpawnGroundReady, Action enterPlayerMode) { _isLiveInWorld = isLiveInWorld ?? throw new ArgumentNullException(nameof(isLiveInWorld)); _isPlayerEntityPresent = isPlayerEntityPresent ?? throw new ArgumentNullException(nameof(isPlayerEntityPresent)); _isPlayerControllerReady = isPlayerControllerReady ?? throw new ArgumentNullException(nameof(isPlayerControllerReady)); + _isSpawnGroundReady = isSpawnGroundReady ?? throw new ArgumentNullException(nameof(isSpawnGroundReady)); _enterPlayerMode = enterPlayerMode ?? throw new ArgumentNullException(nameof(enterPlayerMode)); } @@ -107,6 +117,7 @@ public sealed class PlayerModeAutoEntry if (!_isLiveInWorld()) return false; if (!_isPlayerEntityPresent()) return false; if (!_isPlayerControllerReady()) return false; + if (!_isSpawnGroundReady()) return false; _armed = false; _enterPlayerMode(); diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 56ed5095..468abab3 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -1001,6 +1001,13 @@ public sealed class GameWindow : IDisposable AcDream.Core.Net.WorldSession.State.InWorld, isPlayerEntityPresent: () => _entitiesByServerGuid.ContainsKey(_playerServerGuid), isPlayerControllerReady: () => true, + // #106 gate-2: hold player-mode entry until the terrain under + // the spawn position has streamed in — entering earlier + // integrates gravity against an empty world and free-falls + // the player into the void (retail loads cells synchronously; + // this is the async-streaming equivalent of that invariant). + isSpawnGroundReady: () => _entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe) + && _physicsEngine.SampleTerrainZ(pe.Position.X, pe.Position.Y) is not null, enterPlayerMode: EnterPlayerModeFromAutoEntry); } diff --git a/src/AcDream.Core/Physics/CellTransit.cs b/src/AcDream.Core/Physics/CellTransit.cs index 4ae53df0..88eb096d 100644 --- a/src/AcDream.Core/Physics/CellTransit.cs +++ b/src/AcDream.Core/Physics/CellTransit.cs @@ -619,7 +619,40 @@ public static class CellTransit // No interior cell contained the centre. Return the outdoor XY-column cell if // it was a candidate, else stay on the current cell (retail leaves *result // null → caller keeps curr_cell). - return outdoorResult != 0u ? outdoorResult : currentCellId; + if (outdoorResult != 0u) return outdoorResult; + + // ── #106 gate-2 escape hatch: bogus-indoor-claim recovery ────────── + // Restores the #83/A1.7 + #90 verification that lived in + // PhysicsEngine.ResolveCellId before the collide-then-pick rewrite + // moved membership here: an INDOOR current cell that IS hydrated but + // whose CellBSP no longer overlaps ANY part of the foot sphere is a + // bogus claim — a corrupt server save pairing an indoor cell with a + // position far outside it, or the player walked out through an + // unblocked gap. Keeping it wedges everything downstream: the BFS + // can't reach an exit portal from a cell the sphere isn't in (no + // candidates → frozen), ShadowObjectRegistry's #98 gate reads + // "indoor primary" (no object collision anywhere), and there's no + // wall BSP and no terrain (void fall). Demote to the outdoor column + // under the sphere centre (LandDefs global math — cross-block safe). + // + // Sphere-overlap (BSPQuery.SphereIntersectsCellBsp, + // pseudo_c:317666→:323267), NOT point-in: a doorway push-back leaves + // the centre a few cm outside while the sphere still overlaps — that + // must NOT demote (#90's ping-pong). A cell with no hydrated CellBSP + // cannot be verified — trust the claim (stale beats null while + // streaming hydrates). + if (currentLow >= 0x0100u && containingOutdoorId != 0u) + { + var cur = cache.GetCellStruct(currentCellId); + if (cur?.CellBSP?.Root is not null) + { + var curLocal = Vector3.Transform(worldSphereCenter, cur.InverseWorldTransform); + if (!BSPQuery.SphereIntersectsCellBsp(cur.CellBSP.Root, curLocal, sphereRadius)) + return containingOutdoorId; + } + } + + return currentCellId; } private static int EffectiveSphereCount(IReadOnlyList worldSpheres, int numSpheres) diff --git a/tests/AcDream.Core.Tests/Input/AutoEnterPlayerModeTests.cs b/tests/AcDream.Core.Tests/Input/AutoEnterPlayerModeTests.cs index aa1463c5..86d4bb5d 100644 --- a/tests/AcDream.Core.Tests/Input/AutoEnterPlayerModeTests.cs +++ b/tests/AcDream.Core.Tests/Input/AutoEnterPlayerModeTests.cs @@ -24,6 +24,9 @@ public sealed class AutoEnterPlayerModeTests public bool LiveInWorld; public bool PlayerEntityPresent; public bool PlayerControllerReady; + // Defaults TRUE: the hydration hold is not under test in the + // original K.2 cases (see SpawnGroundNotReady test for it). + public bool SpawnGroundReady = true; public int EnteredCount; public PlayerModeAutoEntry Build() => @@ -31,9 +34,37 @@ public sealed class AutoEnterPlayerModeTests isLiveInWorld: () => LiveInWorld, isPlayerEntityPresent: () => PlayerEntityPresent, isPlayerControllerReady: () => PlayerControllerReady, + isSpawnGroundReady: () => SpawnGroundReady, enterPlayerMode: () => EnteredCount++); } + [Fact] + public void TryEnter_Armed_SpawnGroundNotReady_DoesNotFire() + { + // #106 gate-2 (2026-06-09): player physics must not start before + // the terrain under the spawn position has streamed in — entering + // earlier free-falls the player into the void (gravity integrates + // against an empty world; retail never has this state because it + // loads cells synchronously). The guard holds until the streaming + // pipeline registers the spawn landblock. + var s = new State + { + LiveInWorld = true, PlayerEntityPresent = true, + PlayerControllerReady = true, SpawnGroundReady = false, + }; + var guard = s.Build(); + guard.Arm(); + + Assert.False(guard.TryEnter()); + Assert.Equal(0, s.EnteredCount); + Assert.True(guard.IsArmed); + + // Terrain hydrates → fires on the next tick. + s.SpawnGroundReady = true; + Assert.True(guard.TryEnter()); + Assert.Equal(1, s.EnteredCount); + } + [Fact] public void TryEnter_NotArmed_DoesNotFire() { diff --git a/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs b/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs index dd55654f..f5e78756 100644 --- a/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs +++ b/tests/AcDream.Core.Tests/Physics/CellTransitFindCellSetTests.cs @@ -228,6 +228,103 @@ public class CellTransitFindCellSetTests Assert.Equal(0xA9B40031u, containing); } + // ────────────────────────────────────────────────────────────────── + // #106 gate-2 escape hatch — bogus indoor claim recovery. + // Restores the #83/A1.7 + #90 verification (formerly in ResolveCellId, + // lost in the collide-then-pick rewrite): a HYDRATED indoor current + // cell whose CellBSP no longer overlaps ANY part of the foot sphere is + // a bogus claim (corrupt server save pairing an indoor cell with a + // position far outside it, or walked out through an unblocked gap). + // Staying on it wedges everything: the BFS can't reach an exit portal + // from a cell the sphere isn't in (no outdoor candidates, membership + // frozen), GetNearbyObjects' #98 gate reads "indoor primary" (no + // object collision), no wall BSP and no terrain (void fall). The pick + // must demote to the outdoor column under the sphere centre. + // Sphere-overlap (not point-in) preserves the #90 doorway-pushback + // hysteresis. + // ────────────────────────────────────────────────────────────────── + + /// Cell whose CellBSP half-space is x ≥ 0 (cell-local). + private static CellPhysics MakeCellWithBoundedBsp(Matrix4x4 worldTransform) + { + Matrix4x4.Invert(worldTransform, out var inv); + return new CellPhysics + { + WorldTransform = worldTransform, + InverseWorldTransform = inv, + Resolved = new Dictionary(), + CellBSP = new CellBSPTree + { + Root = new CellBSPNode + { + SplittingPlane = new Plane(new Vector3(1f, 0f, 0f), 0f), + PosNode = new CellBSPNode { Type = BSPNodeType.Leaf }, + }, + } + }; + } + + [Fact] + public void IndoorSeed_SphereFullyOutsideHydratedCell_DemotesToOutdoorColumn() + { + // The gate-2 live wedge shape: claimed cell 0xA9B40150, sphere far + // outside its volume (x = -10, fully behind the x≥0 half-space). + // The BFS finds no portals (sphere nowhere near them), so no outdoor + // candidates exist — pre-fix this returned the bogus claim forever. + // Expected: the global outdoor column under the centre. x=-10 is one + // cell WEST of the A9B4 block edge → block 0xA8B4, lcoord (1351,1440) + // → cell (7,0) → low 0x39. + var cache = new PhysicsDataCache(); + cache.RegisterCellStructForTest(0xA9B40150u, MakeCellWithBoundedBsp(Matrix4x4.Identity)); + + uint containing = CellTransit.FindCellSet( + cache, new Vector3(-10f, 12f, 0f), sphereRadius: 0.5f, + currentCellId: 0xA9B40150u, + out _); + + Assert.Equal(0xA8B40039u, containing); + } + + [Fact] + public void IndoorSeed_SphereStraddlesCellBoundary_StaysCurrent() + { + // #90 hysteresis guard: centre just outside the half-space (x=-0.3) + // but the 0.5 radius still reaches back in → sphere OVERLAPS → the + // claim is legitimate (doorway push-back shape) → NO demotion. + var cache = new PhysicsDataCache(); + cache.RegisterCellStructForTest(0xA9B40150u, MakeCellWithBoundedBsp(Matrix4x4.Identity)); + + uint containing = CellTransit.FindCellSet( + cache, new Vector3(-0.3f, 12f, 0f), sphereRadius: 0.5f, + currentCellId: 0xA9B40150u, + out _); + + Assert.Equal(0xA9B40150u, containing); + } + + [Fact] + public void IndoorSeed_CellWithoutBsp_CannotVerify_StaysCurrent() + { + // Stale-beats-null while streaming hydrates: a registered cell with + // no CellBSP yet cannot be verified — trust the claim (no demotion). + Matrix4x4.Invert(Matrix4x4.Identity, out var inv); + var cellNoBsp = new CellPhysics + { + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = inv, + Resolved = new Dictionary(), + }; + var cache = new PhysicsDataCache(); + cache.RegisterCellStructForTest(0xA9B40150u, cellNoBsp); + + uint containing = CellTransit.FindCellSet( + cache, new Vector3(-10f, 12f, 0f), sphereRadius: 0.5f, + currentCellId: 0xA9B40150u, + out _); + + Assert.Equal(0xA9B40150u, containing); + } + // ────────────────────────────────────────────────────────────────── // Membership hysteresis — the R1-flap root cause. // Retail CObjCell::find_cell_list adds the CURRENT cell at index 0