diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 35514cdd..4320fbb8 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -5471,7 +5471,6 @@ public sealed class GameWindow : IDisposable
// impossible claim / timeout. PortalSpace keeps input frozen meanwhile.
EnsureTeleportArrivalController();
_pendingTeleportRot = rot;
- _lastArrivalVerdict = AcDream.App.World.ArrivalReadiness.NotReady; // reset probe state for each new hold
_teleportArrival!.BeginArrival(newWorldPos, p.LandblockId);
}
}
@@ -5482,8 +5481,6 @@ public sealed class GameWindow : IDisposable
// runtime deps are wired by then).
private AcDream.App.World.TeleportArrivalController? _teleportArrival;
private System.Numerics.Quaternion _pendingTeleportRot = System.Numerics.Quaternion.Identity;
- // §3.4 arrival-gate probe: tracks previous verdict so we log only the flip.
- private AcDream.App.World.ArrivalReadiness _lastArrivalVerdict = AcDream.App.World.ArrivalReadiness.NotReady;
// #145: the LANDBLOCK-relative (cell-local) position used to SEED the player
// body's cell-relative CellPosition. This is the ONE place the streaming center
@@ -5512,36 +5509,24 @@ public sealed class GameWindow : IDisposable
// Reuses the #107 login readiness triplet (GameWindow.cs:1010-1024), evaluated
// against the teleport's (destPos, destCell): an impossible indoor claim short-
// circuits to immediate placement; otherwise hold until terrain is sampled and,
- // for an indoor cell, the cell struct has hydrated. For an outdoor cell, hold
- // until the physics landblock is registered (§3.4 #145 residual fix).
+ // for an indoor cell, the cell struct has hydrated.
private AcDream.App.World.ArrivalReadiness TeleportArrivalReadiness(
System.Numerics.Vector3 destPos, uint destCell)
{
bool claimUnhydratable = IsSpawnClaimUnhydratable(destCell);
+ // #135: an INDOOR destination (sealed dungeon / building interior) gates on the
+ // EnvCell FLOOR hydrating. Retail places on the cell floor. An OUTDOOR destination
+ // places immediately on the server-authoritative position — see TeleportArrivalRules
+ // (#145: holding is futile because streaming doesn't progress during a PortalSpace
+ // hold; the stale source landblock is dropped at recenter so the resolve trusts the
+ // server cell until the destination streams in).
bool indoor = (destCell & 0xFFFFu) >= 0x0100u;
bool indoorCellReady = indoor && !claimUnhydratable
&& _physicsEngine.IsSpawnCellReady(destCell);
- // §3.4: for outdoor arrivals, gate on the physics landblock being resident.
- // The landblock id is the high 16 bits of the cell id (i.e. destCell & 0xFFFF0000).
- // Streaming progresses during the hold because the streaming controller runs before
- // TeleportArrivalController.Tick. The 10 s _maxHoldFrames safety-net remains active.
- bool outdoorReady = !indoor
- && _physicsEngine.IsLandblockLoaded(destCell & 0xFFFF0000u);
- var verdict = AcDream.App.World.TeleportArrivalRules.Decide(
- claimUnhydratable, indoor, indoorCellReady, outdoorReady);
-
- if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeArrivalGateEnabled
- && verdict != _lastArrivalVerdict
- && verdict != AcDream.App.World.ArrivalReadiness.NotReady)
- {
- Console.WriteLine(System.FormattableString.Invariant(
- $"[arrival-gate] destCell=0x{destCell:X8} landblock=0x{(destCell & 0xFFFF0000u):X8} verdict={verdict} indoor={indoor} landblockCount={_physicsEngine.LandblockCount}"));
- }
- _lastArrivalVerdict = verdict;
-
- return verdict;
+ return AcDream.App.World.TeleportArrivalRules.Decide(
+ claimUnhydratable, indoor, indoorCellReady);
}
// The deferred snap (the original OnLivePositionUpdated steps 2-5), now run only
diff --git a/src/AcDream.App/World/TeleportArrivalController.cs b/src/AcDream.App/World/TeleportArrivalController.cs
index 7263299b..eeddf858 100644
--- a/src/AcDream.App/World/TeleportArrivalController.cs
+++ b/src/AcDream.App/World/TeleportArrivalController.cs
@@ -116,29 +116,29 @@ public static class TeleportArrivalRules
/// The destination cell can never hydrate.
/// Destination is an indoor cell (cell index ≥ 0x0100).
/// For an indoor destination, the EnvCell floor has hydrated.
- /// For an outdoor destination, the physics landblock is loaded
- /// ( returned true for the
- /// destination landblock id). Ignored for indoor destinations.
public static ArrivalReadiness Decide(
bool claimUnhydratable,
bool indoor,
- bool indoorCellReady,
- bool outdoorReady)
+ bool indoorCellReady)
{
if (claimUnhydratable)
return ArrivalReadiness.Impossible;
// Indoor (sealed dungeon / building interior): gate on the EnvCell floor hydrating,
- // exactly as #135. Unaffected by the outdoor-landblock gate.
+ // exactly as #135. The dungeon-IN path pre-collapses + waits for the cell struct, and
+ // its placement validates the specific claimed cell (the indoor resolve is cell-keyed,
+ // not the grid-snap), so it is unaffected by the overlap that broke teleport-OUT.
if (indoor)
return indoorCellReady ? ArrivalReadiness.Ready : ArrivalReadiness.NotReady;
- // Outdoor (#145 §3.4): hold until the physics landblock is registered.
- // AddLandblock is atomic (terrain + cells + portals in one write — PhysicsEngine.cs:70),
- // so ContainsKey implies the collision mesh is present and resolvable. The hold is
- // self-resolving: streaming progresses unconditionally during the hold because the
- // streaming controller runs before TeleportArrivalController.Tick (GameWindow.cs:7420-7551).
- // The 10 s timeout safety-net (_maxHoldFrames) remains active.
- return outdoorReady ? ArrivalReadiness.Ready : ArrivalReadiness.NotReady;
+ // Outdoor: place IMMEDIATELY on the server-authoritative position. #145/#138 — holding
+ // for the destination to stream in is futile: streaming does NOT progress while the
+ // player is held in PortalSpace (the destination only loads once placement flips the
+ // state to InWorld). The stale source center landblock is dropped at recenter (see
+ // GameWindow.OnLivePositionUpdated), so the arrival resolve falls through to the
+ // server-authoritative cell/position (Resolve NO-LANDBLOCK verbatim) and the player is
+ // placed grounded there; streaming then loads the destination and the per-frame resolve
+ // grounds onto it. This trusts the server's teleport position — retail-faithful.
+ return ArrivalReadiness.Ready;
}
}
diff --git a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs
index 51ee9977..06a3180c 100644
--- a/src/AcDream.Core/Physics/PhysicsDiagnostics.cs
+++ b/src/AcDream.Core/Physics/PhysicsDiagnostics.cs
@@ -31,19 +31,6 @@ public static class PhysicsDiagnostics
public static bool ProbeResolveEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_RESOLVE") == "1";
- ///
- /// Retail teleport readiness gate (§3.4, 2026-06-21). When true, every transition of
- /// the TeleportArrivalReadiness verdict (NotReady → Ready / Impossible) emits one
- /// [arrival-gate] line: the destination cell id, landblock id, verdict,
- /// PhysicsEngine.LandblockCount at the moment it flips, and whether the
- /// flip was forced (Impossible) or natural (Ready).
- ///
- /// Initial state from ACDREAM_PROBE_RESOLVE=1 — shares the existing probe
- /// flag because this is a readiness-path extension; no new env var needed.
- ///
- public static bool ProbeArrivalGateEnabled { get; set; } =
- Environment.GetEnvironmentVariable("ACDREAM_PROBE_RESOLVE") == "1";
-
///
/// When true, every change to PlayerMovementController.CellId
/// emits one [cell-transit] line: old → new cell, current
@@ -547,7 +534,6 @@ public static class PhysicsDiagnostics
public static void ResetForTest()
{
ProbeResolveEnabled = false;
- ProbeArrivalGateEnabled = false;
ProbeCellEnabled = false;
ProbeBuildingEnabled = false;
ProbeCellSetEnabled = false;
diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs
index 078e8ea5..108b9234 100644
--- a/src/AcDream.Core/Physics/PhysicsEngine.cs
+++ b/src/AcDream.Core/Physics/PhysicsEngine.cs
@@ -31,25 +31,6 @@ public sealed class PhysicsEngine
/// Number of registered landblocks (diagnostic).
public int LandblockCount => _landblocks.Count;
- ///
- /// Returns true iff the landblock CONTAINING the given id has been registered via
- /// . Because registers terrain +
- /// collision cells + portals atomically, a hit implies the collision mesh is present
- /// and resolvable.
- ///
- /// The argument may be any id within the landblock — a full cell id (low 16 = cell
- /// index), the landblock-prefix form (low 16 = 0x0000, as the teleport gate passes via
- /// destCell & 0xFFFF0000), or the dat-id storage form. The low 16 bits are
- /// normalized to the canonical landblock key (0xFFFF) that streaming stores under
- /// ( ORs in 0xFFFF),
- /// so all three forms resolve. The earlier raw ContainsKey(id & 0xFFFF0000)
- /// never matched the 0xFFFF-terminated stored key, leaving the outdoor teleport gate
- /// permanently NotReady (2026-06-21 #145 apparatus re-test).
- ///
- ///
- public bool IsLandblockLoaded(uint landblockId) =>
- _landblocks.ContainsKey((landblockId & 0xFFFF0000u) | 0xFFFFu);
-
///
/// Cell-based spatial index for static object collision.
/// Populated during landblock streaming; queried by the Transition system.
diff --git a/tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs b/tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs
index a9602a9d..eb0b12ea 100644
--- a/tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs
+++ b/tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs
@@ -4,63 +4,49 @@ using Xunit;
namespace AcDream.App.Tests.World;
///
-/// Full truth-table for TeleportArrivalRules.Decide after the §3.4 readiness-gate change.
+/// The teleport-arrival readiness decision (#145/#138).
+///
+/// - Unhydratable claim → place immediately via the caller's safety-net (Impossible).
+/// - Indoor (sealed dungeon / building interior) → hold until the EnvCell floor hydrates.
+/// - Outdoor → place IMMEDIATELY on the server-authoritative position. Holding is futile —
+/// streaming does not progress while the player is held in PortalSpace, so the
+/// destination can't stream in during a hold. The stale source landblock is dropped at
+/// recenter (GameWindow.OnLivePositionUpdated), so the resolve falls through to the
+/// server cell (Resolve NO-LANDBLOCK verbatim) until the destination streams in.
+///
///
public class TeleportArrivalRulesTests
{
[Fact]
- public void Unhydratable_IsImpossible_Outdoor()
+ public void Unhydratable_IsImpossible()
{
Assert.Equal(ArrivalReadiness.Impossible,
- TeleportArrivalRules.Decide(
- claimUnhydratable: true, indoor: false,
- indoorCellReady: false, outdoorReady: false));
- }
-
- [Fact]
- public void Unhydratable_IsImpossible_Indoor()
- {
+ TeleportArrivalRules.Decide(claimUnhydratable: true, indoor: false, indoorCellReady: false));
+ // …even for an indoor claim.
Assert.Equal(ArrivalReadiness.Impossible,
- TeleportArrivalRules.Decide(
- claimUnhydratable: true, indoor: true,
- indoorCellReady: true, outdoorReady: false));
+ TeleportArrivalRules.Decide(claimUnhydratable: true, indoor: true, indoorCellReady: false));
}
[Fact]
public void Indoor_CellReady_IsReady()
{
Assert.Equal(ArrivalReadiness.Ready,
- TeleportArrivalRules.Decide(
- claimUnhydratable: false, indoor: true,
- indoorCellReady: true, outdoorReady: false));
+ TeleportArrivalRules.Decide(claimUnhydratable: false, indoor: true, indoorCellReady: true));
}
[Fact]
- public void Indoor_CellNotReady_IsNotReady()
+ public void Indoor_CellNotReady_HoldsNotReady()
{
Assert.Equal(ArrivalReadiness.NotReady,
- TeleportArrivalRules.Decide(
- claimUnhydratable: false, indoor: true,
- indoorCellReady: false, outdoorReady: false));
+ TeleportArrivalRules.Decide(claimUnhydratable: false, indoor: true, indoorCellReady: false));
}
[Fact]
- public void Outdoor_LandblockLoaded_IsReady()
+ public void Outdoor_PlacesImmediately()
{
+ // #145: never hold an outdoor arrival — place at once on the server position. The
+ // indoorCellReady flag is irrelevant for an outdoor destination.
Assert.Equal(ArrivalReadiness.Ready,
- TeleportArrivalRules.Decide(
- claimUnhydratable: false, indoor: false,
- indoorCellReady: false, outdoorReady: true));
- }
-
- [Fact]
- public void Outdoor_LandblockNotLoaded_IsNotReady()
- {
- // §3.4: the #145 residual — outdoor arrival on a not-yet-streamed landblock
- // must hold. Previously returned Ready immediately.
- Assert.Equal(ArrivalReadiness.NotReady,
- TeleportArrivalRules.Decide(
- claimUnhydratable: false, indoor: false,
- indoorCellReady: false, outdoorReady: false));
+ TeleportArrivalRules.Decide(claimUnhydratable: false, indoor: false, indoorCellReady: false));
}
}
diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsEngineIsLandblockLoadedTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsEngineIsLandblockLoadedTests.cs
deleted file mode 100644
index d32b5a6d..00000000
--- a/tests/AcDream.Core.Tests/Physics/PhysicsEngineIsLandblockLoadedTests.cs
+++ /dev/null
@@ -1,90 +0,0 @@
-using AcDream.Core.Physics;
-using Xunit;
-
-namespace AcDream.Core.Tests.Physics;
-
-public class PhysicsEngineIsLandblockLoadedTests
-{
- private static TerrainSurface MakeFlatTerrain()
- {
- var heights = new byte[81];
- Array.Fill(heights, (byte)50);
- var heightTable = new float[256];
- for (int i = 0; i < 256; i++) heightTable[i] = i * 1f;
- return new TerrainSurface(heights, heightTable);
- }
-
- [Fact]
- public void IsLandblockLoaded_AbsentId_ReturnsFalse()
- {
- var engine = new PhysicsEngine();
- Assert.False(engine.IsLandblockLoaded(0xA9B4FFFFu));
- }
-
- [Fact]
- public void IsLandblockLoaded_AddedId_ReturnsTrue()
- {
- var engine = new PhysicsEngine();
- engine.AddLandblock(0xA9B4FFFFu, MakeFlatTerrain(),
- Array.Empty(), Array.Empty(),
- worldOffsetX: 0f, worldOffsetY: 0f);
-
- Assert.True(engine.IsLandblockLoaded(0xA9B4FFFFu));
- }
-
- [Fact]
- public void IsLandblockLoaded_AnotherIdAbsent_ReturnsFalse()
- {
- var engine = new PhysicsEngine();
- engine.AddLandblock(0xA9B4FFFFu, MakeFlatTerrain(),
- Array.Empty(), Array.Empty(),
- worldOffsetX: 0f, worldOffsetY: 0f);
-
- Assert.False(engine.IsLandblockLoaded(0xDEADBEEFu));
- }
-
- [Fact]
- public void IsLandblockLoaded_AfterRemove_ReturnsFalse()
- {
- var engine = new PhysicsEngine();
- engine.AddLandblock(0xA9B4FFFFu, MakeFlatTerrain(),
- Array.Empty(), Array.Empty(),
- worldOffsetX: 0f, worldOffsetY: 0f);
- engine.RemoveLandblock(0xA9B4FFFFu);
-
- Assert.False(engine.IsLandblockLoaded(0xA9B4FFFFu));
- }
-
- // Regression for the Slice 2 key-mismatch bug (#145 apparatus re-test, 2026-06-21):
- // landblocks are stored under the EncodeLandblockId form (low 16 = 0xFFFF), but the
- // teleport gate (TeleportArrivalReadiness) queries with (destCell & 0xFFFF0000) (low
- // 16 = 0x0000). A raw ContainsKey never matched, so the outdoor gate was permanently
- // NotReady and every outdoor teleport timed out at 10 s. IsLandblockLoaded must
- // normalize so ANY id within the landblock resolves to the stored entry.
- [Theory]
- [InlineData(0xA9B40000u)] // landblock-prefix form — exactly what the gate passes (destCell & 0xFFFF0000)
- [InlineData(0xA9B4000Du)] // an outdoor cell id within the landblock
- [InlineData(0xA9B40143u)] // an indoor cell id within the landblock
- [InlineData(0xA9B4FFFFu)] // the dat-id storage form
- public void IsLandblockLoaded_NormalizesLow16_FindsLandblockForAnyContainedId(uint queryId)
- {
- var engine = new PhysicsEngine();
- engine.AddLandblock(0xA9B4FFFFu, MakeFlatTerrain(),
- Array.Empty(), Array.Empty(),
- worldOffsetX: 0f, worldOffsetY: 0f);
-
- Assert.True(engine.IsLandblockLoaded(queryId));
- }
-
- [Fact]
- public void IsLandblockLoaded_DifferentLandblockPrefix_ReturnsFalse()
- {
- var engine = new PhysicsEngine();
- engine.AddLandblock(0xA9B4FFFFu, MakeFlatTerrain(),
- Array.Empty(), Array.Empty(),
- worldOffsetX: 0f, worldOffsetY: 0f);
-
- // A different landblock prefix must NOT match, even in the 0x..0000 query form.
- Assert.False(engine.IsLandblockLoaded(0xA9B50000u));
- }
-}