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

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

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

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

2315 lines
112 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Retail Teleport Flow Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build the retail-faithful unified teleport flow — a `TeleportAnimState` (TAS) animation state machine + arrival hold that covers **login, logout, death, and portal** with a fade-to-black cover, **holds until the destination landblock is actually loaded** (folding in the #145 unstreamed-arrival cascade fix), and **locks input + camera for the whole transition**.
**Architecture:** A pure Core state machine (`TeleportAnimSequencer`, the 7-state TAS machine) + a thin App orchestrator (`TeleportFlowController`) that wraps it and drives all side effects through injected delegates + a `TeleportFadeOverlay` `UiElement` for the black fade. The readiness gate (`PhysicsEngine.IsLandblockLoaded`) makes outdoor arrivals hold until the collision landblock is resident. Slices 12 land the sequencer + readiness gate (the **#145 fix ships independently of the visuals**); slices 35 route portal/login/death/logout through one controller and de-dup the duplicated readiness/recenter logic; **slice 6 (the literal 3D portal swirl) is a follow-up plan** gated on a cdb asset trace (see "Slice 6" at the end).
**Tech Stack:** C# .NET 10, xunit (`dotnet test`), Silk.NET (GL / OpenAL / input), DatReaderWriter (dat access), the existing `AcDream.Core` / `AcDream.App` / `AcDream.UI.Abstractions` layers. Decomp oracle: `docs/research/named-retail/`. Design doc this plan implements: [`docs/superpowers/specs/2026-06-21-retail-teleport-flow-design.md`](../specs/2026-06-21-retail-teleport-flow-design.md).
---
## File structure
| Path | Responsibility | Slice |
|---|---|---|
| `src/AcDream.Core/World/TeleportAnimSequencer.cs` (new) | Pure 7-state TAS machine: enums, `TeleportAnimSnapshot`, the sequencer. No GL/dat/net. | 1 |
| `tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs` (new) | Golden-timing unit tests for the sequencer. | 1 |
| `src/AcDream.Core/Physics/PhysicsEngine.cs` (modify) | Add `IsLandblockLoaded(uint)`. | 2 |
| `src/AcDream.App/World/TeleportArrivalController.cs` (modify) | Extend `TeleportArrivalRules.Decide` with the `outdoorReady` axis. | 2 |
| `src/AcDream.Core/Physics/PhysicsDiagnostics.cs` (modify) | `ProbeArrivalGateEnabled` flag for the apparatus probe. | 2 |
| `src/AcDream.App/World/TeleportFlowController.cs` (new) | The TAS orchestrator: wraps the sequencer, drives side effects via delegates. | 3 |
| `src/AcDream.App/UI/TeleportFadeOverlay.cs` (new) | Full-screen black alpha-quad `UiElement`, driven by `Snapshot.FadeAlpha`. | 3 |
| `tests/AcDream.App.Tests/World/TeleportFlowControllerTests.cs` (new) | Delegate-dispatch unit tests (spy delegates + stub readiness). | 3 |
| `tests/AcDream.App.Tests/UI/TeleportFadeOverlayTests.cs` (new) | Alpha→Visible + fill-color unit tests. | 3 |
| `src/AcDream.App/Rendering/GameWindow.cs` (modify) | Wiring only (god object — no feature bodies): construct + tick the controller, split `PlaceTeleportArrival`, the four entry points, the yaw-freeze, the portal sounds, the login de-dup, the logout command. | 3,4,5 |
| `docs/architecture/retail-divergence-register.md` (modify) | The four deviation rows (§5 of the design). | 1,3,5 |
## Architecture — the pinned controller design (read before slices 35)
The relationship between the pieces is **load-bearing**; the slices assume exactly this shape:
- **`TeleportAnimSequencer` (pure, Slice 1)** is the 7-state machine. It holds in `Tunnel` while `!worldReady` (no internal timeout), emits `Place` when it leaves `Tunnel`, and `FireLoginComplete` when it reaches `Off`. `FadeAlpha` is 0 inside the tunnel states and smoothsteps on the fade states.
- **`TeleportFlowController` (Slice 3)** owns the sequencer and **all timing/lock/placement decisions**. Each `Tick(dt)` it computes `worldReady` from the injected readiness probe (`Ready` `Impossible` frame-timeout, else hold; **Logout has no destination so it is intrinsically ready**), ticks the sequencer, and dispatches the sequencer's events to injected delegates: `onPlace` (place the player), `onLoginComplete` (flip `PortalSpace→InWorld` + send GameAction `0xA1`), `onLogoutDisconnect`, `onPlayEnterSound`, `onPlayExitSound`.
- **The input lock (`PlayerState.PortalSpace`) persists the WHOLE animation.** It is set at the entry point and cleared **only in `onLoginComplete`** (sequencer reached `Off`) — *not* at placement. This is why `PlaceTeleportArrival` is **split** in Slice 3: the placement half (Resolve + SetPosition + camera) runs on the `Place` event; the `State=InWorld` + `LoginComplete` half runs at `Off`.
- **`Begin(kind, destPos, destCell)` is idempotent on the sequencer.** Portal triggers it twice — once with no coords (`OnTeleportStarted`) and once with coords (`OnLivePositionUpdated`); the second call fills the destination without restarting the animation.
- **Entry points:** Portal/Death enter at `Tunnel` (await coords → `onPlace`); Login enters at `Tunnel` already-placed (readiness returns `Ready` immediately → fast exit; `onPlace` re-resolves harmlessly); Logout enters at `WorldFadeOut` and ends in `onLogoutDisconnect`.
**Cross-slice conventions (so the slices compose):**
1. The 4th `Decide` arg is named **`outdoorReady`** everywhere (terrain-ready for login, landblock-ready for teleport).
2. The **yaw-freeze lives only in Slice 4** (one early-return covering both MMB look + RMB orbit); it is *not* repeated in slices 3 or 5.
3. **`PlaceTeleportArrival` is split exactly once** (Slice 3) into `PlaceTeleportArrivalCore` + `OnTeleportLoginComplete`; slices 4/5 consume those.
4. The portal sound enum is **`DatReaderWriter.Enums.Sound.EnterPortal`/`ExitPortal`** (verified — *not* `UI_EnterPortal=0x6A`); the Core `SoundId` enum is untouched.
5. The old `TeleportArrivalController` (the bare hold) becomes unused once the portal path routes through `_teleportFlow`; its field/plumbing removal is deferred to the Slice 5 de-dup commit to avoid a half-removed field mid-plan.
---
### Slice 1: TeleportAnimSequencer — pure 7-state machine + unit tests
**Placement decision:** `src/AcDream.Core/World/TeleportAnimSequencer.cs` in `namespace AcDream.Core.World`. Core already owns `src/AcDream.Core/World/` (DerethDateTime, WorldEntity, etc.) and carries no GL/net dependency. The enums and record from the shared-type contract land here alongside the sequencer. Tests in `tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs`.
---
### Task 1.1: Create the types file (enums + record)
**Files:**
- Create: `src/AcDream.Core/World/TeleportAnimSequencer.cs`
- Test: `tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs`
- [ ] **Step 1: Write the failing test** — the test file references `TeleportAnimState`, `TeleportEntryKind`, `TeleportAnimEvent`, and `TeleportAnimSnapshot` in `AcDream.Core.World`; the test project already references `AcDream.Core` (`tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj:31`).
```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,
}
/// <summary>Why the teleport was triggered — drives per-entry start state (spec §2.5).</summary>
public enum TeleportEntryKind { Portal, Login, Death, Logout }
/// <summary>
/// Edge-triggered events the sequencer emits on the tick they first occur.
/// Consumers drive audio / placement / LoginComplete from these; the sequencer
/// has no dependency on any of those systems.
/// </summary>
public enum TeleportAnimEvent
{
PlayEnterSound, // Begin(): sound_ui_enter_portal
EnterTunnel, // Off/WorldFade* -> Tunnel: world is now hidden
Place, // Tunnel -> TunnelContinue: world loaded; place the player
PlayExitSound, // TunnelFadeOut -> WorldFadeIn: sound_ui_exit_portal
FireLoginComplete, // WorldFadeIn -> Off: send GameAction 0xA1
}
/// <summary>Immutable per-frame snapshot from the sequencer.</summary>
public readonly record struct TeleportAnimSnapshot(
TeleportAnimState State,
float FadeAlpha, // 0 = clear world, 1 = full black
bool ShowTunnel, // true during TunnelFadeIn..TunnelFadeOut
bool ShowPleaseWait); // true during TunnelContinue only
```
- [ ] **Step 4: Run to verify it passes**
```
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests"
```
Expected: PASS.
- [ ] **Step 5: Commit**
```
git add src/AcDream.Core/World/TeleportAnimSequencer.cs tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs
git commit -m "feat(core/slice-1): TeleportAnimSequencer — define enums, record, event types"
```
---
### Task 1.2: Sequencer skeleton + Begin(), IsActive, initial state
**Files:**
- Modify: `src/AcDream.Core/World/TeleportAnimSequencer.cs`
- Modify: `tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs`
- [ ] **Step 1: Write the failing test**
```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
/// <summary>
/// Pure 7-state teleport animation machine (spec §2.1, acclient gmSmartBoxUI::UseTime 0x004d6e30).
/// No GL / dat / network dependency — fully unit-testable.
/// </summary>
public sealed class TeleportAnimSequencer
{
// Golden constants (spec §2.1, verified VAs):
// TELEPORT_ANIM_FADE_TIME 0x007BD278 = 1.0s
// TELEPORT_ANIM_MIN_CONTINUE_TIME 0x007BD268 = 2.0s
// TELEPORT_ANIM_MAX_CONTINUE_TIME 0x007BD270 = 5.0s
public const float FadeTime = 1.0f;
public const float MinContinue = 2.0f;
public const float MaxContinue = 5.0f;
private TeleportAnimState _state = TeleportAnimState.Off;
private float _elapsed = 0f; // time in current state
private bool _enterSoundPending = false; // edge: fire PlayEnterSound on next Tick
public bool IsActive => _state != TeleportAnimState.Off;
public TeleportAnimState State => _state;
/// <summary>
/// Start the animation. Portal/Login/Death enter at Tunnel (skipping world-fade-out);
/// Logout enters at WorldFadeOut (spec §2.5).
/// </summary>
public void Begin(TeleportEntryKind kind)
{
_state = kind == TeleportEntryKind.Logout
? TeleportAnimState.WorldFadeOut
: TeleportAnimState.Tunnel;
_elapsed = 0f;
_enterSoundPending = true;
}
/// <summary>
/// Advance the machine by <paramref name="dt"/> seconds.
/// <paramref name="worldReady"/> = destination collision landblock is resident.
/// Returns the current snapshot + edge-triggered events fired THIS tick.
/// </summary>
public (TeleportAnimSnapshot snapshot, IReadOnlyList<TeleportAnimEvent> events)
Tick(float dt, bool worldReady)
{
// Full implementation in Task 1.3 stub returns current state with no transitions.
var evts = new List<TeleportAnimEvent>();
if (_enterSoundPending)
{
evts.Add(TeleportAnimEvent.PlayEnterSound);
_enterSoundPending = false;
}
_elapsed += dt;
var snap = BuildSnapshot();
return (snap, evts);
}
private TeleportAnimSnapshot BuildSnapshot()
{
float alpha = ComputeFadeAlpha(_state, _elapsed);
bool showTunnel = _state is TeleportAnimState.TunnelFadeIn
or TeleportAnimState.Tunnel
or TeleportAnimState.TunnelContinue
or TeleportAnimState.TunnelFadeOut;
bool pleaseWait = _state == TeleportAnimState.TunnelContinue;
return new TeleportAnimSnapshot(_state, alpha, showTunnel, pleaseWait);
}
private static float Smoothstep(float t)
{
t = Math.Clamp(t, 0f, 1f);
return t * t * (3f - 2f * t);
}
private static float ComputeFadeAlpha(TeleportAnimState state, float elapsed)
{
float t = Math.Clamp(elapsed / FadeTime, 0f, 1f);
return state switch
{
// Fading TO black (alpha 0→1):
TeleportAnimState.WorldFadeOut => Smoothstep(t),
TeleportAnimState.TunnelFadeIn => 1f - Smoothstep(t), // tunnel fades IN: overlay goes clear
// Full black / fully clear inside tunnel states:
TeleportAnimState.Tunnel => 0f, // world hidden, overlay not needed
TeleportAnimState.TunnelContinue => 0f,
// Fading back out of tunnel:
TeleportAnimState.TunnelFadeOut => Smoothstep(t), // tunnel fades out: overlay goes black
TeleportAnimState.WorldFadeIn => 1f - Smoothstep(t), // world fades in: overlay clears
_ => 0f,
};
}
}
```
- [ ] **Step 4: Run to verify it passes**
```
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests"
```
Expected: PASS (all tests so far).
- [ ] **Step 5: Commit**
```
git add src/AcDream.Core/World/TeleportAnimSequencer.cs tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs
git commit -m "feat(core/slice-1): TeleportAnimSequencer — Begin(), IsActive, enter-sound edge event"
```
---
### Task 1.3: State transitions — timing-correct Tick() implementation
**Files:**
- Modify: `src/AcDream.Core/World/TeleportAnimSequencer.cs`
- Modify: `tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs`
- [ ] **Step 1: Write the failing tests** — drive the machine through each transition with exact elapsed times:
```csharp
// Add to TeleportAnimSequencerTests:
// Helper: drive the sequencer forward by total elapsed time using small fixed steps.
private static (TeleportAnimSnapshot snap, List<TeleportAnimEvent> allEvents)
DriveSeconds(TeleportAnimSequencer seq, float seconds, bool worldReady, float step = 0.016f)
{
var allEvts = new List<TeleportAnimEvent>();
float remaining = seconds;
TeleportAnimSnapshot last = default;
while (remaining > 0f)
{
float dt = Math.Min(step, remaining);
var (snap, evts) = seq.Tick(dt, worldReady);
last = snap;
allEvts.AddRange(evts);
remaining -= dt;
}
return (last, allEvts);
}
// --- Logout path: WorldFadeOut -> TunnelFadeIn -> Tunnel ---
[Fact]
public void Logout_AfterFadeTime_TransitionsToTunnelFadeIn()
{
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Logout);
// Consume the enter-sound tick at dt=0
seq.Tick(0f, worldReady: false);
// Drive just past FadeTime (1.0s)
DriveSeconds(seq, TeleportAnimSequencer.FadeTime + 0.02f, worldReady: false);
Assert.Equal(TeleportAnimState.TunnelFadeIn, seq.State);
}
[Fact]
public void Logout_After2xFadeTime_TransitionsToTunnel()
{
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Logout);
seq.Tick(0f, worldReady: false); // consume enter-sound
DriveSeconds(seq, 2f * TeleportAnimSequencer.FadeTime + 0.02f, worldReady: false);
Assert.Equal(TeleportAnimState.Tunnel, seq.State);
}
// --- Portal/Login/Death path: enters at Tunnel ---
[Fact]
public void Portal_StartsInTunnel_HoldsWhileNotReady()
{
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Portal);
seq.Tick(0f, worldReady: false); // consume enter-sound
// Drive 10s — should not advance past Tunnel while !worldReady
DriveSeconds(seq, 10f, worldReady: false);
Assert.Equal(TeleportAnimState.Tunnel, seq.State);
}
[Fact]
public void Portal_WhenWorldReady_TransitionsToTunnelContinue_EmitsEnterTunnelAndPlace()
{
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Portal);
seq.Tick(0f, worldReady: false); // consume enter-sound
// The very first tick with worldReady=true should advance Tunnel→TunnelContinue and emit Place.
var (_, evts) = seq.Tick(0.016f, worldReady: true);
Assert.Equal(TeleportAnimState.TunnelContinue, seq.State);
Assert.Contains(TeleportAnimEvent.Place, evts);
}
// --- TunnelContinue: MIN_CONTINUE hold then TunnelFadeOut ---
[Fact]
public void TunnelContinue_DoesNotAdvance_BeforeMinContinue()
{
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Portal);
seq.Tick(0f, worldReady: false);
// Enter TunnelContinue
seq.Tick(0.016f, worldReady: true);
Assert.Equal(TeleportAnimState.TunnelContinue, seq.State);
// Drive MinContinue - ε — should still be TunnelContinue
DriveSeconds(seq, TeleportAnimSequencer.MinContinue - 0.1f, worldReady: true);
Assert.Equal(TeleportAnimState.TunnelContinue, seq.State);
}
[Fact]
public void TunnelContinue_AdvancesToTunnelFadeOut_AfterMinContinue()
{
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Portal);
seq.Tick(0f, worldReady: false);
seq.Tick(0.016f, worldReady: true); // -> TunnelContinue
DriveSeconds(seq, TeleportAnimSequencer.MinContinue + 0.05f, worldReady: true);
Assert.Equal(TeleportAnimState.TunnelFadeOut, seq.State);
}
// --- MAX_CONTINUE forces progress even when !worldReady ---
[Fact]
public void TunnelContinue_ForcesAdvance_AtMaxContinue_EvenIfNotReady()
{
// Simulate: world never becomes fully ready but MAX_CONTINUE forces fade-out
// This path exercises the safety-net from spec §3.4.
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Portal);
seq.Tick(0f, worldReady: false);
// Manually push the state to TunnelContinue by passing worldReady=true for one tick,
// then simulate worldReady toggling back to false.
seq.Tick(0.016f, worldReady: true); // -> TunnelContinue
// Drive MaxContinue + ε with worldReady=false (simulating "world never loaded")
DriveSeconds(seq, TeleportAnimSequencer.MaxContinue + 0.05f, worldReady: false);
Assert.Equal(TeleportAnimState.TunnelFadeOut, seq.State);
}
// --- TunnelFadeOut -> WorldFadeIn: PlayExitSound edge event ---
[Fact]
public void TunnelFadeOut_EmitsPlayExitSound_OnTransitionToWorldFadeIn()
{
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Portal);
seq.Tick(0f, worldReady: false);
seq.Tick(0.016f, worldReady: true); // -> TunnelContinue
// Drive through MinContinue -> TunnelFadeOut
DriveSeconds(seq, TeleportAnimSequencer.MinContinue + 0.05f, worldReady: true);
Assert.Equal(TeleportAnimState.TunnelFadeOut, seq.State);
// Drive through FadeTime -> WorldFadeIn; PlayExitSound should fire on that edge
var (_, evts) = DriveSeconds(seq, TeleportAnimSequencer.FadeTime + 0.05f, worldReady: true);
Assert.Equal(TeleportAnimState.WorldFadeIn, seq.State);
Assert.Contains(TeleportAnimEvent.PlayExitSound, evts);
}
// --- WorldFadeIn -> Off: FireLoginComplete edge event ---
[Fact]
public void WorldFadeIn_EmitsFireLoginComplete_OnTransitionToOff()
{
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Portal);
seq.Tick(0f, worldReady: false);
seq.Tick(0.016f, worldReady: true); // -> TunnelContinue
DriveSeconds(seq, TeleportAnimSequencer.MinContinue + 0.05f, worldReady: true);
DriveSeconds(seq, TeleportAnimSequencer.FadeTime + 0.05f, worldReady: true); // -> WorldFadeIn
var (_, evts) = DriveSeconds(seq, TeleportAnimSequencer.FadeTime + 0.05f, worldReady: true);
Assert.Equal(TeleportAnimState.Off, seq.State);
Assert.False(seq.IsActive);
Assert.Contains(TeleportAnimEvent.FireLoginComplete, evts);
}
```
- [ ] **Step 2: Run to verify it fails**
```
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests.Portal_WhenWorldReady_TransitionsToTunnelContinue"
```
Expected: FAIL — Tick() stub never advances state.
- [ ] **Step 3: Implement** — replace the `Tick()` stub in `TeleportAnimSequencer` with the full transition machine. Also tracks elapsed-in-state and `_continueElapsed` for the MIN/MAX gates:
```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<TeleportAnimEvent> events)
Tick(float dt, bool worldReady)
{
var evts = new List<TeleportAnimEvent>();
// Edge events queued by Begin or a prior transition:
if (_enterSoundPending) { evts.Add(TeleportAnimEvent.PlayEnterSound); _enterSoundPending = false; }
if (_enterTunnelPending) { evts.Add(TeleportAnimEvent.EnterTunnel); _enterTunnelPending = false; }
if (_exitSoundPending) { evts.Add(TeleportAnimEvent.PlayExitSound); _exitSoundPending = false; }
if (_loginCompletePending){ evts.Add(TeleportAnimEvent.FireLoginComplete); _loginCompletePending = false; }
_elapsed += dt;
// State-machine transitions:
switch (_state)
{
case TeleportAnimState.WorldFadeOut:
if (_elapsed >= FadeTime)
Advance(TeleportAnimState.TunnelFadeIn, evts, enterTunnel: false);
break;
case TeleportAnimState.TunnelFadeIn:
if (_elapsed >= FadeTime)
Advance(TeleportAnimState.Tunnel, evts, enterTunnel: true);
break;
case TeleportAnimState.Tunnel:
// Hold here until worldReady (EndTeleportAnimation analogue).
if (worldReady)
{
evts.Add(TeleportAnimEvent.Place);
Advance(TeleportAnimState.TunnelContinue, evts, enterTunnel: false);
_continueElapsed = 0f;
}
break;
case TeleportAnimState.TunnelContinue:
_continueElapsed += dt;
bool minMet = _continueElapsed >= MinContinue;
bool maxForce = _continueElapsed >= MaxContinue;
if (minMet || maxForce)
Advance(TeleportAnimState.TunnelFadeOut, evts, enterTunnel: false);
break;
case TeleportAnimState.TunnelFadeOut:
if (_elapsed >= FadeTime)
{
_exitSoundPending = true;
Advance(TeleportAnimState.WorldFadeIn, evts, enterTunnel: false);
// Flush the exit sound in the same tick:
evts.Add(TeleportAnimEvent.PlayExitSound);
_exitSoundPending = false;
}
break;
case TeleportAnimState.WorldFadeIn:
if (_elapsed >= FadeTime)
{
_loginCompletePending = true;
Advance(TeleportAnimState.Off, evts, enterTunnel: false);
evts.Add(TeleportAnimEvent.FireLoginComplete);
_loginCompletePending = false;
}
break;
case TeleportAnimState.Off:
default:
break;
}
return (BuildSnapshot(), evts);
}
private void Advance(TeleportAnimState next, List<TeleportAnimEvent> evts, bool enterTunnel)
{
_state = next;
_elapsed = 0f;
if (enterTunnel) _enterTunnelPending = true;
}
```
- [ ] **Step 4: Run to verify it passes**
```
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests"
```
Expected: PASS (all transition tests).
- [ ] **Step 5: Commit**
```
git add src/AcDream.Core/World/TeleportAnimSequencer.cs tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs
git commit -m "feat(core/slice-1): TeleportAnimSequencer — full 7-state Tick() with timed transitions and edge events"
```
---
### Task 1.4: FadeAlpha endpoints and monotonicity
**Files:**
- Modify: `tests/AcDream.Core.Tests/World/TeleportAnimSequencerTests.cs`
- [ ] **Step 1: Write the failing tests**
```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<TeleportAnimEvent> RunToOff(
TeleportEntryKind kind, bool worldReadyAfterFirstTunnel = true, float step = 0.05f)
{
var seq = new TeleportAnimSequencer();
var allEvts = new List<TeleportAnimEvent>();
seq.Begin(kind);
int safetyNet = 5000;
while (seq.IsActive && --safetyNet > 0)
{
// Simulate world becoming ready on first Tick inside Tunnel
bool ready = worldReadyAfterFirstTunnel && seq.State == TeleportAnimState.Tunnel;
var (_, evts) = seq.Tick(step, worldReady: ready);
allEvts.AddRange(evts);
}
return allEvts;
}
[Fact]
public void Portal_FullSequence_EventsInOrder()
{
var evts = RunToOff(TeleportEntryKind.Portal);
// Required events in order: PlayEnterSound, Place, PlayExitSound, FireLoginComplete.
// EnterTunnel is optional but must come before Place if present.
int enterIdx = evts.IndexOf(TeleportAnimEvent.PlayEnterSound);
int placeIdx = evts.IndexOf(TeleportAnimEvent.Place);
int exitIdx = evts.IndexOf(TeleportAnimEvent.PlayExitSound);
int loginIdx = evts.IndexOf(TeleportAnimEvent.FireLoginComplete);
Assert.True(enterIdx >= 0, "PlayEnterSound must fire");
Assert.True(placeIdx >= 0, "Place must fire");
Assert.True(exitIdx >= 0, "PlayExitSound must fire");
Assert.True(loginIdx >= 0, "FireLoginComplete must fire");
Assert.True(enterIdx < placeIdx, "PlayEnterSound must precede Place");
Assert.True(placeIdx < exitIdx, "Place must precede PlayExitSound");
Assert.True(exitIdx < loginIdx, "PlayExitSound must precede FireLoginComplete");
}
[Fact]
public void Logout_FullSequence_EventsInOrder_WithEnterTunnelAfterFades()
{
var evts = RunToOff(TeleportEntryKind.Logout);
int enterIdx = evts.IndexOf(TeleportAnimEvent.PlayEnterSound);
int tunnelIdx = evts.IndexOf(TeleportAnimEvent.EnterTunnel);
int placeIdx = evts.IndexOf(TeleportAnimEvent.Place);
int exitIdx = evts.IndexOf(TeleportAnimEvent.PlayExitSound);
int loginIdx = evts.IndexOf(TeleportAnimEvent.FireLoginComplete);
Assert.True(enterIdx >= 0, "PlayEnterSound must fire");
Assert.True(tunnelIdx >= 0, "EnterTunnel must fire (Logout path goes through WorldFadeOut->TunnelFadeIn->Tunnel)");
Assert.True(placeIdx >= 0, "Place must fire");
Assert.True(exitIdx >= 0, "PlayExitSound must fire");
Assert.True(loginIdx >= 0, "FireLoginComplete must fire");
Assert.True(enterIdx < tunnelIdx, "PlayEnterSound must precede EnterTunnel");
Assert.True(tunnelIdx < placeIdx, "EnterTunnel must precede Place");
Assert.True(placeIdx < exitIdx, "Place must precede PlayExitSound");
Assert.True(exitIdx < loginIdx, "PlayExitSound must precede FireLoginComplete");
}
[Fact]
public void Portal_NoEventsFiredTwice()
{
var evts = RunToOff(TeleportEntryKind.Portal);
Assert.Equal(1, evts.Count(e => e == TeleportAnimEvent.PlayEnterSound));
Assert.Equal(1, evts.Count(e => e == TeleportAnimEvent.Place));
Assert.Equal(1, evts.Count(e => e == TeleportAnimEvent.PlayExitSound));
Assert.Equal(1, evts.Count(e => e == TeleportAnimEvent.FireLoginComplete));
}
[Fact]
public void Death_BehavesIdenticallyToPortal_FullSequence()
{
var portalEvts = RunToOff(TeleportEntryKind.Portal);
var deathEvts = RunToOff(TeleportEntryKind.Death);
// Same event sequence (both enter at Tunnel)
Assert.Equal(portalEvts, deathEvts);
}
[Fact]
public void Login_BehavesIdenticallyToPortal_FullSequence()
{
var portalEvts = RunToOff(TeleportEntryKind.Portal);
var loginEvts = RunToOff(TeleportEntryKind.Login);
Assert.Equal(portalEvts, loginEvts);
}
[Fact]
public void AfterOff_IsActiveIsFalse_AndStateIsOff()
{
RunToOff(TeleportEntryKind.Portal);
var seq = new TeleportAnimSequencer();
seq.Begin(TeleportEntryKind.Portal);
// Drain to Off
for (int i = 0; i < 5000 && seq.IsActive; i++)
{
bool ready = seq.State == TeleportAnimState.Tunnel;
seq.Tick(0.05f, worldReady: ready);
}
Assert.False(seq.IsActive);
Assert.Equal(TeleportAnimState.Off, seq.State);
}
```
- [ ] **Step 2: Run to verify it fails**
```
dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~TeleportAnimSequencerTests.Portal_FullSequence_EventsInOrder"
```
Expected: FAIL — `EnterTunnel` event not fired correctly through Logout's TunnelFadeIn→Tunnel transition in current stub (Task 1.3's `_enterTunnelPending` wiring needs verification).
- [ ] **Step 3: Implement** — verify `_enterTunnelPending` is flushed at the START of the NEXT Tick, not deferred further. The current `Tick()` already has:
```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<CellSurface>(), Array.Empty<PortalPlane>(),
worldOffsetX: 0f, worldOffsetY: 0f);
Assert.True(engine.IsLandblockLoaded(0xA9B4FFFFu));
}
[Fact]
public void IsLandblockLoaded_AnotherIdAbsent_ReturnsFalse()
{
var engine = new PhysicsEngine();
engine.AddLandblock(0xA9B4FFFFu, MakeFlatTerrain(),
Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(),
worldOffsetX: 0f, worldOffsetY: 0f);
Assert.False(engine.IsLandblockLoaded(0xDEADBEEFu));
}
[Fact]
public void IsLandblockLoaded_AfterRemove_ReturnsFalse()
{
var engine = new PhysicsEngine();
engine.AddLandblock(0xA9B4FFFFu, MakeFlatTerrain(),
Array.Empty<CellSurface>(), Array.Empty<PortalPlane>(),
worldOffsetX: 0f, worldOffsetY: 0f);
engine.RemoveLandblock(0xA9B4FFFFu);
Assert.False(engine.IsLandblockLoaded(0xA9B4FFFFu));
}
}
```
- [ ] **Step 2: Run to verify it fails**
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~PhysicsEngineIsLandblockLoadedTests"`
Expected: FAIL — `CS1061: 'PhysicsEngine' does not contain a definition for 'IsLandblockLoaded'`.
- [ ] **Step 3: Implement**
In `src/AcDream.Core/Physics/PhysicsEngine.cs`, after `LandblockCount` (line 32):
```csharp
/// <summary>
/// Returns true iff a landblock with this id has been registered via
/// <see cref="AddLandblock"/>. Because <see cref="AddLandblock"/> registers
/// terrain + collision cells + portals atomically, ContainsKey implies the
/// collision mesh is present and resolvable.
/// </summary>
public bool IsLandblockLoaded(uint landblockId) =>
_landblocks.ContainsKey(landblockId);
```
- [ ] **Step 4: Run to verify it passes**
Run: `dotnet test tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj --filter "FullyQualifiedName~PhysicsEngineIsLandblockLoadedTests"`
Expected: PASS (4 tests, all green).
- [ ] **Step 5: Commit**
```
git add src/AcDream.Core/Physics/PhysicsEngine.cs tests/AcDream.Core.Tests/Physics/PhysicsEngineIsLandblockLoadedTests.cs && git commit -m "feat(slice2): add PhysicsEngine.IsLandblockLoaded — readiness gate §3.4"
```
---
### Task 2.2: Extend `TeleportArrivalRules.Decide` with `outdoorReady` and update truth-table tests
**Files:**
- Modify: `src/AcDream.App/World/TeleportArrivalController.cs` (`TeleportArrivalRules.Decide`, line 119)
- Modify: `tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs` (extend existing tests + add new ones)
- [ ] **Step 1: Write the failing test**
Replace the content of `tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs`:
```csharp
using AcDream.App.World;
using Xunit;
namespace AcDream.App.Tests.World;
/// <summary>
/// Full truth-table for TeleportArrivalRules.Decide after the §3.4 readiness-gate change.
/// </summary>
public class TeleportArrivalRulesTests
{
[Fact]
public void Unhydratable_IsImpossible_Outdoor()
{
Assert.Equal(ArrivalReadiness.Impossible,
TeleportArrivalRules.Decide(
claimUnhydratable: true, indoor: false,
indoorCellReady: false, outdoorReady: false));
}
[Fact]
public void Unhydratable_IsImpossible_Indoor()
{
Assert.Equal(ArrivalReadiness.Impossible,
TeleportArrivalRules.Decide(
claimUnhydratable: true, indoor: true,
indoorCellReady: true, outdoorReady: false));
}
[Fact]
public void Indoor_CellReady_IsReady()
{
Assert.Equal(ArrivalReadiness.Ready,
TeleportArrivalRules.Decide(
claimUnhydratable: false, indoor: true,
indoorCellReady: true, outdoorReady: false));
}
[Fact]
public void Indoor_CellNotReady_IsNotReady()
{
Assert.Equal(ArrivalReadiness.NotReady,
TeleportArrivalRules.Decide(
claimUnhydratable: false, indoor: true,
indoorCellReady: false, outdoorReady: false));
}
[Fact]
public void Outdoor_LandblockLoaded_IsReady()
{
Assert.Equal(ArrivalReadiness.Ready,
TeleportArrivalRules.Decide(
claimUnhydratable: false, indoor: false,
indoorCellReady: false, outdoorReady: true));
}
[Fact]
public void Outdoor_LandblockNotLoaded_IsNotReady()
{
// §3.4: the #145 residual — outdoor arrival on a not-yet-streamed landblock
// must hold. Previously returned Ready immediately.
Assert.Equal(ArrivalReadiness.NotReady,
TeleportArrivalRules.Decide(
claimUnhydratable: false, indoor: false,
indoorCellReady: false, outdoorReady: false));
}
}
```
- [ ] **Step 2: Run to verify it fails**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportArrivalRulesTests"`
Expected: FAIL — `Outdoor_LandblockNotLoaded_IsNotReady` fails (current code returns `Ready` for all outdoor), and `Outdoor_LandblockLoaded_IsReady` fails with a compile error because the new parameter doesn't exist yet.
- [ ] **Step 3: Implement**
In `src/AcDream.App/World/TeleportArrivalController.cs`, replace `TeleportArrivalRules.Decide` (lines 119143):
```csharp
/// <summary>
/// Decide whether a held teleport arrival can place now.
/// </summary>
/// <param name="claimUnhydratable">The destination cell can never hydrate.</param>
/// <param name="indoor">Destination is an indoor cell (cell index ≥ 0x0100).</param>
/// <param name="indoorCellReady">For an indoor destination, the EnvCell floor has hydrated.</param>
/// <param name="outdoorReady">For an outdoor destination, the physics landblock is loaded
/// (<see cref="PhysicsEngine.IsLandblockLoaded"/> returned true for the destination landblock id).
/// Ignored for indoor destinations.</param>
public static ArrivalReadiness Decide(
bool claimUnhydratable,
bool indoor,
bool indoorCellReady,
bool outdoorReady)
{
if (claimUnhydratable)
return ArrivalReadiness.Impossible;
// Indoor (sealed dungeon / building interior): gate on the EnvCell floor hydrating,
// exactly as #135. Unaffected by the outdoor-landblock gate.
if (indoor)
return indoorCellReady ? ArrivalReadiness.Ready : ArrivalReadiness.NotReady;
// Outdoor (#145 §3.4): hold until the physics landblock is registered.
// AddLandblock is atomic (terrain + cells + portals in one write — PhysicsEngine.cs:70),
// so ContainsKey implies the collision mesh is present and resolvable. The hold is
// self-resolving: streaming progresses unconditionally during the hold because the
// streaming controller runs before TeleportArrivalController.Tick (GameWindow.cs:7420-7551).
// The 10 s timeout safety-net (_maxHoldFrames) remains active.
return outdoorReady ? ArrivalReadiness.Ready : ArrivalReadiness.NotReady;
}
```
- [ ] **Step 4: Run to verify it passes**
Run: `dotnet test tests/AcDream.App.Tests/AcDream.App.Tests.csproj --filter "FullyQualifiedName~TeleportArrivalRulesTests"`
Expected: PASS (6 tests, all green).
- [ ] **Step 5: Commit**
```
git add src/AcDream.App/World/TeleportArrivalController.cs tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs && git commit -m "feat(slice2): TeleportArrivalRules.Decide — outdoor holds until landblock loaded (#145 §3.4)"
```
---
### Task 2.3: Fix the compile break — update the one `Decide` call-site in `GameWindow.cs`
The signature change in Task 2.2 breaks the call at `GameWindow.cs:5528`. This task wires `outdoorReady` and verifies the build is green. There is no unit test for GameWindow wiring (god object); verification is build-green + a described manual run.
**Files:**
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (method `TeleportArrivalReadiness`, around line 5513)
- [ ] **Step 1: Locate the 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:3132` for the template): a `static bool` property seeded from an env var, listed in `ResetForTest()`. The probe log lives in `TeleportArrivalReadiness` in `GameWindow.cs`.
**Files:**
- Modify: `src/AcDream.Core/Physics/PhysicsDiagnostics.cs` (add property + `ResetForTest` entry)
- Modify: `src/AcDream.App/Rendering/GameWindow.cs` (add `_lastArrivalVerdict` field + probe emission in `TeleportArrivalReadiness`)
There is no unit test for a diagnostic emission; verification is the described manual run in Task 2.5.
- [ ] **Step 1: Add the probe flag to `PhysicsDiagnostics`**
In `src/AcDream.Core/Physics/PhysicsDiagnostics.cs`, after `ProbeResolveEnabled` (after line 32):
```csharp
/// <summary>
/// Retail teleport readiness gate (§3.4, 2026-06-21). When true, every transition of
/// the <c>TeleportArrivalReadiness</c> verdict (NotReady → Ready / Impossible) emits one
/// <c>[arrival-gate]</c> line: the destination cell id, landblock id, verdict,
/// <c>PhysicsEngine.LandblockCount</c> at the moment it flips, and whether the
/// flip was forced (Impossible) or natural (Ready).
///
/// <para>Initial state from <c>ACDREAM_PROBE_RESOLVE=1</c> — shares the existing probe
/// flag because this is a readiness-path extension; no new env var needed.</para>
/// </summary>
public static bool ProbeArrivalGateEnabled { get; set; } =
Environment.GetEnvironmentVariable("ACDREAM_PROBE_RESOLVE") == "1";
```
In `PhysicsDiagnostics.ResetForTest()` (line 536 area), add after `ProbeResolveEnabled = false;`:
```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<PlaceCall> Placed = new();
public int LoginComplete;
public int LogoutDisconnect;
public int EnterSound;
public int ExitSound;
}
// readinessQueue: one verdict per Tick (last value repeats once exhausted).
private static TeleportFlowController Make(
Spies s, Queue<ArrivalReadiness> readinessQueue,
int maxHoldFrames = TeleportFlowController.DefaultMaxHoldFrames)
{
ArrivalReadiness last = ArrivalReadiness.NotReady;
return new TeleportFlowController(
readiness: (_, _) => { if (readinessQueue.Count > 0) last = readinessQueue.Dequeue(); return last; },
onPlace: (pos, cell, forced) => s.Placed.Add(new PlaceCall(pos, cell, forced)),
onLoginComplete: () => s.LoginComplete++,
onLogoutDisconnect: () => s.LogoutDisconnect++,
onPlayEnterSound: () => s.EnterSound++,
onPlayExitSound: () => s.ExitSound++,
maxHoldFrames: maxHoldFrames);
}
// Drive enough fixed-dt ticks (1/60s) to walk a portal run all the way to OFF.
private static void RunToOff(TeleportFlowController c, int maxTicks = 2000)
{
for (int i = 0; i < maxTicks && c.IsActive; i++) c.Tick(1f / 60f);
}
[Fact]
public void Tick_BeforeBegin_IsNoOp()
{
var s = new Spies();
var c = Make(s, new Queue<ArrivalReadiness>());
c.Tick(1f / 60f);
Assert.False(c.IsActive);
Assert.Empty(s.Placed);
}
[Fact]
public void Portal_FullRun_FiresEnterPlaceExitLoginComplete_InOrder()
{
var s = new Spies();
// readiness Ready from the first probe so the tunnel exits as soon as it can hold.
var q = new Queue<ArrivalReadiness>();
q.Enqueue(ArrivalReadiness.Ready);
var c = Make(s, q);
c.Begin(TeleportEntryKind.Portal, new Vector3(10, 20, 6), 0x01250126u);
RunToOff(c);
Assert.False(c.IsActive);
Assert.Equal(1, s.EnterSound);
var place = Assert.Single(s.Placed);
Assert.False(place.Forced);
Assert.Equal(0x01250126u, place.Cell);
Assert.Equal(new Vector3(10, 20, 6), place.Pos);
Assert.Equal(1, s.ExitSound);
Assert.Equal(1, s.LoginComplete);
Assert.Equal(0, s.LogoutDisconnect);
}
[Fact]
public void Begin_Idempotent_SecondBeginWithCoords_DoesNotRestartSequencer()
{
var s = new Spies();
var q = new Queue<ArrivalReadiness>();
q.Enqueue(ArrivalReadiness.NotReady); // hold in TUNNEL after first probe
var c = Make(s, q);
c.Begin(TeleportEntryKind.Portal); // no coords (OnTeleportStarted)
var stateAfterFirst = c.Snapshot.State; // sequencer began (Tunnel)
c.Tick(1f / 60f); // holding; no coords -> worldReady false for Portal
c.Begin(TeleportEntryKind.Portal, new Vector3(1, 2, 3), 0x01250126u); // coords arrive
// Still active, still no premature place: the second Begin must NOT re-call _seq.Begin.
Assert.True(c.IsActive);
Assert.Empty(s.Placed);
Assert.Equal(TeleportAnimState.Tunnel, c.Snapshot.State);
}
[Fact]
public void Logout_FullRun_FiresLogoutDisconnect_NotLoginComplete()
{
var s = new Spies();
var c = Make(s, new Queue<ArrivalReadiness>()); // no dest -> Logout worldReady=true intrinsically
c.Begin(TeleportEntryKind.Logout);
RunToOff(c);
Assert.False(c.IsActive);
Assert.Equal(1, s.LogoutDisconnect);
Assert.Equal(0, s.LoginComplete);
Assert.Empty(s.Placed); // _haveDest false -> Place event is a no-op
Assert.Equal(1, s.ExitSound);
}
[Fact]
public void Impossible_ForcesPlace_WithForcedTrue()
{
var s = new Spies();
var q = new Queue<ArrivalReadiness>();
q.Enqueue(ArrivalReadiness.Impossible);
var c = Make(s, q);
c.Begin(TeleportEntryKind.Portal, new Vector3(4, 5, 6), 0x0125FF00u);
RunToOff(c);
var place = Assert.Single(s.Placed);
Assert.True(place.Forced);
}
}
```
- [ ] **Step 2: Run to verify it 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;
/// <summary>
/// Phase B (retail teleport flow): the visual/timing/lock state machine that wraps
/// the pure <see cref="TeleportAnimSequencer"/> (the 7-state TAS machine) around the
/// existing arrival-hold readiness probe. Single owner of "a teleport is happening".
///
/// <para>All side effects are injected delegates (same pure-seam style as
/// <see cref="TeleportArrivalController"/>): readiness probe, placement, login-complete,
/// logout teardown, and the two portal sounds. Carries no GL / dat / net dependency
/// directly, so it is fully unit-testable.</para>
///
/// <para>Drives: the sequencer per frame with a <c>worldReady</c> gate computed from the
/// readiness probe; on the sequencer's <see cref="TeleportAnimEvent.Place"/> it runs the
/// placement (Resolve + SetPosition + camera); at <see cref="TeleportAnimEvent.FireLoginComplete"/>
/// (sequencer reached <see cref="TeleportAnimState.Off"/>) it either disconnects (Logout)
/// or sends LoginComplete + flips PortalSpace->InWorld (all other kinds). The input lock
/// therefore persists the WHOLE animation, not just until placement.</para>
/// </summary>
public sealed class TeleportFlowController
{
/// <summary>~10 s at 60 fps. Coarse safety net for a destination that never streams.</summary>
public const int DefaultMaxHoldFrames = 600;
private readonly Func<Vector3, uint, ArrivalReadiness> _readiness;
private readonly Action<Vector3, uint, bool> _onPlace; // (destPos, destCell, forced)
private readonly Action _onLoginComplete;
private readonly Action _onLogoutDisconnect;
private readonly Action _onPlayEnterSound;
private readonly Action _onPlayExitSound;
private readonly int _maxHoldFrames;
private readonly TeleportAnimSequencer _seq = new();
private TeleportEntryKind _kind;
private Vector3 _destPos;
private uint _destCell;
private bool _haveDest;
private int _heldFrames;
private bool _forced;
public TeleportFlowController(
Func<Vector3, uint, ArrivalReadiness> readiness,
Action<Vector3, uint, bool> onPlace,
Action onLoginComplete,
Action onLogoutDisconnect,
Action onPlayEnterSound,
Action onPlayExitSound,
int maxHoldFrames = DefaultMaxHoldFrames)
{
_readiness = readiness ?? throw new ArgumentNullException(nameof(readiness));
_onPlace = onPlace ?? throw new ArgumentNullException(nameof(onPlace));
_onLoginComplete = onLoginComplete ?? throw new ArgumentNullException(nameof(onLoginComplete));
_onLogoutDisconnect = onLogoutDisconnect ?? throw new ArgumentNullException(nameof(onLogoutDisconnect));
_onPlayEnterSound = onPlayEnterSound ?? throw new ArgumentNullException(nameof(onPlayEnterSound));
_onPlayExitSound = onPlayExitSound ?? throw new ArgumentNullException(nameof(onPlayExitSound));
_maxHoldFrames = maxHoldFrames;
}
/// <summary>Last snapshot from the sequencer — drives the fade overlay alpha + tunnel/please-wait flags.</summary>
public TeleportAnimSnapshot Snapshot { get; private set; }
public bool IsActive => _seq.IsActive;
/// <summary>Start (or supply coords to) a teleport flow. Idempotent on the sequencer:
/// the first call (OnTeleportStarted, no coords) begins it; a later call with coords
/// (OnLivePositionUpdated) fills the destination WITHOUT restarting the animation.</summary>
public void Begin(TeleportEntryKind kind, Vector3 destPos = default, uint destCell = 0)
{
_kind = kind;
if (destCell != 0)
{
_destPos = destPos;
_destCell = destCell;
_haveDest = true;
_heldFrames = 0;
}
if (!_seq.IsActive)
_seq.Begin(kind);
}
public void Tick(float dt)
{
if (!_seq.IsActive) return;
bool worldReady;
if (_haveDest)
{
ArrivalReadiness v = _readiness(_destPos, _destCell);
_heldFrames++;
bool forced = v == ArrivalReadiness.Impossible || _heldFrames >= _maxHoldFrames;
worldReady = v == ArrivalReadiness.Ready || forced;
_forced = forced && v != ArrivalReadiness.Ready;
}
else
{
// Logout has no destination world to load; Portal/Death/Login wait for coords.
worldReady = _kind == TeleportEntryKind.Logout;
}
var (snap, events) = _seq.Tick(dt, worldReady);
Snapshot = snap;
foreach (var e in events)
{
switch (e)
{
case TeleportAnimEvent.PlayEnterSound:
_onPlayEnterSound();
break;
case TeleportAnimEvent.PlayExitSound:
_onPlayExitSound();
break;
case TeleportAnimEvent.Place:
if (_haveDest) _onPlace(_destPos, _destCell, _forced);
break;
case TeleportAnimEvent.FireLoginComplete:
if (_kind == TeleportEntryKind.Logout) _onLogoutDisconnect();
else _onLoginComplete();
break;
case TeleportAnimEvent.EnterTunnel:
// no-op — the sequencer drives the tunnel visual state itself.
break;
}
}
}
}
```
- [ ] **Step 4: Run to verify it passes** — same `--filter`. Expected: PASS.
- [ ] **Step 5: 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;
/// <summary>
/// Phase B (retail teleport flow): a top-most <see cref="UiElement"/> that fills the
/// whole screen with <c>(0,0,0,Alpha)</c>. The <see cref="TeleportFlowController"/> drives
/// <see cref="Alpha"/> from <c>Snapshot.FadeAlpha</c> each frame (0 = clear world, 1 = full
/// black). Added once to the <see cref="UiRoot"/> (highest ZOrder so it sits over panels).
///
/// <para>Divergence-register row: black fade via an alpha overlay quad, NOT retail's
/// camera near-plane collapse (Render::set_vdst to 0.001). Visually identical, no
/// projection surgery — see the design doc §3.3.</para>
/// </summary>
public sealed class TeleportFadeOverlay : UiElement
{
private float _alpha;
/// <summary>0..1 black-fade alpha. Setting it &lt;= 0 hides the overlay entirely so it
/// never draws and never participates in hit-testing.</summary>
public float Alpha
{
get => _alpha;
set
{
_alpha = Math.Clamp(value, 0f, 1f);
Visible = _alpha > 0f;
}
}
public TeleportFadeOverlay()
{
// Decoration: never claim the pointer (the world / panels stay interactive when clear,
// and during a full-black hold the input lock is enforced separately by PortalSpace).
ClickThrough = true;
Visible = false;
ZOrder = int.MaxValue; // over every retail-UI panel
}
/// <summary>The fill color this overlay emits — pure black at the current alpha.
/// Pure (no GL) so it can be asserted in a unit test.</summary>
public Vector4 FillColor() => new(0f, 0f, 0f, _alpha);
protected override void OnDraw(UiRenderContext ctx)
{
if (_alpha <= 0f) return;
// Cover the entire screen regardless of this element's Left/Top (Root sits at origin,
// so local == screen). DrawRect composites after sprites — the fade must cover them.
var size = ctx.ScreenSize;
ctx.DrawRect(0f, 0f, size.X, size.Y, FillColor());
}
}
```
- [ ] **Step 4: Run to verify it passes** — same `--filter`. Expected: PASS.
- [ ] **Step: Add divergence-register row TF-FADE** — the alpha-overlay deviation is introduced here, so its row lands in this commit (CLAUDE.md register rule). In `docs/architecture/retail-divergence-register.md`, matching the existing column order, add (design §5 row 1):
```
| TF-FADE | Adaptation | TeleportFadeOverlay fills the screen with a black alpha quad instead of retail's camera near-plane collapse (Render::set_vdst -> 0.001, TAS §2.2). Visually identical. | src/AcDream.App/UI/TeleportFadeOverlay.cs | Edge-pixel depth-precision differences only; tolerable. |
```
- [ ] **Step 5: 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<EnumIDMap>(...)` — the EnumIDMap pattern (`src/AcDream.App/UI/IconComposer.cs:86-95` for the master→sub two-level lookup).
- `_soundCache.GetSoundTable(uint)` (`src/AcDream.Core/Audio/DatSoundCache.cs:59-66`) → `SoundTable?`.
- `SoundCookbook.Roll(SoundTable, DRWSound, Random)` → `SoundEntry?` (:74); winning `entry.Id` is the Wave id (used exactly so at `src/AcDream.App/Audio/AudioHookSink.cs:113`).
- `_soundCache.GetWave(uint)` → `WaveData?` (`DatSoundCache.cs:39`).
- `_audioEngine.PlayUiWave(uint waveId, WaveData wave, float volume=1, float pitch=1)` (`src/AcDream.App/Audio/OpenAlAudioEngine.cs:278`).
- Fields `_dats` (`:28`), `_audioEngine` (`:250`), `_soundCache` (`:251`).
The UI SoundTable id resolves via `MasterMapId → ClientEnumToID[0x10000003] → ClientEnumToID[7]` per the design's §8 chain. This chain is **medium-confidence** (not yet runtime-confirmed), so the implementation must fail gracefully (no sound + one warning log) if any link misses — that is the design's required fallback, not a workaround.
- [ ] **Step 0 (confirm the enum spelling — required before writing code):** read the member names by greping the dat lib so the case `DRWSound.EnterPortal`/`DRWSound.ExitPortal` compiles. The build error pins exact casing in YOUR build; if casing differs, use the build error to correct it — do not guess a third spelling.
- [ ] **Step 1 (no separate unit test):** This is GameWindow integration (god object) and the resolution depends on live dats — it's build-green + manual, like 4.1. (The reusable picker logic — `SoundCookbook.Roll` — is already unit-covered.) Skip Steps 1-2 of the TDD template; go straight to implement + build + manual-verify.
- [ ] **Step 3: Implement** in GameWindow:
```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<DatReaderWriter.DBObjs.EnumIDMap>(masterDid, out var master)
|| !master.ClientEnumToID.TryGetValue(0x10000003u, out var subDid)
|| !_dats.Portal.TryGet<DatReaderWriter.DBObjs.EnumIDMap>(subDid, out var sub)
|| !sub.ClientEnumToID.TryGetValue(7u, out var tableId))
{
Console.WriteLine("[teleport] UI SoundTable EnumIDMap chain did not resolve — portal sounds disabled (no-op).");
return 0;
}
_uiSoundTableId = tableId;
return tableId;
}
```
Then in `EnsureTeleportFlowController` (Task 3.3), replace the temporary empty sound delegates with `() => PlayPortalSound(PortalSoundKind.Enter)` / `() => PlayPortalSound(PortalSoundKind.Exit)`.
- [ ] **Step: dotnet build 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 15, which ship the complete behavioral feature (float gone, input locked, hold-until-loaded, sounds, the four entry points, the #145 fix).
## Open questions / risks (resolve during execution; none block plan start)
1. **Death trigger (verify in Slice 5 Task 5.2).** Death is assumed to arrive as a server `0xF751` teleport → `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.