acdream/docs/superpowers/plans/2026-06-21-retail-teleport-flow.md
Erik 82462ff153 docs(teleport-flow): implementation plan — 22 TDD tasks across 5 slices (Work item B)
Bite-sized TDD plan for the retail teleport flow. Slice 1: pure
TeleportAnimSequencer (7-state TAS) + golden-timing tests. Slice 2: the
#145 readiness-gate fix (IsLandblockLoaded + Decide outdoorReady axis +
apparatus probe) — ships independently of the visuals. Slice 3:
TeleportFlowController (delegate-injected, unit-tested) + TeleportFadeOverlay
+ portal wiring (PlaceTeleportArrival split: place vs InWorld so the input
lock persists the whole animation). Slice 4: one yaw-freeze + portal sounds
via the EnumIDMap chain. Slice 5: de-dup login readiness onto Decide, route
login/death through the controller, logout (Shift+Esc) + 0xF653 + disconnect,
remove dead _teleportArrival plumbing. Slice 6 (literal 3D swirl) is a
follow-up plan gated on a cdb asset trace.

Drafted via two research + drafting workflows; slices 3-5 redrafted as one
cohesive unit against a pinned controller API after the first parallel pass
produced cross-slice inconsistencies (missing controller task, triplicated
yaw-freeze, a fabricated PlayerMovementController.Update signature). All
load-bearing signatures personally verified against the tree.

Plan: docs/superpowers/plans/2026-06-21-retail-teleport-flow.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 19:42:28 +02:00

112 KiB
Raw Blame History

Retail Teleport Flow Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build the retail-faithful unified teleport flow — a TeleportAnimState (TAS) animation state machine + arrival hold that covers login, logout, death, and portal with a fade-to-black cover, holds until the destination landblock is actually loaded (folding in the #145 unstreamed-arrival cascade fix), and locks input + camera for the whole transition.

Architecture: A pure Core state machine (TeleportAnimSequencer, the 7-state TAS machine) + a thin App orchestrator (TeleportFlowController) that wraps it and drives all side effects through injected delegates + a TeleportFadeOverlay UiElement for the black fade. The readiness gate (PhysicsEngine.IsLandblockLoaded) makes outdoor arrivals hold until the collision landblock is resident. Slices 12 land the sequencer + readiness gate (the #145 fix ships independently of the visuals); slices 35 route portal/login/death/logout through one controller and de-dup the duplicated readiness/recenter logic; slice 6 (the literal 3D portal swirl) is a follow-up plan gated on a cdb asset trace (see "Slice 6" at the end).

Tech Stack: C# .NET 10, xunit (dotnet test), Silk.NET (GL / OpenAL / input), DatReaderWriter (dat access), the existing AcDream.Core / AcDream.App / AcDream.UI.Abstractions layers. Decomp oracle: docs/research/named-retail/. Design doc this plan implements: docs/superpowers/specs/2026-06-21-retail-teleport-flow-design.md.


File structure

Path Responsibility Slice
src/AcDream.Core/World/TeleportAnimSequencer.cs (new) Pure 7-state TAS machine: enums, TeleportAnimSnapshot, the sequencer. No GL/dat/net. 1
tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs (new) Golden-timing unit tests for the sequencer. 1
src/AcDream.Core/Physics/PhysicsEngine.cs (modify) Add IsLandblockLoaded(uint). 2
src/AcDream.App/World/TeleportArrivalController.cs (modify) Extend TeleportArrivalRules.Decide with the outdoorReady axis. 2
src/AcDream.Core/Physics/PhysicsDiagnostics.cs (modify) ProbeArrivalGateEnabled flag for the apparatus probe. 2
src/AcDream.App/World/TeleportFlowController.cs (new) The TAS orchestrator: wraps the sequencer, drives side effects via delegates. 3
src/AcDream.App/UI/TeleportFadeOverlay.cs (new) Full-screen black alpha-quad UiElement, driven by Snapshot.FadeAlpha. 3
tests/AcDream.App.Tests/World/TeleportFlowControllerTests.cs (new) Delegate-dispatch unit tests (spy delegates + stub readiness). 3
tests/AcDream.App.Tests/UI/TeleportFadeOverlayTests.cs (new) Alpha→Visible + fill-color unit tests. 3
src/AcDream.App/Rendering/GameWindow.cs (modify) Wiring only (god object — no feature bodies): construct + tick the controller, split PlaceTeleportArrival, the four entry points, the yaw-freeze, the portal sounds, the login de-dup, the logout command. 3,4,5
docs/architecture/retail-divergence-register.md (modify) The four deviation rows (§5 of the design). 1,3,5

Architecture — the pinned controller design (read before slices 35)

The relationship between the pieces is load-bearing; the slices assume exactly this shape:

  • TeleportAnimSequencer (pure, Slice 1) is the 7-state machine. It holds in Tunnel while !worldReady (no internal timeout), emits Place when it leaves Tunnel, and FireLoginComplete when it reaches Off. FadeAlpha is 0 inside the tunnel states and smoothsteps on the fade states.
  • TeleportFlowController (Slice 3) owns the sequencer and all timing/lock/placement decisions. Each Tick(dt) it computes worldReady from the injected readiness probe (Ready Impossible frame-timeout, else hold; Logout has no destination so it is intrinsically ready), ticks the sequencer, and dispatches the sequencer's events to injected delegates: onPlace (place the player), onLoginComplete (flip PortalSpace→InWorld + send GameAction 0xA1), onLogoutDisconnect, onPlayEnterSound, onPlayExitSound.
  • The input lock (PlayerState.PortalSpace) persists the WHOLE animation. It is set at the entry point and cleared only in onLoginComplete (sequencer reached Off) — not at placement. This is why PlaceTeleportArrival is split in Slice 3: the placement half (Resolve + SetPosition + camera) runs on the Place event; the State=InWorld + LoginComplete half runs at Off.
  • Begin(kind, destPos, destCell) is idempotent on the sequencer. Portal triggers it twice — once with no coords (OnTeleportStarted) and once with coords (OnLivePositionUpdated); the second call fills the destination without restarting the animation.
  • Entry points: Portal/Death enter at Tunnel (await coords → onPlace); Login enters at Tunnel already-placed (readiness returns Ready immediately → fast exit; onPlace re-resolves harmlessly); Logout enters at WorldFadeOut and ends in onLogoutDisconnect.

Cross-slice conventions (so the slices compose):

  1. The 4th Decide arg is named outdoorReady everywhere (terrain-ready for login, landblock-ready for teleport).
  2. The yaw-freeze lives only in Slice 4 (one early-return covering both MMB look + RMB orbit); it is not repeated in slices 3 or 5.
  3. PlaceTeleportArrival is split exactly once (Slice 3) into PlaceTeleportArrivalCore + OnTeleportLoginComplete; slices 4/5 consume those.
  4. The portal sound enum is DatReaderWriter.Enums.Sound.EnterPortal/ExitPortal (verified — not UI_EnterPortal=0x6A); the Core SoundId enum is untouched.
  5. The old TeleportArrivalController (the bare hold) becomes unused once the portal path routes through _teleportFlow; its field/plumbing removal is deferred to the Slice 5 de-dup commit to avoid a half-removed field mid-plan.

Slice 1: TeleportAnimSequencer — pure 7-state machine + unit tests

Placement decision: src/AcDream.Core/World/TeleportAnimSequencer.cs in namespace AcDream.Core.World. Core already owns src/AcDream.Core/World/ (DerethDateTime, WorldEntity, etc.) and carries no GL/net dependency. The enums and record from the shared-type contract land here alongside the sequencer. Tests in tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs.


Task 1.1: Create the types file (enums + record)

Files:

  • Create: src/AcDream.Core/World/TeleportAnimSequencer.cs

  • Test: tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs

  • Step 1: Write the failing test — the test file references TeleportAnimState, TeleportEntryKind, TeleportAnimEvent, and TeleportAnimSnapshot in AcDream.Core.World; the test project already references AcDream.Core (tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj:31).

// tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs
using AcDream.Core.World;

namespace AcDream.Core.Tests.World;

public sealed class TeleportAnimSequencerTests
{
    // --- Task 1.1: type presence ---

    [Fact]
    public void Enums_HaveExpectedValues()
    {
        Assert.Equal(0, (int)TeleportAnimState.Off);
        Assert.Equal(1, (int)TeleportAnimState.WorldFadeOut);
        Assert.Equal(2, (int)TeleportAnimState.TunnelFadeIn);
        Assert.Equal(3, (int)TeleportAnimState.Tunnel);
        Assert.Equal(4, (int)TeleportAnimState.TunnelContinue);
        Assert.Equal(5, (int)TeleportAnimState.TunnelFadeOut);
        Assert.Equal(6, (int)TeleportAnimState.WorldFadeIn);

        _ = TeleportEntryKind.Portal;
        _ = TeleportEntryKind.Login;
        _ = TeleportEntryKind.Death;
        _ = TeleportEntryKind.Logout;

        _ = TeleportAnimEvent.PlayEnterSound;
        _ = TeleportAnimEvent.PlayExitSound;
        _ = TeleportAnimEvent.Place;
        _ = TeleportAnimEvent.FireLoginComplete;
        _ = TeleportAnimEvent.EnterTunnel;
    }

    [Fact]
    public void Snapshot_DefaultsAreOff_ClearAlpha()
    {
        var snap = new TeleportAnimSnapshot(
            TeleportAnimState.Off, FadeAlpha: 0f, ShowTunnel: false, ShowPleaseWait: false);
        Assert.Equal(TeleportAnimState.Off, snap.State);
        Assert.Equal(0f, snap.FadeAlpha);
        Assert.False(snap.ShowTunnel);
        Assert.False(snap.ShowPleaseWait);
    }
}
  • Step 2: Run to verify it fails

    dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests.Enums_HaveExpectedValues"
    

    Expected: FAIL — TeleportAnimState does not exist yet.

  • Step 3: Implement — create the types (no sequencer class body yet, that comes in Task 1.2):

// src/AcDream.Core/World/TeleportAnimSequencer.cs
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,
}

/// <summary>Why the teleport was triggered — drives per-entry start state (spec §2.5).</summary>
public enum TeleportEntryKind { Portal, Login, Death, Logout }

/// <summary>
/// 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.
/// </summary>
public enum TeleportAnimEvent
{
    PlayEnterSound,    // Begin(): sound_ui_enter_portal
    EnterTunnel,       // Off/WorldFade* -> Tunnel: world is now hidden
    Place,             // Tunnel -> TunnelContinue: world loaded; place the player
    PlayExitSound,     // TunnelFadeOut -> WorldFadeIn: sound_ui_exit_portal
    FireLoginComplete, // WorldFadeIn -> Off: send GameAction 0xA1
}

/// <summary>Immutable per-frame snapshot from the sequencer.</summary>
public readonly record struct TeleportAnimSnapshot(
    TeleportAnimState State,
    float FadeAlpha,       // 0 = clear world, 1 = full black
    bool  ShowTunnel,      // true during TunnelFadeIn..TunnelFadeOut
    bool  ShowPleaseWait); // true during TunnelContinue only
  • Step 4: Run to verify it passes

    dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests"
    

    Expected: PASS.

  • Step 5: Commit

    git add src/AcDream.Core/World/TeleportAnimSequencer.cs tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs
    git commit -m "feat(core/slice-1): TeleportAnimSequencer — define enums, record, event types"
    

Task 1.2: Sequencer skeleton + Begin(), IsActive, initial state

Files:

  • Modify: src/AcDream.Core/World/TeleportAnimSequencer.cs

  • Modify: tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs

  • Step 1: Write the failing test

// Add to TeleportAnimSequencerTests:

[Theory]
[InlineData(TeleportEntryKind.Portal,  TeleportAnimState.Tunnel)]
[InlineData(TeleportEntryKind.Login,   TeleportAnimState.Tunnel)]
[InlineData(TeleportEntryKind.Death,   TeleportAnimState.Tunnel)]
[InlineData(TeleportEntryKind.Logout,  TeleportAnimState.WorldFadeOut)]
public void Begin_SetsCorrectStartState(TeleportEntryKind kind, TeleportAnimState expectedStart)
{
    var seq = new TeleportAnimSequencer();
    Assert.False(seq.IsActive);

    seq.Begin(kind);

    Assert.True(seq.IsActive);
    Assert.Equal(expectedStart, seq.State);
}

[Fact]
public void Begin_EmitsPlayEnterSoundOnFirstTick()
{
    // PlayEnterSound is edge-triggered on the first Tick after Begin.
    var seq = new TeleportAnimSequencer();
    seq.Begin(TeleportEntryKind.Portal);
    var (_, events) = seq.Tick(dt: 0f, worldReady: false);
    Assert.Contains(TeleportAnimEvent.PlayEnterSound, events);
}
  • Step 2: Run to verify it fails

    dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests.Begin_SetsCorrectStartState"
    

    Expected: FAIL — TeleportAnimSequencer class missing.

  • Step 3: Implement — add class body to the same file (golden constants + Begin + Tick stub):

// Append to src/AcDream.Core/World/TeleportAnimSequencer.cs

/// <summary>
/// Pure 7-state teleport animation machine (spec §2.1, acclient gmSmartBoxUI::UseTime 0x004d6e30).
/// No GL / dat / network dependency — fully unit-testable.
/// </summary>
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;

    private TeleportAnimState _state  = TeleportAnimState.Off;
    private float _elapsed            = 0f;        // time in current state
    private bool  _enterSoundPending  = false;     // edge: fire PlayEnterSound on next Tick

    public bool              IsActive => _state != TeleportAnimState.Off;
    public TeleportAnimState State    => _state;

    /// <summary>
    /// Start the animation. Portal/Login/Death enter at Tunnel (skipping world-fade-out);
    /// Logout enters at WorldFadeOut (spec §2.5).
    /// </summary>
    public void Begin(TeleportEntryKind kind)
    {
        _state = kind == TeleportEntryKind.Logout
            ? TeleportAnimState.WorldFadeOut
            : TeleportAnimState.Tunnel;
        _elapsed           = 0f;
        _enterSoundPending = true;
    }

    /// <summary>
    /// Advance the machine by <paramref name="dt"/> seconds.
    /// <paramref name="worldReady"/> = destination collision landblock is resident.
    /// Returns the current snapshot + edge-triggered events fired THIS tick.
    /// </summary>
    public (TeleportAnimSnapshot snapshot, IReadOnlyList<TeleportAnimEvent> events)
        Tick(float dt, bool worldReady)
    {
        // Full implementation in Task 1.3  stub returns current state with no transitions.
        var evts = new List<TeleportAnimEvent>();
        if (_enterSoundPending)
        {
            evts.Add(TeleportAnimEvent.PlayEnterSound);
            _enterSoundPending = false;
        }
        _elapsed += dt;
        var snap = BuildSnapshot();
        return (snap, evts);
    }

    private TeleportAnimSnapshot BuildSnapshot()
    {
        float alpha      = ComputeFadeAlpha(_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, alpha, showTunnel, pleaseWait);
    }

    private static float Smoothstep(float t)
    {
        t = Math.Clamp(t, 0f, 1f);
        return t * t * (3f - 2f * t);
    }

    private static float ComputeFadeAlpha(TeleportAnimState state, float elapsed)
    {
        float t = Math.Clamp(elapsed / FadeTime, 0f, 1f);
        return state switch
        {
            // Fading TO black (alpha 0→1):
            TeleportAnimState.WorldFadeOut  => Smoothstep(t),
            TeleportAnimState.TunnelFadeIn  => 1f - Smoothstep(t), // tunnel fades IN: overlay goes clear
            // Full black / fully clear inside tunnel states:
            TeleportAnimState.Tunnel        => 0f,  // world hidden, overlay not needed
            TeleportAnimState.TunnelContinue => 0f,
            // Fading back out of tunnel:
            TeleportAnimState.TunnelFadeOut => Smoothstep(t),       // tunnel fades out: overlay goes black
            TeleportAnimState.WorldFadeIn   => 1f - Smoothstep(t),  // world fades in: overlay clears
            _                               => 0f,
        };
    }
}
  • Step 4: Run to verify it passes

    dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests"
    

    Expected: PASS (all tests so far).

  • Step 5: Commit

    git add src/AcDream.Core/World/TeleportAnimSequencer.cs tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs
    git commit -m "feat(core/slice-1): TeleportAnimSequencer — Begin(), IsActive, enter-sound edge event"
    

