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

@ -1,119 +0,0 @@
using System;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// Fullscreen black quad at a given alpha for retail's world/portal viewport
/// transitions. It is drawn after the active 3-D viewport but before retained
/// UI, matching <c>gmSmartBoxUI::UseTime</c>: the world or portal scene fades
/// while chat, toolbar, windows, and cursor remain fully visible.
///
/// <para>Draws in NDC, so it needs no view/projection. Self-contained GL state
/// (feedback_render_self_contained_gl_state): sets blend + disables depth, restores the
/// frame-global convention (depth test/write on) on exit.</para>
/// </summary>
public sealed class FadeOverlay : IDisposable
{
private const string VertSrc = @"#version 430 core
layout(location = 0) in vec2 aPos;
void main() { gl_Position = vec4(aPos, 0.0, 1.0); }";
private const string FragSrc = @"#version 430 core
uniform float uAlpha;
out vec4 FragColor;
void main() { FragColor = vec4(0.0, 0.0, 0.0, uAlpha); }";
private readonly GL _gl;
private readonly uint _program;
private readonly uint _vao;
private readonly uint _vbo;
private readonly int _locAlpha;
// Two triangles covering the whole NDC viewport.
private static readonly float[] QuadVerts =
{
-1f, -1f, 1f, -1f, 1f, 1f,
-1f, -1f, 1f, 1f, -1f, 1f,
};
public FadeOverlay(GL gl)
{
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
uint vs = Compile(ShaderType.VertexShader, VertSrc);
uint fs = Compile(ShaderType.FragmentShader, FragSrc);
_program = _gl.CreateProgram();
_gl.AttachShader(_program, vs);
_gl.AttachShader(_program, fs);
_gl.LinkProgram(_program);
_gl.GetProgram(_program, ProgramPropertyARB.LinkStatus, out int linked);
if (linked == 0)
throw new InvalidOperationException($"FadeOverlay link failed: {_gl.GetProgramInfoLog(_program)}");
_gl.DeleteShader(vs);
_gl.DeleteShader(fs);
_locAlpha = _gl.GetUniformLocation(_program, "uAlpha");
_vao = _gl.GenVertexArray();
_vbo = _gl.GenBuffer();
_gl.BindVertexArray(_vao);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, _vbo);
unsafe
{
fixed (float* v = QuadVerts)
_gl.BufferData(BufferTargetARB.ArrayBuffer,
(nuint)(QuadVerts.Length * sizeof(float)), v, BufferUsageARB.StaticDraw);
_gl.VertexAttribPointer(0, 2, VertexAttribPointerType.Float, false, 2 * sizeof(float), (void*)0);
}
_gl.EnableVertexAttribArray(0);
_gl.BindVertexArray(0);
_gl.BindBuffer(BufferTargetARB.ArrayBuffer, 0);
}
private uint Compile(ShaderType type, string src)
{
uint s = _gl.CreateShader(type);
_gl.ShaderSource(s, src);
_gl.CompileShader(s);
_gl.GetShader(s, ShaderParameterName.CompileStatus, out int ok);
if (ok == 0)
throw new InvalidOperationException($"FadeOverlay {type} compile failed: {_gl.GetShaderInfoLog(s)}");
return s;
}
/// <summary>
/// Draw the fullscreen black cover at <paramref name="alpha"/> (0 = clear → no-op,
/// 1 = opaque). Call after the active 3-D viewport and before retained UI.
/// </summary>
public void Draw(float alpha)
{
if (alpha <= 0f) return;
alpha = Math.Clamp(alpha, 0f, 1f);
// ---- set state (everything this draw depends on) ----
_gl.Disable(EnableCap.DepthTest);
_gl.DepthMask(false);
_gl.Disable(EnableCap.CullFace);
_gl.Enable(EnableCap.Blend);
_gl.BlendFunc(BlendingFactor.SrcAlpha, BlendingFactor.OneMinusSrcAlpha);
_gl.UseProgram(_program);
_gl.Uniform1(_locAlpha, alpha);
_gl.BindVertexArray(_vao);
_gl.DrawArrays(PrimitiveType.Triangles, 0, 6);
_gl.BindVertexArray(0);
_gl.UseProgram(0);
// ---- restore the frame-global convention ----
_gl.DepthMask(true);
_gl.Enable(EnableCap.DepthTest);
}
public void Dispose()
{
_gl.DeleteProgram(_program);
_gl.DeleteVertexArray(_vao);
_gl.DeleteBuffer(_vbo);
}
}

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

View file

