diff --git a/src/AcDream.App/World/TeleportArrivalController.cs b/src/AcDream.App/World/TeleportArrivalController.cs
new file mode 100644
index 00000000..d6538533
--- /dev/null
+++ b/src/AcDream.App/World/TeleportArrivalController.cs
@@ -0,0 +1,103 @@
+using System;
+using System.Numerics;
+
+namespace AcDream.App.World;
+
+/// Verdict from the per-frame readiness probe for a held teleport arrival.
+public enum ArrivalReadiness
+{
+ /// Destination not yet hydrated; keep holding.
+ NotReady,
+
+ /// Destination terrain + cell are ready; place now.
+ Ready,
+
+ /// The claim can never hydrate (e.g. an indoor cell id outside the dat's
+ /// LandBlockInfo.NumCells range). Place immediately via the caller's safety-net
+ /// demote rather than hold forever.
+ Impossible,
+}
+
+/// Lifecycle of a single teleport arrival.
+public enum TeleportArrivalPhase { Idle, Holding }
+
+///
+/// G.3a (#133) — holds a teleport arrival in portal space until the destination
+/// dungeon landblock/cell has streamed in, THEN places the player. Replaces the
+/// unconditional snap in GameWindow.OnLivePositionUpdated that resolved the
+/// arrival against the resident (old) landblocks before the destination hydrated
+/// and landed the player in ocean.
+///
+/// The controller is pure: readiness and placement are injected delegates,
+/// so it carries no GL / dat / network dependency and is fully unit-testable. The
+/// player stays input-frozen while this is Holding because the GameWindow keeps
+/// PlayerState.PortalSpace until the placement delegate flips it back to
+/// InWorld.
+///
+/// The timeout is a coarse frame count (not wall-clock) so the controller
+/// needs no external clock; it is a loud safety net for a never-hydrating
+/// destination, not a precise deadline.
+///
+public sealed class TeleportArrivalController
+{
+ /// ~10 s at 60 fps. Coarse safety net for a destination that never streams.
+ public const int DefaultMaxHoldFrames = 600;
+
+ private readonly Func _readiness;
+ private readonly Action _place; // (destPos, destCell, forced)
+ private readonly int _maxHoldFrames;
+
+ private Vector3 _destPos;
+ private uint _destCell;
+ private int _heldFrames;
+
+ public TeleportArrivalPhase Phase { get; private set; } = TeleportArrivalPhase.Idle;
+
+ public TeleportArrivalController(
+ Func readiness,
+ Action place,
+ int maxHoldFrames = DefaultMaxHoldFrames)
+ {
+ _readiness = readiness ?? throw new ArgumentNullException(nameof(readiness));
+ _place = place ?? throw new ArgumentNullException(nameof(place));
+ _maxHoldFrames = maxHoldFrames;
+ }
+
+ /// Begin holding for a teleport arrival. Called from OnLivePositionUpdated
+ /// AFTER the streaming origin has been recentered on the destination landblock.
+ /// Re-calling with a fresh server position resets the hold (server-authoritative).
+ public void BeginArrival(Vector3 destPos, uint destCell)
+ {
+ _destPos = destPos;
+ _destCell = destCell;
+ _heldFrames = 0;
+ Phase = TeleportArrivalPhase.Holding;
+ }
+
+ /// Per-frame: evaluate readiness and place when ready / impossible / timed out.
+ /// No-op when Idle.
+ public void Tick()
+ {
+ if (Phase != TeleportArrivalPhase.Holding) return;
+ _heldFrames++;
+
+ ArrivalReadiness verdict = _readiness(_destPos, _destCell);
+ if (verdict == ArrivalReadiness.Ready)
+ {
+ Place(forced: false);
+ return;
+ }
+
+ if (verdict == ArrivalReadiness.Impossible || _heldFrames >= _maxHoldFrames)
+ {
+ Place(forced: true);
+ }
+ // else NotReady -> keep holding
+ }
+
+ private void Place(bool forced)
+ {
+ _place(_destPos, _destCell, forced);
+ Phase = TeleportArrivalPhase.Idle;
+ }
+}
diff --git a/tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs b/tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs
new file mode 100644
index 00000000..fbf8727f
--- /dev/null
+++ b/tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs
@@ -0,0 +1,123 @@
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.App.World;
+using Xunit;
+
+namespace AcDream.App.Tests.World;
+
+public class TeleportArrivalControllerTests
+{
+ // Records each Place(destPos, destCell, forced) call.
+ private sealed record PlaceCall(Vector3 Pos, uint Cell, bool Forced);
+
+ private static TeleportArrivalController Make(
+ ArrivalReadiness verdict,
+ List placed,
+ int maxHoldFrames = TeleportArrivalController.DefaultMaxHoldFrames)
+ => new(
+ readiness: (_, _) => verdict,
+ place: (pos, cell, forced) => placed.Add(new PlaceCall(pos, cell, forced)),
+ maxHoldFrames: maxHoldFrames);
+
+ [Fact]
+ public void BeginArrival_EntersHolding()
+ {
+ var placed = new List();
+ var c = Make(ArrivalReadiness.NotReady, placed);
+
+ c.BeginArrival(new Vector3(1, 2, 3), 0x01250126u);
+
+ Assert.Equal(TeleportArrivalPhase.Holding, c.Phase);
+ Assert.Empty(placed);
+ }
+
+ [Fact]
+ public void Tick_WhenIdle_IsNoOp()
+ {
+ var placed = new List();
+ var c = Make(ArrivalReadiness.Ready, placed);
+
+ c.Tick(); // never began
+
+ Assert.Equal(TeleportArrivalPhase.Idle, c.Phase);
+ Assert.Empty(placed);
+ }
+
+ [Fact]
+ public void Tick_NotReady_KeepsHolding_DoesNotPlace()
+ {
+ var placed = new List();
+ var c = Make(ArrivalReadiness.NotReady, placed);
+ c.BeginArrival(new Vector3(1, 2, 3), 0x01250126u);
+
+ c.Tick();
+ c.Tick();
+
+ Assert.Equal(TeleportArrivalPhase.Holding, c.Phase);
+ Assert.Empty(placed);
+ }
+
+ [Fact]
+ public void Tick_Ready_PlacesUnforced_AndIdles()
+ {
+ var placed = new List();
+ var c = Make(ArrivalReadiness.Ready, placed);
+ c.BeginArrival(new Vector3(30, -60, 6.005f), 0x01250126u);
+
+ c.Tick();
+
+ Assert.Equal(TeleportArrivalPhase.Idle, c.Phase);
+ var call = Assert.Single(placed);
+ Assert.False(call.Forced);
+ Assert.Equal(0x01250126u, call.Cell);
+ Assert.Equal(new Vector3(30, -60, 6.005f), call.Pos);
+ }
+
+ [Fact]
+ public void Tick_Impossible_PlacesForced_AndIdles()
+ {
+ var placed = new List();
+ var c = Make(ArrivalReadiness.Impossible, placed);
+ c.BeginArrival(new Vector3(1, 2, 3), 0x0125FF00u);
+
+ c.Tick();
+
+ Assert.Equal(TeleportArrivalPhase.Idle, c.Phase);
+ var call = Assert.Single(placed);
+ Assert.True(call.Forced);
+ }
+
+ [Fact]
+ public void Tick_Timeout_PlacesForced_AfterMaxHoldFrames()
+ {
+ var placed = new List();
+ var c = Make(ArrivalReadiness.NotReady, placed, maxHoldFrames: 3);
+ c.BeginArrival(new Vector3(1, 2, 3), 0x01250126u);
+
+ c.Tick(); // 1
+ c.Tick(); // 2
+ Assert.Empty(placed);
+ Assert.Equal(TeleportArrivalPhase.Holding, c.Phase);
+
+ c.Tick(); // 3 -> timeout
+
+ var call = Assert.Single(placed);
+ Assert.True(call.Forced);
+ Assert.Equal(TeleportArrivalPhase.Idle, c.Phase);
+ }
+
+ [Fact]
+ public void BeginArrival_AfterPlace_ReArms()
+ {
+ var placed = new List();
+ var c = Make(ArrivalReadiness.Ready, placed);
+
+ c.BeginArrival(new Vector3(1, 0, 0), 0x01250126u);
+ c.Tick(); // places #1, idle
+ c.BeginArrival(new Vector3(2, 0, 0), 0x01250127u);
+ c.Tick(); // places #2, idle
+
+ Assert.Equal(2, placed.Count);
+ Assert.Equal(0x01250127u, placed[1].Cell);
+ }
+}