fix(phys): #106 gate-2 — bogus-indoor-claim recovery + spawn-ground entry hold

Gate-2 fallout chain: session 1's bare-id wedge poisoned ACE's save (the
client reported garbage cells while walking through walls), so session 2
logged in with (cell=0xA9B4013F inn interior, pos=(91.4,32.7)) — a
position that cell does not contain. Probe evidence: exactly one
[cell-transit] line all session; the player free-fell into an empty
world. Two holes, both fixed at the root:

1. CellTransit pick escape hatch — restores the #83/A1.7 + #90
   verification that lived in PhysicsEngine.ResolveCellId before the
   collide-then-pick rewrite moved membership into
   BuildCellSetAndPickContaining: an indoor current cell that IS
   hydrated but whose CellBSP no longer overlaps ANY part of the foot
   sphere is a bogus claim (corrupt save, or walked out through an
   unblocked gap). The portal BFS can never reach an exit portal from a
   cell the sphere isn't in, so no candidates exist and the claim held
   forever — wedging collision (ShadowObjectRegistry's #98 gate reads
   "indoor primary" -> outdoor object sweep skipped), wall BSP, terrain,
   and the render root. The pick now demotes to the outdoor column under
   the sphere centre (the LandDefs.AdjustToOutside result already
   computed for the pick — cross-block safe). Sphere-overlap
   (BSPQuery.SphereIntersectsCellBsp, pseudo_c:317666 -> :323267), NOT
   point-in: doorway push-back leaves the centre a few cm outside while
   the sphere still overlaps — no demotion, #90's ping-pong stays dead.
   An unhydrated cell cannot be verified — stale beats null while
   streaming hydrates (retail-equivalent: stale curr_cell kept when the
   pick finds nothing).

2. PlayerModeAutoEntry spawn-ground hold — player-mode entry now waits
   for the terrain under the spawn position to stream in
   (isSpawnGroundReady predicate, K.2 pattern). Entering earlier
   integrates gravity against an empty world: indoor-claimed spawns got
   no floor from any source and free-fell into the void; outdoor spawns
   raced hydration by ~1s every login. Retail never has this state (it
   loads cells synchronously) — the hold is the async-streaming
   equivalent of that invariant. With the hold, the entry snap
   (Resolve, stepUp=100) runs against hydrated cell floors + terrain
   and re-seats a corrupt save's claim immediately.

Tests: IndoorSeed_SphereFullyOutsideHydratedCell_DemotesToOutdoorColumn
(the gate-2 wedge shape, red pre-fix), straddle + no-BSP guards (the #90
hysteresis and stale-beats-null), TryEnter_Armed_SpawnGroundNotReady_
DoesNotFire. Full suite: 294+218+420 green; Core 1375 green + the same
4 pre-existing door/#99-era failures + 1 skip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-09 23:47:11 +02:00
parent 23adc9c9df
commit 6dbbf953c6
5 changed files with 184 additions and 5 deletions

View file

@ -8,12 +8,13 @@ namespace AcDream.App.Input;
/// <c>GameWindow</c> and ticked each frame from <c>OnUpdate</c>.
///
/// <para>
/// 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 <c>InWorld</c>, 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.
/// </para>
@ -43,6 +44,7 @@ public sealed class PlayerModeAutoEntry
private readonly Func<bool> _isLiveInWorld;
private readonly Func<bool> _isPlayerEntityPresent;
private readonly Func<bool> _isPlayerControllerReady;
private readonly Func<bool> _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.</param>
/// <param name="isSpawnGroundReady">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.</param>
/// <param name="enterPlayerMode">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<bool> isLiveInWorld,
Func<bool> isPlayerEntityPresent,
Func<bool> isPlayerControllerReady,
Func<bool> 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();

View file

@ -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);
}

View file

@ -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<Sphere> worldSpheres, int numSpheres)

View file

@ -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()
{

View file

@ -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.
// ──────────────────────────────────────────────────────────────────
/// <summary>Cell whose CellBSP half-space is x ≥ 0 (cell-local).</summary>
private static CellPhysics MakeCellWithBoundedBsp(Matrix4x4 worldTransform)
{
Matrix4x4.Invert(worldTransform, out var inv);
return new CellPhysics
{
WorldTransform = worldTransform,
InverseWorldTransform = inv,
Resolved = new Dictionary<ushort, ResolvedPolygon>(),
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<ushort, ResolvedPolygon>(),
};
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