fix(world): remove non-retail portal exit fade

This commit is contained in:
Erik 2026-07-15 23:20:52 +02:00
parent 842bd89c16
commit dded9e6b17
21 changed files with 919 additions and 347 deletions

View file

@ -222,7 +222,6 @@ public sealed class GameWindow : IDisposable
private AcDream.App.Rendering.RetailPViewRenderer? _retailPViewRenderer;
private AcDream.App.Rendering.PortalDepthMaskRenderer? _portalDepthMask;
private AcDream.App.Rendering.PortalTunnelPresentation? _portalTunnel;
private AcDream.App.Rendering.FadeOverlay? _fadeOverlay;
private AcDream.App.Rendering.InteriorEntityPartition.Result? _interiorPartition;
// Phase U.3: the shared per-frame clip data (binding=2 mesh SSBO + terrain
@ -2588,8 +2587,7 @@ public sealed class GameWindow : IDisposable
// T1: invisible portal depth writes (seal/punch) — retail
// DrawPortalPolyInternal (Ghidra 0x0059bc90).
_portalDepthMask = new AcDream.App.Rendering.PortalDepthMaskRenderer(_gl);
_fadeOverlay = new AcDream.App.Rendering.FadeOverlay(_gl);
_portalTunnel = AcDream.App.Rendering.PortalTunnelPresentation.TryCreate(
_portalTunnel = AcDream.App.Rendering.PortalTunnelPresentation.CreateRequired(
_gl,
_dats!,
_animLoader!,
@ -2783,6 +2781,9 @@ public sealed class GameWindow : IDisposable
private void ClearInboundEntityState()
{
EndMouseLookAndRestoreCursor();
ResetTeleportTransitState(clearSession: true);
if (_playerController is not null)
_playerController.State = AcDream.App.Input.PlayerState.InWorld;
// Session-scoped origin readiness. A reconnect's first logical player
// CreateObject must initialize the render/streaming center anew;
@ -6817,118 +6818,34 @@ public sealed class GameWindow : IDisposable
entity.Rotation = rmState.Body.Orientation;
}
// 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
&& update.Guid == _playerServerGuid)
// F751 is only a notification gate; the accepted Position may arrive
// before or after it. Canonical physics above always consumes the
// packet first. The presentation coordinator exposes exactly one
// sequence-correlated destination without reordering that state.
if (timestampDisposition is AcDream.Core.Physics.PositionTimestampDisposition.Apply
&& update.Guid == _playerServerGuid
&& _teleportTransit.OfferDestination(
update.TeleportSequence,
timestamps.TeleportAdvanced,
update,
out var teleportDestination))
{
var oldPos = _playerController.Position;
uint streamingOriginLandblockId =
AcDream.App.Streaming.StreamingRegion.EncodeLandblockId(
_liveCenterX, _liveCenterY);
// Retail preserves the full Position (objcell_id + Frame) through
// SmartBox::TeleportPlayer @ 0x00453910. Dungeon EnvCells can have
// negative local frame origins, so XYZ cannot identify the source
// landblock (#215). Compare the controller's authoritative current
// cell with the received destination cell instead.
var landblockTransition =
AcDream.App.Streaming.TeleportLandblockTransition.Classify(
_playerController.CellId,
p.LandblockId,
streamingOriginLandblockId);
int oldLbX = (int)((landblockTransition.SourceLandblockId >> 24) & 0xFFu);
int oldLbY = (int)((landblockTransition.SourceLandblockId >> 16) & 0xFFu);
bool differentLandblock = landblockTransition.CrossesLandblock;
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)
{
// #145: drop the stale SOURCE center landblock from the physics engine
// BEFORE recentering. The destination loads at world-offset (0,0) (the new
// center), but the prior center was ALSO loaded at offset (0,0) and its
// offset is never re-based — so the two overlap, and the Z-agnostic outdoor
// cell-snap (AdjustPosition / Resolve, iterating _landblocks) resolves the
// player into the STALE source landblock (a dungeon's frame). That happens
// for the arrival snap AND every per-frame resolve until the source finally
// unloads, so outbound movement is sent in the dungeon's frame and the server
// rejects every move as a failed transition (#145 / #138). Removing the stale
// center here makes both resolves fall through to the server-authoritative
// position (Resolve's NO-LANDBLOCK verbatim branch, :605) until the
// destination streams in. Only the offset-(0,0) center can collide with the
// destination-local position, so removing it alone is sufficient.
_physicsEngine.RemoveLandblock(streamingOriginLandblockId);
// 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);
// #135: pre-collapse on teleport into a sealed dungeon too — same
// race as login. The destination isn't placed until it hydrates, so
// without this NormalTick loads the full neighbor window during the
// arrival hold. The PortalSpace observer branch (OnUpdate) keeps the
// observer pinned to _liveCenterX/Y while held, so the stale frozen
// player position can't drift the observer off the dungeon and re-expand.
if (_streamingController is not null && IsSealedDungeonCell(p.LandblockId))
_streamingController.PreCollapseToDungeon(lbX, lbY);
else
// Outdoor teleport: the render origin (_liveCenter) just moved. Drop ALL
// resident terrain so overlapping blocks on a NEARBY jump re-bake at the
// new origin instead of rendering shifted — the confirmed "terrain in the
// sky" arcs (stale slots offset by exactly deltaLB×192). 2026-06-22.
_streamingController?.ForceReloadWindow();
}
else
{
newWorldPos = worldPos;
}
// Retail "pink-bubble" transit: do NOT snap here. Record the destination and
// PRIORITIZE its landblock in streaming so it applies ahead of the per-frame
// budget (residency in ~hundreds of ms, not 10-14s). The TAS — ticked per frame
// in OnUpdate — holds the player in PortalSpace behind the fade until the
// destination terrain is resident (TeleportWorldReady), then fires Place. While
// held, no movement resolve runs, so the outbound cell frame can't corrupt.
_pendingTeleportRot = rot;
_pendingTeleportPos = newWorldPos;
_pendingTeleportCell = p.LandblockId;
_teleportHoldSeconds = 0f;
_teleportForced = false;
if (_streamingController is not null)
{
_streamingController.PriorityLandblockId =
AcDream.App.Streaming.StreamingRegion.EncodeLandblockId(lbX, lbY);
// Eager-apply the destination's IMMEDIATE SURROUNDINGS (the near ring), not
// just the one landblock the player stands on — so they arrive in a loaded,
// collidable world instead of a single landblock in the void.
_streamingController.PriorityRadius = TeleportNearRingRadius;
}
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
"AIM", p.LandblockId,
$"lb={lbX},{lbY} indoor={((p.LandblockId & 0xFFFFu) >= 0x0100u)} diffLb={differentLandblock}");
AimTeleportDestination(teleportDestination);
}
}
// Retail teleport transit: the dormant 7-state TAS drives the fade cover, holds the
// Retail teleport transit: the 7-state TAS drives portal/view-plane presentation, holds the
// player in PortalSpace until the destination is resident (TeleportWorldReady), then
// fires Place (materialize) and FireLoginComplete (regain control + ack the server).
// Replaces the old TeleportArrivalController hold/place machine.
private readonly AcDream.Core.World.TeleportAnimSequencer _teleportAnim = new();
private readonly TeleportViewPlaneController _teleportViewPlane = new();
private bool _teleportInProgress;
private readonly AcDream.App.Streaming.TeleportTransitCoordinator<
AcDream.Core.Net.WorldSession.EntityPositionUpdate> _teleportTransit = new();
private System.Numerics.Vector3 _pendingTeleportPos;
private uint _pendingTeleportCell;
private float _teleportHoldSeconds; // wall-clock seconds waiting for residency (safety-net timeout)
private bool _teleportForced; // true when the safety-net timeout force-places
private float _teleportFadeAlpha; // 0 = clear, 1 = full black; consumed by the fade overlay
private System.Numerics.Quaternion _pendingTeleportRot = System.Numerics.Quaternion.Identity;
// Loud safety net for a destination that never streams (worker crash / corrupt dat /
@ -6939,13 +6856,133 @@ public sealed class GameWindow : IDisposable
private const float TeleportMaxHoldSeconds = 10f;
// 2026-06-22: how many landblocks of the player's IMMEDIATE SURROUNDINGS to eager-apply
// (and to hold the fade for) on a teleport. radius 1 = the 3×3 around the destination —
// (and to hold portal space for) on a teleport. radius 1 = the 3×3 around the destination —
// the player's own landblock + its 8 neighbours, ~576 m across — enough that they arrive
// standing on loaded ground with wall collision and a non-empty immediate view. The far
// ring (out to the streaming window) still drains at the per-frame budget after the fade
// lifts. Bigger = a more complete arrival but a longer (still hidden) loading fade.
// ring (out to the streaming window) still drains at the per-frame budget after portal
// space exits. Bigger = a more complete arrival but a longer portal-space hold.
private const int TeleportNearRingRadius = 1;
/// <summary>
/// Bind a sequence-correlated teleport Position to presentation and
/// asynchronous streaming. Canonical physics has already accepted this
/// packet; this method only establishes the destination viewport lifetime.
/// </summary>
private void AimTeleportDestination(
AcDream.Core.Net.WorldSession.EntityPositionUpdate update)
{
var playerController = _playerController
?? throw new InvalidOperationException(
"A teleport destination was accepted before the local player controller existed.");
var p = update.Position;
int lbX = (int)((p.LandblockId >> 24) & 0xFFu);
int lbY = (int)((p.LandblockId >> 16) & 0xFFu);
uint streamingOriginLandblockId =
AcDream.App.Streaming.StreamingRegion.EncodeLandblockId(
_liveCenterX, _liveCenterY);
var origin = new System.Numerics.Vector3(
(lbX - _liveCenterX) * 192f,
(lbY - _liveCenterY) * 192f,
0f);
var worldPos = new System.Numerics.Vector3(
p.PositionX, p.PositionY, p.PositionZ) + origin;
// Retail preserves the full Position (objcell_id + Frame) through
// SmartBox::TeleportPlayer @ 0x00453910. Dungeon EnvCells can have
// negative local frame origins, so XYZ cannot identify the source
// landblock (#215). Player crossing and streaming recentering are
// separate while a prior destination is aimed but not yet placed.
var landblockTransition =
AcDream.App.Streaming.TeleportLandblockTransition.Classify(
playerController.CellId,
p.LandblockId,
streamingOriginLandblockId);
int oldLbX = (int)((landblockTransition.SourceLandblockId >> 24) & 0xFFu);
int oldLbY = (int)((landblockTransition.SourceLandblockId >> 16) & 0xFFu);
bool playerCrossesLandblock = landblockTransition.CrossesLandblock;
bool streamingCenterChanges = landblockTransition.ChangesStreamingCenter;
Console.WriteLine(
$"live: teleport arrival — old lb=({oldLbX},{oldLbY}) " +
$"new lb=({lbX},{lbY}) dist={System.Numerics.Vector3.Distance(worldPos, playerController.Position):F1}");
System.Numerics.Vector3 newWorldPos;
if (streamingCenterChanges)
{
// #145: drop the stale STREAMING center landblock from the physics engine
// BEFORE recentering. The destination loads at world-offset (0,0) (the new
// center), but the prior center was ALSO loaded at offset (0,0) and its
// offset is never re-based — so the two overlap, and the Z-agnostic outdoor
// cell-snap resolves the player into the stale center. On a replacement this
// can be the abandoned first destination, not the unplaced player's source.
_physicsEngine.RemoveLandblock(streamingOriginLandblockId);
_liveCenterX = lbX;
_liveCenterY = lbY;
newWorldPos = new System.Numerics.Vector3(
p.PositionX, p.PositionY, p.PositionZ);
if (_streamingController is not null && IsSealedDungeonCell(p.LandblockId))
_streamingController.PreCollapseToDungeon(lbX, lbY);
else
_streamingController?.ForceReloadWindow();
}
else
{
newWorldPos = worldPos;
}
// Do not snap here. The TAS holds portal space until the destination
// near ring is resident, then emits Place exactly once.
_pendingTeleportRot = new System.Numerics.Quaternion(
p.RotationX, p.RotationY, p.RotationZ, p.RotationW);
_pendingTeleportPos = newWorldPos;
_pendingTeleportCell = p.LandblockId;
_teleportHoldSeconds = 0f;
_teleportForced = false;
if (_streamingController is not null)
{
_streamingController.PriorityLandblockId =
AcDream.App.Streaming.StreamingRegion.EncodeLandblockId(lbX, lbY);
_streamingController.PriorityRadius = TeleportNearRingRadius;
}
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
"AIM", p.LandblockId,
$"seq={update.TeleportSequence} lb={lbX},{lbY} " +
$"indoor={((p.LandblockId & 0xFFFFu) >= 0x0100u)} " +
$"playerCross={playerCrossesLandblock} centerChange={streamingCenterChanges}");
}
/// <summary>
/// End one logical teleport lifetime. This is the single symmetry seam
/// used by successful completion, replacement by a newer sequence, and
/// session teardown, so presentation state and destination state cannot
/// leak independently.
/// </summary>
private void ResetTeleportTransitState(bool clearSession = false)
{
if (clearSession)
_teleportTransit.ClearSession();
else
_teleportTransit.EndActive();
_pendingTeleportPos = default;
_pendingTeleportCell = 0u;
_pendingTeleportRot = System.Numerics.Quaternion.Identity;
_teleportHoldSeconds = 0f;
_teleportForced = false;
_teleportAnim.Reset();
_teleportViewPlane.Reset();
_portalTunnel?.Exit();
if (_streamingController is not null)
{
_streamingController.PriorityLandblockId = 0u;
_streamingController.PriorityRadius = 0;
}
}
// #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
// (_liveCenter) is allowed to touch the physics frame — at the placement seam,
@ -6967,7 +7004,7 @@ public sealed class GameWindow : IDisposable
/// can materialize? Indoor (sealed dungeon / building interior) gates on the EnvCell
/// struct hydrating (#135); outdoor gates on the destination's NEAR RING (the 3×3 around
/// the player, <see cref="TeleportNearRingRadius"/>) being registered — not just the
/// single destination landblock — so the fade only lifts onto a loaded, collidable world
/// single destination landblock — so portal space exits onto a loaded, collidable world
/// (the player's cell-walk can root into neighbour cells; no walk-through-walls and no
/// "only one landblock loaded"). The streaming controller priority-applies that same ring,
/// so it flips fast. An impossible claim (indoor cell id outside the dat's NumCells)
@ -7032,35 +7069,79 @@ public sealed class GameWindow : IDisposable
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
"PLACED", resolved.CellId, $"forced={forced}");
// Do NOT flip to InWorld or send LoginComplete here — the player materializes BEHIND
// the fade and stays input-frozen (PortalSpace) until the TAS fades the world back in
// and fires FireLoginComplete (which regains control + acks the server). This is the
// Do NOT flip to InWorld or send LoginComplete here — the player materializes while
// the portal viewport is active and stays input-frozen (PortalSpace) until the TAS
// restores the destination projection and fires FireLoginComplete. This is the
// retail "pop out the other side" sequence.
Console.WriteLine($"live: teleport materialized — snapped to {snappedPos} cell=0x{resolved.CellId:X8}");
}
/// <summary>
/// Phase B.3: fires when the server sends a PlayerTeleport (0xF751). Freeze movement
/// input (PortalSpace) and begin the retail fade transit. The per-frame TAS tick holds
/// the player behind the fade until the destination is resident, then materializes them
/// (Place) and, after the world fades back in, regains control + acks the server
/// input (PortalSpace) and begin retail portal transit. The per-frame TAS tick holds
/// the player behind the portal viewport until the destination is resident, then materializes
/// them (Place) and, after the destination view-plane restores, regains control + acks the server
/// (FireLoginComplete).
/// </summary>
private void OnTeleportStarted(uint sequence)
{
if (!_liveEntities!.IsFreshTeleportStart(_playerServerGuid, (ushort)sequence))
ushort teleportSequence = (ushort)sequence;
if (!_liveEntities!.IsFreshTeleportStart(_playerServerGuid, teleportSequence)
|| !_teleportTransit.CanBegin(teleportSequence))
return;
EndMouseLookAndRestoreCursor();
if (_playerController is not null)
_playerController.State = AcDream.App.Input.PlayerState.PortalSpace;
_teleportInProgress = true;
// A fresh sequence is a new logical transit. Discard the prior
// destination and presentation before this sequence can observe them;
// its destination PositionUpdate may arrive on a later network tick.
ResetTeleportTransitState();
_teleportTransit.QueueStart(teleportSequence);
TryActivatePendingTeleportPresentation();
Console.WriteLine($"live: teleport queued (seq={sequence})");
}
/// <summary>
/// Converge a queued F751 notification with the ordinary live-player
/// projection lifecycle. Usually this activates synchronously. If a
/// session transition has not materialized the player projection yet, the
/// notification and any correlated Position remain queued until it does.
/// </summary>
private void TryActivatePendingTeleportPresentation()
{
if (!_teleportTransit.HasPendingStart)
return;
// Retail has no detached fly/orbit gameplay mode. If acdream's live
// developer camera currently owns the view, restore the existing
// player-mode lifecycle before transit so placement still has the
// canonical local physics controller and chase-camera handoff.
var playerController = _playerController;
if (playerController is null)
{
if (!_entitiesByServerGuid.ContainsKey(_playerServerGuid))
return;
_playerMode = true;
if (!EnterPlayerModeNow(loggingTag: "teleport"))
{
_playerMode = false;
return;
}
playerController = _playerController;
}
if (playerController is null)
return;
playerController.State = AcDream.App.Input.PlayerState.PortalSpace;
_teleportHoldSeconds = 0f;
_teleportForced = false;
_teleportViewPlane.Begin(
_cameraController?.Active.Projection ?? System.Numerics.Matrix4x4.Identity);
_teleportAnim.Begin(AcDream.Core.World.TeleportEntryKind.Portal);
Console.WriteLine($"live: teleport started (seq={sequence})");
bool hasBufferedDestination = _teleportTransit.Activate(
out var bufferedDestination);
if (hasBufferedDestination)
AimTeleportDestination(bufferedDestination);
Console.WriteLine($"live: teleport presentation started (seq={_teleportTransit.Sequence})");
}
/// <summary>
@ -8881,14 +8962,19 @@ public sealed class GameWindow : IDisposable
// Step 2: routed through the controller; functionally identical.
_liveSessionController?.Tick();
// Usually F751 activates immediately. This no-op convergence check
// covers the session edge where the canonical player exists before
// its normal live projection/controller has materialized.
TryActivatePendingTeleportPresentation();
// Retail teleport transit. Runs AFTER streaming (which priority-applies the
// destination landblock this frame) and the live-session drain, so a destination
// that became resident this frame materializes the same frame. The TAS holds at
// Tunnel until worldReady, fires Place (materialize, hidden), then after the min-
// continue + fades fires FireLoginComplete (regain control + ack). PortalTunnelPresentation
// replaces the world viewport through the tunnel-family states; FadeOverlay transitions
// between the two viewports below the retained UI.
if (_teleportInProgress)
// continue + view-plane transitions fires FireLoginComplete (regain control + ack).
// PortalTunnelPresentation replaces the world viewport through the tunnel-family states;
// retail switches the two 3-D viewports directly, below the retained UI.
if (_teleportTransit.IsActive)
{
bool haveDest = _pendingTeleportCell != 0u;
bool ready = haveDest && TeleportWorldReady(_pendingTeleportCell);
@ -8905,12 +8991,6 @@ public sealed class GameWindow : IDisposable
int tunnelFrame = _portalTunnel?.CurrentAnimationFrame ?? 0;
var (snap, evts) = _teleportAnim.Tick((float)dt, ready, tunnelFrame);
_teleportViewPlane.Update(snap);
// The fade is a viewport transition below retained UI. If the
// installed DATs are corrupt and the retail portal scene could
// not be built, retain an opaque world cover during transit.
_teleportFadeAlpha = _portalTunnel is null && snap.ShowTunnel
? 1f
: snap.FadeAlpha;
foreach (var e in evts)
{
@ -8937,10 +9017,7 @@ public sealed class GameWindow : IDisposable
// each portal transition.
_liveSession?.SendGameAction(
AcDream.Core.Net.Messages.GameActionLoginComplete.Build());
_teleportInProgress = false;
_pendingTeleportCell = 0u;
_teleportViewPlane.Reset();
_teleportFadeAlpha = 0f;
ResetTeleportTransitState();
break;
default:
// The retained audio engine does not yet own the
@ -9575,8 +9652,9 @@ public sealed class GameWindow : IDisposable
if (_cameraController is not null)
{
var camera = _cameraController.Active;
var worldProjection = _teleportViewPlane.Apply(camera.Projection);
var activeCamera = _cameraController.Active;
var camera = _teleportViewPlane.ApplyTo(activeCamera);
var worldProjection = camera.Projection;
var frustum = AcDream.App.Rendering.FrustumPlanes.FromViewProjection(camera.View * worldProjection);
// Extract camera world position from the inverse of the view
@ -10489,7 +10567,7 @@ public sealed class GameWindow : IDisposable
// Retail gmSmartBoxUI swaps the world SmartBox viewport for a
// CreatureMode portal-space viewport. This replacement scene and
// its black transition draw BEFORE retained UI, so chat, windows,
// its projection transition draws BEFORE retained UI, so chat, windows,
// cursor, and toolbar remain visible and interactive in transit.
var portalProjection = _teleportViewPlane.Apply(
_cameraController!.Active.Projection);
@ -10497,7 +10575,6 @@ public sealed class GameWindow : IDisposable
_window!.Size.X,
_window.Size.Y,
portalProjection);
_fadeOverlay?.Draw(_teleportFadeAlpha);
// Phase D.2b Sub-phase C Slice 2 — paperdoll 3-D doll: render the re-dressed player clone into the
// viewport's off-screen texture BEFORE the 2-D UI pass blits it, but only when the inventory window
@ -14693,7 +14770,6 @@ public sealed class GameWindow : IDisposable
_wbDrawDispatcher?.Dispose();
_envCellRenderer?.Dispose(); // Phase A8
_portalDepthMask?.Dispose(); // T1
_fadeOverlay?.Dispose();
_clipFrame?.Dispose(); // Phase U.3
_skyRenderer?.Dispose(); // depends on sampler cache; dispose first
_particleRenderer?.Dispose(); // releases particle mesh refs before WB