@ -103,10 +103,11 @@ public sealed class PortalTunnelPresentation : IDisposable
/// <summary>
/// Resolve retail's enum-mapped portal Setup and animation through the
/// installed DATs. Missing assets fail closed with an actionable message;
/// no substitute tunnel is fabricated.
/// installed DATs. Retail dereferences this scene unconditionally; missing
/// required assets therefore fail startup with an actionable diagnostic.
/// No substitute tunnel is fabricated.
/// </summary>
public static PortalTunnelPresentation? TryCreate(
public static PortalTunnelPresentation CreateRequired(
GL gl,
DatCollection dats,
IAnimationLoader animationLoader,
@ -132,21 +133,18 @@ public sealed class PortalTunnelPresentation : IDisposable
? null
: animationLoader.LoadAnimation(animationDid);
if (setup is null || animation is null)
{
Console.Error.WriteLine(
"[portal-space] retail DAT assets unavailable: "
+ $"setup=0x{setupDid:X8} ({(setup is null ? "missing" : "ok")}), "
+ $"animation=0x{animationDid:X8} ({(animation is null ? "missing" : "ok")})");
return null;
}
EnsureRequiredAssets(
setupDid,
setup is not null,
animationDid,
animation is not null);
return new PortalTunnelPresentation(
gl,
dispatcher,
lightUbo,
meshAdapter,
setup,
setup!,
setupDid,
animationDid,
animationLoader,
@ -155,6 +153,21 @@ public sealed class PortalTunnelPresentation : IDisposable
displayNotice);
}
internal static void EnsureRequiredAssets(
uint setupDid,
bool setupLoaded,
uint animationDid,
bool animationLoaded)
{
if (setupLoaded && animationLoaded)
return;
throw new InvalidOperationException(
"[portal-space] required retail DAT assets unavailable: "
+ $"setup=0x{setupDid:X8} ({(setupLoaded ? "ok" : "missing")}), "
+ $"animation=0x{animationDid:X8} ({(animationLoaded ? "ok" : "missing")})");
}
public bool IsVisible => _visible;
public int CurrentAnimationFrame => _sequence.GetCurrFrameNumber();
public uint SetupDid => _setupDid;
@ -210,7 +223,7 @@ public sealed class PortalTunnelPresentation : IDisposable
/// <summary>
/// Replace the already-rendered world viewport with retail portal space.
/// The caller then draws the fade and retained UI above this pass.
/// The caller then draws retained UI above this pass.
/// </summary>
public void Draw(int width, int height, Matrix4x4 smartBoxProjection)
{

View file

@ -8,14 +8,16 @@ namespace AcDream.App.Rendering;
/// (<c>0x004D6E30</c>) eases SmartBox's view-plane distance between the
/// active game's value and <c>TRANSITION_VIEW_PLANE_DISTANCE = 0.001</c>.
/// <c>Render::set_vdst</c> (<c>0x0054B240</c>) converts that distance back to
/// FOV and adjusts the near plane. This is the wide projection warp visible
/// while the destination world emerges from black.
/// FOV and adjusts the near plane. At the exit edge retail swaps directly
/// from portal space to the destination world at the transition projection;
/// there is no black-alpha compositor between them.
/// </summary>
public sealed class TeleportViewPlaneController
{
public const float TransitionViewPlaneDistance = 0.001f;
private float _gameViewPlaneDistance = 1f;
private readonly ProjectionOverrideCamera _projectionCamera = new();
public bool Enabled { get; private set; }
public float CurrentViewPlaneDistance { get; private set; } = 1f;
@ -38,20 +40,42 @@ public sealed class TeleportViewPlaneController
}
/// <summary>
/// Apply the four retail fade-state branches. Their projection blend is
/// numerically the same table-driven level as the black fade: normal to
/// 0.001 on fade-out, and 0.001 back to normal on fade-in.
/// Apply retail's view-plane state machine. Here "fade" is retail's state
/// name for a projection transition: normal to 0.001 on fade-out, and
/// 0.001 back to normal on fade-in. A logout transition retains the
/// captured override during stable Tunnel; <c>EndTeleportAnimation</c>
/// (<c>0x004D65D5</c>) releases it when entering TunnelContinue.
/// </summary>
public void Update(TeleportAnimSnapshot snapshot)
{
Enabled = snapshot.State is TeleportAnimState.WorldFadeOut
or TeleportAnimState.TunnelFadeIn
or TeleportAnimState.TunnelFadeOut
or TeleportAnimState.WorldFadeIn;
switch (snapshot.State)
{
case TeleportAnimState.WorldFadeOut:
case TeleportAnimState.TunnelFadeIn:
case TeleportAnimState.TunnelFadeOut:
case TeleportAnimState.WorldFadeIn:
Enabled = true;
CurrentViewPlaneDistance = Lerp(
_gameViewPlaneDistance,
TransitionViewPlaneDistance,
snapshot.ViewPlaneBlend);
break;
CurrentViewPlaneDistance = Enabled
? Lerp(_gameViewPlaneDistance, TransitionViewPlaneDistance, snapshot.FadeAlpha)
: _gameViewPlaneDistance;
case TeleportAnimState.Tunnel:
// Retail preserves the captured FOV override through the
// logout path's stable tunnel. A normal portal begins in
// Tunnel with no override, so its disabled state is retained.
if (Enabled)
CurrentViewPlaneDistance = _gameViewPlaneDistance;
break;
case TeleportAnimState.TunnelContinue:
case TeleportAnimState.Off:
default:
Enabled = false;
CurrentViewPlaneDistance = _gameViewPlaneDistance;
break;
}
}
public void Reset()
@ -93,6 +117,39 @@ public sealed class TeleportViewPlaneController
return Matrix4x4.CreatePerspectiveFieldOfView(fov, aspect, near, far);
}
/// <summary>
/// Decorate the active camera with the same projection returned by
/// <see cref="Apply(Matrix4x4)"/>. Retail's <c>Render::set_vdst</c> is
/// global to every 3-D draw; callers must pass this returned camera to
/// terrain, meshes, particles, sky, weather, and portal-cell rendering so
/// rasterization and frustum culling cannot use different projections.
/// </summary>
public ICamera ApplyTo(ICamera baseCamera)
{
ArgumentNullException.ThrowIfNull(baseCamera);
_projectionCamera.Update(baseCamera, Apply(baseCamera.Projection));
return _projectionCamera;
}
private static float Lerp(float from, float to, float amount) =>
from + (to - from) * Math.Clamp(amount, 0f, 1f);
private sealed class ProjectionOverrideCamera : ICamera
{
private ICamera _source = null!;
public Matrix4x4 View => _source.View;
public Matrix4x4 Projection { get; private set; } = Matrix4x4.Identity;
public float Aspect
{
get => _source.Aspect;
set => _source.Aspect = value;
}
public void Update(ICamera source, Matrix4x4 projection)
{
_source = source;
Projection = projection;
}
}
}