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