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; } }