Task 1.3: State transitions — timing-correct Tick() implementation

Files:

  • Modify: src/AcDream.Core/World/TeleportAnimSequencer.cs

  • Modify: tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs

  • Step 1: Write the failing tests — drive the machine through each transition with exact elapsed times:

// Add to TeleportAnimSequencerTests:

// Helper: drive the sequencer forward by total elapsed time using small fixed steps.
private static (TeleportAnimSnapshot snap, List<TeleportAnimEvent> allEvents)
    DriveSeconds(TeleportAnimSequencer seq, float seconds, bool worldReady, float step = 0.016f)
{
    var allEvts = new List<TeleportAnimEvent>();
    float remaining = seconds;
    TeleportAnimSnapshot last = default;
    while (remaining > 0f)
    {
        float dt = Math.Min(step, remaining);
        var (snap, evts) = seq.Tick(dt, worldReady);
        last = snap;
        allEvts.AddRange(evts);
        remaining -= dt;
    }
    return (last, allEvts);
}

// --- Logout path: WorldFadeOut -> TunnelFadeIn -> Tunnel ---

[Fact]
public void Logout_AfterFadeTime_TransitionsToTunnelFadeIn()
{
    var seq = new TeleportAnimSequencer();
    seq.Begin(TeleportEntryKind.Logout);

    // Consume the enter-sound tick at dt=0
    seq.Tick(0f, worldReady: false);

    // Drive just past FadeTime (1.0s)
    DriveSeconds(seq, TeleportAnimSequencer.FadeTime + 0.02f, worldReady: false);

    Assert.Equal(TeleportAnimState.TunnelFadeIn, seq.State);
}

[Fact]
public void Logout_After2xFadeTime_TransitionsToTunnel()
{
    var seq = new TeleportAnimSequencer();
    seq.Begin(TeleportEntryKind.Logout);
    seq.Tick(0f, worldReady: false); // consume enter-sound

    DriveSeconds(seq, 2f * TeleportAnimSequencer.FadeTime + 0.02f, worldReady: false);

    Assert.Equal(TeleportAnimState.Tunnel, seq.State);
}

// --- Portal/Login/Death path: enters at Tunnel ---

[Fact]
public void Portal_StartsInTunnel_HoldsWhileNotReady()
{
    var seq = new TeleportAnimSequencer();
    seq.Begin(TeleportEntryKind.Portal);
    seq.Tick(0f, worldReady: false); // consume enter-sound

    // Drive 10s — should not advance past Tunnel while !worldReady
    DriveSeconds(seq, 10f, worldReady: false);

    Assert.Equal(TeleportAnimState.Tunnel, seq.State);
}

[Fact]
public void Portal_WhenWorldReady_TransitionsToTunnelContinue_EmitsEnterTunnelAndPlace()
{
    var seq = new TeleportAnimSequencer();
    seq.Begin(TeleportEntryKind.Portal);
    seq.Tick(0f, worldReady: false); // consume enter-sound

    // The very first tick with worldReady=true should advance Tunnel→TunnelContinue and emit Place.
    var (_, evts) = seq.Tick(0.016f, worldReady: true);

    Assert.Equal(TeleportAnimState.TunnelContinue, seq.State);
    Assert.Contains(TeleportAnimEvent.Place, evts);
}

// --- TunnelContinue: MIN_CONTINUE hold then TunnelFadeOut ---

[Fact]
public void TunnelContinue_DoesNotAdvance_BeforeMinContinue()
{
    var seq = new TeleportAnimSequencer();
    seq.Begin(TeleportEntryKind.Portal);
    seq.Tick(0f, worldReady: false);
    // Enter TunnelContinue
    seq.Tick(0.016f, worldReady: true);
    Assert.Equal(TeleportAnimState.TunnelContinue, seq.State);

    // Drive MinContinue - ε — should still be TunnelContinue
    DriveSeconds(seq, TeleportAnimSequencer.MinContinue - 0.1f, worldReady: true);

    Assert.Equal(TeleportAnimState.TunnelContinue, seq.State);
}

[Fact]
public void TunnelContinue_AdvancesToTunnelFadeOut_AfterMinContinue()
{
    var seq = new TeleportAnimSequencer();
    seq.Begin(TeleportEntryKind.Portal);
    seq.Tick(0f, worldReady: false);
    seq.Tick(0.016f, worldReady: true); // -> TunnelContinue

    DriveSeconds(seq, TeleportAnimSequencer.MinContinue + 0.05f, worldReady: true);

    Assert.Equal(TeleportAnimState.TunnelFadeOut, seq.State);
}

// --- MAX_CONTINUE forces progress even when !worldReady ---

[Fact]
public void TunnelContinue_ForcesAdvance_AtMaxContinue_EvenIfNotReady()
{
    // Simulate: world never becomes fully ready but MAX_CONTINUE forces fade-out
    // This path exercises the safety-net from spec §3.4.
    var seq = new TeleportAnimSequencer();
    seq.Begin(TeleportEntryKind.Portal);
    seq.Tick(0f, worldReady: false);
    // Manually push the state to TunnelContinue by passing worldReady=true for one tick,
    // then simulate worldReady toggling back to false.
    seq.Tick(0.016f, worldReady: true); // -> TunnelContinue

    // Drive MaxContinue + ε with worldReady=false (simulating "world never loaded")
    DriveSeconds(seq, TeleportAnimSequencer.MaxContinue + 0.05f, worldReady: false);

    Assert.Equal(TeleportAnimState.TunnelFadeOut, seq.State);
}

// --- TunnelFadeOut -> WorldFadeIn: PlayExitSound edge event ---

[Fact]
public void TunnelFadeOut_EmitsPlayExitSound_OnTransitionToWorldFadeIn()
{
    var seq = new TeleportAnimSequencer();
    seq.Begin(TeleportEntryKind.Portal);
    seq.Tick(0f, worldReady: false);
    seq.Tick(0.016f, worldReady: true); // -> TunnelContinue

    // Drive through MinContinue -> TunnelFadeOut
    DriveSeconds(seq, TeleportAnimSequencer.MinContinue + 0.05f, worldReady: true);
    Assert.Equal(TeleportAnimState.TunnelFadeOut, seq.State);

    // Drive through FadeTime -> WorldFadeIn; PlayExitSound should fire on that edge
    var (_, evts) = DriveSeconds(seq, TeleportAnimSequencer.FadeTime + 0.05f, worldReady: true);
    Assert.Equal(TeleportAnimState.WorldFadeIn, seq.State);
    Assert.Contains(TeleportAnimEvent.PlayExitSound, evts);
}

// --- WorldFadeIn -> Off: FireLoginComplete edge event ---

[Fact]
public void WorldFadeIn_EmitsFireLoginComplete_OnTransitionToOff()
{
    var seq = new TeleportAnimSequencer();
    seq.Begin(TeleportEntryKind.Portal);
    seq.Tick(0f, worldReady: false);
    seq.Tick(0.016f, worldReady: true); // -> TunnelContinue
    DriveSeconds(seq, TeleportAnimSequencer.MinContinue + 0.05f, worldReady: true);
    DriveSeconds(seq, TeleportAnimSequencer.FadeTime + 0.05f, worldReady: true); // -> WorldFadeIn

    var (_, evts) = DriveSeconds(seq, TeleportAnimSequencer.FadeTime + 0.05f, worldReady: true);

    Assert.Equal(TeleportAnimState.Off, seq.State);
    Assert.False(seq.IsActive);
    Assert.Contains(TeleportAnimEvent.FireLoginComplete, evts);
}
  • Step 2: Run to verify it fails

    dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests.Portal_WhenWorldReady_TransitionsToTunnelContinue"
    

    Expected: FAIL — Tick() stub never advances state.

  • Step 3: Implement — replace the Tick() stub in TeleportAnimSequencer with the full transition machine. Also tracks elapsed-in-state and _continueElapsed for the MIN/MAX gates:

// Replace the existing Tick() and add _continueElapsed field in TeleportAnimSequencer:

private float _continueElapsed = 0f; // tracks time inside TunnelContinue
private bool  _exitSoundPending = false;
private bool  _loginCompletePending = false;
private bool  _enterTunnelPending = false;

public (TeleportAnimSnapshot snapshot, IReadOnlyList<TeleportAnimEvent> events)
    Tick(float dt, bool worldReady)
{
    var evts = new List<TeleportAnimEvent>();

    // Edge events queued by Begin or a prior transition:
    if (_enterSoundPending)   { evts.Add(TeleportAnimEvent.PlayEnterSound);    _enterSoundPending = false; }
    if (_enterTunnelPending)  { evts.Add(TeleportAnimEvent.EnterTunnel);       _enterTunnelPending = false; }
    if (_exitSoundPending)    { evts.Add(TeleportAnimEvent.PlayExitSound);     _exitSoundPending = false; }
    if (_loginCompletePending){ evts.Add(TeleportAnimEvent.FireLoginComplete); _loginCompletePending = false; }

    _elapsed += dt;

    // State-machine transitions:
    switch (_state)
    {
        case TeleportAnimState.WorldFadeOut:
            if (_elapsed >= FadeTime)
                Advance(TeleportAnimState.TunnelFadeIn, evts, enterTunnel: false);
            break;

        case TeleportAnimState.TunnelFadeIn:
            if (_elapsed >= FadeTime)
                Advance(TeleportAnimState.Tunnel, evts, enterTunnel: true);
            break;

        case TeleportAnimState.Tunnel:
            // Hold here until worldReady (EndTeleportAnimation analogue).
            if (worldReady)
            {
                evts.Add(TeleportAnimEvent.Place);
                Advance(TeleportAnimState.TunnelContinue, evts, enterTunnel: false);
                _continueElapsed = 0f;
            }
            break;

        case TeleportAnimState.TunnelContinue:
            _continueElapsed += dt;
            bool minMet  = _continueElapsed >= MinContinue;
            bool maxForce = _continueElapsed >= MaxContinue;
            if (minMet || maxForce)
                Advance(TeleportAnimState.TunnelFadeOut, evts, enterTunnel: false);
            break;

        case TeleportAnimState.TunnelFadeOut:
            if (_elapsed >= FadeTime)
            {
                _exitSoundPending = true;
                Advance(TeleportAnimState.WorldFadeIn, evts, enterTunnel: false);
                // Flush the exit sound in the same tick:
                evts.Add(TeleportAnimEvent.PlayExitSound);
                _exitSoundPending = false;
            }
            break;

        case TeleportAnimState.WorldFadeIn:
            if (_elapsed >= FadeTime)
            {
                _loginCompletePending = true;
                Advance(TeleportAnimState.Off, evts, enterTunnel: false);
                evts.Add(TeleportAnimEvent.FireLoginComplete);
                _loginCompletePending = false;
            }
            break;

        case TeleportAnimState.Off:
        default:
            break;
    }

    return (BuildSnapshot(), evts);
}

private void Advance(TeleportAnimState next, List<TeleportAnimEvent> evts, bool enterTunnel)
{
    _state   = next;
    _elapsed = 0f;
    if (enterTunnel) _enterTunnelPending = true;
}
  • Step 4: Run to verify it passes

    dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests"
    

    Expected: PASS (all transition tests).

  • Step 5: Commit

    git add src/AcDream.Core/World/TeleportAnimSequencer.cs tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs
    git commit -m "feat(core/slice-1): TeleportAnimSequencer — full 7-state Tick() with timed transitions and edge events"
    

Task 1.4: FadeAlpha endpoints and monotonicity

Files:

  • Modify: tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs

  • Step 1: Write the failing tests

// Add to TeleportAnimSequencerTests:

[Fact]
public void FadeAlpha_IsZeroAtStart_OfWorldFadeOut()
{
    var seq = new TeleportAnimSequencer();
    seq.Begin(TeleportEntryKind.Logout);
    var (snap, _) = seq.Tick(dt: 0f, worldReady: false);
    // At elapsed=0 in WorldFadeOut: smoothstep(0)=0 => alpha=0 (world fully visible).
    Assert.Equal(0f, snap.FadeAlpha, precision: 4);
}

