# 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.