feat(teleport C): TAS-driven fade transit + retire TeleportArrivalController

Wires the dormant TeleportAnimSequencer as the transit driver: on PlayerTeleport
the player holds in PortalSpace behind a full-screen fade (FadeOverlay) until the
destination terrain is resident (TeleportWorldReady, gated on the priority-applied
landblock), then materializes (Place), and after the world fades back in regains
control + acks the server (FireLoginComplete). No movement resolves against the
empty world, so the outbound cell frame can't corrupt. Outdoor changes from
place-immediately back to hold-until-resident (now fast, not a band-aid).

- FadeOverlay: fullscreen NDC black quad, alpha = ShowTunnel ? 1 : FadeAlpha.
- Retires TeleportArrivalController + its 2 tests (TAS subsumes the driver role).
- Divergence register: AD-2 updated to the new mechanism; AD-31 (fade vs swirl).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-06-22 14:03:25 +02:00
parent 9ac719424c
commit 3ce1fae332
7 changed files with 237 additions and 407 deletions

View file

@ -0,0 +1,121 @@
using System;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// Fullscreen black quad at a given alpha — the teleport fade cover (retail-teleport
/// spec §C, 2026-06-22). Drawn LAST in the frame (over the world + UI) so it covers
/// everything; the <see cref="AcDream.Core.World.TeleportAnimSequencer"/> drives the alpha
/// (opaque-black through the transit, ramping the world back in on WorldFadeIn). The
/// authentic 3D portal-swirl is deferred — this black cover replaces it for now.
///
/// <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). Must be called LAST in the frame, after the world and UI, so it covers
/// everything on screen.
/// </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

