diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 85746ee9..985119f6 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -1012,13 +1012,15 @@ public sealed class GameWindow : IDisposable
// additionally waits for the claimed cell's hydration so the
// entry snap's AdjustPosition validation can act (retail loads
// the cell synchronously before SetPosition; this is the
- // async-streaming equivalent). A claim whose landblock's
- // interior batch hydrated WITHOUT it is poisoned — proceed
- // and let AdjustPosition demote it.
+ // async-streaming equivalent). Claims that can never hydrate
+ // (id outside the landblock's NumCells range per the dat)
+ // don't hold the gate — the Resolve-head safety net demotes
+ // them loudly.
&& (!_lastSpawnByGuid.TryGetValue(_playerServerGuid, out var sp)
|| sp.Position is not { } spawnClaim
|| spawnClaim.LandblockId == 0
- || _physicsEngine.IsSpawnCellReady(spawnClaim.LandblockId)),
+ || _physicsEngine.IsSpawnCellReady(spawnClaim.LandblockId)
+ || IsSpawnClaimUnhydratable(spawnClaim.LandblockId)),
enterPlayerMode: EnterPlayerModeFromAutoEntry);
}
@@ -11305,6 +11307,34 @@ public sealed class GameWindow : IDisposable
/// the guard already guarantee _entitiesByServerGuid contains
/// the player guid, so the inner TryGetValue is a fast-path success.
///
+ // #107 (2026-06-10): memoized "this indoor spawn claim can never hydrate"
+ // check against the dat's LandBlockInfo.NumCells. Used by the auto-entry
+ // hold so a garbage claim doesn't stall login forever; the Resolve-head
+ // safety net demotes it loudly once entry proceeds.
+ private (uint Claim, bool Unhydratable)? _spawnClaimRangeMemo;
+
+ private bool IsSpawnClaimUnhydratable(uint claim)
+ {
+ if ((claim & 0xFFFFu) < 0x0100u) return false;
+ if (_spawnClaimRangeMemo is { } m && m.Claim == claim) return m.Unhydratable;
+
+ bool unhydratable = false;
+ if (_dats is not null)
+ {
+ DatReaderWriter.DBObjs.LandBlockInfo? lbInfo;
+ lock (_datLock)
+ {
+ lbInfo = _dats.Get(
+ (claim & 0xFFFF0000u) | 0xFFFEu);
+ }
+ uint low = claim & 0xFFFFu;
+ unhydratable = lbInfo is null || lbInfo.NumCells == 0
+ || low >= 0x0100u + lbInfo.NumCells;
+ }
+ _spawnClaimRangeMemo = (claim, unhydratable);
+ return unhydratable;
+ }
+
private void EnterPlayerModeFromAutoEntry()
{
_playerMode = true;
diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs
index 02c12512..8bdb532c 100644
--- a/src/AcDream.Core/Physics/PhysicsDataCache.cs
+++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs
@@ -160,21 +160,6 @@ public sealed class PhysicsDataCache
/// (indoor room geometry). No-ops if the id is already cached or the
/// CellStruct has no physics BSP.
///
- ///
- /// #107: true when ANY cell struct for the landblock (high-16 prefix of
- /// ) is cached. Cell structs for a landblock are
- /// registered as a batch by the streaming completion, so "some cells present
- /// but the claimed one absent" means the claimed id is bogus (poisoned save)
- /// rather than still-streaming.
- ///
- public bool HasAnyCellStructInLandblock(uint cellId)
- {
- uint prefix = cellId & 0xFFFF0000u;
- foreach (var key in _cellStruct.Keys)
- if ((key & 0xFFFF0000u) == prefix) return true;
- return false;
- }
-
public void CacheCellStruct(uint envCellId, DatReaderWriter.DBObjs.EnvCell envCell,
CellStruct cellStruct, Matrix4x4 worldTransform)
{
diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs
index e21153e3..24edafac 100644
--- a/src/AcDream.Core/Physics/PhysicsEngine.cs
+++ b/src/AcDream.Core/Physics/PhysicsEngine.cs
@@ -430,21 +430,56 @@ public sealed class PhysicsEngine
/// green).
///
///
+ ///
+ /// #107: does any loaded landblock carry a for
+ /// this cell id? Distinguishes "partially hydrated" (floor data present,
+ /// struct pending — the legacy floor-snap can ground the claim) from
+ /// "completely unknown" (the Resolve safety net demotes loudly).
+ ///
+ private bool HasCellSurface(uint cellId)
+ {
+ // Masked low-word compare (house norm in this file): production
+ // CellSurfaces carry full prefixed ids (GameWindow.cs:5923), test
+ // fixtures bare low words. A zero-prefix (bare, pre-#106 convention)
+ // claim matches any loaded landblock by low word — the legacy Resolve
+ // body below treats bare claims the same way.
+ uint low = cellId & 0xFFFFu;
+ uint prefix = cellId & 0xFFFF0000u;
+ foreach (var kvp in _landblocks)
+ {
+ if (prefix != 0u && (kvp.Key & 0xFFFF0000u) != prefix) continue;
+ foreach (var cell in kvp.Value.Cells)
+ if ((cell.CellId & 0xFFFFu) == low) return true;
+ }
+ return false;
+ }
+
///
/// #107 auto-entry hold (gate-2 extension, 2026-06-10): true when the
/// server-claimed spawn cell is ready for to
/// act on. Outdoor claims need only terrain (the existing gate). Indoor
/// claims wait until the claimed cell's struct is hydrated — the async-
/// streaming equivalent of retail's synchronous cell load before
- /// SetPosition — OR until the landblock's interior batch hydrated WITHOUT
- /// the claimed id (a poisoned save: proceed and let AdjustPosition demote).
+ /// SetPosition.
+ ///
+ ///
+ /// ⚠️ The first version disambiguated "claim bogus" via "any cell struct
+ /// in the landblock present" — WRONG: interiors hydrate in id order on the
+ /// background worker, so the render-thread predicate can observe the
+ /// mid-population state (early cells present, the claim not yet) and open
+ /// the gate before AdjustPosition's stab search can act (the 2026-06-10
+ /// gate-run regression: claim 0xA9B40172 committed raw → outdoor demote on
+ /// first movement → transparent interior). Claims that can NEVER hydrate
+ /// (id outside the landblock's NumCells range) are now filtered by the
+ /// caller against the dat, and carries a loud
+ /// outdoor-demote safety net for any unhydrated indoor claim that still
+ /// gets through.
+ ///
///
public bool IsSpawnCellReady(uint cellId)
{
if ((cellId & 0xFFFFu) < 0x0100u) return true;
- if (DataCache is null) return false;
- if (DataCache.GetCellStruct(cellId) is not null) return true;
- return DataCache.HasAnyCellStructInLandblock(cellId);
+ return DataCache?.GetCellStruct(cellId) is not null;
}
public (uint cellId, bool found) AdjustPosition(uint seedCellId, Vector3 worldPoint)
@@ -518,6 +553,32 @@ public sealed class PhysicsEngine
$"[spawn-adjust] claimed cell 0x{cellId:X8} does not contain ({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) — corrected to 0x{adjustedCellId:X8} (retail AdjustPosition :280009)"));
cellId = adjustedCellId;
}
+ else if (!adjustedFound
+ && (cellId & 0xFFFFu) >= 0x0100u
+ && DataCache?.GetCellStruct(cellId) is null
+ && !HasCellSurface(cellId))
+ {
+ // #107 safety net (2026-06-10 gate-run regression): an indoor claim
+ // the engine knows NOTHING about (no cell struct AND no CellSurface
+ // floor data) cannot be validated or grounded — committing it raw
+ // reproduces the fake-grounded wedge. Retail goes lost-cell here
+ // (GotoLostCell, :283418); our recoverable equivalent is the
+ // outdoor landcell under the point (documented divergence — we have
+ // no lost-cell machinery). When only the struct is missing but the
+ // CellSurface floor exists (partial hydration), the legacy indoor
+ // floor-snap below handles the claim — don't demote. The auto-entry
+ // hold should make this unreachable in practice; if the line fires,
+ // the hold has a gap.
+ var (outdoorCellId, outdoorFound) = AdjustPosition(
+ (cellId & 0xFFFF0000u) | 0x0001u,
+ currentPos + new Vector3(0f, 0f, FootSphereCenterLift));
+ if (outdoorFound)
+ {
+ Console.WriteLine(System.FormattableString.Invariant(
+ $"[spawn-adjust] UNHYDRATED indoor claim 0x{cellId:X8} at ({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) — demoted to outdoor 0x{outdoorCellId:X8} (lost-cell equivalent)"));
+ cellId = outdoorCellId;
+ }
+ }
var candidatePos = currentPos + new Vector3(delta.X, delta.Y, 0f);
diff --git a/tests/AcDream.Core.Tests/Conformance/Issue107SpawnDiagnosticTests.cs b/tests/AcDream.Core.Tests/Conformance/Issue107SpawnDiagnosticTests.cs
index b92fa546..7215bb25 100644
--- a/tests/AcDream.Core.Tests/Conformance/Issue107SpawnDiagnosticTests.cs
+++ b/tests/AcDream.Core.Tests/Conformance/Issue107SpawnDiagnosticTests.cs
@@ -108,6 +108,36 @@ public sealed class Issue107SpawnDiagnosticTests
_out.WriteLine($"0x{PoisonedClaim:X8}.SeenOutside={inn!.SeenOutside}");
}
+ [Fact]
+ public void AdjacentRoomClaim_CorrectsToContainingRoom_ViaStabList()
+ {
+ // 2026-06-10 gate-run regression: ACE saved the cell one room off
+ // (claim 0xA9B40172, position inside 0xA9B40171 — adjacent rooms of the
+ // threshold cottage, connected via the 20 cm threshold cell 0x173).
+ // With every cell hydrated, retail's find_visible_child_cell stab-list
+ // search (acclient :311444) must correct the claim. The live failure
+ // was the auto-entry hold opening before 0x171 hydrated (the
+ // mid-population "any cell present" disambiguator, since removed) —
+ // this test pins the hydrated-path correctness.
+ var datDir = ConformanceDats.ResolveDatDir();
+ if (datDir is null) { _out.WriteLine("dats unavailable — skipped"); return; }
+ using var dats = new DatCollection(datDir, DatAccessType.Read);
+ var cache = new PhysicsDataCache();
+ for (uint low = 0x0100; low <= 0x01FF; low++)
+ {
+ try { ConformanceDats.LoadEnvCell(dats, cache, 0xA9B40000u | low); }
+ catch { }
+ }
+
+ // The gate-run spawn point (ACE save: cell 0x172, position in 0x171).
+ var spawnFootCenter = new Vector3(155.390f, 11.020f, 94.0f + FootRadius);
+
+ uint corrected = CellTransit.FindVisibleChildCell(
+ cache, 0xA9B40172u, spawnFootCenter, useStabList: true);
+ _out.WriteLine($"FindVisibleChildCell(claim 0x172, stab) -> 0x{corrected:X8}");
+ Assert.Equal(ActualCell, corrected);
+ }
+
[Fact]
public void PoisonedClaim_IsADifferentBuilding_55mAway()
{