diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 67137574..85746ee9 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -1007,7 +1007,18 @@ public sealed class GameWindow : IDisposable
// 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,
+ && _physicsEngine.SampleTerrainZ(pe.Position.X, pe.Position.Y) is not null
+ // #107 gate-2 extension (2026-06-10): an INDOOR spawn claim
+ // 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.
+ && (!_lastSpawnByGuid.TryGetValue(_playerServerGuid, out var sp)
+ || sp.Position is not { } spawnClaim
+ || spawnClaim.LandblockId == 0
+ || _physicsEngine.IsSpawnCellReady(spawnClaim.LandblockId)),
enterPlayerMode: EnterPlayerModeFromAutoEntry);
}
@@ -4852,24 +4863,42 @@ public sealed class GameWindow : IDisposable
int oldLbY = _liveCenterY + (int)System.Math.Floor(oldPos.Y / 192f);
bool differentLandblock = (lbX != oldLbX || lbY != oldLbY);
- bool farAway = System.Numerics.Vector3.Distance(worldPos, oldPos) > 100f;
- if (differentLandblock || farAway)
+ // #107 (2026-06-10): ANY player position update while in PortalSpace
+ // IS the teleport arrival. Retail/holtburger exit portal space on the
+ // next position event unconditionally (holtburger messages.rs
+ // PlayerTeleport handler: log + LoginComplete; the destination applies
+ // through the normal position flow — no distance test). The old
+ // `differentLandblock || farAway(>100m)` arrival gate was an
+ // invention: ACE's same-landblock short-hop position corrections
+ // (e.g. right after an indoor login) matched neither condition, so
+ // PortalSpace never exited and movement input stayed frozen for the
+ // whole session (the #107 "input ignored" wedge shape —
+ // flood-fix-gate2.log: `teleport started (seq=1)` with no arrival).
{
Console.WriteLine(
$"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " +
$"new lb=({lbX},{lbY}) dist={System.Numerics.Vector3.Distance(worldPos, oldPos):F1}");
- // 1. Recenter the streaming controller on the new landblock.
- _liveCenterX = lbX;
- _liveCenterY = lbY;
+ System.Numerics.Vector3 newWorldPos;
+ if (differentLandblock)
+ {
+ // 1. Recenter the streaming controller on the new landblock.
+ _liveCenterX = lbX;
+ _liveCenterY = lbY;
- // Recompute worldPos with new center (it becomes local-to-center).
- // After recentering, the new position is (p.PositionX, p.PositionY, p.PositionZ)
- // relative to the new origin — which maps to world-space (0,0,0) + local offset.
- // The streamingController.Tick will pick up _liveCenterX/_liveCenterY automatically.
- var newWorldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ);
- // (after recentering, origin is (0,0,0) since lb == center)
+ // Recompute worldPos with new center (it becomes local-to-center).
+ // After recentering, the new position is (p.PositionX, p.PositionY, p.PositionZ)
+ // relative to the new origin — which maps to world-space (0,0,0) + local offset.
+ // The streamingController.Tick will pick up _liveCenterX/_liveCenterY automatically.
+ newWorldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ);
+ // (after recentering, origin is (0,0,0) since lb == center)
+ }
+ else
+ {
+ // Same landblock: worldPos is already in the current center frame.
+ newWorldPos = worldPos;
+ }
// 2. Resolve through physics for the correct ground Z.
uint newCellId = p.LandblockId;
@@ -6811,11 +6840,31 @@ public sealed class GameWindow : IDisposable
{
// Convert world position back to AC wire coordinates.
// World origin is _liveCenterX/_liveCenterY; each landblock is 192 units.
- int lbX = _liveCenterX + (int)MathF.Floor(result.Position.X / 192f);
- int lbY = _liveCenterY + (int)MathF.Floor(result.Position.Y / 192f);
+ //
+ // #107 (2026-06-10): the wire (cell, position) pair must be
+ // SELF-CONSISTENT — derive the landblock frame from the
+ // resolver's FULL cell id (always prefixed post-#106) instead
+ // of recomputing it from the position. The old composition
+ // welded a position-derived landblock onto result.CellId's low
+ // word; any tick where the two disagreed (landblock crossing,
+ // indoor cells near a boundary) sent ACE an id naming a
+ // DIFFERENT landblock's cell — and ACE persists the pair
+ // verbatim into the character save, which is the poisoned
+ // (cell-from-one-building, position-in-another) restore shape
+ // behind the #107 login wedge.
+ uint wireCellId = result.CellId;
+ int lbX = (int)(wireCellId >> 24);
+ int lbY = (int)((wireCellId >> 16) & 0xFFu);
+ if ((wireCellId & 0xFFFF0000u) == 0u)
+ {
+ // Defensive: bare low-word id (should not occur post-#106) —
+ // fall back to the position-derived landblock.
+ lbX = _liveCenterX + (int)MathF.Floor(result.Position.X / 192f);
+ lbY = _liveCenterY + (int)MathF.Floor(result.Position.Y / 192f);
+ wireCellId = ((uint)lbX << 24) | ((uint)lbY << 16) | (wireCellId & 0xFFFFu);
+ }
float localX = result.Position.X - (lbX - _liveCenterX) * 192f;
float localY = result.Position.Y - (lbY - _liveCenterY) * 192f;
- uint wireCellId = ((uint)lbX << 24) | ((uint)lbY << 16) | (result.CellId & 0xFFFFu);
var wirePos = new System.Numerics.Vector3(localX, localY, result.Position.Z);
var wireRot = YawToAcQuaternion(_playerController.Yaw);
byte contactByte = result.IsOnGround ? (byte)1 : (byte)0;
diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs
index 67a8bcf2..02c12512 100644
--- a/src/AcDream.Core/Physics/PhysicsDataCache.cs
+++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs
@@ -160,6 +160,21 @@ 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)
{
@@ -211,6 +226,9 @@ public sealed class PhysicsDataCache
Portals = portals,
PortalPolygons = portalPolygons,
VisibleCellIds = visibleCellIds,
+ // #107: retail CEnvCell.seen_outside — consumed by AdjustPosition's
+ // indoor not-found fallback (acclient :280037).
+ SeenOutside = envCell.Flags.HasFlag(DatReaderWriter.Enums.EnvCellFlags.SeenOutside),
};
_cellStruct[envCellId] = cellPhysics;
@@ -574,4 +592,13 @@ public sealed class CellPhysics
/// visibility filter.
///
public IReadOnlySet VisibleCellIds { get; init; } = new System.Collections.Generic.HashSet();
+
+ ///
+ /// #107: retail CEnvCell.seen_outside (dat EnvCellFlags.SeenOutside).
+ /// True for interiors with outdoor-visible portals (ground-floor cottage rooms).
+ /// PhysicsEngine.AdjustPosition's indoor branch falls back to the outdoor
+ /// landcell under the point when the claimed cell's visible graph does not
+ /// contain it AND this flag is set (retail acclient :280037-280046).
+ ///
+ public bool SeenOutside { get; init; }
}
diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs
index 95553b6c..e21153e3 100644
--- a/src/AcDream.Core/Physics/PhysicsEngine.cs
+++ b/src/AcDream.Core/Physics/PhysicsEngine.cs
@@ -411,11 +411,42 @@ public sealed class PhysicsEngine
///
/// SmartBox::update_viewer calls this to seat the camera sweep's start
/// cell at the head-pivot (:280032, indoor branch only) and again as fallback 1
- /// at the sought eye (:280078). Retail's indoor seen_outside →
- /// adjust_to_outside sub-fallback (:280037-280046) is deferred — not on the
- /// cottage/cellar camera path (see the design spec §6).
+ /// at the sought eye (:280078). The player snap path
+ /// (SetPositionInternal :283908 → our ) calls it to
+ /// validate the server-restored (cell, position) pair before any physics runs —
+ /// the #107 indoor-login wedge was this validation missing: a poisoned save
+ /// (cell id from one building, position inside another) was trusted verbatim,
+ /// the player stood fake-grounded with no walkable floor, and the first movement
+ /// demoted them outdoor mid-building → 2.4 m fall under the cottage floor.
+ ///
+ ///
+ /// #107 (2026-06-10) completed the previously-deferred indoor
+ /// seen_outside → adjust_to_outside sub-fallback (:280037-280046): when
+ /// the claimed cell is hydrated, nothing in its visible graph contains the
+ /// point, and the cell has outdoor-visible portals, retail demotes to the
+ /// landcell under the point. The corner-seal replay (`b21bb28`) shows camera
+ /// eyes always land inside cells/openings, so the camera path does not reach
+ /// this sub-branch in the gated scenarios (CameraCornerSealReplayTests stays
+ /// green).
///
///
+ ///
+ /// #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).
+ ///
+ 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);
+ }
+
public (uint cellId, bool found) AdjustPosition(uint seedCellId, Vector3 worldPoint)
{
if (seedCellId == 0u) return (seedCellId, false);
@@ -425,7 +456,15 @@ public sealed class PhysicsEngine
// Indoor: find_visible_child_cell(this, point, arg3 = 1) (:280028).
if (DataCache is null) return (seedCellId, false);
uint child = CellTransit.FindVisibleChildCell(DataCache, seedCellId, worldPoint, useStabList: true);
- return child != 0u ? (child, true) : (seedCellId, false);
+ if (child != 0u) return (child, true);
+
+ // Retail :280037-280046: claimed cell hydrated + seen_outside →
+ // Position::adjust_to_outside (fall through to the grid snap below).
+ // A non-hydrated or not-seen-outside claim stays (seed, false) —
+ // retail's lost-cell path; our callers keep their legacy fallback.
+ var claimed = DataCache.GetCellStruct(seedCellId);
+ if (claimed is null || !claimed.SeenOutside)
+ return (seedCellId, false);
}
// Outdoor: LandDefs::adjust_to_outside — snap to the landcell under the
@@ -460,6 +499,26 @@ public sealed class PhysicsEngine
///
public ResolveResult Resolve(Vector3 currentPos, uint cellId, Vector3 delta, float stepUpHeight)
{
+ // #107 (2026-06-10): retail CPhysicsObj::SetPositionInternal (:283892)
+ // step 1 — AdjustPosition (:283908) validates/corrects the claimed cell
+ // from the position BEFORE any physics runs. This legacy Resolve is the
+ // player snap path (login entry + teleport arrival — the SetPosition
+ // shaped calls); both hand it a server-restored (cell, position) pair
+ // that can be poisoned (the #107 capture: cell id from one building,
+ // position inside another, 55 m apart). Retail validates at the foot-
+ // sphere CENTER (localtoglobal of sphere_path.local_sphere, :283903);
+ // the player's foot sphere is radius 0.48 m centred 0.48 m above the
+ // feet (PlayerMovementController body — capture input.sphereRadius).
+ const float FootSphereCenterLift = 0.48f;
+ var (adjustedCellId, adjustedFound) = AdjustPosition(
+ cellId, currentPos + new Vector3(0f, 0f, FootSphereCenterLift));
+ if (adjustedFound && adjustedCellId != cellId)
+ {
+ Console.WriteLine(System.FormattableString.Invariant(
+ $"[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;
+ }
+
var candidatePos = currentPos + new Vector3(delta.X, delta.Y, 0f);
// Find the landblock this candidate position falls in.
diff --git a/tests/AcDream.Core.Tests/Conformance/Issue107SpawnDiagnosticTests.cs b/tests/AcDream.Core.Tests/Conformance/Issue107SpawnDiagnosticTests.cs
new file mode 100644
index 00000000..b92fa546
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Conformance/Issue107SpawnDiagnosticTests.cs
@@ -0,0 +1,133 @@
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.Core.Physics;
+using AcDream.Core.World.Cells;
+using DatReaderWriter;
+using DatReaderWriter.Options;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace AcDream.Core.Tests.Conformance;
+
+///
+/// #107 (2026-06-10): the indoor-login spawn wedge. The live capture
+/// (resolve-107-login1.jsonl) showed ACE restoring the player with a POISONED
+/// (cell, position) pair — cell id 0xA9B40162 (a different building, ~55 m
+/// away) with a position inside cell 0xA9B40171 (the threshold cottage). The
+/// unvalidated claim left the player fake-grounded (no contact plane, no
+/// walkable polygon); the first movement demoted them to outdoor mid-building
+/// and they fell 2.4 m through the cottage floor onto the terrain underneath.
+///
+/// These tests pin the dat-level facts that drove the fix (the retail
+/// AdjustPosition validation at the player snap, PhysicsEngine.Resolve head):
+/// the position is genuinely inside 0x171, genuinely NOT inside 0x162's
+/// visible graph, and the production pick recovers correctly from a GOOD seed
+/// while demoting to outdoor from the poisoned one (retail-identical: retail's
+/// find_visible_child_cell also cannot recover a cross-building claim and
+/// falls to seen_outside → adjust_to_outside, acclient :280037).
+///
+public sealed class Issue107SpawnDiagnosticTests
+{
+ private readonly ITestOutputHelper _out;
+ public Issue107SpawnDiagnosticTests(ITestOutputHelper output) => _out = output;
+
+ private const float FootRadius = 0.48f; // capture input.sphereRadius
+ private const uint PoisonedClaim = 0xA9B40162u; // the inn-side cell ACE reported
+ private const uint ActualCell = 0xA9B40171u; // the cell that contains the position
+
+ /// The captured spawn position (A9B4-local == capture world frame).
+ private static readonly Vector3 Spawn = new(156.53314f, 11.775104f, 96.5f);
+
+ [Fact]
+ public void SpawnPosition_IsInside0171_NotInside0162_PickRecoversFromGoodSeed()
+ {
+ 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();
+
+ var containing = new List();
+ int loaded = 0;
+ for (uint low = 0x0100; low <= 0x01FF; low++)
+ {
+ uint cellId = 0xA9B40000u | low;
+ EnvCell cell;
+ try { cell = ConformanceDats.LoadEnvCell(dats, cache, cellId); }
+ catch { continue; }
+ loaded++;
+ if (cell.PointInCell(Spawn + new Vector3(0, 0, FootRadius)))
+ {
+ containing.Add(cellId);
+ _out.WriteLine($"cell 0x{cellId:X8} CONTAINS spawn footCenter");
+ }
+ }
+ Assert.True(loaded > 0, "expected Holtburg interior cells to load");
+
+ // The poisoned-pair facts: position inside 0x171, NOT inside 0x162.
+ Assert.Contains(ActualCell, containing);
+ Assert.DoesNotContain(PoisonedClaim, containing);
+
+ var footCenter = Spawn + new Vector3(0, 0, FootRadius);
+
+ // Production pick with the GOOD seed keeps the player indoor.
+ uint pickGood = CellTransit.FindCellList(cache, footCenter, FootRadius, ActualCell);
+ _out.WriteLine($"FindCellList(seed=0x{ActualCell:X8}) -> 0x{pickGood:X8}");
+ Assert.Equal(ActualCell, pickGood);
+
+ // Production pick with the POISONED seed demotes to the outdoor column —
+ // retail-identical for a cross-building claim (0x171 is not in 0x162's
+ // stab list). The login-side fix is AdjustPosition at the snap, which
+ // demotes IMMEDIATELY instead of after the first movement.
+ uint pickBad = CellTransit.FindCellList(cache, footCenter, FootRadius, PoisonedClaim);
+ _out.WriteLine($"FindCellList(seed=0x{PoisonedClaim:X8}) -> 0x{pickBad:X8}");
+ Assert.Equal(0xA9B40031u, pickBad);
+ }
+
+ [Fact]
+ public void SeenOutside_IsPopulated_ForGroundFloorInteriors()
+ {
+ 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();
+
+ ConformanceDats.LoadEnvCell(dats, cache, ActualCell);
+ ConformanceDats.LoadEnvCell(dats, cache, PoisonedClaim);
+
+ var room = cache.GetCellStruct(ActualCell);
+ Assert.NotNull(room);
+ // 0x171 is a ground-floor cottage room with an exterior doorway —
+ // retail marks it seen_outside; AdjustPosition's indoor not-found
+ // fallback (acclient :280037) depends on this flag being cached.
+ Assert.True(room!.SeenOutside, "0xA9B40171 should be seen_outside");
+ _out.WriteLine($"0x{ActualCell:X8}.SeenOutside={room.SeenOutside}");
+
+ var inn = cache.GetCellStruct(PoisonedClaim);
+ Assert.NotNull(inn);
+ _out.WriteLine($"0x{PoisonedClaim:X8}.SeenOutside={inn!.SeenOutside}");
+ }
+
+ [Fact]
+ public void PoisonedClaim_IsADifferentBuilding_55mAway()
+ {
+ var datDir = ConformanceDats.ResolveDatDir();
+ if (datDir is null) { _out.WriteLine("dats unavailable — skipped"); return; }
+ using var dats = new DatCollection(datDir, DatAccessType.Read);
+
+ var claim = dats.Get(PoisonedClaim);
+ var actual = dats.Get(ActualCell);
+ Assert.NotNull(claim);
+ Assert.NotNull(actual);
+
+ float dist = Vector3.Distance(claim!.Position.Origin, actual!.Position.Origin);
+ _out.WriteLine(
+ $"claim origin=({claim.Position.Origin.X:F1},{claim.Position.Origin.Y:F1}) " +
+ $"actual origin=({actual.Position.Origin.X:F1},{actual.Position.Origin.Y:F1}) dist={dist:F1} m");
+ // The pair is not a one-tick-stale neighbour confusion — the buildings
+ // are tens of metres apart. The poisoning happened on the WIRE side
+ // (the old position-derived-landblock wireCellId composition) or in a
+ // prior wedged session's chaos, not in the per-tick membership pick.
+ Assert.True(dist > 20f, $"expected cross-building distance, got {dist:F1} m");
+ }
+}