@ -171,6 +171,7 @@ public sealed class GameWindow : IDisposable
// each frame on an indoor root (null on the outdoor root).
private AcDream.App.Rendering.RetailPViewRenderer? _retailPViewRenderer;
private AcDream.App.Rendering.PortalDepthMaskRenderer? _portalDepthMask;
private AcDream.App.Rendering.FadeOverlay? _fadeOverlay; // teleport fade cover (spec C)
private AcDream.App.Rendering.InteriorEntityPartition.Result? _interiorPartition;
// Phase U.3: the shared per-frame clip data (binding=2 mesh SSBO + terrain
@ -2211,6 +2212,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);
}
// Phase G.1 sky renderer — its own shader (sky.vert / sky.frag)
@ -5462,29 +5464,44 @@ public sealed class GameWindow : IDisposable
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);
// 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;
_teleportHoldFrames = 0;
_teleportForced = false;
if (_streamingController is not null)
_streamingController.PriorityLandblockId =
AcDream.App.Streaming.StreamingRegion.EncodeLandblockId(lbX, lbY);
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
"AIM", p.LandblockId,
$"lb={lbX},{lbY} indoor={((p.LandblockId & 0xFFFFu) >= 0x0100u)} diffLb={differentLandblock}");
}
}
// 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;
// Retail teleport transit: the dormant 7-state TAS drives the fade cover, 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 bool _teleportInProgress;
private System.Numerics.Vector3 _pendingTeleportPos;
private uint _pendingTeleportCell;
private int _teleportHoldFrames; // frames 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;
// ~10s at 60fps. Loud safety net for a destination that never streams (worker crash /
// corrupt dat / OOB coords) — mirrors the retired controller's 600-frame ceiling. Now
// rarely fires because priority-apply makes residency fast.
private const int TeleportMaxHoldFrames = 600;
// #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,
@ -5501,35 +5518,21 @@ public sealed class GameWindow : IDisposable
return worldPos - origin;
}
private void EnsureTeleportArrivalController()
/// <summary>
/// worldReady for the TAS transit: is the player's teleport destination resident so we
/// can materialize? Indoor (sealed dungeon / building interior) gates on the EnvCell
/// struct hydrating (#135); outdoor gates on the destination terrain landblock being
/// registered (priority-applied). An impossible claim (indoor cell id outside the dat's
/// NumCells) returns true so the TAS stops holding and the forced placement surfaces the
/// failure loudly rather than holding forever.
/// </summary>
private bool TeleportWorldReady(uint destCell)
{
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)
{
bool claimUnhydratable = IsSpawnClaimUnhydratable(destCell);
// #135: an INDOOR destination (sealed dungeon / building interior) gates on the
// EnvCell FLOOR hydrating. Retail places on the cell floor. An OUTDOOR destination
// places immediately on the server-authoritative position — see TeleportArrivalRules
// (#145: holding is futile because streaming doesn't progress during a PortalSpace
// hold; the stale source landblock is dropped at recenter so the resolve trusts the
// server cell until the destination streams in).
if (IsSpawnClaimUnhydratable(destCell)) return true;
bool indoor = (destCell & 0xFFFFu) >= 0x0100u;
bool indoorCellReady = indoor && !claimUnhydratable
&& _physicsEngine.IsSpawnCellReady(destCell);
return AcDream.App.World.TeleportArrivalRules.Decide(
claimUnhydratable, indoor, indoorCellReady);
return indoor
? _physicsEngine.IsSpawnCellReady(destCell)
: _physicsEngine.IsLandblockTerrainResident(destCell);
}
// The deferred snap (the original OnLivePositionUpdated steps 2-5), now run only
@ -5563,29 +5566,30 @@ public sealed class GameWindow : IDisposable
contactPlaneNormal: System.Numerics.Vector3.UnitZ,
dt: 1f / 60f);
_playerController.State = AcDream.App.Input.PlayerState.InWorld;
AcDream.Core.Physics.PhysicsDiagnostics.LogTeleport(
"PLACED", resolved.CellId, $"forced={forced}");
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());
// 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
// 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 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.
/// 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
/// (FireLoginComplete).
/// </summary>
private void OnTeleportStarted(uint sequence)
{
if (_playerController is not null)
_playerController.State = AcDream.App.Input.PlayerState.PortalSpace;
EnsureTeleportArrivalController();
_teleportInProgress = true;
_teleportHoldFrames = 0;
_teleportForced = false;
_teleportAnim.Begin(AcDream.Core.World.TeleportEntryKind.Portal);
Console.WriteLine($"live: teleport started (seq={sequence})");
}
@ -7573,11 +7577,52 @@ 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();
// 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). The fade overlay
// is opaque black during the tunnel states (we render a fade, not the 3D swirl) and
// ramps the world back in on WorldFadeIn.
if (_teleportInProgress)
{
bool haveDest = _pendingTeleportCell != 0u;
bool ready = haveDest && TeleportWorldReady(_pendingTeleportCell);
if (haveDest && !ready && ++_teleportHoldFrames >= TeleportMaxHoldFrames)
{
ready = true;
_teleportForced = true;
}
var (snap, evts) = _teleportAnim.Tick((float)dt, ready);
_teleportFadeAlpha = snap.ShowTunnel ? 1f : snap.FadeAlpha;
foreach (var e in evts)
{
switch (e)
{
case AcDream.Core.World.TeleportAnimEvent.Place:
PlaceTeleportArrival(_pendingTeleportPos, _pendingTeleportCell, _teleportForced);
if (_streamingController is not null)
_streamingController.PriorityLandblockId = 0u;
break;
case AcDream.Core.World.TeleportAnimEvent.FireLoginComplete:
if (_playerController is not null)
_playerController.State = AcDream.App.Input.PlayerState.InWorld;
// holtburger client/messages.rs:434 — re-send LoginComplete after
// each portal transition.
_liveSession?.SendGameAction(
AcDream.Core.Net.Messages.GameActionLoginComplete.Build());
_teleportInProgress = false;
_pendingTeleportCell = 0u;
_teleportFadeAlpha = 0f;
break;
default:
// PlayEnterSound / EnterTunnel / PlayExitSound — audio polish deferred.
break;
}
}
}
// Phase K.1a — tick the input dispatcher so Hold-type bindings
// re-fire while their chord is held. K.1b adds the subscribers
@ -9019,6 +9064,11 @@ public sealed class GameWindow : IDisposable
_uiHost.Draw(new System.Numerics.Vector2(_window!.Size.X, _window.Size.Y));
}
// Teleport fade cover (retail-teleport spec C). Drawn AFTER the world + retail UI so
// it covers them during a transit; the ImGui devtools below composite on top so they
// stay visible for debugging. Alpha is 0 outside a teleport → Draw is a no-op.
_fadeOverlay?.Draw(_teleportFadeAlpha);
// Phase D.2a — end ImGui frame. Runs AFTER all scene + debug draws
// so ImGui composites on top. ImGuiController save/restores the
// GL state it touches (blend, scissor, VAO, shader, texture); any
@ -13072,6 +13122,7 @@ 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
_samplerCache?.Dispose();

View file