[Fact]
public void FadeAlpha_IsOneAtEnd_OfWorldFadeOut()
{
    var seq = new TeleportAnimSequencer();
    seq.Begin(TeleportEntryKind.Logout);
    seq.Tick(0f, worldReady: false); // consume enter-sound tick, elapsed≈0

    // Drive to just BEFORE the transition (so we're still in WorldFadeOut)
    DriveSeconds(seq, TeleportAnimSequencer.FadeTime - 0.02f, worldReady: false);
    Assert.Equal(TeleportAnimState.WorldFadeOut, seq.State);

    var (snap, _) = seq.Tick(0f, worldReady: false);
    // smoothstep(clamp((1.0-0.02)/1.0,0,1)) should be close to 1
    Assert.True(snap.FadeAlpha > 0.95f, $"Expected FadeAlpha near 1, got {snap.FadeAlpha}");
}

[Fact]
public void FadeAlpha_IsMonotonicallyIncreasing_DuringWorldFadeOut()
{
    var seq = new TeleportAnimSequencer();
    seq.Begin(TeleportEntryKind.Logout);
    seq.Tick(0f, worldReady: false);

    float prev = -1f;
    // Sample 50 steps within FadeTime
    float step = TeleportAnimSequencer.FadeTime / 50f;
    for (int i = 0; i < 48; i++) // stop before transition
    {
        var (snap, _) = seq.Tick(step, worldReady: false);
        if (seq.State != TeleportAnimState.WorldFadeOut) break;
        Assert.True(snap.FadeAlpha >= prev - 0.001f,
            $"Alpha decreased: {prev} -> {snap.FadeAlpha} at step {i}");
        prev = snap.FadeAlpha;
    }
}

[Fact]
public void FadeAlpha_IsZero_DuringTunnelStates()
{
    var seq = new TeleportAnimSequencer();
    seq.Begin(TeleportEntryKind.Portal);
    seq.Tick(0f, worldReady: false);

    // In Tunnel state, overlay alpha should be 0 (the tunnel viewport is shown; no alpha quad needed)
    var (snap, _) = seq.Tick(0.016f, worldReady: false);
    Assert.Equal(TeleportAnimState.Tunnel, seq.State);
    Assert.Equal(0f, snap.FadeAlpha);
}

[Fact]
public void ShowTunnel_TrueInTunnelFamilyStates_FalseOtherwise()
{
    // Logout path: WorldFadeOut -> TunnelFadeIn -> Tunnel -> TunnelContinue -> TunnelFadeOut -> WorldFadeIn -> Off
    var seq = new TeleportAnimSequencer();
    seq.Begin(TeleportEntryKind.Logout);
    seq.Tick(0f, worldReady: false);

    // WorldFadeOut: ShowTunnel = false
    var (snap, _) = seq.Tick(0.016f, worldReady: false);
    Assert.Equal(TeleportAnimState.WorldFadeOut, seq.State);
    Assert.False(snap.ShowTunnel);

    // Advance to TunnelFadeIn
    DriveSeconds(seq, TeleportAnimSequencer.FadeTime, worldReady: false);
    Assert.Equal(TeleportAnimState.TunnelFadeIn, seq.State);
    var (snap2, _) = seq.Tick(0f, worldReady: false);
    Assert.True(snap2.ShowTunnel);

    // Advance to Tunnel
    DriveSeconds(seq, TeleportAnimSequencer.FadeTime, worldReady: false);
    Assert.Equal(TeleportAnimState.Tunnel, seq.State);
    var (snap3, _) = seq.Tick(0f, worldReady: false);
    Assert.True(snap3.ShowTunnel);
}

[Fact]
public void ShowPleaseWait_TrueOnlyInTunnelContinue()
{
    var seq = new TeleportAnimSequencer();
    seq.Begin(TeleportEntryKind.Portal);
    seq.Tick(0f, worldReady: false);

    // Tunnel: ShowPleaseWait false
    var (snap1, _) = seq.Tick(0.016f, worldReady: false);
    Assert.Equal(TeleportAnimState.Tunnel, seq.State);
    Assert.False(snap1.ShowPleaseWait);

    // Advance to TunnelContinue
    var (snap2, _) = seq.Tick(0.016f, worldReady: true);
    Assert.Equal(TeleportAnimState.TunnelContinue, seq.State);
    Assert.True(snap2.ShowPleaseWait);
}
  • Step 2: Run to verify it fails

    dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests.FadeAlpha_IsZeroAtStart_OfWorldFadeOut"
    

    Expected: FAIL — ComputeFadeAlpha result not yet connected through Tick (the stub returned 0 unconditionally, Task 1.2 hadn't wired BuildSnapshot into the real state in Tick). After Task 1.3 landed, the alpha logic is correct — this test validates it runs through the right smoothstep path. (If Task 1.3 is already green, these may pass immediately; the step 2 confirms they exercise the code path.)

  • Step 3: ImplementComputeFadeAlpha is already implemented in Task 1.2; no new implementation needed. The only refinement: ensure _elapsed is sampled correctly relative to the current state's entry time (already reset to 0f in Advance). No code changes expected if Task 1.3 is green.

  • Step 4: Run to verify it passes

    dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests"
    

    Expected: PASS.

  • Step 5: Commit

    git add tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs
    git commit -m "test(core/slice-1): FadeAlpha endpoint + monotonicity + ShowTunnel/ShowPleaseWait coverage"
    

Task 1.5: Full portal event sequence and logout event sequence

Files:

  • Modify: tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs

  • Step 1: Write the failing tests — exercise the complete ordered event stream for each major path:

// Add to TeleportAnimSequencerTests:

// Helper: run the sequencer to completion collecting ordered events.
private static List<TeleportAnimEvent> RunToOff(
    TeleportEntryKind kind, bool worldReadyAfterFirstTunnel = true, float step = 0.05f)
{
    var seq    = new TeleportAnimSequencer();
    var allEvts = new List<TeleportAnimEvent>();
    seq.Begin(kind);

    int safetyNet = 5000;
    while (seq.IsActive && --safetyNet > 0)
    {
        // Simulate world becoming ready on first Tick inside Tunnel
        bool ready = worldReadyAfterFirstTunnel && seq.State == TeleportAnimState.Tunnel;
        var (_, evts) = seq.Tick(step, worldReady: ready);
        allEvts.AddRange(evts);
    }
    return allEvts;
}

[Fact]
public void Portal_FullSequence_EventsInOrder()
{
    var evts = RunToOff(TeleportEntryKind.Portal);

    // Required events in order: PlayEnterSound, Place, PlayExitSound, FireLoginComplete.
    // EnterTunnel is optional but must come before Place if present.
    int enterIdx  = evts.IndexOf(TeleportAnimEvent.PlayEnterSound);
    int placeIdx  = evts.IndexOf(TeleportAnimEvent.Place);
    int exitIdx   = evts.IndexOf(TeleportAnimEvent.PlayExitSound);
    int loginIdx  = evts.IndexOf(TeleportAnimEvent.FireLoginComplete);

    Assert.True(enterIdx >= 0, "PlayEnterSound must fire");
    Assert.True(placeIdx  >= 0, "Place must fire");
    Assert.True(exitIdx   >= 0, "PlayExitSound must fire");
    Assert.True(loginIdx  >= 0, "FireLoginComplete must fire");

    Assert.True(enterIdx < placeIdx,  "PlayEnterSound must precede Place");
    Assert.True(placeIdx < exitIdx,   "Place must precede PlayExitSound");
    Assert.True(exitIdx  < loginIdx,  "PlayExitSound must precede FireLoginComplete");
}

[Fact]
public void Logout_FullSequence_EventsInOrder_WithEnterTunnelAfterFades()
{
    var evts = RunToOff(TeleportEntryKind.Logout);

    int enterIdx  = evts.IndexOf(TeleportAnimEvent.PlayEnterSound);
    int tunnelIdx = evts.IndexOf(TeleportAnimEvent.EnterTunnel);
    int placeIdx  = evts.IndexOf(TeleportAnimEvent.Place);
    int exitIdx   = evts.IndexOf(TeleportAnimEvent.PlayExitSound);
    int loginIdx  = evts.IndexOf(TeleportAnimEvent.FireLoginComplete);

    Assert.True(enterIdx  >= 0, "PlayEnterSound must fire");
    Assert.True(tunnelIdx >= 0, "EnterTunnel must fire (Logout path goes through WorldFadeOut->TunnelFadeIn->Tunnel)");
    Assert.True(placeIdx  >= 0, "Place must fire");
    Assert.True(exitIdx   >= 0, "PlayExitSound must fire");
    Assert.True(loginIdx  >= 0, "FireLoginComplete must fire");

    Assert.True(enterIdx  < tunnelIdx, "PlayEnterSound must precede EnterTunnel");
    Assert.True(tunnelIdx < placeIdx,  "EnterTunnel must precede Place");
    Assert.True(placeIdx  < exitIdx,   "Place must precede PlayExitSound");
    Assert.True(exitIdx   < loginIdx,  "PlayExitSound must precede FireLoginComplete");
}

[Fact]
public void Portal_NoEventsFiredTwice()
{
    var evts = RunToOff(TeleportEntryKind.Portal);
    Assert.Equal(1, evts.Count(e => e == TeleportAnimEvent.PlayEnterSound));
    Assert.Equal(1, evts.Count(e => e == TeleportAnimEvent.Place));
    Assert.Equal(1, evts.Count(e => e == TeleportAnimEvent.PlayExitSound));
    Assert.Equal(1, evts.Count(e => e == TeleportAnimEvent.FireLoginComplete));
}

[Fact]
public void Death_BehavesIdenticallyToPortal_FullSequence()
{
    var portalEvts = RunToOff(TeleportEntryKind.Portal);
    var deathEvts  = RunToOff(TeleportEntryKind.Death);
    // Same event sequence (both enter at Tunnel)
    Assert.Equal(portalEvts, deathEvts);
}

[Fact]
public void Login_BehavesIdenticallyToPortal_FullSequence()
{
    var portalEvts = RunToOff(TeleportEntryKind.Portal);
    var loginEvts  = RunToOff(TeleportEntryKind.Login);
    Assert.Equal(portalEvts, loginEvts);
}

[Fact]
public void AfterOff_IsActiveIsFalse_AndStateIsOff()
{
    RunToOff(TeleportEntryKind.Portal);
    var seq = new TeleportAnimSequencer();
    seq.Begin(TeleportEntryKind.Portal);

    // Drain to Off
    for (int i = 0; i < 5000 && seq.IsActive; i++)
    {
        bool ready = seq.State == TeleportAnimState.Tunnel;
        seq.Tick(0.05f, worldReady: ready);
    }

    Assert.False(seq.IsActive);
    Assert.Equal(TeleportAnimState.Off, seq.State);
}
  • Step 2: Run to verify it fails

    dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests.Portal_FullSequence_EventsInOrder"
    

    Expected: FAIL — EnterTunnel event not fired correctly through Logout's TunnelFadeIn→Tunnel transition in current stub (Task 1.3's _enterTunnelPending wiring needs verification).

  • Step 3: Implement — verify _enterTunnelPending is flushed at the START of the NEXT Tick, not deferred further. The current Tick() already has:

    if (_enterTunnelPending) { evts.Add(TeleportAnimEvent.EnterTunnel); _enterTunnelPending = false; }
    

    which runs before the state-machine switch. The Advance(TunnelFadeIn→Tunnel, enterTunnel: true) sets _enterTunnelPending = true, which flushes on the next Tick. This is already correct. No code change needed — the RunToOff helper exercises the Logout path which was not hit before. If EnterTunnel fires on the same tick as the transition, it is counted in allEvts correctly because DriveSeconds accumulates all per-tick events.

  • Step 4: Run to verify it passes

    dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests"
    

    Expected: PASS for all sequencer tests.

  • Step 5: Commit

    git add tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs
    git commit -m "test(core/slice-1): full portal+logout event-sequence ordering + no-duplicate-fire coverage"
    

Task 1.6: EnterTunnel fired for Portal path via Logout-to-Tunnel transition correctness + Build green

Files:

  • Modify: src/AcDream.Core/World/TeleportAnimSequencer.cs (if any gap surfaces)

  • Run: dotnet build and full test suite

  • Step 1: Write the failing test — assert EnterTunnel fires on the Portal path (Portal enters Tunnel directly; the Begin sets state to Tunnel but EnterTunnel should still fire since the tunnel is newly entered):

// Add to TeleportAnimSequencerTests:

[Fact]
public void Portal_EmitsEnterTunnel_OnFirstTick()
{
    // Portal begins directly in Tunnel; EnterTunnel signals "world is now hidden".
    var seq = new TeleportAnimSequencer();
    seq.Begin(TeleportEntryKind.Portal);

    // First tick: should emit PlayEnterSound AND EnterTunnel (both on entry)
    var (_, evts) = seq.Tick(0f, worldReady: false);
    Assert.Contains(TeleportAnimEvent.PlayEnterSound, evts);
    Assert.Contains(TeleportAnimEvent.EnterTunnel,   evts);
}
  • Step 2: Run to verify it fails

    dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests.Portal_EmitsEnterTunnel_OnFirstTick"
    

    Expected: FAIL — Begin with Portal kind doesn't set _enterTunnelPending.

  • Step 3: Implement — update Begin() to also set _enterTunnelPending when entering at Tunnel:

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
    _exitSoundPending     = false;
    _loginCompletePending = false;
}
  • Step 4: Run all sequencer tests + build

    dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests"
    dotnet build src/AcDream.Core/AcDream.Core.csproj
    

    Expected: all PASS, build green.

  • Step 5: Commit

    git add src/AcDream.Core/World/TeleportAnimSequencer.cs tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs
    git commit -m "feat(core/slice-1): Begin() sets EnterTunnel pending for portal/login/death entry; all sequencer tests pass"
    

Task 1.7: Full build + full test suite green; register divergence row

Files:

  • Modify: docs/architecture/retail-divergence-register.md (add smoothstep row per spec §5 / §3.2)

  • Step 1: No test to write — this is the completion gate.

  • Step 2: Run full suite:

    dotnet build
    dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj
    

    Expected: build green; all tests pass (including pre-existing suite).

  • Step 3: Implement — add the divergence-register row. Read the existing register to find the insertion point: Open docs/architecture/retail-divergence-register.md, locate the table body, and add one row:

    | TAS-smoothstep | TeleportAnimSequencer.ComputeFadeAlpha | GetAnimLevel 1024-entry table approximated as smoothstep | approximation | src/AcDream.Core/World/TeleportAnimSequencer.cs | retire if GetAnimLevel table resolved via cdb read (spec §8) |
    

    (Exact column order matches the existing register rows — read the file header before inserting.)

  • Step 4: Build + test green (same commands as Step 2).

  • Step 5: Commit

    git add docs/architecture/retail-divergence-register.md
    git commit -m "docs(slice-1): retail-divergence-register — TAS smoothstep approximation row (spec §5 row 2)"
    

