diff --git a/docs/superpowers/plans/2026-06-21-retail-teleport-flow.md b/docs/superpowers/plans/2026-06-21-retail-teleport-flow.md
new file mode 100644
index 00000000..23784ea3
--- /dev/null
+++ b/docs/superpowers/plans/2026-06-21-retail-teleport-flow.md
@@ -0,0 +1,2315 @@
+# 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 1–2 land the sequencer + readiness gate (the **#145 fix ships independently of the visuals**); slices 3–5 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`](../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 3–5)
+
+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`).
+
+```csharp
+// 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):
+
+```csharp
+// 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,
+}
+
+/// Why the teleport was triggered — drives per-entry start state (spec §2.5).
+public enum TeleportEntryKind { Portal, Login, Death, Logout }
+
+///
+/// Edge-triggered events the sequencer emits on the tick they first occur.
+/// Consumers drive audio / placement / LoginComplete from these; the sequencer
+/// has no dependency on any of those systems.
+///
+public enum TeleportAnimEvent
+{
+ PlayEnterSound, // Begin(): sound_ui_enter_portal
+ EnterTunnel, // 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
+}
+
+/// Immutable per-frame snapshot from the sequencer.
+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**
+
+```csharp
+// 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):
+
+```csharp
+// Append to src/AcDream.Core/World/TeleportAnimSequencer.cs
+
+///
+/// Pure 7-state teleport animation machine (spec §2.1, acclient gmSmartBoxUI::UseTime 0x004d6e30).
+/// No GL / dat / network dependency — fully unit-testable.
+///
+public sealed class TeleportAnimSequencer
+{
+ // Golden constants (spec §2.1, verified VAs):
+ // TELEPORT_ANIM_FADE_TIME 0x007BD278 = 1.0s
+ // TELEPORT_ANIM_MIN_CONTINUE_TIME 0x007BD268 = 2.0s
+ // TELEPORT_ANIM_MAX_CONTINUE_TIME 0x007BD270 = 5.0s
+ public const float FadeTime = 1.0f;
+ public const float MinContinue = 2.0f;
+ public const float MaxContinue = 5.0f;
+
+ 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;
+
+ ///
+ /// Start the animation. Portal/Login/Death enter at Tunnel (skipping world-fade-out);
+ /// Logout enters at WorldFadeOut (spec §2.5).
+ ///
+ public void Begin(TeleportEntryKind kind)
+ {
+ _state = kind == TeleportEntryKind.Logout
+ ? TeleportAnimState.WorldFadeOut
+ : TeleportAnimState.Tunnel;
+ _elapsed = 0f;
+ _enterSoundPending = true;
+ }
+
+ ///
+ /// Advance the machine by seconds.
+ /// = destination collision landblock is resident.
+ /// Returns the current snapshot + edge-triggered events fired THIS tick.
+ ///
+ public (TeleportAnimSnapshot snapshot, IReadOnlyList events)
+ Tick(float dt, bool worldReady)
+ {
+ // Full implementation in Task 1.3 – stub returns current state with no transitions.
+ var evts = new List();
+ 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:
+
+```csharp
+// Add to TeleportAnimSequencerTests:
+
+// Helper: drive the sequencer forward by total elapsed time using small fixed steps.
+private static (TeleportAnimSnapshot snap, List allEvents)
+ DriveSeconds(TeleportAnimSequencer seq, float seconds, bool worldReady, float step = 0.016f)
+{
+ var allEvts = new List();
+ 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:
+
+```csharp
+// 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 events)
+ Tick(float dt, bool worldReady)
+{
+ var evts = new List();
+
+ // 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 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**
+
+```csharp
+// 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: Implement** — `ComputeFadeAlpha` 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:
+
+```csharp
+// Add to TeleportAnimSequencerTests:
+
+// Helper: run the sequencer to completion collecting ordered events.
+private static List RunToOff(
+ TeleportEntryKind kind, bool worldReadyAfterFirstTunnel = true, float step = 0.05f)
+{
+ var seq = new TeleportAnimSequencer();
+ var allEvts = new List();
+ 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:
+ ```csharp
+ 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):
+
+```csharp
+// 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:
+
+```csharp
+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**
+
+```csharp
+// 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(), Array.Empty(),
+ 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(), Array.Empty(),
+ 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(), Array.Empty(),
+ 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):
+
+ ```csharp
+ ///
+ /// Returns true iff a landblock with this id has been registered via
+ /// . Because registers
+ /// terrain + collision cells + portals atomically, ContainsKey implies the
+ /// collision mesh is present and resolvable.
+ ///
+ 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`:
+
+ ```csharp
+ using AcDream.App.World;
+ using Xunit;
+
+ namespace AcDream.App.Tests.World;
+
+ ///
+ /// Full truth-table for TeleportArrivalRules.Decide after the §3.4 readiness-gate change.
+ ///
+ 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 119–143):
+
+ ```csharp
+ ///
+ /// Decide whether a held teleport arrival can place now.
+ ///
+ /// The destination cell can never hydrate.
+ /// Destination is an indoor cell (cell index ≥ 0x0100).
+ /// For an indoor destination, the EnvCell floor has hydrated.
+ /// For an outdoor destination, the physics landblock is loaded
+ /// ( returned true for the destination landblock id).
+ /// Ignored for indoor destinations.
+ 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 anchor** — `GameWindow.cs:5528`:
+
+ ```csharp
+ // 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:
+
+ ```csharp
+ // 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:
+
+ ```csharp
+ 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:31–32` 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):
+
+ ```csharp
+ ///
+ /// Retail teleport readiness gate (§3.4, 2026-06-21). When true, every transition of
+ /// the TeleportArrivalReadiness verdict (NotReady → Ready / Impossible) emits one
+ /// [arrival-gate] line: the destination cell id, landblock id, verdict,
+ /// PhysicsEngine.LandblockCount at the moment it flips, and whether the
+ /// flip was forced (Impossible) or natural (Ready).
+ ///
+ /// Initial state from ACDREAM_PROBE_RESOLVE=1 — shares the existing probe
+ /// flag because this is a readiness-path extension; no new env var needed.
+ ///
+ public static bool ProbeArrivalGateEnabled { get; set; } =
+ Environment.GetEnvironmentVariable("ACDREAM_PROBE_RESOLVE") == "1";
+ ```
+
+ In `PhysicsDiagnostics.ResetForTest()` (line 536 area), add after `ProbeResolveEnabled = false;`:
+
+ ```csharp
+ 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:
+
+ ```csharp
+ 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:
+
+ ```csharp
+ 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:
+
+ ```csharp
+ _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:
+
+ ```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-147` — `record`-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:
+
+```csharp
+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 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 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());
+ 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();
+ 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();
+ 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()); // 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();
+ 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 fails** — `dotnet 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:
+
+```csharp
+using System;
+using System.Collections.Generic;
+using System.Numerics;
+using AcDream.Core.World;
+
+namespace AcDream.App.World;
+
+///
+/// Phase B (retail teleport flow): the visual/timing/lock state machine that wraps
+/// the pure (the 7-state TAS machine) around the
+/// existing arrival-hold readiness probe. Single owner of "a teleport is happening".
+///
+/// All side effects are injected delegates (same pure-seam style as
+/// ): 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.
+///
+/// Drives: the sequencer per frame with a worldReady gate computed from the
+/// readiness probe; on the sequencer's it runs the
+/// placement (Resolve + SetPosition + camera); at
+/// (sequencer reached ) 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.
+///
+public sealed class TeleportFlowController
+{
+ /// ~10 s at 60 fps. Coarse safety net for a destination that never streams.
+ public const int DefaultMaxHoldFrames = 600;
+
+ private readonly Func _readiness;
+ private readonly Action _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 readiness,
+ Action 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;
+ }
+
+ /// Last snapshot from the sequencer — drives the fade overlay alpha + tunnel/please-wait flags.
+ public TeleportAnimSnapshot Snapshot { get; private set; }
+
+ public bool IsActive => _seq.IsActive;
+
+ /// 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.
+ 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: Commit** — `git 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:**
+
+```csharp
+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 fails** — `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportFadeOverlayTests"`. Expected: FAIL — `TeleportFadeOverlay` does not exist.
+
+- [ ] **Step 3: Implement:**
+
+```csharp
+using System;
+using System.Numerics;
+
+namespace AcDream.App.UI;
+
+///
+/// Phase B (retail teleport flow): a top-most that fills the
+/// whole screen with (0,0,0,Alpha). The drives
+/// from Snapshot.FadeAlpha each frame (0 = clear world, 1 = full
+/// black). Added once to the (highest ZOrder so it sits over panels).
+///
+/// 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.
+///
+public sealed class TeleportFadeOverlay : UiElement
+{
+ private float _alpha;
+
+ /// 0..1 black-fade alpha. Setting it <= 0 hides the overlay entirely so it
+ /// never draws and never participates in hit-testing.
+ 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
+ }
+
+ /// The fill color this overlay emits — pure black at the current alpha.
+ /// Pure (no GL) so it can be asserted in a unit test.
+ 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: Commit** — `git 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`):
+
+```csharp
+// 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:
+
+```csharp
+// 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:
+
+```csharp
+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):
+
+```csharp
+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:
+
+```csharp
+// 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 green** — `dotnet 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: Commit** — `git 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`):
+
+```csharp
+// 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 green** — `dotnet 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: Commit** — `git 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(...)` — 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:
+
+```csharp
+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(masterDid, out var master)
+ || !master.ClientEnumToID.TryGetValue(0x10000003u, out var subDid)
+ || !_dats.Portal.TryGet(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 green** — `dotnet 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: Commit** — `git 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):
+
+```csharp
+[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 fails** — `dotnet 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`:
+
+```csharp
+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: Commit** — `git 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.cs` — `EnterPlayerModeFromAutoEntry` 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 `OnTeleportStarted` → `Begin(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 ...")`:
+
+```csharp
+// 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):
+
+```csharp
+// 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 green** — `dotnet 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: Commit** — `git 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.cs` — `OnInputAction` 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`):
+
+```csharp
+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 green** — `dotnet 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: Commit** — `git 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 decls** — `rg -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 green** — `dotnet 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: Commit** — `git 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 `isSpawnGroundReady`→`BeginArrival` 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 1–5, 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 → `OnTeleportStarted` → `Begin(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.