namespace AcDream.Core.World;
// acclient.h:6871 — TeleportAnimState enum, verbatim order.
public enum TeleportAnimState
{
Off = 0,
WorldFadeOut = 1,
TunnelFadeIn = 2,
Tunnel = 3,
TunnelContinue = 4,
TunnelFadeOut = 5,
WorldFadeIn = 6,
}
/// Why the teleport was triggered — drives per-entry start state (spec §2.5).
public enum TeleportEntryKind { Portal, Login, Death, Logout }
///
/// Edge-triggered events the sequencer emits on the tick they first occur.
/// Consumers drive audio / placement / LoginComplete from these; the sequencer
/// has no dependency on any of those systems.
///
public enum TeleportAnimEvent
{
PlayEnterSound, // Begin(): sound_ui_enter_portal
EnterTunnel, // First tunnel-family frame: portal viewport replaces world
Place, // Tunnel -> TunnelContinue: world loaded; place the player
PlayExitSound, // TunnelFadeOut -> WorldFadeIn: sound_ui_exit_portal
FireLoginComplete, // WorldFadeIn -> Off: send GameAction 0xA1
}
/// Immutable per-frame snapshot from the sequencer.
public readonly record struct TeleportAnimSnapshot(
TeleportAnimState State,
float ViewPlaneBlend, // 0 = game view distance, 1 = transition distance
bool ShowTunnel, // true during TunnelFadeIn..TunnelFadeOut
bool ShowPleaseWait); // true during TunnelContinue only
///
/// Pure 7-state teleport animation machine (spec §2.1, acclient gmSmartBoxUI::UseTime 0x004d6e30).
/// No GL / dat / network dependency — fully unit-testable.
///
public sealed class TeleportAnimSequencer
{
// Golden constants (spec §2.1, verified VAs):
// TELEPORT_ANIM_FADE_TIME 0x007BD278 = 1.0s
// TELEPORT_ANIM_MIN_CONTINUE_TIME 0x007BD268 = 2.0s
// TELEPORT_ANIM_MAX_CONTINUE_TIME 0x007BD270 = 5.0s
public const float FadeTime = 1.0f;
public const float MinContinue = 2.0f;
public const float MaxContinue = 5.0f;
public const float TunnelFramesPerSecond = 40.0f;
public const int TunnelEndFrame = 120;
public const float ExitWindowLow = FadeTime + 0.1f;
public const float ExitWindowHigh = FadeTime + 0.3f;
private const short LastVisibleOutgoingAnimationLevel = 1022;
// UIGlobals::Init @ 0x004EE470. Retail integrates 100 integer sine
// samples into a 0..1024 easing table.
private static readonly short[] RetailAnimationLevels = BuildRetailAnimationLevels();
private TeleportAnimState _state = TeleportAnimState.Off;
private float _elapsed = 0f; // time in current state
private float _continueElapsed = 0f; // tracks time inside TunnelContinue
private bool _enterSoundPending = false;
private bool _enterTunnelPending = false;
public bool IsActive => _state != TeleportAnimState.Off;
public TeleportAnimState State => _state;
///
/// Start the animation. Portal/Login/Death enter at Tunnel (skipping world-fade-out);
/// Logout enters at WorldFadeOut (spec §2.5).
///
public void Begin(TeleportEntryKind kind)
{
_state = kind == TeleportEntryKind.Logout
? TeleportAnimState.WorldFadeOut
: TeleportAnimState.Tunnel;
_elapsed = 0f;
_continueElapsed = 0f;
_enterSoundPending = true;
_enterTunnelPending = _state == TeleportAnimState.Tunnel; // true for Portal/Login/Death
}
///
/// Cancel the current transition and discard every pending edge event.
/// Session teardown and a replacement teleport both require a clean
/// lifetime; no event from the abandoned transition may reach the next
/// session or destination.
///
public void Reset()
{
_state = TeleportAnimState.Off;
_elapsed = 0f;
_continueElapsed = 0f;
_enterSoundPending = false;
_enterTunnelPending = false;
}
///
/// Advance the machine by seconds.
/// = the complete destination-load barrier is
/// satisfied (Near-tier render meshes/textures plus collision residency).
/// Returns the current snapshot + edge-triggered events fired THIS tick.
///
public (TeleportAnimSnapshot snapshot, IReadOnlyList events)
Tick(float dt, bool worldReady, int tunnelAnimationFrame = 72)
{
var evts = new List();
// Edge events queued by Begin or a prior transition (enter-sound + enter-tunnel only;
// exit-sound and login-complete are emitted inline at their transitions below):
if (_enterSoundPending) { evts.Add(TeleportAnimEvent.PlayEnterSound); _enterSoundPending = false; }
if (_enterTunnelPending) { evts.Add(TeleportAnimEvent.EnterTunnel); _enterTunnelPending = false; }
_elapsed += dt;
// State-machine transitions:
switch (_state)
{
case TeleportAnimState.WorldFadeOut:
if (OutgoingViewportReachedTerminalProjection())
// UseTime's viewport-visibility block precedes this state
// transition, so retail shows portal space on the next tick.
Advance(TeleportAnimState.TunnelFadeIn, enterTunnel: true);
break;
case TeleportAnimState.TunnelFadeIn:
if (_elapsed >= FadeTime)
Advance(TeleportAnimState.Tunnel, enterTunnel: false);
break;
case TeleportAnimState.Tunnel:
// Hold here until worldReady (EndTeleportAnimation analogue).
if (worldReady)
{
evts.Add(TeleportAnimEvent.Place);
Advance(TeleportAnimState.TunnelContinue, enterTunnel: false);
_continueElapsed = 0f;
}
break;
case TeleportAnimState.TunnelContinue:
_continueElapsed += dt;
// gmSmartBoxUI::UseTime @ 0x004D7264: after the two-second
// minimum, start the one-second fade only while the portal
// animation has 1.1..1.3 seconds left. Retail performs the
// frame subtraction as unsigned arithmetic.
uint remainingFrames = unchecked((uint)TunnelEndFrame - (uint)tunnelAnimationFrame);
float remainingSeconds = remainingFrames / TunnelFramesPerSecond;
bool minMet = _continueElapsed >= MinContinue
&& remainingSeconds > ExitWindowLow
&& remainingSeconds < ExitWindowHigh;
bool maxForce = _continueElapsed >= MaxContinue;
if (minMet || maxForce)
Advance(TeleportAnimState.TunnelFadeOut, enterTunnel: false);
break;
case TeleportAnimState.TunnelFadeOut:
if (OutgoingViewportReachedTerminalProjection())
{
Advance(TeleportAnimState.WorldFadeIn, enterTunnel: false);
evts.Add(TeleportAnimEvent.PlayExitSound);
}
break;
case TeleportAnimState.WorldFadeIn:
if (_elapsed >= FadeTime)
{
Advance(TeleportAnimState.Off, enterTunnel: false);
evts.Add(TeleportAnimEvent.FireLoginComplete);
}
break;
case TeleportAnimState.Off:
default:
break;
}
return (BuildSnapshot(), evts);
}
///
/// Retire an outgoing viewport before the first quantized projection that
/// exposes the finite tunnel boundary. Retail's whole-frame black clear and
/// frame-paced D3D presentation show level 1022 as the last tunnel sample;
/// paired captures show no 1023/1024 portal frame before the world swap.
/// An uncapped modern loop can otherwise publish those sub-20 ms samples
/// and let the desktop compositor hold one for a complete display refresh.
///
private bool OutgoingViewportReachedTerminalProjection()
=> GetRetailAnimationLevel(_elapsed / FadeTime) > LastVisibleOutgoingAnimationLevel;
private void Advance(TeleportAnimState next, bool enterTunnel)
{
_state = next;
_elapsed = 0f;
if (enterTunnel) _enterTunnelPending = true;
}
private TeleportAnimSnapshot BuildSnapshot()
{
float viewBlend = ComputeViewPlaneBlend(_state, _elapsed);
bool showTunnel = _state is TeleportAnimState.TunnelFadeIn
or TeleportAnimState.Tunnel
or TeleportAnimState.TunnelContinue
or TeleportAnimState.TunnelFadeOut;
bool pleaseWait = _state == TeleportAnimState.TunnelContinue;
return new TeleportAnimSnapshot(_state, viewBlend, showTunnel, pleaseWait);
}
///
/// Retail UIGlobals::GetAnimLevel (0x004EE540), returning
/// an integer level in the inclusive range 0..1024.
///
public static short GetRetailAnimationLevel(float t)
{
t = Math.Clamp(t, 0f, 1f);
// x87 _ftol2 truncates -99*t toward zero, then retail subtracts the
// resulting negative word offset from the table base.
int negativeIndex = (int)Math.Truncate(-99.0 * t);
return RetailAnimationLevels[-negativeIndex];
}
///
/// Retail's four states named *Fade* do not drive an alpha cover.
/// gmSmartBoxUI::UseTime (0x004D7133-0x004D725F) uses this eased
/// level only to interpolate SmartBox's view-plane distance.
///
private static float ComputeViewPlaneBlend(TeleportAnimState state, float elapsed)
{
float t = Math.Clamp(elapsed / FadeTime, 0f, 1f);
float level = GetRetailAnimationLevel(t) / 1024f;
return state switch
{
// Normal game view distance -> transition distance.
TeleportAnimState.WorldFadeOut => level,
TeleportAnimState.TunnelFadeOut => level,
// Transition distance -> normal game view distance.
TeleportAnimState.TunnelFadeIn => 1f - level,
TeleportAnimState.WorldFadeIn => 1f - level,
// Stable viewport states use the ordinary game projection.
TeleportAnimState.Tunnel => 0f,
TeleportAnimState.TunnelContinue => 0f,
_ => 0f,
};
}
private static short[] BuildRetailAnimationLevels()
{
const double retailPi = 3.1415920000000002d;
const double reciprocal99 = 0.010101010101010102d;
const double scale = 1024.0d;
var samples = new short[100];
int total = 0;
for (int i = 0; i < samples.Length; i++)
{
short sample = (short)Math.Truncate(Math.Sin(i * retailPi * reciprocal99) * scale);
samples[i] = sample;
total += sample;
}
int running = 0;
for (int i = 0; i < samples.Length; i++)
{
running += samples[i];
samples[i] = (short)((running << 10) / total);
}
return samples;
}
}