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.