@ -37,8 +37,8 @@ public static class DungeonStreamingGate
// must follow the DESTINATION, which the PortalSpace observer pin already does, so
// the source-cell gate is suppressed. Otherwise a teleport OUT of a dungeon keeps
// streaming collapsed on the source dungeon (CurrCell still sealed) → the outdoor
// destination never hydrates → TeleportArrivalReadiness holds 600 frames → force-
// snap to ocean. A teleport INTO a dungeon is handled explicitly upstream by
// destination never hydrates → the TAS transit holds 600 frames → force-snap to
// ocean. A teleport INTO a dungeon is handled explicitly upstream by
// StreamingController.PreCollapseToDungeon (and the controller's _collapsed latch
// holds it through the hold), so suppressing the gate here doesn't regress it.
if (isTeleportHold)

View file

@ -1,144 +0,0 @@
using System;
using System.Numerics;
namespace AcDream.App.World;
/// <summary>Verdict from the per-frame readiness probe for a held teleport arrival.</summary>
public enum ArrivalReadiness
{
/// <summary>Destination not yet hydrated; keep holding.</summary>
NotReady,
/// <summary>Destination terrain + cell are ready; place now.</summary>
Ready,
/// <summary>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.</summary>
Impossible,
}
/// <summary>Lifecycle of a single teleport arrival.</summary>
public enum TeleportArrivalPhase { Idle, Holding }
/// <summary>
/// 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 <c>GameWindow.OnLivePositionUpdated</c> that resolved the
/// arrival against the resident (old) landblocks before the destination hydrated
/// and landed the player in ocean.
///
/// <para>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
/// <c>PlayerState.PortalSpace</c> until the placement delegate flips it back to
/// InWorld.</para>
///
/// <para>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.</para>
/// </summary>
public sealed class TeleportArrivalController
{
/// <summary>~10 s at 60 fps. Coarse safety net for a destination that never streams.</summary>
public const int DefaultMaxHoldFrames = 600;
private readonly Func<Vector3, uint, ArrivalReadiness> _readiness;
private readonly Action<Vector3, uint, bool> _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<Vector3, uint, ArrivalReadiness> readiness,
Action<Vector3, uint, bool> place,
int maxHoldFrames = DefaultMaxHoldFrames)
{
_readiness = readiness ?? throw new ArgumentNullException(nameof(readiness));
_place = place ?? throw new ArgumentNullException(nameof(place));
_maxHoldFrames = maxHoldFrames;
}
/// <summary>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).</summary>
public void BeginArrival(Vector3 destPos, uint destCell)
{
_destPos = destPos;
_destCell = destCell;
_heldFrames = 0;
Phase = TeleportArrivalPhase.Holding;
}
/// <summary>Per-frame: evaluate readiness and place when ready / impossible / timed out.
/// No-op when Idle.</summary>
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)
{
// Flip to Idle BEFORE invoking the placement delegate so the machine
// reflects "done holding" even if the delegate were to re-enter Tick.
Phase = TeleportArrivalPhase.Idle;
_place(_destPos, _destCell, forced);
}
}
/// <summary>
/// Pure readiness decision for a teleport arrival, factored out of
/// <c>GameWindow.TeleportArrivalReadiness</c> for testing.
/// </summary>
public static class TeleportArrivalRules
{
/// <summary>
/// Decide whether a held teleport arrival can place now.
/// </summary>
/// <param name="claimUnhydratable">The destination cell can never hydrate.</param>
/// <param name="indoor">Destination is an indoor cell (cell index ≥ 0x0100).</param>
/// <param name="indoorCellReady">For an indoor destination, the EnvCell floor has hydrated.</param>
public static ArrivalReadiness Decide(
bool claimUnhydratable,
bool indoor,
bool indoorCellReady)
{
if (claimUnhydratable)
return ArrivalReadiness.Impossible;
// Indoor (sealed dungeon / building interior): gate on the EnvCell floor hydrating,
// exactly as #135. The dungeon-IN path pre-collapses + waits for the cell struct, and
// its placement validates the specific claimed cell (the indoor resolve is cell-keyed,
// not the grid-snap), so it is unaffected by the overlap that broke teleport-OUT.
if (indoor)
return indoorCellReady ? ArrivalReadiness.Ready : ArrivalReadiness.NotReady;
// Outdoor: place IMMEDIATELY on the server-authoritative position. #145/#138 — holding
// for the destination to stream in is futile: streaming does NOT progress while the
// player is held in PortalSpace (the destination only loads once placement flips the
// state to InWorld). The stale source center landblock is dropped at recenter (see
// GameWindow.OnLivePositionUpdated), so the arrival resolve falls through to the
// server-authoritative cell/position (Resolve NO-LANDBLOCK verbatim) and the player is
// placed grounded there; streaming then loads the destination and the per-frame resolve
// grounds onto it. This trusts the server's teleport position — retail-faithful.
return ArrivalReadiness.Ready;
}
}