diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 2365ca14..1c1db412 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -4874,7 +4874,7 @@ public sealed class GameWindow : IDisposable entity.Rotation = rmState.Body.Orientation; } - // Phase B.3: portal-space arrival detection. + // Phase B.3 / G.3a (#133): 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 @@ -4888,79 +4888,109 @@ public sealed class GameWindow : IDisposable bool differentLandblock = (lbX != oldLbX || lbY != oldLbY); - // #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}"); + + System.Numerics.Vector3 newWorldPos; + if (differentLandblock) { - Console.WriteLine( - $"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " + - $"new lb=({lbX},{lbY}) dist={System.Numerics.Vector3.Distance(worldPos, oldPos):F1}"); - - 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. - 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; - 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.SetPosition(snappedPos); - entity.ParentCellId = resolved.CellId; - entity.Rotation = rot; - _playerController.SetPosition(snappedPos, resolved.CellId); - - // 4. Recenter chase camera on the new position. - _chaseCamera?.Update(snappedPos, _playerController.Yaw); - _retailChaseCamera?.Update(snappedPos, _playerController.Yaw, - playerVelocity: System.Numerics.Vector3.Zero, - isOnGround: true, - contactPlaneNormal: System.Numerics.Vector3.UnitZ, - dt: 1f / 60f); - - // 5. 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()); + // Recenter the streaming controller on the new landblock NOW (kick + // off the dungeon load). After recentering, the destination is + // (p.PositionX, p.PositionY, p.PositionZ) relative to the new origin. + _liveCenterX = lbX; + _liveCenterY = lbY; + newWorldPos = new System.Numerics.Vector3(p.PositionX, p.PositionY, p.PositionZ); } + else + { + newWorldPos = worldPos; + } + + // G.3a: do NOT snap here. The destination dungeon landblock has not + // streamed in yet; an immediate Resolve falls back to the resident + // (old) landblocks and lands the player in ocean (#133). HOLD the snap + // in portal space — TeleportArrivalController.Tick (per frame) places + // the player via PlaceTeleportArrival once the destination cell + // hydrates (TeleportArrivalReadiness == Ready), or force-places on an + // impossible claim / timeout. PortalSpace keeps input frozen meanwhile. + EnsureTeleportArrivalController(); + _pendingTeleportRot = rot; + _teleportArrival!.BeginArrival(newWorldPos, p.LandblockId); } } + // G.3a (#133): holds a teleport arrival in portal space until the destination + // dungeon landblock/cell has hydrated, then places the player via the unchanged + // validated-claim Resolve path. Lazily constructed on the first teleport (all + // runtime deps are wired by then). + private AcDream.App.World.TeleportArrivalController? _teleportArrival; + private System.Numerics.Quaternion _pendingTeleportRot = System.Numerics.Quaternion.Identity; + + private void EnsureTeleportArrivalController() + { + if (_teleportArrival is not null) return; + _teleportArrival = new AcDream.App.World.TeleportArrivalController( + readiness: TeleportArrivalReadiness, + place: PlaceTeleportArrival); + } + + // 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. + private AcDream.App.World.ArrivalReadiness TeleportArrivalReadiness( + System.Numerics.Vector3 destPos, uint destCell) + { + if (IsSpawnClaimUnhydratable(destCell)) + return AcDream.App.World.ArrivalReadiness.Impossible; + if (_physicsEngine.SampleTerrainZ(destPos.X, destPos.Y) is null) + return AcDream.App.World.ArrivalReadiness.NotReady; + bool indoor = (destCell & 0xFFFFu) >= 0x0100u; + if (indoor && !_physicsEngine.IsSpawnCellReady(destCell)) + return AcDream.App.World.ArrivalReadiness.NotReady; + return AcDream.App.World.ArrivalReadiness.Ready; + } + + // The deferred snap (the original OnLivePositionUpdated steps 2-5), now run only + // once the destination is ready (or force-run on impossible/timeout, logged loud). + private void PlaceTeleportArrival( + System.Numerics.Vector3 destPos, uint destCell, bool forced) + { + var resolved = _physicsEngine.Resolve( + destPos, destCell, System.Numerics.Vector3.Zero, _playerController!.StepUpHeight); + var snappedPos = new System.Numerics.Vector3( + resolved.Position.X, resolved.Position.Y, resolved.Position.Z); + + if (forced) + Console.WriteLine( + $"live: teleport HOLD gave up (impossible/timeout) — force-snapping " + + $"cell=0x{destCell:X8} pos={destPos} -> 0x{resolved.CellId:X8} {snappedPos}"); + + if (_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) + { + pe.SetPosition(snappedPos); + pe.ParentCellId = resolved.CellId; + pe.Rotation = _pendingTeleportRot; + } + _playerController.SetPosition(snappedPos, resolved.CellId); + + _chaseCamera?.Update(snappedPos, _playerController.Yaw); + _retailChaseCamera?.Update(snappedPos, _playerController.Yaw, + playerVelocity: System.Numerics.Vector3.Zero, + isOnGround: true, + contactPlaneNormal: System.Numerics.Vector3.UnitZ, + dt: 1f / 60f); + + _playerController.State = AcDream.App.Input.PlayerState.InWorld; + Console.WriteLine($"live: teleport complete — snapped to {snappedPos} cell=0x{resolved.CellId:X8}"); + + // Tell the server the client finished loading the new landblock (holtburger + // client/messages.rs:434 — re-send LoginComplete after each portal transition). + _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. @@ -4972,6 +5002,7 @@ public sealed class GameWindow : IDisposable { if (_playerController is not null) _playerController.State = AcDream.App.Input.PlayerState.PortalSpace; + EnsureTeleportArrivalController(); Console.WriteLine($"live: teleport started (seq={sequence})"); } @@ -6837,6 +6868,12 @@ public sealed class GameWindow : IDisposable // Step 2: routed through the controller; functionally identical. _liveSessionController?.Tick(); + // G.3a (#133): advance any held teleport arrival. Runs AFTER streaming + // (which applies the destination landblock) and the live-session drain + // (which may have just called BeginArrival), so a destination that + // hydrated this frame is placed the same frame. + _teleportArrival?.Tick(); + // Phase K.1a — tick the input dispatcher so Hold-type bindings // re-fire while their chord is held. K.1b adds the subscribers // that actually consume the events.