Slice 2 Task List — Readiness Gate (#145 Residual / Work-Item-A Fix)


Task 2.1: Add PhysicsEngine.IsLandblockLoaded with unit test

Files:

  • Modify: src/AcDream.Core/Physics/PhysicsEngine.cs (after LandblockCount property, line 32)

  • Test: tests/AcDream.Core.Tests/Physics/PhysicsEngineIsLandblockLoadedTests.cs (new file)

  • Step 1: Write the failing test

// tests/AcDream.Core.Tests/Physics/PhysicsEngineIsLandblockLoadedTests.cs
using AcDream.Core.Physics;
using Xunit;

namespace AcDream.Core.Tests.Physics;

public class PhysicsEngineIsLandblockLoadedTests
{
    private static TerrainSurface MakeFlatTerrain()
    {
        var heights = new byte[81];
        Array.Fill(heights, (byte)50);
        var heightTable = new float[256];
        for (int i = 0; i < 256; i++) heightTable[i] = i * 1f;
        return new TerrainSurface(heights, heightTable);
    }

    [Fact]
    public void IsLandblockLoaded_AbsentId_ReturnsFalse()
    {
        var engine = new PhysicsEngine();
        Assert.False(engine.IsLandblockLoaded(0xA9B4FFFFu));
    }

    [Fact]
    public void IsLandblockLoaded_AddedId_ReturnsTrue()
    {
        var engine = new PhysicsEngine();
        engine.AddLandblock(0xA9B4FFFFu, MakeFlatTerrain(),
            Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(),
            worldOffsetX: 0f, worldOffsetY: 0f);

        Assert.True(engine.IsLandblockLoaded(0xA9B4FFFFu));
    }

    [Fact]
    public void IsLandblockLoaded_AnotherIdAbsent_ReturnsFalse()
    {
        var engine = new PhysicsEngine();
        engine.AddLandblock(0xA9B4FFFFu, MakeFlatTerrain(),
            Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(),
            worldOffsetX: 0f, worldOffsetY: 0f);

        Assert.False(engine.IsLandblockLoaded(0xDEADBEEFu));
    }

    [Fact]
    public void IsLandblockLoaded_AfterRemove_ReturnsFalse()
    {
        var engine = new PhysicsEngine();
        engine.AddLandblock(0xA9B4FFFFu, MakeFlatTerrain(),
            Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(),
            worldOffsetX: 0f, worldOffsetY: 0f);
        engine.RemoveLandblock(0xA9B4FFFFu);

        Assert.False(engine.IsLandblockLoaded(0xA9B4FFFFu));
    }
}
  • Step 2: Run to verify it fails

    Run: dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~PhysicsEngineIsLandblockLoadedTests"

    Expected: FAIL — CS1061: 'PhysicsEngine' does not contain a definition for 'IsLandblockLoaded'.

  • Step 3: Implement

    In src/AcDream.Core/Physics/PhysicsEngine.cs, after LandblockCount (line 32):

    /// <summary>
    /// Returns true iff a landblock with this id has been registered via
    /// <see cref="AddLandblock"/>. Because <see cref="AddLandblock"/> registers
    /// terrain + collision cells + portals atomically, ContainsKey implies the
    /// collision mesh is present and resolvable.
    /// </summary>
    public bool IsLandblockLoaded(uint landblockId) =>
        _landblocks.ContainsKey(landblockId);
    
  • Step 4: Run to verify it passes

    Run: dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~PhysicsEngineIsLandblockLoadedTests"

    Expected: PASS (4 tests, all green).

  • Step 5: Commit

    git add src/AcDream.Core/Physics/PhysicsEngine.cs tests/AcDream.Core.Tests/Physics/PhysicsEngineIsLandblockLoadedTests.cs && git commit -m "feat(slice2): add PhysicsEngine.IsLandblockLoaded — readiness gate §3.4"
    

Task 2.2: Extend TeleportArrivalRules.Decide with outdoorReady and update truth-table tests

Files:

  • Modify: src/AcDream.App/World/TeleportArrivalController.cs (TeleportArrivalRules.Decide, line 119)

  • Modify: tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs (extend existing tests + add new ones)

  • Step 1: Write the failing test

    Replace the content of tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs:

    using AcDream.App.World;
    using Xunit;
    
    namespace AcDream.App.Tests.World;
    
    /// <summary>
    /// Full truth-table for TeleportArrivalRules.Decide after the §3.4 readiness-gate change.
    /// </summary>
    public class TeleportArrivalRulesTests
    {
        [Fact]
        public void Unhydratable_IsImpossible_Outdoor()
        {
            Assert.Equal(ArrivalReadiness.Impossible,
                TeleportArrivalRules.Decide(
                    claimUnhydratable: true, indoor: false,
                    indoorCellReady: false, outdoorReady: false));
        }
    
        [Fact]
        public void Unhydratable_IsImpossible_Indoor()
        {
            Assert.Equal(ArrivalReadiness.Impossible,
                TeleportArrivalRules.Decide(
                    claimUnhydratable: true, indoor: true,
                    indoorCellReady: true, outdoorReady: false));
        }
    
        [Fact]
        public void Indoor_CellReady_IsReady()
        {
            Assert.Equal(ArrivalReadiness.Ready,
                TeleportArrivalRules.Decide(
                    claimUnhydratable: false, indoor: true,
                    indoorCellReady: true, outdoorReady: false));
        }
    
        [Fact]
        public void Indoor_CellNotReady_IsNotReady()
        {
            Assert.Equal(ArrivalReadiness.NotReady,
                TeleportArrivalRules.Decide(
                    claimUnhydratable: false, indoor: true,
                    indoorCellReady: false, outdoorReady: false));
        }
    
        [Fact]
        public void Outdoor_LandblockLoaded_IsReady()
        {
            Assert.Equal(ArrivalReadiness.Ready,
                TeleportArrivalRules.Decide(
                    claimUnhydratable: false, indoor: false,
                    indoorCellReady: false, outdoorReady: true));
        }
    
        [Fact]
        public void Outdoor_LandblockNotLoaded_IsNotReady()
        {
            // §3.4: the #145 residual — outdoor arrival on a not-yet-streamed landblock
            // must hold. Previously returned Ready immediately.
            Assert.Equal(ArrivalReadiness.NotReady,
                TeleportArrivalRules.Decide(
                    claimUnhydratable: false, indoor: false,
                    indoorCellReady: false, outdoorReady: false));
        }
    }
    
  • Step 2: Run to verify it fails

    Run: dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportArrivalRulesTests"

    Expected: FAIL — Outdoor_LandblockNotLoaded_IsNotReady fails (current code returns Ready for all outdoor), and Outdoor_LandblockLoaded_IsReady fails with a compile error because the new parameter doesn't exist yet.

  • Step 3: Implement

    In src/AcDream.App/World/TeleportArrivalController.cs, replace TeleportArrivalRules.Decide (lines 119143):

    /// <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>
    /// <param name="outdoorReady">For an outdoor destination, the physics landblock is loaded
    ///     (<see cref="PhysicsEngine.IsLandblockLoaded"/> returned true for the destination landblock id).
    ///     Ignored for indoor destinations.</param>
    public static ArrivalReadiness Decide(
        bool claimUnhydratable,
        bool indoor,
        bool indoorCellReady,
        bool outdoorReady)
    {
        if (claimUnhydratable)
            return ArrivalReadiness.Impossible;
    
        // Indoor (sealed dungeon / building interior): gate on the EnvCell floor hydrating,
        // exactly as #135. Unaffected by the outdoor-landblock gate.
        if (indoor)
            return indoorCellReady ? ArrivalReadiness.Ready : ArrivalReadiness.NotReady;
    
        // Outdoor (#145 §3.4): hold until the physics landblock is registered.
        // AddLandblock is atomic (terrain + cells + portals in one write — PhysicsEngine.cs:70),
        // so ContainsKey implies the collision mesh is present and resolvable. The hold is
        // self-resolving: streaming progresses unconditionally during the hold because the
        // streaming controller runs before TeleportArrivalController.Tick (GameWindow.cs:7420-7551).
        // The 10 s timeout safety-net (_maxHoldFrames) remains active.
        return outdoorReady ? ArrivalReadiness.Ready : ArrivalReadiness.NotReady;
    }
    
  • Step 4: Run to verify it passes

    Run: dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportArrivalRulesTests"

    Expected: PASS (6 tests, all green).

  • Step 5: Commit

    git add src/AcDream.App/World/TeleportArrivalController.cs tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs && git commit -m "feat(slice2): TeleportArrivalRules.Decide — outdoor holds until landblock loaded (#145 §3.4)"
    

Task 2.3: Fix the compile break — update the one Decide call-site in GameWindow.cs

The signature change in Task 2.2 breaks the call at GameWindow.cs:5528. This task wires outdoorReady and verifies the build is green. There is no unit test for GameWindow wiring (god object); verification is build-green + a described manual run.

Files:

  • Modify: src/AcDream.App/Rendering/GameWindow.cs (method TeleportArrivalReadiness, around line 5513)

  • Step 1: Locate the anchorGameWindow.cs:5528:

    // existing line 5528:
    return AcDream.App.World.TeleportArrivalRules.Decide(
        claimUnhydratable, indoor, indoorCellReady);
    
  • Step 2: Apply the edit — replace the three-argument call with a four-argument call:

    // After the existing:
    //   bool indoor = (destCell & 0xFFFFu) >= 0x0100u;
    //   bool indoorCellReady = indoor && !claimUnhydratable
    //       && _physicsEngine.IsSpawnCellReady(destCell);
    // Add:
    bool outdoorReady = !indoor
        && _physicsEngine.IsLandblockLoaded(destCell & 0xFFFF0000u);
    
    return AcDream.App.World.TeleportArrivalRules.Decide(
        claimUnhydratable, indoor, indoorCellReady, outdoorReady);
    

    The full method body after the edit:

    private AcDream.App.World.ArrivalReadiness TeleportArrivalReadiness(
        System.Numerics.Vector3 destPos, uint destCell)
    {
        bool claimUnhydratable = IsSpawnClaimUnhydratable(destCell);
    
        bool indoor = (destCell & 0xFFFFu) >= 0x0100u;
        bool indoorCellReady = indoor && !claimUnhydratable
            && _physicsEngine.IsSpawnCellReady(destCell);
        bool outdoorReady = !indoor
            && _physicsEngine.IsLandblockLoaded(destCell & 0xFFFF0000u);
    
        return AcDream.App.World.TeleportArrivalRules.Decide(
            claimUnhydratable, indoor, indoorCellReady, outdoorReady);
    }
    
  • Step 3: Build green

    Run: dotnet build src/AcDream.App/AcDream.App.csproj -c Debug

    Expected: zero errors.

  • Step 4: Commit

    git add src/AcDream.App/Rendering/GameWindow.cs && git commit -m "feat(slice2): wire outdoorReady into TeleportArrivalReadiness (GameWindow)"
    

Task 2.4: Add ProbeArrivalGate flag to PhysicsDiagnostics and log the NotReady → Ready transition

The existing PhysicsDiagnostics pattern (read PhysicsDiagnostics.cs:3132 for the template): a static bool property seeded from an env var, listed in ResetForTest(). The probe log lives in TeleportArrivalReadiness in GameWindow.cs.

Files:

  • Modify: src/AcDream.Core/Physics/PhysicsDiagnostics.cs (add property + ResetForTest entry)
  • Modify: src/AcDream.App/Rendering/GameWindow.cs (add _lastArrivalVerdict field + probe emission in TeleportArrivalReadiness)

There is no unit test for a diagnostic emission; verification is the described manual run in Task 2.5.

  • Step 1: Add the probe flag to PhysicsDiagnostics

    In src/AcDream.Core/Physics/PhysicsDiagnostics.cs, after ProbeResolveEnabled (after line 32):

    /// <summary>
    /// Retail teleport readiness gate (§3.4, 2026-06-21). When true, every transition of
    /// the <c>TeleportArrivalReadiness</c> verdict (NotReady → Ready / Impossible) emits one
    /// <c>[arrival-gate]</c> line: the destination cell id, landblock id, verdict,
    /// <c>PhysicsEngine.LandblockCount</c> at the moment it flips, and whether the
    /// flip was forced (Impossible) or natural (Ready).
    ///
    /// <para>Initial state from <c>ACDREAM_PROBE_RESOLVE=1</c> — shares the existing probe
    /// flag because this is a readiness-path extension; no new env var needed.</para>
    /// </summary>
    public static bool ProbeArrivalGateEnabled { get; set; } =
        Environment.GetEnvironmentVariable("ACDREAM_PROBE_RESOLVE") == "1";
    

    In PhysicsDiagnostics.ResetForTest() (line 536 area), add after ProbeResolveEnabled = false;:

    ProbeArrivalGateEnabled       = false;
    
  • Step 2: Add the transition-tracking field and probe emission to GameWindow

    In src/AcDream.App/Rendering/GameWindow.cs, near the TeleportArrivalController field (grep for _teleportArrival to find the declaration block), add:

    private AcDream.App.World.ArrivalReadiness _lastArrivalVerdict = AcDream.App.World.ArrivalReadiness.NotReady;
    

    Then in TeleportArrivalReadiness (after computing the verdict and before returning), add the probe emission:

    private AcDream.App.World.ArrivalReadiness TeleportArrivalReadiness(
        System.Numerics.Vector3 destPos, uint destCell)
    {
        bool claimUnhydratable = IsSpawnClaimUnhydratable(destCell);
    
        bool indoor = (destCell & 0xFFFFu) >= 0x0100u;
        bool indoorCellReady = indoor && !claimUnhydratable
            && _physicsEngine.IsSpawnCellReady(destCell);
        bool outdoorReady = !indoor
            && _physicsEngine.IsLandblockLoaded(destCell & 0xFFFF0000u);
    
        var verdict = AcDream.App.World.TeleportArrivalRules.Decide(
            claimUnhydratable, indoor, indoorCellReady, outdoorReady);
    
        if (AcDream.Core.Physics.PhysicsDiagnostics.ProbeArrivalGateEnabled
            && verdict != _lastArrivalVerdict
            && verdict != AcDream.App.World.ArrivalReadiness.NotReady)
        {
            Console.WriteLine(System.FormattableString.Invariant(
                $"[arrival-gate] destCell=0x{destCell:X8} " +
                $"landblock=0x{(destCell & 0xFFFF0000u):X8} " +
                $"verdict={verdict} indoor={indoor} " +
                $"landblockCount={_physicsEngine.LandblockCount}"));
        }
        _lastArrivalVerdict = verdict;
    
        return verdict;
    }
    

    Reset _lastArrivalVerdict to NotReady in BeginArrival (i.e. wherever _teleportArrival.BeginArrival is called) so each new hold starts fresh. Find the BeginArrival call site by searching for _teleportArrival.BeginArrival in GameWindow.cs; immediately before or after that call add:

    _lastArrivalVerdict = AcDream.App.World.ArrivalReadiness.NotReady;
    
  • Step 3: Build green

    Run: dotnet build src/AcDream.App/AcDream.App.csproj -c Debug

    Expected: zero errors, zero warnings on the touched files.

  • Step 4: Commit

    git add src/AcDream.Core/Physics/PhysicsDiagnostics.cs src/AcDream.App/Rendering/GameWindow.cs && git commit -m "feat(slice2): arrival-gate probe — log NotReady→Ready flip with landblock count"
    

Task 2.5: Apparatus re-test — verify the gate on the 0xC98C edge-arrival scenario

This task has no automated xunit test — it is a described manual run against a live session using the probe.

What the test proves: The readiness gate correctly holds an outdoor arrival until AddLandblock fires for the destination, then flips Ready in the same streaming frame — no cell-march, no Z free-fall.

  • Step 1: Launch with the probe enabled

    In PowerShell:

    $env:ACDREAM_DAT_DIR   = "$env:USERPROFILE\Documents\Asheron's Call"
    $env:ACDREAM_LIVE      = "1"
    $env:ACDREAM_TEST_HOST = "127.0.0.1"
    $env:ACDREAM_TEST_PORT = "9000"
    $env:ACDREAM_TEST_USER = "testaccount"
    $env:ACDREAM_TEST_PASS = "testpassword"
    $env:ACDREAM_PROBE_RESOLVE = "1"   # gates both ProbeResolveEnabled and ProbeArrivalGateEnabled
    dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Debug 2>&1 | Tee-Object -FilePath "launch-gate-retest.log"
    
  • Step 2: Reproduce the 0xC98C scenario

    Use the /teleport GM command to portal to a landblock near a streaming edge — ideally the same destination (0xC98C…) documented in launch5.log. Alternatively, use /tp 0xC98C0200 or portal via a gate that forces a cross-landblock hop.

  • Step 3: Inspect the log

    Look for [arrival-gate] lines in launch-gate-retest.log. The expected pattern:

    [arrival-gate] destCell=0xC98C0200 landblock=0xC98C0000 verdict=Ready indoor=False landblockCount=N
    live: teleport complete — snapped to (...) cell=0xC98C...
    

    The [arrival-gate] line MUST appear before the live: teleport complete line. landblockCount should be ≥ 1 at the moment the gate flips.

    The probe MUST NOT be followed by any sequence of [resolve] lines that show branch=NO-LANDBLOCK after the placement — that is the Z-free-fall symptom. If NO-LANDBLOCK lines appear after live: teleport complete, the fix has a gap and must be investigated before closing.

  • Step 4: Confirm no regression on indoor arrival

    Enter a dungeon (walk into a portal in Holtburg). The log should show [arrival-gate] indoor=True lines while NotReady, then a flip to Ready once the EnvCell floor hydrates — same behavior as before. The change must not regress the indoor path.

  • Step 5: Commit result note

    If the run passes, record a one-line note in the commit that closes this apparatus task:

    git commit --allow-empty -m "test(slice2): apparatus re-test passed — arrival gate flips Ready before placement on 0xC98C edge arrival; no NO-LANDBLOCK march observed"
    

    If the run reveals a gap, file it as a new issue before continuing.


Task 2.6: Full test suite green + run-all confirmation

  • Step 1: Run the full suite

    dotnet test AcDream.slnx -c Debug
    

    Expected: all tests pass. No new test failures introduced by the Decide signature change. (The only existing caller was TeleportArrivalRulesTests.cs, which was updated in Task 2.2. TeleportArrivalControllerTests.cs exercises the controller via the delegate — not Decide directly — so it is unaffected.)

  • Step 2: Confirm TeleportArrivalControllerTests still passes

    Run: dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportArrivalControllerTests"

    Expected: PASS (the controller's injected _readiness delegate passes ArrivalReadiness values directly; it does not call Decide internally, so the signature change is invisible to it).

  • Step 3: Commit (if not already committed per-task above)

    No additional commit if each task was committed individually. Otherwise:

    git add -u && git commit -m "feat(slice2): readiness gate complete — IsLandblockLoaded + Decide(outdoorReady) + probe (#145 §3.4)"
    

Slice 3: TeleportFlowController + TeleportFadeOverlay (portal through the sequencer + fade)

Task 3.1 — TeleportFlowController (delegate-injected, unit-tested)

Files:

  • Create src/AcDream.App/World/TeleportFlowController.cs (namespace AcDream.App.World).

  • Test: tests/AcDream.App.Tests/World/TeleportFlowControllerTests.cs (match style of tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs:1-147record-based call spies, static Make helper, [Fact]).

  • Consumes (do NOT redefine): AcDream.Core.World.TeleportAnimSequencer / TeleportAnimState / TeleportEntryKind / TeleportAnimEvent / TeleportAnimSnapshot (slice 1); AcDream.App.World.ArrivalReadiness (src/AcDream.App/World/TeleportArrivalController.cs:7-19).

  • Step 1: Write the failing test. Real xunit, mirroring TeleportArrivalControllerTests spy style:

using System.Collections.Generic;
using System.Numerics;
using AcDream.App.World;
using AcDream.Core.World;
using Xunit;

namespace AcDream.App.Tests.World;

public class TeleportFlowControllerTests
{
    private sealed record PlaceCall(Vector3 Pos, uint Cell, bool Forced);

    private sealed class Spies
    {
        public readonly List<PlaceCall> Placed = new();
        public int LoginComplete;
        public int LogoutDisconnect;
        public int EnterSound;
        public int ExitSound;
    }

    // readinessQueue: one verdict per Tick (last value repeats once exhausted).
    private static TeleportFlowController Make(
        Spies s, Queue<ArrivalReadiness> readinessQueue,
        int maxHoldFrames = TeleportFlowController.DefaultMaxHoldFrames)
    {
        ArrivalReadiness last = ArrivalReadiness.NotReady;
        return new TeleportFlowController(
            readiness: (_, _) => { if (readinessQueue.Count > 0) last = readinessQueue.Dequeue(); return last; },
            onPlace: (pos, cell, forced) => s.Placed.Add(new PlaceCall(pos, cell, forced)),
            onLoginComplete: () => s.LoginComplete++,
            onLogoutDisconnect: () => s.LogoutDisconnect++,
            onPlayEnterSound: () => s.EnterSound++,
            onPlayExitSound: () => s.ExitSound++,
            maxHoldFrames: maxHoldFrames);
    }

    // Drive enough fixed-dt ticks (1/60s) to walk a portal run all the way to OFF.
    private static void RunToOff(TeleportFlowController c, int maxTicks = 2000)
    {
        for (int i = 0; i < maxTicks && c.IsActive; i++) c.Tick(1f / 60f);
    }

    [Fact]
    public void Tick_BeforeBegin_IsNoOp()
    {
        var s = new Spies();
        var c = Make(s, new Queue<ArrivalReadiness>());
        c.Tick(1f / 60f);
        Assert.False(c.IsActive);
        Assert.Empty(s.Placed);
    }

    [Fact]
    public void Portal_FullRun_FiresEnterPlaceExitLoginComplete_InOrder()
    {
        var s = new Spies();
        // readiness Ready from the first probe so the tunnel exits as soon as it can hold.
        var q = new Queue<ArrivalReadiness>();
        q.Enqueue(ArrivalReadiness.Ready);
        var c = Make(s, q);

        c.Begin(TeleportEntryKind.Portal, new Vector3(10, 20, 6), 0x01250126u);
        RunToOff(c);

        Assert.False(c.IsActive);
        Assert.Equal(1, s.EnterSound);
        var place = Assert.Single(s.Placed);
        Assert.False(place.Forced);
        Assert.Equal(0x01250126u, place.Cell);
        Assert.Equal(new Vector3(10, 20, 6), place.Pos);
        Assert.Equal(1, s.ExitSound);
        Assert.Equal(1, s.LoginComplete);
        Assert.Equal(0, s.LogoutDisconnect);
    }

    [Fact]
    public void Begin_Idempotent_SecondBeginWithCoords_DoesNotRestartSequencer()
    {
        var s = new Spies();
        var q = new Queue<ArrivalReadiness>();
        q.Enqueue(ArrivalReadiness.NotReady); // hold in TUNNEL after first probe
        var c = Make(s, q);

        c.Begin(TeleportEntryKind.Portal);                 // no coords (OnTeleportStarted)
        var stateAfterFirst = c.Snapshot.State;            // sequencer began (Tunnel)
        c.Tick(1f / 60f);                                  // holding; no coords -> worldReady false for Portal
        c.Begin(TeleportEntryKind.Portal, new Vector3(1, 2, 3), 0x01250126u); // coords arrive

        // Still active, still no premature place: the second Begin must NOT re-call _seq.Begin.
        Assert.True(c.IsActive);
        Assert.Empty(s.Placed);
        Assert.Equal(TeleportAnimState.Tunnel, c.Snapshot.State);
    }

    [Fact]
    public void Logout_FullRun_FiresLogoutDisconnect_NotLoginComplete()
    {
        var s = new Spies();
        var c = Make(s, new Queue<ArrivalReadiness>()); // no dest -> Logout worldReady=true intrinsically
        c.Begin(TeleportEntryKind.Logout);
        RunToOff(c);

        Assert.False(c.IsActive);
        Assert.Equal(1, s.LogoutDisconnect);
        Assert.Equal(0, s.LoginComplete);
        Assert.Empty(s.Placed);              // _haveDest false -> Place event is a no-op
        Assert.Equal(1, s.ExitSound);
    }

    [Fact]
    public void Impossible_ForcesPlace_WithForcedTrue()
    {
        var s = new Spies();
        var q = new Queue<ArrivalReadiness>();
        q.Enqueue(ArrivalReadiness.Impossible);
        var c = Make(s, q);
        c.Begin(TeleportEntryKind.Portal, new Vector3(4, 5, 6), 0x0125FF00u);
        RunToOff(c);
        var place = Assert.Single(s.Placed);
        Assert.True(place.Forced);
    }
}
  • Step 2: Run to verify it failsdotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportFlowControllerTests". Expected: FAIL — TeleportFlowController does not exist (compile error).

  • Step 3: Implement the pinned class verbatim:

using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.World;

namespace AcDream.App.World;

/// <summary>
/// Phase B (retail teleport flow): the visual/timing/lock state machine that wraps
/// the pure <see cref="TeleportAnimSequencer"/> (the 7-state TAS machine) around the
/// existing arrival-hold readiness probe. Single owner of "a teleport is happening".
///
/// <para>All side effects are injected delegates (same pure-seam style as
/// <see cref="TeleportArrivalController"/>): readiness probe, placement, login-complete,
/// logout teardown, and the two portal sounds. Carries no GL / dat / net dependency
/// directly, so it is fully unit-testable.</para>
///
/// <para>Drives: the sequencer per frame with a <c>worldReady</c> gate computed from the
/// readiness probe; on the sequencer's <see cref="TeleportAnimEvent.Place"/> it runs the
/// placement (Resolve + SetPosition + camera); at <see cref="TeleportAnimEvent.FireLoginComplete"/>
/// (sequencer reached <see cref="TeleportAnimState.Off"/>) it either disconnects (Logout)
/// or sends LoginComplete + flips PortalSpace->InWorld (all other kinds). The input lock
/// therefore persists the WHOLE animation, not just until placement.</para>
/// </summary>
public sealed class TeleportFlowController
{
    /// <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> _onPlace;       // (destPos, destCell, forced)
    private readonly Action _onLoginComplete;
    private readonly Action _onLogoutDisconnect;
    private readonly Action _onPlayEnterSound;
    private readonly Action _onPlayExitSound;
    private readonly int _maxHoldFrames;

    private readonly TeleportAnimSequencer _seq = new();

    private TeleportEntryKind _kind;
    private Vector3 _destPos;
    private uint _destCell;
    private bool _haveDest;
    private int _heldFrames;
    private bool _forced;

    public TeleportFlowController(
        Func<Vector3, uint, ArrivalReadiness> readiness,
        Action<Vector3, uint, bool> onPlace,
        Action onLoginComplete,
        Action onLogoutDisconnect,
        Action onPlayEnterSound,
        Action onPlayExitSound,
        int maxHoldFrames = DefaultMaxHoldFrames)
    {
        _readiness = readiness ?? throw new ArgumentNullException(nameof(readiness));
        _onPlace = onPlace ?? throw new ArgumentNullException(nameof(onPlace));
        _onLoginComplete = onLoginComplete ?? throw new ArgumentNullException(nameof(onLoginComplete));
        _onLogoutDisconnect = onLogoutDisconnect ?? throw new ArgumentNullException(nameof(onLogoutDisconnect));
        _onPlayEnterSound = onPlayEnterSound ?? throw new ArgumentNullException(nameof(onPlayEnterSound));
        _onPlayExitSound = onPlayExitSound ?? throw new ArgumentNullException(nameof(onPlayExitSound));
        _maxHoldFrames = maxHoldFrames;
    }

    /// <summary>Last snapshot from the sequencer — drives the fade overlay alpha + tunnel/please-wait flags.</summary>
    public TeleportAnimSnapshot Snapshot { get; private set; }

    public bool IsActive => _seq.IsActive;

    /// <summary>Start (or supply coords to) a teleport flow. Idempotent on the sequencer:
    /// the first call (OnTeleportStarted, no coords) begins it; a later call with coords
    /// (OnLivePositionUpdated) fills the destination WITHOUT restarting the animation.</summary>
    public void Begin(TeleportEntryKind kind, Vector3 destPos = default, uint destCell = 0)
    {
        _kind = kind;
        if (destCell != 0)
        {
            _destPos = destPos;
            _destCell = destCell;
            _haveDest = true;
            _heldFrames = 0;
        }
        if (!_seq.IsActive)
            _seq.Begin(kind);
    }

    public void Tick(float dt)
    {
        if (!_seq.IsActive) return;

        bool worldReady;
        if (_haveDest)
        {
            ArrivalReadiness v = _readiness(_destPos, _destCell);
            _heldFrames++;
            bool forced = v == ArrivalReadiness.Impossible || _heldFrames >= _maxHoldFrames;
            worldReady = v == ArrivalReadiness.Ready || forced;
            _forced = forced && v != ArrivalReadiness.Ready;
        }
        else
        {
            // Logout has no destination world to load; Portal/Death/Login wait for coords.
            worldReady = _kind == TeleportEntryKind.Logout;
        }

        var (snap, events) = _seq.Tick(dt, worldReady);
        Snapshot = snap;

        foreach (var e in events)
        {
            switch (e)
            {
                case TeleportAnimEvent.PlayEnterSound:
                    _onPlayEnterSound();
                    break;
                case TeleportAnimEvent.PlayExitSound:
                    _onPlayExitSound();
                    break;
                case TeleportAnimEvent.Place:
                    if (_haveDest) _onPlace(_destPos, _destCell, _forced);
                    break;
                case TeleportAnimEvent.FireLoginComplete:
                    if (_kind == TeleportEntryKind.Logout) _onLogoutDisconnect();
                    else _onLoginComplete();
                    break;
                case TeleportAnimEvent.EnterTunnel:
                    // no-op — the sequencer drives the tunnel visual state itself.
                    break;
            }
        }
    }
}
  • Step 4: Run to verify it passes — same --filter. Expected: PASS.
  • Step 5: Commitgit add src/AcDream.App/World/TeleportFlowController.cs tests/AcDream.App.Tests/World/TeleportFlowControllerTests.cs && git commit -m "feat(teleport): Slice 3 — TeleportFlowController wrapping the TAS sequencer with delegate side-effects"

Task 3.2 — TeleportFadeOverlay (full-screen alpha-black UiElement)

Files:

  • Create src/AcDream.App/UI/TeleportFadeOverlay.cs (namespace AcDream.App.UI).
  • Test: tests/AcDream.App.Tests/UI/TeleportFadeOverlayTests.cs (match a UiElement test, e.g. tests/AcDream.App.Tests/UI/UiMeterTests.cs style — these tests assert geometry/state without a live GL context).
  • Depends on: UiElement (src/AcDream.App/UI/UiElement.cs:32, virtual OnDraw(UiRenderContext) at :168, Visible at :70, Width/Height at :50-51); UiRenderContext.DrawRect(float,float,float,float,Vector4) (src/AcDream.App/UI/UiRenderContext.cs:78); UiRenderContext.ScreenSize (:19); UiElement.ClickThrough (:78); UiElement.ZOrder (:94).

The overlay holds a settable Alpha (0..1). When Alpha <= 0 it sets Visible = false (so it never participates in hit-test or draw). Because the overlay has no public render-surface seam that's GL-free, the testable logic is "Alpha→Visible gating + the draw color it would emit". Extract that into a pure method FillColor() so the test asserts it without a UiRenderContext.

  • Step 1: Write the failing test:
using System.Numerics;
using AcDream.App.UI;
using Xunit;

namespace AcDream.App.Tests.UI;

public class TeleportFadeOverlayTests
{
    [Fact]
    public void Alpha_Zero_IsInvisible()
    {
        var o = new TeleportFadeOverlay { Alpha = 0f };
        Assert.False(o.Visible);
    }

    [Fact]
    public void Alpha_Positive_IsVisible_AndBlackWithThatAlpha()
    {
        var o = new TeleportFadeOverlay { Alpha = 0.5f };
        Assert.True(o.Visible);
        Assert.Equal(new Vector4(0f, 0f, 0f, 0.5f), o.FillColor());
    }

    [Fact]
    public void Alpha_Clamps_To_Unit_Range()
    {
        var o = new TeleportFadeOverlay { Alpha = 5f };
        Assert.Equal(1f, o.FillColor().W);
        o.Alpha = -3f;
        Assert.False(o.Visible);
        Assert.Equal(0f, o.FillColor().W);
    }
}
  • Step 2: Run to verify it failsdotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportFadeOverlayTests". Expected: FAIL — TeleportFadeOverlay does not exist.

  • Step 3: Implement:

using System;
using System.Numerics;

namespace AcDream.App.UI;

/// <summary>
/// Phase B (retail teleport flow): a top-most <see cref="UiElement"/> that fills the
/// whole screen with <c>(0,0,0,Alpha)</c>. The <see cref="TeleportFlowController"/> drives
/// <see cref="Alpha"/> from <c>Snapshot.FadeAlpha</c> each frame (0 = clear world, 1 = full
/// black). Added once to the <see cref="UiRoot"/> (highest ZOrder so it sits over panels).
///
/// <para>Divergence-register row: black fade via an alpha overlay quad, NOT retail's
/// camera near-plane collapse (Render::set_vdst to 0.001). Visually identical, no
/// projection surgery — see the design doc §3.3.</para>
/// </summary>
public sealed class TeleportFadeOverlay : UiElement
{
    private float _alpha;

    /// <summary>0..1 black-fade alpha. Setting it &lt;= 0 hides the overlay entirely so it
    /// never draws and never participates in hit-testing.</summary>
    public float Alpha
    {
        get => _alpha;
        set
        {
            _alpha = Math.Clamp(value, 0f, 1f);
            Visible = _alpha > 0f;
        }
    }

    public TeleportFadeOverlay()
    {
        // Decoration: never claim the pointer (the world / panels stay interactive when clear,
        // and during a full-black hold the input lock is enforced separately by PortalSpace).
        ClickThrough = true;
        Visible = false;
        ZOrder = int.MaxValue;   // over every retail-UI panel
    }

    /// <summary>The fill color this overlay emits — pure black at the current alpha.
    /// Pure (no GL) so it can be asserted in a unit test.</summary>
    public Vector4 FillColor() => new(0f, 0f, 0f, _alpha);

    protected override void OnDraw(UiRenderContext ctx)
    {
        if (_alpha <= 0f) return;
        // Cover the entire screen regardless of this element's Left/Top (Root sits at origin,
        // so local == screen). DrawRect composites after sprites — the fade must cover them.
        var size = ctx.ScreenSize;
        ctx.DrawRect(0f, 0f, size.X, size.Y, FillColor());
    }
}
  • Step 4: Run to verify it passes — same --filter. Expected: PASS.
  • Step: Add divergence-register row TF-FADE — the alpha-overlay deviation is introduced here, so its row lands in this commit (CLAUDE.md register rule). In docs/architecture/retail-divergence-register.md, matching the existing column order, add (design §5 row 1):
| TF-FADE | Adaptation | TeleportFadeOverlay fills the screen with a black alpha quad instead of retail's camera near-plane collapse (Render::set_vdst -> 0.001, TAS §2.2). Visually identical. | src/AcDream.App/UI/TeleportFadeOverlay.cs | Edge-pixel depth-precision differences only; tolerable. |
  • Step 5: Commitgit add src/AcDream.App/UI/TeleportFadeOverlay.cs tests/AcDream.App.Tests/UI/TeleportFadeOverlayTests.cs docs/architecture/retail-divergence-register.md && git commit -m "feat(teleport): Slice 3 — TeleportFadeOverlay full-screen alpha-black fade quad + TF-FADE register row"

Task 3.3 — GameWindow: wire the flow controller + overlay for the PORTAL path (integration, build-green + manual)

Files: src/AcDream.App/Rendering/GameWindow.cs (god object — no unit test; per the structure rules new LOGIC lives in 3.1/3.2, this task is wiring only).

Anchors (all line numbers from the current tree):

  • OnTeleportStarted at :5579.

  • OnLivePositionUpdated portal-space branch / BeginArrival call at :5472-5474.

  • PlaceTeleportArrival body at :5534-5570 (the SPLIT target).

  • EnsureTeleportArrivalController at :5501.

  • Per-frame _teleportArrival?.Tick(); at :7555.

  • _uiHost.Draw(...) at :8994.

  • _audioEngine/_soundCache/_dats fields at :250-251/:28.

  • GameActionLoginComplete.Build() (src/AcDream.Core.Net/Messages/GameActionLoginComplete.cs).

  • Step 1 (edit): Add fields + a lazy ensure near _teleportArrival (:5482):

// Phase B (retail teleport flow): the TAS state machine + its full-screen fade overlay.
// Replaces the bare _teleportArrival hold for the player. Lazily constructed on the
// first teleport (all runtime deps wired by then), same as _teleportArrival.
private AcDream.App.World.TeleportFlowController? _teleportFlow;
private AcDream.App.UI.TeleportFadeOverlay? _teleportFade;

private void EnsureTeleportFlowController()
{
    if (_teleportFlow is not null) return;
    _teleportFlow = new AcDream.App.World.TeleportFlowController(
        readiness:          TeleportArrivalReadiness,
        onPlace:            PlaceTeleportArrivalCore,
        onLoginComplete:    OnTeleportLoginComplete,
        onLogoutDisconnect: () => { _liveSessionController?.Dispose(); _liveSessionController = null; },
        onPlayEnterSound:   () => PlayPortalSound(PortalSoundKind.Enter),   // Slice 4 supplies PlayPortalSound
        onPlayExitSound:    () => PlayPortalSound(PortalSoundKind.Exit));
    _teleportFade = new AcDream.App.UI.TeleportFadeOverlay();
    _uiHost?.Root.AddChild(_teleportFade);
}

(Slice 4 adds PlayPortalSound + PortalSoundKind. For Slice 3 to build green on its own, temporarily pass onPlayEnterSound: () => { } / onPlayExitSound: () => { } and replace them in Task 4.2 — note this in the commit message.)

  • Step 2 (edit): SPLIT PlaceTeleportArrival (:5534-5570). Rename the existing body to PlaceTeleportArrivalCore and REMOVE its last two statements (_playerController.State = InWorld; at :5563 and the _liveSession?.SendGameAction(...LoginComplete...) at :5568-5569). Add the new login-complete delegate that owns exactly those two lines:
// The placement half (Resolve + SetPosition + camera). Runs on the sequencer's Place
// event. Does NOT flip PortalSpace->InWorld and does NOT send LoginComplete — those move
// to OnTeleportLoginComplete (fired at sequencer OFF) so the input lock persists the
// WHOLE animation, not just until placement.
private void PlaceTeleportArrivalCore(
    System.Numerics.Vector3 destPos, uint destCell, bool forced)
{
    // ... existing body verbatim through the camera Update calls (:5537-5561) ...
    if (forced)
        Console.WriteLine($"live: teleport HOLD gave up (impossible/timeout) — force-snapping ...");
    // (entity SetPosition + ParentCellId, _playerController.SetPosition, chase-camera Update)
    Console.WriteLine($"live: teleport placed — snapped to {snappedPos} cell=0x{resolved.CellId:X8}");
    // NOTE: no State=InWorld, no LoginComplete here anymore.
}

// Fired at sequencer OFF (TeleportAnimEvent.FireLoginComplete) for every non-logout kind.
// Flips the input lock back on AND tells the server the client finished loading.
private void OnTeleportLoginComplete()
{
    if (_playerController is not null)
        _playerController.State = AcDream.App.Input.PlayerState.InWorld;
    _liveSession?.SendGameAction(
        AcDream.Core.Net.Messages.GameActionLoginComplete.Build());
}
  • Step 3 (edit): Portal trigger. In OnTeleportStarted (:5583), after EnsureTeleportArrivalController(); add:
EnsureTeleportFlowController();
_teleportFlow!.Begin(AcDream.Core.World.TeleportEntryKind.Portal);  // no coords yet

In OnLivePositionUpdated portal-space branch, REPLACE the _teleportArrival!.BeginArrival(newWorldPos, p.LandblockId); at :5474 with (keeping the recenter / pre-collapse above it untouched):

EnsureTeleportFlowController();
_pendingTeleportRot = rot;
_teleportFlow!.Begin(AcDream.Core.World.TeleportEntryKind.Portal, newWorldPos, p.LandblockId);
  • Step 4 (edit): per-frame tick + overlay drive. REPLACE _teleportArrival?.Tick(); at :7555 with:
// Phase B: advance the teleport flow (TAS animation + arrival hold). Runs AFTER streaming
// + the live-session drain, same ordering invariant the old _teleportArrival.Tick had.
_teleportFlow?.Tick(dt);
if (_teleportFade is not null && _teleportFlow is not null)
    _teleportFade.Alpha = _teleportFlow.Snapshot.FadeAlpha;

(dt is in scope at this call site — confirm by reading the enclosing method; if the local is named differently, use that name. Add a "read GameWindow.cs:7551 enclosing-method dt local to confirm name" check.) The fade overlay draws inside the existing _uiHost.Draw(...) pass at :8994 because it was added as a UiRoot child in EnsureTeleportFlowController — no new draw site.

  • Step: dotnet build greendotnet build src/AcDream.App/AcDream.App.csproj -c Debug.
  • Step: MANUAL/VISUAL verification. Launch against ACE (CLAUDE.md launch block), walk the player into a portal. Expect: screen fades to black, holds while the destination streams, fades back in; the camera no longer "floats" to the destination through an unstreamed world; input stays frozen until the fade-in completes (try pressing W during the black hold — no movement until the world is back). The tunnel states currently render as a black hold (the swirl is Slice 6).
  • Step 5: Commitgit add -A && git commit -m "feat(teleport): Slice 3 — route portal teleports through TeleportFlowController + fade overlay; split placement from login-complete"

Note: _teleportArrival becomes dead once the portal path routes through _teleportFlow. Leave it in place for Slice 3 (login/death/logout still need the unified path in Slice 5); Slice 5's de-dup commit removes the now-unused _teleportArrival field + EnsureTeleportArrivalController + BeginArrival plumbing.


Slice 4: Yaw-freeze in PortalSpace + portal sounds

Task 4.1 — Yaw freeze during PortalSpace (build-green + manual; NO fabricated-signature unit test)

Files: src/AcDream.App/Rendering/GameWindow.cs — the chase-camera mouse-move block.

Anchor: the single if (_playerMode && _cameraController.IsChaseMode && _chaseCamera is not null) block at :1095, which contains BOTH the MMB mouse-look sub-branch (:1098) and the RMB orbit sub-branch (:1120). One early-return at the TOP of that block covers both (per the prompt — do NOT add it in slice 3 or 5).

  • Step 1 (edit): Insert as the first statement inside the :1095 block, before float sens = _sensChase; (:1097):
// Phase B (retail teleport flow): freeze look/orbit yaw while the teleport animation
// holds the player in PortalSpace. The keyboard-turn freeze already happens (the
// PlayerMovementController.Update early-return precedes the yaw block), but the Silk
// mouse-move handler bypasses Update — so MMB mouse-look (below) and RMB orbit both
// leak yaw without this gate. Covers BOTH sub-branches since they share this block.
if (_playerController is not null
    && _playerController.State == AcDream.App.Input.PlayerState.PortalSpace)
{
    _lastMouseX = pos.X;
    _lastMouseY = pos.Y;
    return;
}
  • Step: dotnet build greendotnet build src/AcDream.App/AcDream.App.csproj -c Debug.
  • Step: MANUAL/VISUAL verification. During a portal (while the fade holds), hold MMB and move the mouse, then hold RMB and move the mouse. Expect: neither rotates the character/camera while in PortalSpace; both resume normally after the fade-in completes (State back to InWorld). (Mouse input can't be unit-tested through Silk — this is build-green + manual per the prompt; do NOT fabricate a PlayerMovementController.Update signature test.)
  • Step 5: Commitgit add src/AcDream.App/Rendering/GameWindow.cs && git commit -m "fix(teleport): Slice 4 — freeze MMB look + RMB orbit yaw while held in PortalSpace"

Task 4.2 — Portal Enter/Exit sounds (clean EnumIDMap chain, no stopgap)

Files: src/AcDream.App/Rendering/GameWindow.cs (PlayPortalSound + PortalSoundKind).

VERIFIED CORRECTION — enum member names. The design doc / handoff says DRWSound.UI_EnterPortal=0x6A / UI_ExitPortal=0x6B. That is wrong for our dat library. The real members are DRWSound.EnterPortal and DRWSound.ExitPortal (no UI_ prefix). Therefore:

  • Do NOT add UI_EnterPortal=0x6A/UI_ExitPortal=0x6B to the Core SoundId enum (src/AcDream.Core/Audio/AudioModel.cs:16-42). The portal-sound chain uses DatReaderWriter.Enums.Sound (aliased DRWSound), NOT the Core SoundId — exactly as SoundCookbook.Roll(SoundTable, DRWSound, Random) is typed (src/AcDream.Core/Audio/SoundCookbook.cs:74-82, alias at :4). The Core SoundId enum needs no change.
  • Add a confirm step below to pin the exact member spelling before use.

Pinned chain elements (all read from source):

  • _dats.Portal.Header.MasterMapId + _dats.Portal.TryGet<EnumIDMap>(...) — the EnumIDMap pattern (src/AcDream.App/UI/IconComposer.cs:86-95 for the master→sub two-level lookup).
  • _soundCache.GetSoundTable(uint) (src/AcDream.Core/Audio/DatSoundCache.cs:59-66) → SoundTable?.
  • SoundCookbook.Roll(SoundTable, DRWSound, Random)SoundEntry? (:74); winning entry.Id is the Wave id (used exactly so at src/AcDream.App/Audio/AudioHookSink.cs:113).
  • _soundCache.GetWave(uint)WaveData? (DatSoundCache.cs:39).
  • _audioEngine.PlayUiWave(uint waveId, WaveData wave, float volume=1, float pitch=1) (src/AcDream.App/Audio/OpenAlAudioEngine.cs:278).
  • Fields _dats (:28), _audioEngine (:250), _soundCache (:251).

The UI SoundTable id resolves via MasterMapId → ClientEnumToID[0x10000003] → ClientEnumToID[7] per the design's §8 chain. This chain is medium-confidence (not yet runtime-confirmed), so the implementation must fail gracefully (no sound + one warning log) if any link misses — that is the design's required fallback, not a workaround.

  • Step 0 (confirm the enum spelling — required before writing code): read the member names by greping the dat lib so the case DRWSound.EnterPortal/DRWSound.ExitPortal compiles. The build error pins exact casing in YOUR build; if casing differs, use the build error to correct it — do not guess a third spelling.

  • Step 1 (no separate unit test): This is GameWindow integration (god object) and the resolution depends on live dats — it's build-green + manual, like 4.1. (The reusable picker logic — SoundCookbook.Roll — is already unit-covered.) Skip Steps 1-2 of the TDD template; go straight to implement + build + manual-verify.

  • Step 3: Implement in GameWindow:

private enum PortalSoundKind { Enter, Exit }

// Phase B (retail teleport flow): play the EnterPortal / ExitPortal UI sound.
// Resolves the UI SoundTable via the master EnumIDMap chain (design §8):
//   Portal.Header.MasterMapId -> EnumIDMap.ClientEnumToID[0x10000003] -> [7] -> SoundTable id
// then rolls the EnterPortal/ExitPortal entry and plays its Wave on the UI pool.
// Medium-confidence chain: if any link misses, log once + play nothing (the design's
// required graceful fallback, not a workaround — the visual flow proceeds regardless).
private uint _uiSoundTableId; // 0 = unresolved/unknown; resolved lazily on first portal sound
private bool _uiSoundTableTried;

private void PlayPortalSound(PortalSoundKind kind)
{
    if (_audioEngine is null || !_audioEngine.IsAvailable || _soundCache is null || _dats is null)
        return;

    uint tableId = ResolveUiSoundTableId();
    if (tableId == 0) return;

    var table = _soundCache.GetSoundTable(tableId);
    if (table is null) return;

    var sound = kind == PortalSoundKind.Enter
        ? DatReaderWriter.Enums.Sound.EnterPortal
        : DatReaderWriter.Enums.Sound.ExitPortal;

    var entry = AcDream.Core.Audio.SoundCookbook.Roll(table, sound, Random.Shared);
    if (entry is null) return;

    var wave = _soundCache.GetWave((uint)entry.Id);
    if (wave is null) return;

    _audioEngine.PlayUiWave((uint)entry.Id, wave,
        volume: System.Math.Clamp(entry.Volume > 0 ? entry.Volume : 1f, 0f, 1f));
}

private uint ResolveUiSoundTableId()
{
    if (_uiSoundTableTried) return _uiSoundTableId;
    _uiSoundTableTried = true;
    if (_dats is null) return 0;

    uint masterDid = (uint)_dats.Portal.Header.MasterMapId;
    if (masterDid == 0
        || !_dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(masterDid, out var master)
        || !master.ClientEnumToID.TryGetValue(0x10000003u, out var subDid)
        || !_dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(subDid, out var sub)
        || !sub.ClientEnumToID.TryGetValue(7u, out var tableId))
    {
        Console.WriteLine("[teleport] UI SoundTable EnumIDMap chain did not resolve — portal sounds disabled (no-op).");
        return 0;
    }
    _uiSoundTableId = tableId;
    return tableId;
}

Then in EnsureTeleportFlowController (Task 3.3), replace the temporary empty sound delegates with () => PlayPortalSound(PortalSoundKind.Enter) / () => PlayPortalSound(PortalSoundKind.Exit).

  • Step: dotnet build greendotnet build src/AcDream.App/AcDream.App.csproj -c Debug. (If DatReaderWriter.Enums.Sound.EnterPortal / .ExitPortal fails to resolve, the build error pins the real casing — fix it from the error, do not guess.)
  • Step: MANUAL/VISUAL (audio) verification. Portal in: expect the retail EnterPortal whoosh at the start of the fade; expect the ExitPortal sound at the tunnel→world fade-in edge. If the log prints "UI SoundTable EnumIDMap chain did not resolve", the §8 index guess (0x10000003[7]) is wrong — capture the real chain via a cdb trace / dat dump and correct the two constants (do NOT add a hardcoded table id workaround).
  • Step 5: Commitgit add src/AcDream.App/Rendering/GameWindow.cs && git commit -m "feat(teleport): Slice 4 — EnterPortal/ExitPortal UI sounds via master EnumIDMap chain, graceful no-sound fallback"

Divergence-register: no new row for sounds (faithful port). If the §8 chain stays unresolved and you ship with sounds disabled, add a stopgap row noting "portal sounds pending EnumIDMap-chain runtime confirmation" and delete it when resolved.


Slice 5: Unify login + death + logout (de-dup readiness; logout command)

Task 5.1 — De-dup the login readiness predicate onto TeleportArrivalRules.Decide

Files:

  • src/AcDream.App/Rendering/GameWindow.cs — the isSpawnGroundReady login predicate (:1036-1065).
  • Test: extend tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs (the slice-2 Decide(claimUnhydratable, indoor, indoorCellReady, outdoorReady) 4-arg form).

The login predicate (:1036-1064) and TeleportArrivalReadiness (:5513-5530) were verbatim duplicates of the readiness logic. Slice 2 already rewrote TeleportArrivalReadiness to call TeleportArrivalRules.Decide(..., outdoorReady) with outdoorReady from IsLandblockLoaded. This task collapses the LOGIN predicate to call the SAME Decide, computing its outdoorReady arg from SampleTerrainZ(...) != null (login's existing outdoor gate). Do NOT create a separate ComputeArrivalReadiness method (the prompt forbids it) — both sites call Decide directly.

  • Step 1: Write the failing test — add the outdoor-axis case proving Decide honors outdoorReady (this guards the shared method the login predicate will now depend on):
[Fact]
public void Outdoor_NotLoaded_HoldsNotReady()
{
    // Slice 2 axis: outdoor now HOLDS until the landblock is loaded (folds in #145 residual).
    Assert.Equal(ArrivalReadiness.NotReady,
        TeleportArrivalRules.Decide(
            claimUnhydratable: false, indoor: false, indoorCellReady: false, outdoorReady: false));
}

[Fact]
public void Outdoor_Loaded_IsReady()
{
    Assert.Equal(ArrivalReadiness.Ready,
        TeleportArrivalRules.Decide(
            claimUnhydratable: false, indoor: false, indoorCellReady: false, outdoorReady: true));
}

(If slice 2 already added these exact cases, instead add a login-shaped case asserting the indoor branch ignores outdoorReady: Decide(false, true, true, false) == Ready. Read the current TeleportArrivalRulesTests.cs first and only add what's missing — do not duplicate.)

  • Step 2: Run to verify it failsdotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportArrivalRulesTests". Expected: FAIL if the 4-arg Decide cases aren't present yet (compile/assert), or PASS-already if slice 2 covered them (then this task is the GameWindow rewrite only — note it).

  • Step 3: Implement — rewrite the login predicate body (:1036-1064) to delegate to Decide:

isSpawnGroundReady: () =>
{
    if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) return false;

    // #135/#145 (de-dup §3.5): the login spawn-readiness verdict is the SAME decision as a
    // teleport arrival — route both through TeleportArrivalRules.Decide. Indoor gates on the
    // EnvCell floor; outdoor gates on the terrain under the spawn having streamed in
    // (SampleTerrainZ != null — login's existing outdoor gate, the async equivalent of
    // retail's synchronous cell load).
    uint claim = (_lastSpawnByGuid.TryGetValue(_playerServerGuid, out var sp)
                  && sp.Position is { } spawnClaim && spawnClaim.LandblockId != 0)
                 ? spawnClaim.LandblockId : 0u;

    bool claimUnhydratable = claim != 0 && IsSpawnClaimUnhydratable(claim);
    bool indoor = claim != 0 && (claim & 0xFFFFu) >= 0x0100u;
    bool indoorCellReady = indoor && !claimUnhydratable && _physicsEngine.IsSpawnCellReady(claim);
    bool outdoorReady = _physicsEngine.SampleTerrainZ(pe.Position.X, pe.Position.Y) is not null;

    return AcDream.App.World.TeleportArrivalRules.Decide(
        claimUnhydratable, indoor, indoorCellReady, outdoorReady) == AcDream.App.World.ArrivalReadiness.Ready;
},

(Read :1036-1064 first to preserve the exact field names — _lastSpawnByGuid, sp.Position, IsSpawnClaimUnhydratable, IsSpawnCellReady, SampleTerrainZ are all confirmed in the current body. The original returned bool; Decide(...) == Ready preserves that — login holds on NotReady/Impossible, places on Ready. Preserve the old un-hydratable-indoor truth table: if the old code demoted an un-hydratable indoor claim to the outdoor terrain gate (placed on SampleTerrainZ != null) rather than holding, treat claimUnhydratable && indoor as "use outdoorReady" — read the old branch and reproduce its exact behavior; do not silently change login placement timing.)

  • Step 4: Run to verify it passes — Rules --filter PASS; then dotnet build src/AcDream.App/AcDream.App.csproj -c Debug green.
  • Step: MANUAL/VISUAL verification. Log in fresh (outdoor spawn at Holtburg) and into a dungeon: both must reach in-world FPS-steady with no free-fall (the #106/#135 invariant the predicate guards). Regression-gate: login stays FPS-steady (the design's explicit login regression gate).
  • Step 5: Commitgit add src/AcDream.App/Rendering/GameWindow.cs tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs && git commit -m "refactor(teleport): Slice 5 — de-dup login spawn-readiness onto TeleportArrivalRules.Decide (§3.5)"

Task 5.2 — Route LOGIN + DEATH through the flow controller

Files: src/AcDream.App/Rendering/GameWindow.csEnterPlayerModeFromAutoEntry success branch (:2610-2624); OnTeleportStarted (:5579, death confirmation comment).

Anchors:

  • EnterPlayerModeFromAutoEntry at :2610; its success branch logs "auto-entered player mode" at :2622.

  • EnterPlayerModeNow returns true after setting up _playerController with Position (PlayerMovementController.Position => _body.Position, src/AcDream.App/Input/PlayerMovementController.cs:131) and CellId (:133).

  • Death: server 0xF751 teleport-to-lifestone already routes through OnTeleportStartedBegin(Portal) (Task 3.3). NO new death code.

  • Step 1 (LOGIN edit): In EnterPlayerModeFromAutoEntry success branch (:2620-2623), after the Console.WriteLine($"live: auto-entered player mode ..."):

// Phase B (retail teleport flow) §4: route LOGIN through the same TAS flow. The world is
// already loaded (auto-entry only fires once isSpawnGroundReady passed), so the readiness
// probe returns Ready immediately -> the tunnel exits fast -> continue + fade-in plays.
// onPlace re-resolves the player position harmlessly (idempotent). This gives login the
// retail fade-in instead of a hard pop-in.
EnsureTeleportFlowController();
_teleportFlow!.Begin(
    AcDream.Core.World.TeleportEntryKind.Login,
    _playerController!.Position,
    _playerController.CellId);
  • Step 2 (DEATH comment): Above OnTeleportStarted (:5579), add a confirming comment (no code):
// Phase B §4: DEATH needs NO dedicated code. ACE teleports the corpse owner to the
// lifestone after the death anim (Player_Death.cs:247 / Player_Location.cs:686); the client
// observes it as an ordinary 0xF751 teleport, which already enters here -> Begin(Portal) ->
// TUNNEL. The large-position-jump branch of OnLivePositionUpdated is a second safety net.
  • Step: dotnet build greendotnet build src/AcDream.App/AcDream.App.csproj -c Debug.
  • Step: MANUAL/VISUAL verification. (1) Login: expect a short black hold → fade-in (NOT an instant pop) and FPS-steady. (2) Death (let the character die against a creature, or use a GM kill): expect death anim → fade → respawn at the lifestone with the same fade-in. Confirm LoginComplete is sent after login fade-in (the server accepts subsequent movement). If death shows NO fade (the teleport arrived without a preceding 0xF751, so OnTeleportStarted never fired): apply the open-question #1 contingency — add a large-position-jump → PortalSpace + Begin(Death) trigger in OnLivePositionUpdated. File it as a follow-up task; do not block the slice.
  • Step 5: Commitgit add src/AcDream.App/Rendering/GameWindow.cs && git commit -m "feat(teleport): Slice 5 — route login + death through TeleportFlowController (Login enters at TUNNEL, exits fast)"

Task 5.3 — LOGOUT command (animation + 0xF653 + clean disconnect)

Files:

  • src/AcDream.App/Rendering/GameWindow.csOnInputAction switch (:11442 method; add a case near :11646).
  • Test: extend tests/AcDream.App.Tests/World/TeleportFlowControllerTests.cs (the Logout_FullRun_FiresLogoutDisconnect_NotLoginComplete case in Task 3.1 already covers the controller side — this task is the input wiring + the disconnect delegate, which is integration).

Confirmed:

  • InputAction.LOGOUT exists (src/AcDream.UI.Abstractions/Input/InputAction.cs:100) with a retail default keybind Shift+Escape (src/AcDream.UI.Abstractions/Input/KeyBindings.cs:212) — so NO new action and NO new keybind needed.

  • OnInputAction filters to Press/DoubleClick before the switch (:11501), so a case fires on Press.

  • Logout teardown = _liveSessionController?.Dispose()LiveSessionController.Dispose() (src/AcDream.App/Net/LiveSessionController.cs:87) → WorldSession.Dispose() (src/AcDream.Core.Net/WorldSession.cs:1319) which sends 0xF653 CharacterLogOff (:1328) then DISCONNECT. The flow controller's onLogoutDisconnect delegate (wired in Task 3.3) already calls exactly this.

  • Step 1 (no new unit test): the controller's Logout behavior is unit-covered by Logout_FullRun_FiresLogoutDisconnect_NotLoginComplete (Task 3.1). The input-case wiring is GameWindow integration (build-green + manual). Confirm the existing 3.1 test passes: dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportFlowControllerTests.Logout". Expected: PASS (already implemented in Slice 3).

  • Step 2 (edit): Add a case to the OnInputAction switch, before the closing brace at :11647 (after the EscapeKey case at :11632-11646):

case AcDream.UI.Abstractions.Input.InputAction.LOGOUT:
    // Phase B §4: retail logout = the TAS animation starting at WORLD_FADE_OUT, then 0xF653
    // CharacterLogOff + clean disconnect (via WorldSession.Dispose, fired by the controller's
    // onLogoutDisconnect delegate at sequencer OFF). Char-select-return UI is deferred (§9 /
    // register row 4). Idempotent: ignore if a flow is already running or we're offline.
    if (_liveSession is not null
        && _liveSession.CurrentState == AcDream.Core.Net.WorldSession.State.InWorld
        && (_teleportFlow is null || !_teleportFlow.IsActive))
    {
        if (_playerController is not null)
            _playerController.State = AcDream.App.Input.PlayerState.PortalSpace;  // lock input for the whole fade
        EnsureTeleportFlowController();
        _teleportFlow!.Begin(AcDream.Core.World.TeleportEntryKind.Logout);
    }
    break;
  • Step: dotnet build greendotnet build src/AcDream.App/AcDream.App.csproj -c Debug.
  • Step: MANUAL/VISUAL verification. In-world, press Shift+Esc. Expect: world fades to black (WORLD_FADE_OUT → TUNNEL_FADE_IN front segments — Logout's ~6s budget) → at sequencer OFF the client sends 0xF653 and disconnects cleanly (the window closes / session ends; verify via WireMCP capture on loopback that a 0xF653 CharacterLogOff frame goes out before the DISCONNECT, and that ACE clears the session promptly — graceful close, not a hard kill). No char-select screen (deferred, §9).
  • Step 5: Commitgit add src/AcDream.App/Rendering/GameWindow.cs && git commit -m "feat(teleport): Slice 5 — LOGOUT (Shift+Esc) plays the TAS fade then sends 0xF653 + disconnects"

Task 5.4 — Remove the now-dead TeleportArrivalController plumbing + add divergence-register row 4

Files:

  • src/AcDream.App/Rendering/GameWindow.cs — remove the unused _teleportArrival field, EnsureTeleportArrivalController, and the BeginArrival call path (all superseded by _teleportFlow after slices 3/5).

  • docs/architecture/retail-divergence-register.md — add row 4 (logout → disconnect, not char-select).

  • Step 1: Confirm _teleportArrival is unreferenced except its own declsrg -n "_teleportArrival|EnsureTeleportArrivalController" src/AcDream.App/Rendering/GameWindow.cs. Expected: only the field declaration, the (now-shimmed) ensure, and no live .Tick() / .BeginArrival() callers (slices 3/5 replaced them with _teleportFlow). If a live caller remains, route it through _teleportFlow first.

  • Step 2: Remove the dead plumbing — delete the _teleportArrival field decl, the EnsureTeleportArrivalController method, and any EnsureTeleportArrivalController(); calls left in OnTeleportStarted (the EnsureTeleportFlowController() call from Task 3.3 supersedes it). Keep TeleportArrivalController.cs (the class + TeleportArrivalRules) — TeleportArrivalRules.Decide is still used by TeleportArrivalReadiness; only the unused instance plumbing in GameWindow goes.

  • Step 3: dotnet build greendotnet build src/AcDream.App/AcDream.App.csproj -c Debug. Then full suite: dotnet test AcDream.slnx -c Debug.

  • Step 4: Add divergence-register row 4 — in docs/architecture/retail-divergence-register.md, matching the existing column order:

| Logout returns to disconnect/window-close, not a char-select screen | Stopgap | Retail shows char-select after logout; acdream closes the session. Retire when the char-select / re-login UI is built (design §9). | src/AcDream.App/World/TeleportFlowController.cs (onLogoutDisconnect) | No char-select after logout; user must relaunch to reconnect. |
  • Step 5: Commitgit add src/AcDream.App/Rendering/GameWindow.cs docs/architecture/retail-divergence-register.md && git commit -m "refactor(teleport): Slice 5 — remove dead _teleportArrival plumbing; divergence row 4 (logout→disconnect)"

Cross-slice consistency notes (the parallel-draft fixes):

  1. One sound-enum decision, stated once: DRWSound.EnterPortal/ExitPortal (NOT UI_*=0x6A/0x6B); Core SoundId enum untouched. Owned by Task 4.2; referenced nowhere else.
  2. One yaw-freeze, in Slice 4 only (Task 4.1) — a single early-return at GameWindow.cs:1095 covering both MMB + RMB sub-branches; not duplicated in 3 or 5.
  3. PlaceTeleportArrival split happens once (Task 3.3): PlaceTeleportArrivalCore (placement only) + OnTeleportLoginComplete (State=InWorld + LoginComplete). Slices 4/5 consume these, never re-split.
  4. _teleportArrival removal is the explicit Task 5.4 once login no longer uses the old isSpawnGroundReadyBeginArrival path — avoids a half-removed field across slices.
  5. Begin idempotency (sequencer not restarted by the second coords-carrying Begin) is the contract that makes Portal's two-call trigger (OnTeleportStarted no-coords + OnLivePositionUpdated coords) safe — unit-pinned in Task 3.1.

Slice 6 (FOLLOW-UP PLAN — not in this plan): the literal 3D portal swirl

The retail tunnel is a 3D sub-viewport (UIElement_Viewport Type 0xD) rendering a portal CPhysicsObj (dat GetDIDByEnum(0x10000001,7) setup + (0x10000002,7) animation, 40 fps, randomized camera spin) shown during the Tunnel* states. acdream has no offscreen-3D / sub-viewport infrastructure, and the asset ids only resolve via a runtime trace. This slice is therefore its own spec→plan, executed in the same pass after its prerequisite:

  • Prerequisite (cdb trace): attach cdb to a live retail client (see CLAUDE.md "Retail debugger toolchain"), bp acclient!CPhysicsObj::makeObject during a portal, and capture the resolved GetDIDByEnum(0x10000001,7) / (0x10000002,7) dat ids. Also capture UIGlobals::GetAnimLevel's 1024-entry table if the fade ease needs to be exact (else smoothstep stands — register row TF-EASE).
  • Then: build a PortalTunnelView (offscreen 3D render of the swirl CPhysicsObj at 40 fps with the randomized camera rotation) and blit it during Snapshot.ShowTunnel; the sequencer/overlay seams already exist (ShowTunnel/ShowPleaseWait flags are wired in slices 1/3). Until then the tunnel states render as the black fade-hold — a coherent, retail-shaped flow.

This staging is the design's §7 slice 6 + §8 prerequisites. It does not block slices 15, which ship the complete behavioral feature (float gone, input locked, hold-until-loaded, sounds, the four entry points, the #145 fix).

Open questions / risks (resolve during execution; none block plan start)

  1. Death trigger (verify in Slice 5 Task 5.2). Death is assumed to arrive as a server 0xF751 teleport → OnTeleportStartedBegin(Portal). The research could not confirm from acdream code alone that ACE always sends 0xF751 before the lifestone UpdatePosition. Gate: the Task 5.2 manual death test. Contingency if it fails (no fade on death): add a large-position-jump detector in OnLivePositionUpdated that sets PortalSpace + calls _teleportFlow.Begin(TeleportEntryKind.Death, …) when the player jumps landblocks with no preceding 0xF751. File it as a follow-up task only if the manual test shows death bypassing the flow.
  2. Portal sound enum casing + the EnumIDMap chain (Slice 4 Task 4.2). The member is DatReaderWriter.Enums.Sound.EnterPortal/ExitPortal (verified against the dat lib; the design doc's UI_EnterPortal=0x6A is wrong). If the build still can't resolve it, the compile error pins the exact casing — fix from the error, don't guess. The UI-SoundTable EnumIDMap index chain (0x10000003[7]) is medium-confidence; the implementation logs once and plays no sound if it misses (graceful, per design) — capture the real chain via cdb/dat-dump and correct the two constants rather than hardcoding a table id.
  3. Login Impossible truth-table (Slice 5 Task 5.1). Preserve the old login predicate's exact behavior for an un-hydratable indoor claim (it demoted to the outdoor terrain gate). Read GameWindow.cs:1036-1064 before rewriting and confirm Decide(...) == Ready reproduces it; if the old code placed (not held) on an un-hydratable indoor claim, keep that. Regression gate: login stays FPS-steady (the #106/#135 invariant).
  4. Apparatus gate for the #145 fix (Slice 2 Task 2.5). Before relying on the readiness-gate change, run the ACDREAM_PROBE_RESOLVE apparatus on a 0xC98C-style edge arrival and confirm the gate flips Ready before placement with no NO-LANDBLOCK march. This is the "no guess-patch" gate (the original #145 burned 5 attempts). Tracked as task #4 in the session.
  5. DatReaderWriter source-of-truth. Confirm whether acdream references the vendored references/DatReaderWriter or the chorizite.datreaderwriter NuGet DLL — the two disagreed on the Sound enum member names during research. The build resolves this at Slice 4.

Phase completion checklist (per CLAUDE.md)

  • Every AC-specific algorithm cites its decomp reference (the sequencer constants cite acclient.h:6871 + the VAs from the design §2.1).
  • Every deviation this work introduces has a retail-divergence-register.md row (TF-FADE alpha overlay, TF-EASE smoothstep, logout→disconnect; tasks 1.7 / 3.x / 5.3 add them).
  • Conformance tests exist for the critical paths (sequencer golden timings; Decide truth table; controller event dispatch).
  • dotnet build green, dotnet test green.
  • Visual verification by the user (the mandatory stop): portal feel, login fade-in, death→lifestone, logout fade.
  • Roadmap / milestones updated when the feature lands; ISSUES #145 residual closed if the apparatus re-test passes.

Execution

Plan complete. Two execution options:

  1. Subagent-Driven (recommended) — dispatch a fresh subagent per task, review between tasks, fast iteration (REQUIRED SUB-SKILL: superpowers:subagent-driven-development). Best for a plan this size (it isolates the god-object wiring tasks from the pure-logic tasks).
  2. Inline Execution — execute tasks in this session with batched checkpoints (REQUIRED SUB-SKILL: superpowers:executing-plans).

Recommended order: Slice 1 → Slice 2 (lands the #145 fix; run the apparatus gate) → Slice 3 → Slice 4 → Slice 5. Each slice ends build+test green; slices 3/5 end with a visual stop.