diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs
index 01feaf4..db3e938 100644
--- a/src/AcDream.App/Input/PlayerMovementController.cs
+++ b/src/AcDream.App/Input/PlayerMovementController.cs
@@ -33,6 +33,14 @@ public readonly record struct MovementResult(
float? SidestepSpeed,
float? TurnSpeed);
+///
+/// Portal-space state for the player movement controller.
+/// PortalSpace freezes all movement input while the server is moving the
+/// player through a portal — resumed once the destination UpdatePosition
+/// arrives and the player is snapped to the new location.
+///
+public enum PlayerState { InWorld, PortalSpace }
+
///
/// Per-frame player movement controller. Reads input, drives the
/// physics engine, tracks motion state for animation + server messages.
@@ -60,6 +68,15 @@ public sealed class PlayerMovementController
public float GravityAccel { get; set; } = 20f;
public float AirControlFactor { get; set; } = 0.2f;
+ ///
+ /// Current portal-space state. Set to PortalSpace when the server sends
+ /// PlayerTeleport (0xF751); set back to InWorld once the destination
+ /// UpdatePosition arrives and the player is snapped to the new cell.
+ /// While in PortalSpace, Update returns immediately with a zero-movement
+ /// result so no WASD input or physics is processed.
+ ///
+ public PlayerState State { get; set; } = PlayerState.InWorld;
+
public float Yaw { get; set; }
public Vector3 Position { get; private set; }
public uint CellId { get; private set; }
@@ -87,6 +104,24 @@ public sealed class PlayerMovementController
public MovementResult Update(float dt, MovementInput input)
{
+ // Portal-space guard: while teleporting, no input is processed and
+ // no physics is resolved. Return a zero-movement result so the caller
+ // can detect the frozen state (MotionStateChanged = false, no commands).
+ if (State == PlayerState.PortalSpace)
+ {
+ return new MovementResult(
+ Position: Position,
+ CellId: CellId,
+ IsOnGround: !IsAirborne,
+ MotionStateChanged: false,
+ ForwardCommand: null,
+ SidestepCommand: null,
+ TurnCommand: null,
+ ForwardSpeed: null,
+ SidestepSpeed: null,
+ TurnSpeed: null);
+ }
+
// 1. Apply turning from keyboard + mouse.
if (input.TurnRight)
Yaw -= TurnSpeed * dt;
diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 59d7dd1..5073673 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -424,6 +424,7 @@ public sealed class GameWindow : IDisposable
_liveSession.EntitySpawned += OnLiveEntitySpawned;
_liveSession.MotionUpdated += OnLiveMotionUpdated;
_liveSession.PositionUpdated += OnLivePositionUpdated;
+ _liveSession.TeleportStarted += OnTeleportStarted;
_liveSession.Connect(user, pass);
if (_liveSession.Characters is null || _liveSession.Characters.Characters.Count == 0)
@@ -928,6 +929,13 @@ public sealed class GameWindow : IDisposable
/// landblock-local position into acdream world space (same math as
/// CreateObject hydration) and update the entity's Position/Rotation
/// in place so the next Draw picks up the new transform.
+ ///
+ /// Phase B.3 extension: if the player controller is in PortalSpace and
+ /// this update is for our own character, detect a large position change
+ /// (different landblock or > 100 units distance). If detected, recenter
+ /// the streaming controller, resolve the new position through physics,
+ /// snap the player entity + controller, and return to InWorld. Also sends
+ /// LoginComplete so the server knows the client has loaded the destination.
///
private void OnLivePositionUpdated(AcDream.Core.Net.WorldSession.EntityPositionUpdate update)
{
@@ -950,6 +958,79 @@ public sealed class GameWindow : IDisposable
entity.Position = worldPos;
entity.Rotation = rot;
+
+ // Phase B.3: portal-space arrival detection.
+ // Only runs for our own player character while in PortalSpace.
+ if (_playerController is not null
+ && _playerController.State == AcDream.App.Input.PlayerState.PortalSpace
+ && update.Guid == _playerServerGuid)
+ {
+ // Compute old landblock coords from controller position (using the
+ // current streaming origin as the reference center).
+ var oldPos = _playerController.Position;
+ int oldLbX = _liveCenterX + (int)System.Math.Floor(oldPos.X / 192f);
+ 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)
+ {
+ 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;
+
+ // 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)
+
+ // 2. Resolve through physics for the correct ground Z.
+ uint newCellId = p.LandblockId;
+ var resolved = _physicsEngine.Resolve(
+ newWorldPos, newCellId,
+ System.Numerics.Vector3.Zero, _playerController.StepUpHeight);
+ var snappedPos = new System.Numerics.Vector3(
+ resolved.Position.X, resolved.Position.Y, resolved.Position.Z);
+
+ // 3. Snap player entity + controller.
+ entity.Position = snappedPos;
+ entity.Rotation = rot;
+ _playerController.SetPosition(snappedPos, resolved.CellId);
+
+ // 4. Return to InWorld.
+ _playerController.State = AcDream.App.Input.PlayerState.InWorld;
+ Console.WriteLine($"live: teleport complete — snapped to {snappedPos} cell=0x{resolved.CellId:X8}");
+
+ // 5. Send LoginComplete to tell the server the client finished loading.
+ // Per holtburger's PlayerTeleport handler (client/messages.rs:434-440),
+ // retail clients call send_login_complete() after each portal transition.
+ // ResetLoginComplete() clears the latch so the 0xF746 PlayerCreate path
+ // doesn't also send one. We send directly here instead.
+ _liveSession?.SendGameAction(
+ AcDream.Core.Net.Messages.GameActionLoginComplete.Build());
+ }
+ }
+ }
+
+ ///
+ /// Phase B.3: fires when the server sends a PlayerTeleport (0xF751).
+ /// Freeze movement input by setting the player controller to PortalSpace.
+ /// The controller's Update() will return a zero-movement result until the
+ /// destination UpdatePosition arrives and OnLivePositionUpdated resets the
+ /// state to InWorld.
+ ///
+ private void OnTeleportStarted(uint sequence)
+ {
+ if (_playerController is not null)
+ _playerController.State = AcDream.App.Input.PlayerState.PortalSpace;
+ Console.WriteLine($"live: teleport started (seq={sequence})");
}
///
diff --git a/src/AcDream.Core.Net/WorldSession.cs b/src/AcDream.Core.Net/WorldSession.cs
index 9172477..3c34631 100644
--- a/src/AcDream.Core.Net/WorldSession.cs
+++ b/src/AcDream.Core.Net/WorldSession.cs
@@ -92,6 +92,26 @@ public sealed class WorldSession : IDisposable
///
public event Action? PositionUpdated;
+ ///
+ /// Fires when the server sends a PlayerTeleport (0xF751) game message,
+ /// signalling that the player is entering portal space. The uint payload
+ /// is the teleport sequence number parsed from the message body (u16,
+ /// aligned to 4 bytes — per holtburger's teleport.rs wire layout).
+ /// Subscribers should freeze movement input until the destination
+ /// UpdatePosition arrives.
+ ///
+ public event Action? TeleportStarted;
+
+ ///
+ /// Allow re-sending LoginComplete after a portal teleport. The normal
+ /// _loginCompleteSent latch prevents duplicate sends on the initial spawn
+ /// path; this method resets it so the teleport completion path can send
+ /// another LoginComplete to tell the server the client has finished loading
+ /// the destination cell. Pattern from holtburger's PlayerTeleport handler
+ /// (client/messages.rs line 434-440: call send_login_complete on teleport).
+ ///
+ public void ResetLoginComplete() => _loginCompleteSent = false;
+
/// Raised every time the state machine transitions.
public event Action? StateChanged;
@@ -426,6 +446,23 @@ public sealed class WorldSession : IDisposable
posUpdate.Value.Velocity));
}
}
+ else if (op == 0xF751u) // PlayerTeleport — server is moving us through a portal
+ {
+ // Phase B.3: holtburger opcodes.rs confirms 0xF751 is the
+ // PlayerTeleport standalone GameMessage (NOT wrapped in 0xF7B0).
+ // Wire layout (teleport.rs): u16 teleport_sequence, then
+ // aligned to 4 bytes. Per holtburger's client handler, the
+ // correct response is send_login_complete() at the destination.
+ // Here we fire TeleportStarted so GameWindow can freeze
+ // movement; the LoginComplete is sent from GameWindow once
+ // the destination UpdatePosition is received and the player
+ // has been snapped to the new cell.
+ uint sequence = 0;
+ if (body.Length >= 6)
+ sequence = System.Buffers.Binary.BinaryPrimitives.ReadUInt16LittleEndian(
+ body.AsSpan(4, 2));
+ TeleportStarted?.Invoke(sequence);
+ }
}
}